diff --git a/.cursor/skills/goa-release/SKILL.md b/.cursor/skills/goa-release/SKILL.md index 10c6e841d3..e462acce74 100644 --- a/.cursor/skills/goa-release/SKILL.md +++ b/.cursor/skills/goa-release/SKILL.md @@ -5,8 +5,10 @@ description: Release Goa v3, including preflight tests, dependency updates, semv # Goa Release -Use this skill only from the Goa repository. `make release` pushes branches and tags for Goa, -examples, and plugins, so do not run it unless the user explicitly wants to perform the release. +Use this skill only from the Goa repository. A stable release and an opt-in preview are separate +workflows. `make release` pushes branches and tags for Goa, examples, and plugins. A preview tags +Goa alone from a reviewed feature branch. Do not run either publishing command unless the user +explicitly approves that exact release. ## Release Contract @@ -17,8 +19,11 @@ examples, and plugins, so do not run it unless the user explicitly wants to perf - Apply semver within v3: - Patch release: increment `BUILD`. - Minor release: increment `MINOR` and reset `BUILD=0`. +- An opt-in preview uses `v3.MINOR.BUILD-preview.NUMBER`. Its version is a testing label, not a + promise of the final stable minor version. The major version remains 3. - The preparation commit message must be exactly `Prepare v3.x.y`, where `x` is `MINOR` and `y` is `BUILD`. +- The preview preparation commit message must be exactly `Prepare v3.x.y-preview.n`. - Keep the working trees clean between phases. Never discard local changes unless the user explicitly approves it. - Do not push preparation commits manually. `make release-goa` runs `git pull origin ` in @@ -44,6 +49,55 @@ Determine the target version before changing any repository: 6. Use the exact confirmed target version for every preparation commit, tag check, and release command. +## Preview Release Contract + +Use this workflow only when the confirmed version ends in `-preview.N`. + +- Work from the reviewed feature branch. Do not switch to or move the stable `v3` branch. +- Publish Goa only. Do not update, tag, or push the examples and plugins repositories. +- Keep the stable README badge and stable `@latest` installation instructions unchanged. +- Make the preview public only after the upgrade guide describes every known source, design, wire, + deployment, and rollback effect in plain English. +- Treat `prepare-preview` and `release-preview` as separate approval points. The first creates the + version commit. The second creates and pushes the public tag. +- Never run `make release` with `PREVIEW_NUMBER` set. That target is the stable multi-repository + release and deliberately rejects preview versions. + +### Prepare a preview + +1. Confirm the complete version, such as `v3.31.0-preview.1`. +2. Fetch `origin`, verify the feature branch contains the latest stable branch, and verify the + working tree is clean. Preserve and report any existing local changes. +3. Verify the exact preview tag is absent locally and on `origin`. +4. Read `UPGRADING.md` against the current diff. Confirm it tells application and plugin authors + how to install, regenerate, migrate, deploy, roll back, and report a problem. +5. Run: + + ```bash + make prepare-preview MINOR=31 BUILD=0 PREVIEW_NUMBER=1 + ``` + + The target runs the full preflight, updates the Makefile version and `pkg/version.go`, verifies + the command reports the preview version, and creates the exact preparation commit. It does not + create or push a tag. + +6. Push the feature branch through the normal review workflow and wait for every required check. +7. Review the complete diff and GitHub release notes one final time. + +### Publish a preview + +1. Inventory the current branch, commit, local tag, and remote branch and tag. +2. Get explicit maintainer approval to publish the exact preview tag. +3. Run `make release-preview` and watch it until it exits. The target reruns the full preflight, + verifies the current commit is already on the same remote feature branch, creates the tag, and + pushes only the tag. +4. If the command fails after creating the local tag, inspect local and remote state before any + rerun. Never delete a public tag or force-push without an approved recovery plan. +5. Create a GitHub release for the tag with `--prerelease`. Link `UPGRADING.md`, describe the + preview as opt-in, and explain that the final stable minor version follows compatibility review. +6. Verify the Go proxy resolves the exact preview version and that `@latest` still resolves the + current stable release. + ## Required Repositories Before release, verify these repositories exist, are on the expected branches, are clean, and are @@ -102,8 +156,10 @@ For each repository: If files changed, commit them with `Prepare v3.x.y`. Do not push; `make release-plugins` pushes the branch. -5. In the Goa repository, edit only `MINOR` and `BUILD` in `Makefile` for the target version. - Leave `MAJOR=3`. +5. In the Goa repository, edit only `MINOR`, `BUILD`, and the preview marker in `Makefile` for the + target version. Leave `MAJOR=3`. For a stable release, `PREVIEW_NUMBER` must be empty. The stable + release target clears the suffix in `pkg/version.go`; do not edit that generated version value + by hand. 6. If Goa files changed from dependency updates or the version bump, commit them with `Prepare v3.x.y`. 7. Re-check all three repositories are clean. They may be ahead of upstream by their preparation @@ -180,4 +236,3 @@ Channel guidance: - Substack: Write a short note for readers who may not know Goa deeply. Use a friendly title, a brief explanation of Goa, a user-centered story for the release highlights, upgrade guidance if any, and contributor thanks. Keep it proportional to the release size. - diff --git a/AGENTS.md b/AGENTS.md index 9d7e4efd67..5acc4d7461 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -55,10 +55,36 @@ No commented-out code—delete dead code. ### Codegen Implementation +- **Critical — finish work during generation**: Use the complete design, generation plan, + and Go templates to decide everything that is known before the generated + program runs. Select branches, names, types, imports, field paths, helper + calls, and emitted files while generating source. Templates must write only + the selected code. Do not make generated programs inspect generated type + shapes, parse generator-made names, carry generator mode flags, or execute + branches whose answer was already known. Runtime code should contain only + logic that depends on actual runtime values. When a runtime value is truly + required, keep that input narrow and specialize all surrounding code during + generation. - **Use NameScope helpers** for type references: `GoTypeRef`, `GoFullTypeRef`, `GoTypeName`. Never concatenate strings for types. - Let Goa decide pointer/value semantics. Do not force `pointer=true` except in transport validation. - **Keep helper visibility minimal**: If logic is shared only inside one codegen area, keep it package-private or move it under an `internal` package. Do not export helpers from a parent package just to share them across sibling generators. - **Avoid pass-through wrappers**: When two helper functions differ only by forwarding arguments or hard-coding `nil`, collapse them into a single implementation instead of adding an extra layer. +- **Generated packages own names**: When several services or plugins write to + one Go package, collect every package-level name before rendering and then + make those names final. A declaration and every HTTP, gRPC, or JSON-RPC use + of it must read the same name record. Do not give each service or plugin a + separate name scope for the same package, and do not add declarations after + names become final. +- **Keep identity typed and explicit**: Do not hide a declaration's kind, + package, or use in a decorated name or a made-up string map key. Do not + change an expression's `Hash` behavior to solve a generation problem. Pass a + typed identifier where the generated declaration is named. +- **Trace the complete lifecycle**: Before changing relocated types, union + naming, generation roots, plugins, or file merging, follow the declaration + from the evaluated design through service analysis, its generated Go + package, the emitted service code, HTTP and gRPC uses, plugin changes, and + the final file merge. A service-only rendering test is not enough. + See [`codegen/ARCHITECTURE.md`](codegen/ARCHITECTURE.md). ### Documentation @@ -129,4 +155,9 @@ To reproduce a codegen issue: ### Slices/Maps and Required Fields -Do not rely on nil vs empty to encode presence. Goa uses `omitempty`—both nil and empty serialize as "missing". If empty is valid, do not mark the field as required. +Do not rely on nil versus empty slices or maps to encode domain meaning. +Required JSON fields may contain empty collections unless a length constraint +forbids them; keep `Required` when the JSON property must be present. Protobuf +repeated and map fields cannot distinguish absent from empty, so their generated +validation checks length and contents, not presence. Message, scalar, and oneof +presence checks remain independent of collection emptiness. diff --git a/Makefile b/Makefile index c839d3adaf..56533cbc4f 100644 --- a/Makefile +++ b/Makefile @@ -8,13 +8,18 @@ # - "test" runs the tests # - "release" creates a new release commit, tags the commit and pushes the tag to GitHub. # "release" also updates the examples and plugins repo and pushes the updates to GitHub. +# - "prepare-preview" creates the version commit for an opt-in Goa preview. +# - "release-preview" tags and publishes that preview without changing other repositories. # # Meta targets: # - "all" is the default target, it runs "lint" and "test" # MAJOR=3 -MINOR=30 +MINOR=31 BUILD=0 +PREVIEW_NUMBER?=5 + +PREVIEW_TAG=v$(MAJOR).$(MINOR).$(BUILD)-preview.$(PREVIEW_NUMBER) GOOS=$(shell go env GOOS) GOARCH=$(shell go env GOARCH) @@ -25,15 +30,18 @@ GOLANGCI_LINT_VERSION?=v2.11.3 GOLANGCI_LINT=$(GOBIN_DIR)/golangci-lint PROTOC_BIN=protoc PROTOC_DEST=$(GOBIN_DIR)/$(PROTOC_BIN) +PROTOC_INCLUDE_DEST=$(GOPATH)/include -.PHONY: all all-tests ci depend lint test test-release integration-test build-goa release release-preflight release-goa release-examples release-plugins -.NOTPARALLEL: release release-goa release-examples release-plugins +.PHONY: all all-tests ci depend lint test test-release integration-test build-goa +.PHONY: release stable-release-check release-preflight release-goa release-examples release-plugins +.PHONY: preview-version preview-check preview-preflight prepare-preview release-preview +.NOTPARALLEL: release release-goa release-examples release-plugins prepare-preview release-preview # Only list test and build dependencies # Standard dependencies are installed via go get DEPEND=\ - google.golang.org/protobuf/cmd/protoc-gen-go@latest \ - google.golang.org/grpc/cmd/protoc-gen-go-grpc@latest + google.golang.org/protobuf/cmd/protoc-gen-go@v1.36.12 \ + google.golang.org/grpc/cmd/protoc-gen-go-grpc@v1.6.2 all: lint test integration-test @@ -80,6 +88,8 @@ depend: $(UNZIP) $(PROTOC).zip @rm -f "$(PROTOC_DEST)" && \ cp $(PROTOC_EXEC) "$(PROTOC_DEST)" && \ + mkdir -p "$(PROTOC_INCLUDE_DEST)" && \ + cp -R "$(PROTOC)/include/." "$(PROTOC_INCLUDE_DEST)" && \ chmod 0755 "$(PROTOC_DEST)" && \ rm -rf $(PROTOC) && \ "$(PROTOC_DEST)" --version @@ -114,11 +124,102 @@ build-goa: release-preflight: lint test-release integration-test -release: release-goa release-examples release-plugins +stable-release-check: + @if [ "$(MAJOR)" != "3" ]; then \ + echo "error: Goa releases must use major version 3"; \ + exit 1; \ + fi + @if [ -n "$(PREVIEW_NUMBER)" ]; then \ + echo "error: PREVIEW_NUMBER is set; use the preview release targets"; \ + exit 1; \ + fi + +release: stable-release-check release-goa release-examples release-plugins @echo "Release v$(MAJOR).$(MINOR).$(BUILD) complete" +preview-version: + @if [ "$(MAJOR)" != "3" ]; then \ + echo "error: Goa preview releases must use major version 3"; \ + exit 1; \ + fi + @case "$(MINOR)" in \ + ""|*[!0-9]*|0[0-9]*) \ + echo "error: MINOR must be a non-negative integer without a leading zero"; \ + exit 1; \ + ;; \ + esac + @case "$(BUILD)" in \ + ""|*[!0-9]*|0[0-9]*) \ + echo "error: BUILD must be a non-negative integer without a leading zero"; \ + exit 1; \ + ;; \ + esac + @case "$(PREVIEW_NUMBER)" in \ + ""|*[!0-9]*) \ + echo "error: PREVIEW_NUMBER must be a positive integer"; \ + exit 1; \ + ;; \ + 0|0*) \ + echo "error: PREVIEW_NUMBER must not be zero or start with zero"; \ + exit 1; \ + ;; \ + esac + @echo "$(PREVIEW_TAG)" + +preview-check: preview-version + @status="$$(git status --porcelain)"; \ + if [ -n "$$status" ]; then \ + echo "error: goa repo has uncommitted changes:"; \ + echo "$$status"; \ + exit 1; \ + fi + @if git rev-parse --verify --quiet "refs/tags/$(PREVIEW_TAG)" >/dev/null; then \ + echo "error: local tag $(PREVIEW_TAG) already exists"; \ + exit 1; \ + fi + @if git ls-remote --exit-code --tags origin "refs/tags/$(PREVIEW_TAG)" >/dev/null 2>&1; then \ + echo "error: remote tag $(PREVIEW_TAG) already exists"; \ + exit 1; \ + fi + +preview-preflight: preview-check release-preflight + +prepare-preview: preview-preflight + sed 's/^MINOR=.*/MINOR=$(MINOR)/' Makefile > _tmp && mv _tmp Makefile + sed 's/^BUILD=.*/BUILD=$(BUILD)/' Makefile > _tmp && mv _tmp Makefile + sed 's/^PREVIEW_NUMBER?=.*/PREVIEW_NUMBER?=$(PREVIEW_NUMBER)/' Makefile > _tmp && mv _tmp Makefile + sed 's/Minor = .*/Minor = $(MINOR)/' pkg/version.go > _tmp && mv _tmp pkg/version.go + sed 's/Build = .*/Build = $(BUILD)/' pkg/version.go > _tmp && mv _tmp pkg/version.go + sed 's/Suffix = .*/Suffix = "preview.$(PREVIEW_NUMBER)"/' pkg/version.go > _tmp && mv _tmp pkg/version.go + @test "$$(go run ./cmd/goa version)" = "Goa version $(PREVIEW_TAG)" + git add Makefile pkg/version.go + git commit -m "Prepare $(PREVIEW_TAG)" + +release-preview: preview-preflight + @expected="Goa version $(PREVIEW_TAG)"; \ + actual="$$(go run ./cmd/goa version)"; \ + if [ "$$actual" != "$$expected" ]; then \ + echo "error: generated version is $$actual, expected $$expected"; \ + exit 1; \ + fi + @branch="$$(git branch --show-current)"; \ + if [ -z "$$branch" ]; then \ + echo "error: preview release must run from a branch"; \ + exit 1; \ + fi; \ + remote_head="$$(git ls-remote origin "refs/heads/$$branch" | awk '{print $$1}')"; \ + local_head="$$(git rev-parse HEAD)"; \ + if [ "$$remote_head" != "$$local_head" ]; then \ + echo "error: push branch $$branch and wait for its checks before publishing $(PREVIEW_TAG)"; \ + exit 1; \ + fi + git tag $(PREVIEW_TAG) + git push origin $(PREVIEW_TAG) + @echo "Preview $(PREVIEW_TAG) published; examples and plugins were not changed" + release-goa: # First make sure all is clean + @test "$$(git branch --show-current)" = "v$(MAJOR)" @status="$$(git status --porcelain)"; \ if [ -n "$$status" ]; then \ echo "error: goa repo has uncommitted changes:"; \ @@ -126,28 +227,29 @@ release-goa: exit 1; \ fi cd $(GOPATH)/src/goa.design/examples && \ - git checkout main && \ - git pull origin main && \ + test "$$(git branch --show-current)" = main && \ status="$$(git status --porcelain)" && \ if [ -n "$$status" ]; then \ echo "error: examples repo has uncommitted changes:"; \ echo "$$status"; \ exit 1; \ - fi + fi && \ + git pull --ff-only origin main cd $(GOPATH)/src/goa.design/plugins && \ - git checkout v$(MAJOR) && \ - git pull origin v$(MAJOR) && \ + test "$$(git branch --show-current)" = "v$(MAJOR)" && \ status="$$(git status --porcelain)" && \ if [ -n "$$status" ]; then \ echo "error: plugins repo has uncommitted changes:"; \ echo "$$status"; \ exit 1; \ - fi + fi && \ + git pull --ff-only origin v$(MAJOR) go mod tidy # Bump version number, commit and push sed 's/Major = .*/Major = $(MAJOR)/' pkg/version.go > _tmp && mv _tmp pkg/version.go sed 's/Minor = .*/Minor = $(MINOR)/' pkg/version.go > _tmp && mv _tmp pkg/version.go sed 's/Build = .*/Build = $(BUILD)/' pkg/version.go > _tmp && mv _tmp pkg/version.go + sed 's/Suffix = .*/Suffix = ""/' pkg/version.go > _tmp && mv _tmp pkg/version.go sed 's/goa\/v3@v.*tab=doc/goa\/v3@v$(MAJOR).$(MINOR).$(BUILD)\/dsl?tab=doc/' README.md > _tmp && mv _tmp README.md $(MAKE) release-preflight git add . @@ -170,11 +272,14 @@ release-examples: release-plugins: cd $(GOPATH)/src/goa.design/plugins && \ - sed 's/goa.design\/goa\/v.*/goa.design\/goa\/v$(MAJOR) v$(MAJOR).$(MINOR).$(BUILD)/' go.mod > _tmp && mv _tmp go.mod && \ + for module in $$(find . -name go.mod -not -path './.git/*'); do \ + (cd "$$(dirname "$$module")" && \ + go mod edit -require=goa.design/goa/v$(MAJOR)@v$(MAJOR).$(MINOR).$(BUILD) && \ + go mod tidy) || exit 1; \ + done && \ make && \ git add . && \ git commit -m "Release v$(MAJOR).$(MINOR).$(BUILD)" && \ git tag v$(MAJOR).$(MINOR).$(BUILD) && \ git push origin v$(MAJOR) && \ git push origin v$(MAJOR).$(MINOR).$(BUILD) - diff --git a/README.md b/README.md index e5d9cb6db5..3505c53af7 100644 --- a/README.md +++ b/README.md @@ -182,6 +182,17 @@ curl -X POST http://localhost:8000/jsonrpc \ -d '{"jsonrpc":"2.0","method":"hello.say_hello","params":{"name":"world"},"id":"1"}' ``` +## Upgrading to v3.31.0 + +v3.31.0 is a substantial generator upgrade that stays on the v3 module path +and includes intentional breaking changes. Regenerate the complete generated +tree and review changes to handwritten code and plugins. Some gRPC, JSON-RPC, +and streaming contracts require coordinated client/server deployment. + +Read the [v3.31.0 upgrade guide](UPGRADING.md) before updating. It covers the +exact installation commands, source and design migrations, deployment order, +and rollback. + ## Documentation Our documentation site at [goa.design](https://goa.design) provides comprehensive guides and references: diff --git a/RELEASE.md b/RELEASE.md index 0fa574e862..ab6fdab57f 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -2,28 +2,120 @@ This document is intended to help Goa maintainers release new versions of Goa. -## Using `make release` +## Publishing an opt-in preview -1. Update `MAJOR`, `MINOR` and `BUILD` as needed in `Makefile`. +A preview publishes Goa alone from the reviewed feature branch. It does not +move the stable `v3` branch, update the stable README badge, or change the +examples and plugins repositories. Because a stable release exists, the Go +command does not select a preview for `@latest`; testers must request the full +version. + +Preview versions use `v3.MINOR.BUILD-preview.NUMBER`. For example: + +```text +v3.31.0-preview.1 +``` + +Goa stays on major version 3. A preview's minor version is a testing label; +the stable minor version and compatibility guidance follow the final review. + +### Prepare the preview + +1. Confirm the complete preview version with the maintainer. +2. Make sure the current Goa feature branch contains the intended generator + changes, public upgrade guide, and release notes. +3. Make sure the working tree is clean and the preview tag does not exist + locally or on `origin`. +4. Run the preparation target with the confirmed version parts: + + ```bash + make prepare-preview MINOR=31 BUILD=0 PREVIEW_NUMBER=1 + ``` + + The target validates the version, runs the complete Goa release preflight, + updates `Makefile` and `pkg/version.go`, verifies `goa version`, and creates + the commit `Prepare v3.31.0-preview.1`. It does not create or push a tag. + +5. Push the feature branch through its normal review workflow and wait for all + required checks. Do not merge it into the stable branch merely to publish + the preview. +6. Confirm that the public upgrade guide still matches the exact tagged code. + +### Publish the preview + +Publishing creates a public tag. Inventory the local and remote branch and tag +one final time, then get explicit maintainer approval before running: + +```bash +make release-preview +``` + +The target reruns the complete preflight, checks that the current commit is +already present on the branch with the same name on `origin`, creates the +preview tag, and pushes only that tag. It does not push a branch or touch the +examples and plugins repositories. + +If publication fails after the local tag is created, inspect the local and +remote tag before doing anything else. Do not rerun blindly, delete a public +tag, or force-push. + +Create the GitHub release only after the tag push succeeds, and mark it as a +pre-release: + +```bash +gh release create v3.31.0-preview.1 \ + --prerelease \ + --title "Goa v3.31.0-preview.1" \ + --notes-file PREVIEW_NOTES.md +``` + +Release notes should link to `UPGRADING.md`, identify the users most likely to +be affected, and ask +for reports with a small design and generated diff. Remove the temporary notes +file after GitHub has accepted the release. + +Testers install the preview explicitly: + +```bash +go get goa.design/goa/v3@v3.31.0-preview.1 +go install goa.design/goa/v3/cmd/goa@v3.31.0-preview.1 +``` + +## Publishing a stable release + +### Using `make release` + +Follow [the release skill](.cursor/skills/goa-release/SKILL.md) for dependency +updates, checks, preparation commits, publication, and release notes. + +1. Keep `MAJOR=3` and update `MINOR` and `BUILD` in `Makefile`. Leave + `PREVIEW_NUMBER` empty. The stable release target refuses to run while a + preview number is set and clears the preview suffix in `pkg/version.go`. 2. Make sure the `goa.design/examples` and `goa.design/plugins` repositories exist in `$(go env GOPATH)/src` and are clean: - `$(go env GOPATH)/src/goa.design/examples` on branch `main` - `$(go env GOPATH)/src/goa.design/plugins` on branch `v3` 3. Run `make release` +Merge compatible preview changes into Goa `v3`, examples `main`, and plugins +`v3` before releasing. The release target checks those branches and refuses to +switch a working tree automatically. All repositories must be clean; local +preparation commits may be ahead of the remote, but the remote must not be +ahead. Do not push preparation commits separately. + `make release` runs a preflight check (`release-preflight`) after bumping the version and updating the README badge. The preflight runs `lint`, `test-release` (no coverage artifact) and `integration-test` before tagging and pushing. -## Manual release procedure - -1. Update `MAJOR`, `MINOR` and `BUILD` as needed in `Makefile`. -2. Update `pkg/version.go` and `README.md` to reflect the new version. -3. Commit and push to v3. -4. Create and push release git tag. -5. Update `go.mod` in the examples repo `master` branch. -6. Run `make` in the examples repo. -7. Push the examples repo `master` branch. -8. Create and push release git tag. -9. Update `go.mod` in the plugins repo `v3` branch. -10. Run `make` in the plugins repo. -11. Create and push release git tag. +The examples phase updates every example module. The plugins phase updates +the root module and every nested example module before regenerating and testing. + +### Recovering a partial release + +If a phase fails, inspect the current branch, working tree, local tag, and +remote branch and tag in all three repositories. Preserve successful pushes. +Fix the failed phase and run the narrowest remaining target: +`release-goa`, `release-examples`, or `release-plugins`. Never blindly repeat +the complete release, recreate an existing tag, or replace a public tag. + +After all three tags are published, create the Goa GitHub release and verify +that the Go proxy resolves the stable version and the downstream module pins. diff --git a/UPGRADING.md b/UPGRADING.md new file mode 100644 index 0000000000..ed50927d12 --- /dev/null +++ b/UPGRADING.md @@ -0,0 +1,584 @@ +# Upgrading to Goa v3.31.0 + +**This is a substantial generator upgrade with intentional breaking changes.** +Goa remains on the `goa.design/goa/v3` module path. Keeping v3 does not mean +that every generated API, plugin API, or transport exchange is compatible +with v3.30.0. Read the relevant sections before regenerating a production +application. + +The release promotes the generation preview and includes the subsequent gRPC +collection-presence and cancellation fixes. Goa still requires Go 1.25 or later. +The examples and plugins repositories require Go 1.26 or later, as on their +existing stable branches. + +Generated application starters import Clue for logging and debugging. Clue +v1.3.0 requires Go 1.26. Applications staying on Go 1.25 can use Clue v1.2.6: +run `go get goa.design/clue@v1.2.6` before tidying a new starter module. + +## Plan the upgrade + +| If you use… | Required action | +| --- | --- | +| Generated Goa packages | Upgrade the module and command together, regenerate the complete `gen` tree, compile, and test. Do not mix files from different generator versions. | +| `OneOf`, interceptors, multipart decoders, or direct transport helpers | Update handwritten callers for the generated signatures and types described below. | +| Required gRPC scalar fields | Regenerate protobuf code and update direct message literals. Coordinate peers when required zero or empty values matter. | +| JSON-RPC errors, selected views, or server streams | Update both generated peers and custom clients for the changed envelopes and stream lifecycle. JSON-RPC WebSocket generation has been removed. | +| Dynamic gRPC views or optional primitive HTTP SSE data | Regenerate and deploy both peers together for the cases listed under coordinated deployment. | +| Code-generation plugins | Upgrade `goa.design/plugins/v3` to v3.31.0 and migrate custom plugins that declare names or call removed generator APIs. | + +There is no persisted-data migration. Keep the previous binaries, dependency +versions, design, and generated tree available for rollback. + +## Why generation changed + +Goa used to make some generated-name and type decisions in separate passes. +Those passes could describe the same Go package while seeing different sets of +names. One pass could declare a validation function under one name while a +later pass called another name. Similar disagreements affected imports, union +types, conversions, result views, examples, and plugin output. + +The new generator prepares the complete design first. It then chooses every +generated package, file, declaration, import, validation function, conversion, +and helper once. HTTP, gRPC, JSON-RPC, command, example, OpenAPI, and plugin +generation all use those recorded choices. Templates write the selected code; +generated programs do not inspect their own type names or choose between paths +that were already known during generation. + +That architectural correction exposed places where the old output accepted +values that the design rejected, described transport behavior the transport +could not provide, or exposed generator details as public APIs. This release +fixes those contracts together instead of preserving contradictory behavior. + +## Corrections carried forward from the preview + +The stable release includes the preview fixes for selected HTTP result views, +explicit response-body attributes, collection types with suffixed identifiers, +required arrays of viewed types, named string credentials, and shared errors. +Generated HTTP path files also retain imports for custom parameter types such +as `uuid.UUID`. +Required gRPC arrays and maps now accept valid empty collections after a +protobuf round trip; their length and item rules still apply. + +HTTP response selection also becomes explicit. A method must define exactly +one response without `Tag`; this is its default response. Every other success +response must use `Tag`. Remove extra untagged responses or give them a tag +that the result can select. Generated encoders test tagged responses in design +order and use the default last. + +## Identify affected features + +Review the matching migration sections if your project has any of these characteristics: + +- it uses a Goa code-generation plugin; +- it uses required primitive fields, `OneOf`, result views, defaults, repeated + values, maps, or metadata with gRPC; +- it uses JSON-RPC, server-sent events, or WebSocket streams; +- it implements generated interceptors; +- it constructs generated transport or protobuf types directly; +- it replaces generated sections or calls Goa generator packages directly; or +- it has several services, generation roots, transports, or plugins that write + into the same generated Go package. + +Test ordinary HTTP-only services as well. Review the generated diff even when +none of the specialized migrations below applies. + +## Install v3.31.0 + +Start from a branch with the current generated tree committed. Install both the +Goa module and the `goa` command from the same release version: + +```bash +go get goa.design/goa/v3@v3.31.0 +go install goa.design/goa/v3/cmd/goa@v3.31.0 +goa version +``` + +The Go command records the same release version in `go.mod`. The installed +command reports the same version: + +```text +Goa version v3.31.0 +``` + +Do not use an older `goa` command with the new module. Also install the +protobuf generators covered by this release if the design uses gRPC: + +```bash +go install google.golang.org/protobuf/cmd/protoc-gen-go@v1.36.12 +go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@v1.6.2 +``` + +Goa checks these two program versions before writing gRPC files. + +If your design imports the official plugins, update that module in the same +application change: + +```bash +go get goa.design/plugins/v3@v3.31.0 +``` + +Use matching release tags when copying examples. Custom plugins must complete +the generator-library migration below before regenerating an application. + +## Regenerate and test an application + +1. Record the existing generated output in version control. +2. Update the Goa module and command as shown above. +3. Run the same complete `goa gen` command used by the project. `goa gen` + deletes and recreates the complete `gen` directory. +4. Run `goa example` only if the project normally maintains Goa starter files. + This command creates missing files but does not replace handwritten files. +5. Read every generation error. Several invalid or ambiguous designs now stop + before source is written; the sections below explain how to update them. +6. Compile the complete project and update handwritten code for intentional + source changes. +7. Run unit, integration, and client/server tests. Compare generated `.proto` + and OpenAPI files with the committed versions, especially field numbers, + routes, selected views, errors, and examples. +8. Run generation a second time and confirm that it produces no further diff. + +Never copy individual files from the old generated tree into the new one. A +declaration and every use of it must come from the same generation run. + +## Changes most application authors will see + +### Required gRPC primitive fields now preserve presence + +A required boolean, number, string, enum, byte slice, or alias must distinguish +an omitted value from an explicit zero value. The generated protobuf schema now +uses proto3 presence for these singular fields. In generated protobuf Go code, +booleans, numbers, strings, enums, and their aliases therefore become pointers. +Byte slices remain slices, and messages remain pointers. Goa service types keep +their existing field layout. + +The protobuf field numbers and binary wire types do not change. The behavior +does: generated clients and servers reject an omitted required field instead of +converting it to the service type's zero value. Explicit `false`, `0`, an empty +string, an empty byte slice, and protobuf null remain valid when supplied. + +Code that constructs protobuf messages directly must set the new pointers or +use generated getters when reading them. Regenerate both peers when required +zero or empty values matter. An old client cannot prove to a new server that it +explicitly selected a zero value because its schema did not record presence. + +Defaults now use the same rule. Goa applies an authored default only when the +incoming value is absent. An explicit zero or empty value stays explicit. +Service results are sent exactly as returned; result conversion does not add +input defaults. + +### `OneOf` values have one explicit selected branch + +Generated union branch storage is now private. Construct, update, and read a +union with its generated constructor, setter, accessor, and `Kind` method: + +```go +value := NewValueText("hello") +value.SetCount(3) +count, ok := value.AsCount() +``` + +The exact names use the union and branch names from the design. Compiler-made +branch types have descriptive names such as `ValueBranchText`; discovery-order +names such as `Value2` are gone. + +A union holds exactly one valid branch. Selecting a new branch replaces the +old selection. A required union rejects no selected branch, a typed wrapper +whose value is nil, and a selected nil message, byte slice, or `Any` value. A +selected nonnil empty message remains valid. JSON and protobuf data keep the +same union representation. + +Update handwritten code that set or read public branch fields. If two authored +unions would need the same public Go name in one package, give them distinct +`TypeName` values; generation now reports the collision instead of silently +renumbering public types. + +### Required array items reject JSON `null` + +`ArrayOfRequired` marks every array item as required. Incoming HTTP and +JSON-RPC body types now use pointers for primitive items and primitive aliases +so the decoder can distinguish `null` from a zero value. A request containing +`[null]` returns a validation error. Valid requests still become ordinary +value slices in the service layer, and generated response bodies remain value +slices. + +Only code that constructs incoming generated transport bodies directly needs +to use pointers for these items. Service implementations do not change. + +### Interceptor information is read-only + +Generated interceptor methods now receive `NameInfo`, a read-only interface, +instead of `*NameInfo`, a pointer to a public struct whose fields were already +private. The interface provides the same public accessor methods for the +service, method, call kind, and typed value. + +Update each handwritten interceptor signature by removing the `*`. Code that +used the public accessors otherwise remains the same. The generated private +implementation is specific to the method and call kind, so it does not inspect +method names or switch on payload types while the service is running. + +### Selected gRPC views use their exact contract + +Every selected result view now has matching conversion and validation. A field +omitted by a view is not treated as required, while a missing field selected by +that view is rejected before conversion. A dynamic server stream sends the +selected view in initial `goa-view` metadata, and the generated client reads it +before decoding the first message. + +Regenerate and deploy both sides of dynamic viewed streams together. Do the +same for a viewed method where a non-default view omits fields used by the old +default conversion. Fixed-view methods otherwise keep their wire choice. + +Top-level validators keep stable exported names such as +`ValidateShowResponse` and `ValidateShowResponseTiny`. Validators used only by +another validator are private. Handwritten code must call the top-level +validator for its selected view rather than an old numbered or nested helper. + +### API-level errors are definitions, not automatic method errors + +An error declared at API level defines a reusable error. It no longer means +that every service and method can return that error. Select it where it is +actually returned: + +```go +var _ = API("calc", func() { + Error("busy", Busy) +}) + +var _ = Service("calculator", func() { + Error("busy") +}) +``` + +The name-only service or method declaration selects the API definition and +keeps its type, validation, default, description, and fault settings. Supplying +another argument defines a separate local error. Add explicit selections to +designs that relied on the old automatic lookup. + +### Custom errors use the exact declared type + +Goa adds `Error`, `ErrorName`, and `GoaErrorName` methods only to the exact +authored type passed to `Error`. A named type nested inside that error remains +an ordinary generated type and no longer receives error methods accidentally. +Update application code that returned or inspected the nested type as though it +were the declared service error. + +When the exact error type is also used as a payload or result, Goa emits one Go +declaration and adds the error methods beside it. The same rule applies when +`struct:pkg:path` places the type in a shared generated package: every service +imports the one declaration instead of generating competing data and error +copies. This is a generated Go source correction. It does not change the +transport body or require a persisted-data migration. + +One authored error type must also have one static error name across the complete +generation. Goa now rejects a type used as, for example, `not_found` in one +service and `missing` in another service, even when the services are evaluated +through different generation roots. The old generator checked only errors in +the same method, so its generated `ErrorName` result could depend on which +service happened to emit the shared type. + +If one type intentionally represents several names, add an `ErrorName` field to +the type and set the name on each error value. Otherwise, define a separate type +for each static name. Regenerate the complete design after making the choice; +generating services separately does not create separate names for a type placed +in one shared Go package. + +### Named string credentials and map keys keep their declared types + +Security credentials may use the built-in `String` type or a named string type. +Generated service, HTTP, and gRPC code now preserves that named type while +converting its value to the plain string required by authorization functions. +HTTP Basic authentication also preserves whether an optional username or +password was absent. It sends the Authorization header whenever either value is +required or present. + +Regenerating changes generated HTTP Basic payload constructor arguments. +Handwritten code that calls those transport constructors directly must pass the +username and password values expected by the regenerated signature. Code that +uses Goa's generated decoders changes automatically. + +Goa now rejects security fields that use a non-string type, define a default, or +use `struct:field:type`. Those designs could not preserve the security contract +safely and previously produced invalid or misleading code. Replace the field +with `String` or a named string type and perform any application-specific +conversion after authorization. + +OpenAPI v2 output now also preserves the declared value schema for maps whose +keys use a named string type. Custom clients generated from the old Swagger may +have treated those values as unrestricted. Regenerate such clients so they use +the designed value type. + +## Transport changes + +### JSON-RPC supports unary calls and explicit server-sent-event streams + +JSON-RPC methods now have one of two contracts: + +- one HTTP request followed by one JSON response; or +- one HTTP request followed by server results through + `ServerSentEvents()`. + +Design validation rejects client streams, bidirectional streams, JSON-RPC +WebSocket streams, server streams without `ServerSentEvents()`, and a method +that defines both `Result` and `StreamingResult`. Move an unsupported method to +unary JSON-RPC, JSON-RPC server-sent events, ordinary HTTP WebSocket, or gRPC. + +Request and response handling now follows JSON-RPC 2.0 for IDs, +notifications, invalid objects, batches, `result: null`, invalid parameters, +internal errors, accepted media types, and streams in batches. A method +declared as a notification requires an absent `id`; every ordinary method +requires a non-null string or numeric `id`. Regenerate custom peers together +and update them to follow the declared method kind. + +A result whose view is chosen by the caller uses this value inside JSON-RPC's +top-level `result` member: + +```json +{ + "view": "detailed", + "body": { + "name": "example" + } +} +``` + +Goa adds this envelope only when the caller chooses among views. Unviewed and +fixed-view results keep their body shape. The value is valid JSON-RPC, which +allows any JSON value in `result`; a separate protocol built on JSON-RPC must +still use the result shape required by that protocol. + +Designed JSON-RPC errors keep the authored JSON-RPC code and place their Goa +error name and body in the error's `data` member. Custom clients that decode +that data must accept the new `{ "name": ..., "body": ... }` shape. +Regenerate both peers together for designed errors. A client receiving an +unknown error code, name, or body preserves the raw JSON-RPC error instead of +misidentifying it as a designed error. + +Server streams now use the service stream's `Send`, `SendWithContext`, and +`Close` methods. Clients use `Recv()` or `RecvWithContext(ctx)` instead of +`Recv(ctx)`. Each sent value is a notification; the opening request has a +non-null ID and ends with a terminal success or error response. Update custom +peers for this lifecycle. The former JSON-RPC-only `StreamEvent`, `SendAndClose`, +`SendError`, concrete client-stream, and WebSocket configuration APIs are removed. + +Code calling `jsonrpc.IDToString` directly must handle its new `(string, error)` +return. Positional `RawRequest` and `RawResponse` literals must use named fields. + +### HTTP decoding, validation, and streams are more exact + +- Multipart decoders fill and validate the request body before constructing + the service payload. Generated multipart decoder callback signatures may + change, and invalid nested fields now report their complete field and array + paths. +- Exclusive maximum validation rejects the maximum itself as well as larger + values. +- A map assigned to the complete query string reads raw keys such as + `?a=1&b=2`, matching generated clients. A custom client that used bracketed + keys must change. +- Float query values use Go's shortest text that converts back to the same + value. Systems that sign, cache, or compare exact URL text must accept the + compact spelling. +- Server-sent events write primitive values as raw event text, preserve the + difference between an absent optional value and a present empty string, and + return write and flush errors. +- An empty successful WebSocket stream now completes the handshake, sends a + normal close frame, and closes once. +- Generated clients return body read and close errors for bodies they consume. + A method that deliberately returns the raw body still leaves it open for the + caller. + +### Other gRPC corrections + +- Byte metadata uses its actual string contents. For example, `[]byte{65, 66}` + becomes `"AB"`, not `"[65 66]"`. Floating-point metadata uses the width + declared by the design. +- Protobuf cannot distinguish an omitted repeated or map field from an empty + one. Generated validators no longer report a false missing-field error. + Authored length and item rules still run. +- A nil repeated or map wrapper used as a map value converts to an empty + collection instead of panicking. +- A validator that has no work is no longer generated. Remove handwritten + calls to such a function. +- A schema with a real protobuf name collision may receive stable suffixes. + Regenerate both sides of a formerly conflicting schema because its descriptor + names or gRPC method path can change. + +## Generated starter code, examples, and OpenAPI + +Generated commands now execute the endpoint, receive stream values, print +them, and return endpoint, stream, output, and close errors. Review handwritten +command code against the new starter when updating it. gRPC flags for complete +messages now decode protobuf JSON. + +Update a command's `main.go` and its transport helpers together: private +`doHTTP`, `doGRPC`, and `doJSONRPC` helpers now execute and print results rather +than returning an endpoint and payload. `goa example` keeps existing files, so +it cannot complete this handwritten migration automatically. Input-streaming +and bidirectional command starters return an unsupported-input error. + +Direct callers of generated `BuildPayload` functions must also follow +the new signatures: a flag without a default uses `*string`, with nil meaning +omitted. A pointer to an empty string means explicitly supplied. Required +flags are checked rather than populated with the literal text `REQUIRED`. + +Generated example values now belong to the design declaration that authored +them. Another service or transport can no longer consume shared random state +and change those values. This causes a large one-time text change in some +examples, but a second generation must be identical. + +OpenAPI output now has consistent base64 byte examples, empty security scope +arrays instead of JSON null, independent server-variable examples, selected +view schemas, and server-sent-event data schemas. Review these changes as +contract corrections rather than accepting the complete generated diff +without inspection. + +## Designs that now fail validation + +Generation now rejects ambiguous designs before writing files. In addition to +the error, response, security, and streaming rules above, check these cases: + +- Nested defaults must satisfy their complete type, required fields, and + validation rules. +- A relocated authored type using `struct:pkg:path` must give its referenced + authored types explicit generated package locations too. +- Ordinary HTTP and JSON-RPC routes on one server cannot overlap, even when + their path parameter names differ. +- JSON-RPC routes must use `POST`; method names cannot start with `rpc.`. + Keep request IDs as one direct string payload field with no competing + parameter, header, cookie, or event-ID mapping. +- Multipart requests need a non-empty request body for the decoder to fill. +- `SSERequestID` must be the sole mapping for `Last-Event-ID`. A viewed stream + cannot use `SSEEventData` to discard the rest of its view information. +- HTTP client-streaming and bidirectional methods must use a fixed response + view. Server-only streams may still select a view from their request. +- Remove duplicate conversions and unsupported transport mappings, including + non-primitive gRPC metadata and JSON-RPC response fields mapped to HTTP + response headers or cookies. + +The [complete design migration table](codegen/ARCHITECTURE.md#designs-that-now-stop-generation) +describes each rejected combination and its replacement. + +## Plugin and generator-library migration + +The released four-argument plugin registration functions, `Genfunc`, the +replaceable `Generators` variable, and the exported `Service`, `Transport`, +`OpenAPI`, and `Example` functions remain. Their released ordering and support +for repeated callback names also remain. Common generated-name fields such as +`MountHandler`, `HandlerInit`, constructors, codecs, validators, multipart +helpers, server-sent-event names, WebSocket names, and gRPC names remain +available for existing templates. + +A plugin that edits ordinary generated values or files should usually continue +to work. A plugin that declares a package-level name or chooses a generated +name must move that work to `generator.PluginFactory` and declare it during +`Plugin.Plan`. This lets Goa check the plugin's names with every other name in +the generated package before templates run. + +A preparation plugin that adds a service must attach it to the owning root and +call `EvaluateAttachedServices`. Public helpers that directly ran plugin +callbacks or rebuilt Goa's private service or transport analysis are removed; +the complete generation run now owns those steps once. + +Several public planning and template-data structures gained private state or +typed declaration records. As a result: + +- positional struct literals must become named-field literals; +- values containing maps or function-planning state can no longer be compared + with `==` or used as map keys; +- plugins that replaced interceptor section templates must use the new typed + section data; and +- plugins that render viewed gRPC conversions must use the per-view conversion + lists rather than one default conversion for every view. + +The complete table of changed generator APIs and their replacements is in +[Code Generation Architecture](codegen/ARCHITECTURE.md#generator-library-migration). +That document is the detailed contract for plugin authors; this guide is the +application-facing summary. + +## Changes that require coordinated deployment + +Updating the Goa module does not change a compiled application. The changes +take effect only after regeneration and compilation. There is no persisted-data +migration. + +Regenerate, deploy, and roll back both client and server together for: + +- designed JSON-RPC errors; +- caller-selected JSON-RPC views; +- JSON-RPC server-sent-event streams; +- dynamic caller-selected gRPC streams; +- viewed gRPC methods whose chosen view omits fields used by the old default + conversion; +- HTTP server-sent-event streams with optional primitive data; and +- gRPC metadata whose byte or floating-point text matters to another program. + +Required protobuf fields keep compatible field numbers and binary types, but a +new server may reject a required zero value from an old client because the old +message did not record whether that zero was present. + +Most other changes are source changes in generated Go packages or stricter +validation of previously invalid input. They require regeneration and a normal +application build, but not a data migration. + +For required protobuf zero or empty values, update all affected clients before +enabling the new server's presence validation, or deploy matching peers +together. For changed JSON-RPC and streaming exchanges, use a coordinated +cutover or route clients to a matching server version. An independently rolling +deployment cannot assume those old and new generated contracts interoperate. + +The gRPC cancellation fix also applies when rebuilding clients that already +call `grpc.ContextError`. A locally canceled call has one context cause while +retaining its gRPC status and transport details. Independent cleanup errors +remain separate; no coordinated deployment is required for this runtime fix. + +## Roll back to v3.30.0 + +Restore the previous design and handwritten application changes as well as the +module version. For a project upgrading from v3.30.0: + +```bash +go get goa.design/goa/v3@v3.30.0 +go install goa.design/goa/v3/cmd/goa@v3.30.0 +goa gen YOUR_MODULE/design +``` + +Restore the previous plugin dependency versions too, if used. Regenerate the +complete `gen` directory with the v3.30.0 command, or restore the recorded v3.30.0 +generated tree and matching binaries. Do not mix generated versions. For a +changed transport exchange, roll back both client and server together. No +persisted data needs to be changed or restored. + +## Existing limitations checked for this release + +The issue review reproduced two problems that also affect v3.30.0: + +- A custom `uuid.UUID` field mapped to an HTTP query parameter can produce a + client that does not compile. Server-side text decoding is supported, but the + generated client does not perform the corresponding conversion to text. + Until [#3924](https://github.com/goadesign/goa/issues/3924) is resolved, keep + the contract field as `String` with `FormatUUID` and convert inside the + implementation when a UUID value is needed. +- A recursive gRPC result containing an array of itself can make generation + recurse indefinitely ([#2515](https://github.com/goadesign/goa/issues/2515)). + This release does not fix that case. + +## Report a problem + +Open a [GitHub issue][issues] for a reproducible bug. Use +[GitHub Discussions][discussions] for design and migration questions. + +A useful report includes: + +- the exact Goa version, Go version, and operating system; +- `protoc`, `protoc-gen-go`, and `protoc-gen-go-grpc` versions for gRPC; +- the affected transport and whether a plugin participates; +- the smallest design that reproduces the behavior; +- the exact `goa gen` or `goa example` command; +- the relevant before-and-after generated diff; +- the complete generation, compilation, or runtime error; and +- whether both client and server were regenerated. + +Please remove credentials and private application data. A small public +reproduction is ideal. + +[discussions]: https://github.com/goadesign/goa/discussions +[issues]: https://github.com/goadesign/goa/issues diff --git a/codegen/ARCHITECTURE.md b/codegen/ARCHITECTURE.md new file mode 100644 index 0000000000..735457e756 --- /dev/null +++ b/codegen/ARCHITECTURE.md @@ -0,0 +1,1117 @@ +# Code Generation Architecture + +This document defines how Goa turns one evaluated design into generated files. +It is the contract for generator authors: one run prepares the design, builds +one retained plan, freezes every emitted name, and renders that exact plan. + +## Why this contract exists + +The original downstream failure produced a reference to one validation function name +and a declaration with another name. The function and its caller were emitted +into the same generated Go package, but separate analyses allocated their names +from separately initialized scopes. The first ownership work fixed concrete +service, HTTP, JSON-RPC, and gRPC cases by adding a generation-wide package +catalog and transport-local catalogs. It also exposed the remaining design +mistake: planning still records only selected type families, then rendering +rebuilds service and transport data and allocates other package-level names. + +For example, a render-time service scope can still choose names for endpoint +constructors, error constructors, validators, and stream helpers after the +generation has supposedly frozen. A second `NewServicesData` call can rebuild +the same logical wire model with a different traversal context. Both behaviors +let a declaration and its reference disagree. + +The terminal contract is stronger and smaller: every package-level type, +function, constant, and variable has one declaration record owned by the +package that emits it. Every subsystem retains the analysis that created those +records. Rendering reads that analysis; it never reconstructs it. + +## Run lifecycle + +The `goa` command compiles and runs a temporary generator for one evaluated +design. One run follows this order: + +1. Resolve the command and create fresh core generator and factory-plugin + objects. Copy plugins registered through the released callback API into the + same run. +2. Evaluate and validate the design roots. +3. Run preparation plugins, which may add or change expressions. Construct one + `codegen.Generation` with exclusive access to those evaluated roots. Its + final preparation step wraps raw method attributes and records each exact + generated wrapper. Concurrent runs use distinct expression graphs; the + generation does not copy the graph or coordinate two runs mutating the same + unprepared objects. +4. Record the prepared roots for mutation auditing. No later phase may mutate + an expression root. The audit compares retained semantic state after every + later callback; it does not claim that the expression graph was physically + copied or made immutable. +5. Build one typed `generator.Plan`. It creates and retains the core service + plan for each root, then the selected HTTP, gRPC, JSON-RPC, OpenAPI, and + example plans that consume those exact service plans. Factory plugins + receive the same plan when they declare their output. +6. Each subsystem completes collection, sorts declarations by stable typed + identity, and declares every package-level symbol in its actual output + package. The generation then freezes package names and import qualifiers. +7. Link each retained subsystem plan once. Linking converts recorded design + facts and final declaration references into template data. All generated + names are fixed, but later plugin callbacks may still make the permitted + edits to ordinary section values. Linking cannot discover a declaration, + reserve a name or import, mutate an expression, or create another analysis + graph. +8. Core generators and factory plugins render from the same `generator.Plan` + and exact core service plans. Released callbacks receive their original + generated package, roots, and current file list instead. +9. Merge contributions with the same canonical output path and render files. + +Collection must be complete before freeze. Stable ordering makes preferred-name +suffixes independent of map iteration, traversal order, plugin registration +order, and process history. Freeze turns every declaration record into a +read-only value. Linking resolves those records exactly once before rendering; +it does not repeat collection or allocate another name. Render performs no +expression mutation, graph analysis, declaration discovery, name allocation, +or import allocation. + +## Fresh run objects + +Registration stores immutable factories, not mutable generator or plugin +instances. A factory is called once for each generation run: + +```go +type Plugin struct { + Prepare PrepareFunc + Plan func(*Plan) error + Generate func(*Plan, []*codegen.File) ([]*codegen.File, error) +} + +type PluginFactory func() Plugin + +func RegisterPlugin(name, command string, factory PluginFactory) +func RegisterPluginFirst(name, command string, factory PluginFactory) +func RegisterPluginLast(name, command string, factory PluginFactory) +``` + +These factory APIs belong to `codegen/generator`, which runs generation +commands. Goa also keeps the released four-argument registration functions in +`codegen`. Those functions store callback pairs in an internal registry; the +generator copies them into the same run when generation starts. Plugin authors +cannot inspect the registry or run callbacks themselves. Core generator +factories follow the same fresh-instance rule. + +Factory plugin names are non-empty and unique within one command across the +First, normal, and Last groups. Released callback registrations may repeat a +name, as Goa v3 allowed; equal names keep registration order. Both APIs reject +unknown commands and stop accepting registrations when generation first +starts. + +A factory may close over immutable configuration. Per-run roots, plans, files, +caches, and errors belong to the returned object. Concurrent and repeated +generation runs must not observe one another. The factory registry is immutable +while runs execute, and tests install isolated registries. The released +`Generators` variable remains replaceable for compatibility. Callers configure +it before starting concurrent runs; each run reads its function list once and +turns that list into fresh internal generators. + +## The retained core plan + +`generator.Plan` is the typed value shared by core generators and plugins. Its +fields are private. It exposes the active `Generation` and the exact service +plan built for a registered root: + +```go +func (p *Plan) Generation() *codegen.Generation +func (p *Plan) Service(root *expr.RootExpr) *service.Plan +``` + +Selected core subsystems are stored in typed fields, not in a generic map. +There is no `PlanKey`, string key, extension registry, or `any`-typed plan bag. +A plugin that needs core service declarations consumes `Plan.Service(root)`; +it may not call service analysis again or rebuild an equivalent plan from the +root. + +Each subsystem has one retained planning entry point. Service files can be +shared by several Goa roots in one generation, so service planning accepts the +complete root batch: + +```go +func service.NewPlans(generation *codegen.Generation, inputs ...service.PlanInput) ([]*service.Plan, error) +func service.NewPlan(root *expr.RootExpr, generation *codegen.Generation, examples *expr.ExampleGenerator) (*service.Plan, error) +``` + +`NewPlans` requires every service root owned by the generation exactly once. +It assigns relocated declaration files and external conversion methods across +the complete run before names freeze. Exact compiler copies with the same +retained Go layout share one declaration; copies that bind one declaration to +different fields, tags, pointer policies, union branches, or file facts are +rejected. `NewPlan` is only the strict single-root convenience form and rejects +a generation that contains more than one service root. + +HTTP, gRPC, JSON-RPC, OpenAPI, and example generation use equivalent typed +constructors. A transport plan receives the exact `*service.Plan` for its root. +JSON-RPC may retain and reuse its HTTP plan because it emits HTTP codecs and +wire files, but it does not rebuild HTTP analysis. Render functions accept the +retained subsystem plan, not a `Generation`, generated module path, expression +root, or reconstructed `ServicesData`. + +The plan stores collected design facts, linked render data, and final +declaration pointers. It does not store callbacks that repeat analysis. +`NewServicesData`, `renderOnly`, and the released functions that ran plugin +callbacks are old entry points that the retained plan replaces. The released +`Genfunc` type, replaceable `Generators` variable, and core generator functions +remain available. Goa adapts built-in functions to this plan and runs external +functions after every generated name is final. + +## Generated package ownership + +The generation owns a package catalog keyed by the actual generated Go import +path. Each package record owns: + +- one declaration namespace for every package-level type, function, constant, + and variable; +- one import qualifier for every complete import path referenced by files in + that package; and +- the canonical output directory that corresponds to its import path. + +The common declaration record is `NameDeclaration`. It keeps its preferred and +final spellings private. `Name()` panics before freeze and returns the same +final spelling for the remainder of the run after freeze. Existing type, +union, branch, HTTP wire, protobuf, validator, and helper records contain or +reference `NameDeclaration`; they do not carry another independently mutable +name. + +Package-level declarations include less obvious symbols: constants that record +a union's selected branch, union constructors, endpoint constructors, error and +result constructors, validation functions, conversion functions, stream +interfaces and helpers, HTTP body constructors, protobuf oneof wrappers, +client and server constructors, and package variables emitted by templates. +Local variables, parameters, struct fields, and method names remain owned by +their lexical render scope because they cannot collide with package-level +declarations. + +Service package paths are assigned once across every prepared design root in a +generation run. Equal authored service names share one generated package even +when they come from different APIs. Different names that reduce to the same Go +package name receive stable numeric suffixes. Natural package names are +reserved first, so an authored service whose normal package is `read_value2` +keeps that path while another collision advances to `read_value3`. The linked +service data exposes the final directory as `PathName`. HTTP, gRPC, JSON-RPC, +MCP, examples, and command-line generators read that retained path and its +saved import; they never rebuild a package path from the service name. + +Generated command-line parsers also plan names at the scope that Go checks: +the complete `ParseEndpoint` function. The parser reserves imported package +names first, then parameters and fixed local variables, then each command's +flag variables and conversion variables. Templates receive those exact names +and write the selected conversion directly. They do not search generated text, +replace variable names, or decide a conversion from a type name at runtime. + +### Exact and preferred symbols + +An exact symbol is part of an authored or external contract. Two distinct +exact declarations that normalize to the same Go identifier in one package are +rejected before rendering. Examples include two relocated authored types named +`foo-bar` and `foo_bar`, or two explicit external names that both require +`FooBar`. A Goa `OneOf` also owns one exact public family: the union type, its +kind type, branch constants, constructors, and compiler-created branch types. +For a union named `Value` with a `text` branch, these include `Value`, +`ValueKind`, `ValueKindText`, `NewValueText`, and `ValueBranchText`. +HTTP transport copies add the exact body role to the union name: +`ValueRequestBody`, `ValueStreamingBody`, and `ValueResponseBody`. A response +view named `detailed` uses `ValueDetailedResponseBody`. Copies of one authored +union in the same role and view reuse that family. If those copies would emit +different definitions, generation fails and the design must use separate +`OneOf` declarations. + +A preferred symbol is generated from a semantic role. It may receive a stable +numeric suffix when another declaration already owns the preferred spelling. +Examples include a generated `ValidatePayload` or protobuf request message. +The declaration's typed identity—not discovery order—decides which record +receives each spelling. + +Exact declarations reserve first. Preferred declarations are sorted by stable +typed identity and allocated second. A subsystem must reject two distinct +identities whose ordering facts are equal; pointer addresses, expression +hashes, map order, and rendered text are not tie-breakers. + +A companion whose spelling includes another declaration, such as +`Validate`, is registered as a dependent declaration before freeze. +The base and companion may live in different generated packages when both +belong to the same generation run. Goa first chooses every independent name in +every package, then derives and reserves each companion from its base's exact +final name. A declaration from another generation run is rejected. Callers +never rebuild the companion by concatenating a separately resolved type +string. + +### Imports and output paths + +Complete import path is the only import identity. Static-template requirements +have priority over generated-package preferences, which have priority over +design metadata preferences. References and `ImportSpec` values consume the +same frozen binding, while each file imports only the paths it uses. + +Each generated source contribution owns a `GeneratedImportPlan`. Before +freeze, its generator records the runtime packages written directly by +templates, the generated packages visible in type expressions, and any +packages used while generated code reads nested fields. The generated package +chooses one qualifier for each complete path across all of its files. After +freeze, each contribution resolves only its saved paths to those chosen +qualifiers. Contributions that share an output file merge their completed +imports. A renderer must not walk the design again, scan generated source, or +add a broad list and remove unused imports later. + +The output planner canonicalizes both generated import paths and filesystem +paths before collection. If two different package identities normalize to the +same import path or output directory, planning rejects them. It does not let +one package win, merge their declarations, or add a suffix to a directory. +Multiple file contributions may share a canonical path only when they declare +the same package identity. The file merger appends every body section and runs +every file finalizer in contributor order. It rejects conflicting package +headers, import bindings, or keep-existing-file settings instead of choosing a +contributor. + +Planning claims packages with the exact raw path supplied by the owner. The +claim is validated as a legal Go import path and preserves enough information +to reject two distinct raw paths that normalize to one import or output +directory. After freeze, generators use only canonical-path lookup; every +claim, including a repeated claim, is rejected because collection is closed. + +## Expression identity and declaration identity + +Expression identity answers a design question. Declaration identity answers +whether two generated package-level symbols are the same emitted contract. +They are deliberately separate. + +`UserType.Origin()` identifies the first declaration in one family of exact +copies. A generated transport type may start a new family, and renaming a type +deliberately starts another one. Goa uses this identity while binding types, +normalizing designs, planning service and transport declarations, creating +protobuf messages, transforming values, validating fields, building views, +and generating examples. It keeps unrelated same-named types separate, but it +does not by itself prove that two generated declarations have the same fields +or behavior. + +A `GoTypePlan` records the exact attribute copy used at each occurrence. Two +copies may bind to one generated declaration, but their retained child layouts +remain separate so later transforms can find the fields from either copy. The +only child layout shared while planning is an active recursive reference, which +stops a type that refers to itself from expanding forever. + +Plugins that write a named type call `DeclareGeneratedType` to reserve its Go +name, then call `BindGeneratedType` for every user type expression that must +refer to that declaration. The binding follows `UserType.Origin()`, so copies +reuse one name while unrelated equal-shaped types may use different names. Do +not pass a user type to `DeclareName` to express this ownership: that older +lookup compares the type definition and can treat unrelated declarations as +the same type. + +An emitted declaration identity contains every fact that changes its generated +source: owning package and role, source provenance, wire shape, validation, +defaults, views, pointer policy, ordered union branches, and protocol-specific +metadata as applicable. Equal semantic `ID()` values do not merge distinct +origins. Conversely, the same authored origin may produce distinct request and +response records when their emitted contracts differ. + +`expr.Union.Hash()` remains expression identity. `UnionTypeID` describes the Go +and JSON definition emitted for one union occurrence. `UnionDeclarationID` +combines that definition with `AttributeExpr.AuthoredAttribute()`: copies of +one authored `OneOf` share a declaration only while their emitted definitions +also match, while separately authored unions remain separate even when every +branch is equal. Do not change expression hashes, decorate string keys, or add +general expression provenance to coordinate code generation. + +## Example value identity + +Example configuration is immutable. One generation run creates an unanchored +`ExampleGenerator` for each prepared root, and every value draw must first +select a typed `ExampleIdentity`. The public identity constructors accept the +owning evaluated expression: a user type, method payload or result, method +error, HTTP request body, successful HTTP response, or HTTP error. Callers do +not join service names, response positions, or role labels into seed strings. + +Structural descent is also typed. Object members, array elements, map keys, map +values, and union branches use distinct kind-tagged, length-framed segments. +For example, object member `"0"` cannot share a stream with array element zero, +and a result field named `NotFound` cannot share a stream with the method error +named `NotFound`. Length-constrained arrays and maps derive one stream per +element, key, and value instead of consuming a shared stream in traversal +order. + +Named user types own their examples globally. Anonymous request, response, and +streaming shapes retain the explicit method or transport owner passed by their +caller. A zero-value generator intentionally disables OpenAPI examples; every +configured but unanchored value draw panics because it violates the identity +contract. + +## Service plan + +`service.Plan` owns every service and views package declaration for one root. +Its constructor collects service declarations, normalized method wrappers, +relocated authored types, projected view types, unions and their complete +families, endpoints, clients, constructors, validators, conversions, +interceptors, errors, stream types, and package variables. It also collects the +imports and exact output files those declarations require. + +The plan retains one package-backed attributor for each service and views +package. HTTP, gRPC, JSON-RPC, example, and plugins use those attributors and +canonical declaration records. No consumer recreates a service `NameScope`, +calls `NewServicesData`, or reconstructs a name from a DSL spelling and package +alias. + +When multiple prepared roots contribute to one generated package, the core +plan collects all their declarations before the package freezes and emits the +package once. A root not present in the Generation snapshot is rejected. + +## Value conversion plans + +`TransformPlan` records one conversion from a source Go value to a target Go +value. Creating the plan copies both type graphs, finds every recursive helper +function the conversion will call, and records the choices made by hooks that +change the type shape. Later edits to the design types or hook value cannot +change the planned conversion. Two generated copies remain separate even when +they came from the same authored type; only an edge back to the exact copied +value closes a recursive cycle. + +Planning hooks may return a different source or target attribute for one +conversion step, but they must not edit either graph they receive. The planner +checks this immediately after each hook returns, including changes to nested +defaults and metadata, and rejects the plan when a hook mutates its input. +This makes the returned choice explicit without letting one hook change what a +later hook or the renderer sees. + +The caller declares every helper function in the package that will contain it, +binds those declarations and the final source and target type resolvers, and +then renders the conversion. Planning and rendering therefore use the same +function declarations and final package qualifiers. `GoTransformWithAttrs` +keeps its released interface, but now performs these same steps internally and +returns the released helper data after rendering once. + +Each recursive call is a helper occurrence. `Helpers` returns those occurrences +with the requiredness that decides whether the generated caller checks for nil. +Several occurrences can call the same function. `HelperDefinitions` returns one +record for each distinct function body, comparing the complete retained source +and target attributes because hooks may inspect their metadata, defaults, and +validation. Caller requiredness is deliberately not part of a definition. +Each definition also carries a private authored location made from object +fields, array elements, map keys and values, and union branches. Equivalent +calls keep the earliest location, so reordering sibling fields does not change +which colliding declaration keeps its preferred Go name. The location is used +only for ordering and never appears in generated source. +Generators normally declare one name per definition and bind it with +`BindHelperDefinition`; `BindHelperDeclaration` remains available for callers +that manage individual occurrences. A transport may supply +`SameHelperDefinition` only when its complete hook set proves that one retained +difference cannot change the function body. gRPC uses this for protobuf field +numbers: a field number changes the serialized position, not the Go code that +copies the field value. + +When several `TransformPlan` values write helpers to the same Go package, the +caller collects them in one `TransformHelperRegistry` before package names are +final. Each plan is supplied with the source and target `GoTypePlan` values that +describe the actual generated parameter and result types. The caller also +supplies the package name order for each exact helper location. After every plan +has been collected, the registry groups functions only when those Go types, the +conversion rules, and the ordered child function calls are equivalent. It +repeats the child comparison until recursive call graphs stop changing. Each +group returns the complete order and definition from one real helper occurrence; +it never combines the conversion identity from one occurrence with the location +from another. The registry never compares rendered Go source, hashes, or +preferred names. The caller declares one function for each returned group and +binds that declaration to the complete group before rendering. + +Each helper occurrence retains its own authored location. A shared function +definition still keeps the earliest location for stable name ordering, but that +location cannot be used to find the generated type at another call. For +example, identical recursive values reached through a direct field and a map +value may share one function even though their generated layouts must first be +resolved at two different paths. + +`Helpers` and `HelperDefinitions` return detached type descriptions with +plan-owned IDs. A caller +may inspect or change those descriptions while choosing a function declaration, +but cannot change the type graph used for rendering. Rendering also rejects a +hook that changes the retained graph and checks that calls sharing a declaration +produce the same parameter type, result type, and body. When one plan is +rendered again with the same source variable, target variable, and assignment +choice, it returns a copy of the first result instead of calling hooks again. +This lets one conversion plan serve repeated template requests without letting +hook state change previously generated code. + +Most conversions can discover their helper calls from the source and target +types. A custom union renderer that calls helpers itself must also implement +`TransformHooks.PlanUnionHelpers`. That hook records the exact branch pairs for +which the renderer will request helpers. This keeps union rendering +specialized: generated code contains only the branch conversions selected +during generation and performs no runtime lookup by branch name or package. + +Authored defaults use the same final type resolver and pointer rules as value +conversion. `RenderGoValue` writes the exact composite literal, local scalar +declarations, and planned `OneOf` constructor call while the complete design +and generated package names are available. Transform code and HTTP request +constructors both consume that result; they do not format defaults with Go +reflection in the generated program. HTTP command defaults are separate +because they are wire values: HTTP codegen first applies mapped JSON field +names and byte encoding, then the generic command generator serializes that +already selected value. This keeps service Go layout and HTTP JSON layout from +guessing about each other. + +## HTTP and JSON-RPC plans + +Each actual HTTP client or server output package owns a retained wire plan. It +collects complete detached request, response, WebSocket, SSE, error, union, +constructor, validator, codec, and helper declarations before freeze. The plan +keeps request and response policy in declaration identity, so one authored +origin can reuse a record only when the complete emitted wire contract agrees. + +HTTP transforms enter the service and wire owners independently. Detached HTTP +bodies do not carry service package metadata. JSON-RPC consumes the exact HTTP +plan for the files and codecs it shares, then adds its own package declarations +to typed JSON-RPC plans. It does not create a second HTTP catalog. + +Every HTTP and JSON-RPC output file records the service, views, and authored +package paths it will use before package names become final. Linking resolves +only those saved paths to their final qualifiers; it does not inspect design +expressions again or rebuild a service path from its name. Public plan snapshots +return detached nested values, so changing a snapshot cannot change a later +read or rendered file. + +A method with separate ordinary and streamed results plans the streamed SSE +body independently. When the retained service value and HTTP body have the +same Go layout, the client assigns the decoded value directly. When their Go +layouts differ, the client validates the decoded body and uses the exact saved +conversion. An empty streamed result emits its explicit zero-value return. +These choices are made while generating source; the generated client does not +inspect types or select a conversion at runtime. + +Every validator and helper reference stores its canonical declaration. A call +site's traversal context may select which declaration it needs, but it never +selects or changes that declaration's name. + +Reusable API- and service-level HTTP or gRPC error mappings select response +policy by error name. The endpoint method's effective error declaration owns +the service value that encoders and decoders carry. Planning compares pure, +fully finalized copies of the mapping and method attributes, including emitted +type shape, validations, defaults, struct metadata, and ordered union branches. +It accepts equivalent declarations and binds the mapping to the method record; +it rejects incompatible shadowing before rendering without mutating the +evaluated design. + +## Protobuf and gRPC plans + +The protobuf plan owns a descriptor model for each emitted `.proto` package and +the corresponding Go package produced by the supported `protoc` and +`protoc-gen-go` toolchain. Protobuf source declarations and protoc-generated Go +declarations are different, explicit families. + +A declaration family records every package-level Go symbol Goa refers to, +including messages, nested messages, enums and enum values, oneof interfaces +and wrapper structs, service interfaces, client and server types, and version- +dependent support symbols. Preferred protobuf names do not become identity. +Field numbers, ordered fields and oneof branches, validation, defaults, source +provenance, and endpoint role are identity facts where they change output. + +The protoc Go naming algorithm is selected by an explicit supported toolchain +version. One versioned implementation derives Go names for a descriptor family; +templates and transforms do not carry scattered approximations of protoc +CamelCase or oneof naming. Changing the supported compiler or plugin version +requires a new versioned naming contract and generated-module proof against the +real toolchain. + +gRPC validators and conversions consume frozen descriptor-family records. +Validator identity is independent of the call site that first discovers it, +and conversion contexts cannot allocate a message, wrapper, or validator name. +Explicit metadata remains a detached native primitive wire contract and uses +the canonical service transform after parsing or before serialization. + +## Plugins + +Preparation is the only plugin phase allowed to mutate expression roots. A +plugin that adds a service, method, type, or transport mapping attaches it to a +registered root during preparation. Core normalization then observes it before +planning. + +After adding services and any new user types to that root, the preparation +plugin calls `(*expr.RootExpr).EvaluateAttachedServices`. This checks that the +new expressions belong to the same root, prepares and validates all of them, +and finishes none of them when any one is invalid. It is the public operation +for adding evaluated services; plugins must not run individual expression +steps or use the package-global root. + +Factory plugin planning receives `*generator.Plan`. It may declare plugin-owned +output through the same Generation, and it consumes core declarations through +the exact retained service plan. Factory plugin rendering receives the same +plan after names are final. It may add files and sections, but it cannot create +another root, re-run service or transport analysis, reserve a name, or change +an expression. Released callbacks keep their original arguments and do not +gain a planning phase. + +An HTTP plugin calls `Plan.HTTP(root)` with the exact prepared service root it +received. The method returns the ordinary HTTP plan for that root. It returns +false for a different root value and for a root that only has JSON-RPC methods. +During `Plugin.Plan`, the plugin may declare an exported server handler wrapper +for an exact HTTP service, an unexported handler wrapper for one exact HTTP +endpoint, or an extra server mount for an exact HTTP service in that plan. Goa +submits those function names to the service's generated server package, so +collisions are settled with Goa's own names before source is written. + +A declared handler wrapper has the shape `func(http.Handler) http.Handler`. +Goa writes direct nested calls inside each exported endpoint and file mount +helper. Direct callers of those helpers therefore receive the same wrapping as +callers of the service's `Mount` function. Endpoint handlers, file handlers, +and redirects defined by the design are covered, and the first declared +wrapper is the outermost. The service's `Mount` function passes each handler to +its helper unchanged, so wrappers run exactly once. +The linked HTTP service data retains that exact declaration list and copies it +into each generated endpoint and file mount helper, so plugin output can match +the declaration by pointer even for a service that contains only files. +An endpoint handler wrapper also has the shape +`func(http.Handler) http.Handler`, but Goa writes it only in that endpoint's +exported mount helper. Service wrappers surround endpoint wrappers. File mount +helpers receive service wrappers only. This lets a plugin add behavior to one +designed endpoint without changing another endpoint, a file route, or an extra +plugin mount. +An extra server mount has the shape `func(goahttp.Muxer)` and supplies the +method label, HTTP verb, and path that Goa adds to the generated server's +`Mounts` list. Goa calls extra mounts after routes from the design, in +declaration order. Extra mounts are separate calls and are not passed through +the declared handler wrappers. Both declarations must happen before generation +freezes; later calls, JSON-RPC plans, foreign services, missing route fields, +and changes to the linked declarations are generation errors. + +MCP generation therefore attaches its generated service expressions during +prepare and later consumes `Plan.Service(root)`. Agent tool specifications use +one retained typed specification plan for each output package; public specs and +transport specs are distinct packages with distinct declaration owners. No +plugin coordinates through a process-global map, a latest result, a decorated +hash, `PlanKey`, or render order. + +When a plugin needs a generated payload field, it asks the retained service +plan for `MethodPayloadLayout`. The returned `GoTypePlan` contains the exact Go +field spelling and pointer choice already selected by service generation. For +example, a design field sent as JSON `cursor` may be generated as +`OriginalCursor` because of field metadata; the plugin keeps `cursor` on the +wire and emits `payload.OriginalCursor`. It must not call `Goify`, inspect the +JSON name, or repeat the service pointer rules. + +## File assembly + +`SectionTemplate.Name` labels a section for diagnostics. It is not declaration +identity. Package plans remove identical declaration contributions before they +become sections. The file merger combines imports and appends every non-header +section in producer order, even when diagnostic labels match. Conflicting +declarations remain visible and fail during planning or Go compilation instead +of disappearing silently. + +## Compatibility and operations + +Goa keeps the released callback registration API alongside the retained-plan +API. Both registration styles enter the same generation run, use the same +prepared designs and current file list, and run in the released first, normal, +and last order. The released API still accepts the same name more than once for +one command. Registrations with the same position and name run in registration +order, matching released Goa v3. Factory plugin names remain unique, and Goa rejects +a released registration whose command and name match a factory registration so +the same plugin cannot run twice through two APIs. Goa does not restore the +released functions that ran plugins; the generator remains the only code that +executes them. + +This compatibility is deliberately limited. A released preparation callback +may change a design before Goa chooses names. A released generation callback +may edit ordinary values and remove or reorder files, sections, or list +entries. A nil file entry is ignored for every list size. Released Goa ignored +nil among several files but accidentally panicked when nil was the only file. +Goa's own templates read declarations chosen during planning rather than the +released string copies, so changing only a released name field does not rename +Goa's code. The public strings remain final name snapshots for existing plugin +templates. A plugin may deliberately replace declaration fields, sections, +templates, source, or file finalizers; as in released Goa, that plugin owns the +correctness of the resulting source. The released four-argument callbacks do +not receive the retained plan. Any declaration or reference they add after Goa +has fixed its planned names also remains plugin-owned: Goa preserves that output +for compatibility, but does not choose, reconcile, or check those added names. +Move a plugin to `generator.PluginFactory` and declare its names during +`Plugin.Plan` when Goa must prevent collisions with the rest of the generated +package. + +An ordinary callback error is returned unchanged and stops the run. If the +same callback also changes a prepared design after planning, Goa reports that +forbidden change instead. The changed root remains visible after the failed +run, so hiding it behind the callback error could corrupt a later run. + +A plugin that adds package declarations or chooses Go names must use a +`generator.PluginFactory` and declare that work through `Plugin.Plan` before +names become final. Rebuilding Goa's private service or transport records is +not supported; plugins should render their own typed values instead. Upgrade +those plugins with Goa, then regenerate the whole generated tree from an empty +output directory. + +A gRPC plugin that renders viewed-result conversions must use +`ResponseData.ServerConverts` and `ClientConverts`, or +`StreamData.SendConverts` and `RecvConverts`, as appropriate. The singular +conversion fields remain available for existing templates, but they describe +the default or fixed view only. Reusing one singular conversion for every view +can read a field that the selected view deliberately omitted. + +The remaining breaks are limited to generation and generated source except +where the runtime changes are listed below. There is no persisted-data +migration or staged generator mode. Already compiled programs do not start +using the new generator merely because the Goa module is updated. + +### Generator library migration + +The following table lists the preserved, removed, or signature-changed +exported APIs in this change. “No direct replacement” means callers must stop +performing that step; the run or the owning retained plan now performs it once. + +| Package | Old API | New API or required change | +| --- | --- | --- | +| `codegen` and `codegen/generator` | `codegen.RegisterPlugin`, `RegisterPluginFirst`, and `RegisterPluginLast` accepted prepare and generate callbacks | These four-argument functions remain available. Goa runs their callbacks in the same run as plugins registered through `codegen/generator`. Registration now panics for an empty name, an unknown command, a nil generate callback, or a call made after generation has started. Use the factory API when a plugin must plan new package declarations or read service and transport plans from the current run. | +| `codegen` | `PrepareFunc`, `GenerateFunc`, `RunPluginsPrepare`, and `RunPlugins` | `PrepareFunc` and `GenerateFunc` remain available for registration. The two run functions have no replacement because `generator.Generate` owns the complete plugin lifecycle. | +| `codegen/generator` | `Genfunc`, the replaceable `Generators` variable, and the exported `Example`, `Service`, `Transport`, and `OpenAPI` functions | These released entry points remain available. Built-in functions selected through `Generators` join the command's shared planning pass. Other `Genfunc` values run after Goa has fixed every generated name. Calling a built-in function directly creates and runs its own complete plan without registered plugins. Use a plugin factory when added output must declare package names or inspect the command's service and transport plans. | +| `codegen` | `NormalizeRoot` | No direct replacement. `codegen.NewGeneration` performs normalization once and records the exact generated method types. | +| `codegen` | `AddServiceMetaTypeImports` | No direct replacement. The owning service or transport plan records the imports used by each output file. | +| `codegen` | `NewAttributeContextForConversion` | No direct replacement. Build and bind a `TransformPlan`; its source and target contexts retain the correct package owners. | +| `codegen` | Custom `Attributor` implementations needed only `Scoper`, `Name`, `Ref`, and `Field` | Implement `Package`, `Enter`, `IsSumType`, and `ValidatorCall` too. These methods make package ownership and exact validator calls explicit. | +| `codegen` | `AttributeContext.DefaultPkg` and `SamePackageConversion` | Removed. Package lookup belongs to the `Attributor`; enter the source or target attribute instead of setting a default package or a same-package mode. `ArrayElementPointer` is new and is only for a wire array that must distinguish JSON `null` from a primitive zero value. | +| `codegen` | `TransformHooks.HelperNameAttrs` | Removed. Bind each recursive helper through `TransformPlan.Helpers` and `BindHelperDeclaration`; do not derive a helper name from a second attribute walk. A custom `TransformUnion` that calls `TransformHelperName` must also implement `PlanUnionHelpers` so planning can declare those functions before rendering. | +| `codegen` | `WrapDirective.InitTypeName` | Set `WrapDirective.Target` to the wrapper attribute. Rendering resolves its already planned Go type name. | +| `codegen` | `TransformFunctionData` contained only `Name`, parameter and result references, and code | It now also identifies the planned helper with `ID` and `Declaration`. `Name` remains as a deprecated copy of `Declaration.Name()` for every rendered helper. Unkeyed struct literals must be updated. | +| `codegen` | `TransformPlan.Helpers` and `BindHelperDeclaration` | These methods remain available for individual helper calls. New generators should use `HelperDefinitions` and `BindHelperDefinition` so required and optional calls to the same conversion share one strict function. | +| `codegen` | `GoTransformWithAttrs` returned a separate nil-tolerant helper for some optional calls | The signature is unchanged, but returned helpers are strict: optional callers check for nil before calling them, and equivalent required and optional calls may return one shared helper instead of two. Plugins that call a returned helper directly must perform the same nil check at the call site. | +| `codegen/cli` | `BuildCommandData(data)`, `EndpointParserFile(..., data, parseSection)`, `UsageCommands(data)`, `UsageExamples(data)`, and `FlagsCode(data)` | These released signatures and section data remain available. Goa's HTTP and gRPC planners call private planned variants that carry final import, declaration, and local-variable names. | +| `codegen/cli` | `BuildFunctionData.Name`, `ActualParams`, and `FormalParams` | These fields remain and contain the final generated name and parameter lists. New planning code may also read `BuildFunctionData.Declaration`. | +| `codegen/cli` | `NewFlagData` accepted a type name; `FieldLoadCode` accepted type and validation source strings; `FlagArgData.TypeName` and `Validate`; positional `FlagData` literals | The released functions and fields remain available and keep their string-based behavior. Goa's transport planners use one opaque `FlagPlan`, created by `NewFlagPlan`, so validation is written against the exact parsed value without rewriting generated text. Supplying both `Validate` and `Plan` is rejected. Use named fields when constructing `FlagData` because it now contains private planning state. | +| `codegen/example` | Global `Servers`, `ServersData`, `ServersData.Get`, and `APIPkg` | No direct replacement. Create `example.Plan` with `example.NewPlan`, then get its copied `example.Root`. Package names come from the associated service plan. The pure `RootPath(genpkg)` helper remains available. | +| `codegen/example` | `CLIFiles(genpkg, root)` and `ServerFiles(genpkg, root, services)` | Call `CLIFiles(root)` and `ServerFiles(root, services)` with the `*example.Root` returned by the retained example plan. | +| `codegen/example` | `VariableData.VarName`; `HandlerArg.Endpoint` and `Service` contained generated local variable names | `VariableData.VarName` is removed. `HandlerArg.Service` contains the design service name, `Endpoint` reports whether the endpoint collection is needed, and `Variable` contains the final local variable name after the example plan is linked. `Data` also reports whether the server uses HTTP or JSON-RPC. | +| `codegen/service` | `NewServicesData` | Create one `codegen.Generation`, then call `service.NewPlans` for the complete root batch. Use `service.NewPlan` only when the generation has exactly one service root. After freeze and `Link`, use `Plan.Services`. | +| `codegen/service` | `ClientFile`, `EndpointFile`, `ConvertFiles`, `InterceptorsFiles`, and `ViewsFile`; `Files(genpkg, service, services, userTypePkgs)` | No direct per-file replacement. Call `service.Files(plans...)` after every supplied plan is linked and handle its `([]*codegen.File, error)` result. The plans decide the complete file set. | +| `codegen/service` | `SetUserTypeImports`, `AddServiceDataMetaTypeImports`, and `AddUserTypeImports` | No direct replacement. Imports are retained per file by the service plan. | +| `codegen/service` | `ExampleServiceFiles(genpkg, root, services)` and `ExampleInterceptorsFiles(genpkg, root, services)` | Pass the linked `*service.Plan` to `ExampleServiceFiles(plan)` or `ExampleInterceptorsFiles(plan)`. | +| `codegen/service` | Public render-data fields `Data.UserTypeImports`; `MethodData.IsJSONRPC`, `IsJSONRPCSSE`, and `IsJSONRPCWebSocket`; `EndpointMethodData.IsJSONRPC`, `IsJSONRPCSSE`, and `IsJSONRPCWebSocket`; and `StreamData.SendAndCloseName`, `SendAndCloseDesc`, `SendAndCloseWithContextName`, and `SendAndCloseWithContextDesc` | Removed. Factory plugins inspect the HTTP or JSON-RPC plan's endpoint data. Released service-template plugins must update templates that read these fields. There is no JSON-RPC WebSocket replacement because design validation rejects that transport combination. `EndpointsData.VarName`, `ClientVarName`, and `ServiceVarName` and `EndpointMethodData.ClientVarName` and `ServiceVarName` remain as deprecated copies of their final declarations. `Data.ViewsPkg` and `ProjectedTypeData.ViewsPkg` remain available when Goa generates a views package. `ErrorInitData.Name`, `InitData.Name`, and `ValidateData.Name` also remain as deprecated copies of real planned declarations. A custom error has no generated service constructor, so both its constructor declaration and compatibility name are empty. `ValidateData` contains function calls now, so it cannot be compared with `==` or used as a map key. | +| `codegen/service` interceptor section data | Interceptor wrapper sections exposed `map[string]any` values with keys such as `Method`, `Service`, and `Interceptors` | The sections now use private typed data built for the exact method and call kind. Plugins that replace only a section's source must update to the current section contract; plugins cannot type-assert the old map. This lets generated accessors use exact payload, result, and stream types without runtime method checks. | +| Generated service interceptors | An interceptor method accepted `info *NameInfo`, where `NameInfo` was an exported struct with private fields | The method accepts `info NameInfo`, where `NameInfo` is an interface with the same public accessor methods. Update handwritten interceptor signatures by removing `*`. Goa now writes a private implementation for each service method and call kind, so payload and stream accessors use the exact generated types without inspecting the method at runtime. | +| `expr` and `dsl` | `APIExpr.ExampleGenerator`; `dsl.Randomizer(expr.Randomizer)`; `NewRandom` | Store an immutable `APIExpr.RandomizerFactory`. Pass `NewFakerRandomizerFactory` or `NewDeterministicRandomizerFactory` to the DSL. For direct example generation, call `NewExampleGenerator(factory).At(identity)`. The standalone `NewFakerRandomizer` and `NewDeterministicRandomizer` constructors remain available. | +| `expr` | Exported concrete `FakerRandomizer` and `DeterministicRandomizer`; the embedded `ExampleGenerator.Randomizer`; `ExampleGenerator.Derived`, `Rebased`, `Field`, `PreviouslySeen`, and `HaveSeen` | The two standalone randomizer types and their released constructors remain available. Generation uses `RandomizerFactory` so every typed example identity receives a fresh value sequence. The embedded stream and recursion methods are removed; select a public typed `ExampleIdentity`, then descend with `Member`, `ArrayElement`, `MapKey`, `MapValue`, or `UnionMember`. | +| `expr` | Custom implementations of `UserType` | Add `Origin() UserType`. A copy returns the first declaration in its current family. An independently created or intentionally renamed type returns itself. Goa uses this identity to recognize copies without treating unrelated types with the same name as one type. | +| `expr` | Repeated calls to `(*AttributeExpr).Validate` in one process could skip errors reported by an earlier call | Every call now validates the supplied expression and reports its errors. Direct users must not rely on a previous call hiding a later validation failure. | +| `expr` | A non-pointer `ResultTypeExpr` value satisfied `UserType` through its embedded `*UserTypeExpr` | Use `*ResultTypeExpr`. Renaming a result must also clear the result's stored copy origin, so `Rename` now belongs to the pointer and a value no longer satisfies `UserType`. Ordinary result expressions were already created and passed as pointers. | +| `expr` | Preparation plugins added services and called expression steps themselves | After adding the services and any new user types to their owning root, call `(*RootExpr).EvaluateAttachedServices`. It prepares and validates the complete added set before finishing any of it. | +| `expr` | Positional struct literals for `ResultTypeExpr`, `SchemeExpr`, `ServiceExpr`, and `UserTypeExpr` | Use literals with named fields or the package constructors. These structs now contain private identity fields, so code outside `expr` cannot initialize every field by position. | +| `expr` | `UnionToObject` | No direct replacement. HTTP and gRPC plans retain their own wire representation for a union. | +| `codegen` | Comparing `TransformAttrs` values or using them as map keys | `TransformAttrs` now stores maps and function-planning state, so it is no longer comparable. Pass pointers or compare the specific public fields that matter to the caller. Positional literals also need to become named-field literals. | +| `grpc/codegen` | `NewServicesData(serviceData)` | Create `grpc/codegen.Plan` values with `NewPlans`. `ServicesData.GRPCServices` and `ServicesData.Get` remain available, but callers should read the instance built by the plan instead of rebuilding gRPC analysis. | +| `grpc/codegen` | `ClientFiles(genpkg, data)`, `ClientCLIFiles(genpkg, data)`, `ProtoFiles(genpkg, data)`, `ServerFiles(genpkg, data)`, `ServerTypeFiles(genpkg, data)`, and `ClientTypeFiles(genpkg, data)` | These released signatures remain available. They render the files already recorded by `data` and panic when `genpkg` differs from `data.GenPkg()`. New plugin code should prefer the corresponding linked `Plan` methods. | +| `grpc/codegen` | `ExampleCLIFiles` and `ExampleServerFiles` | Create an `ExamplePlan` with `NewExamplePlan`; call its `CLIFiles` and `ServerFiles` methods. | +| `grpc/codegen` | `EndpointData.ClientMethodName`, `MetadataData.Map`, `MetadataData.MapStringSlice`, and `ValidationData.Name` | All four remain as deprecated copies of final generated data. Valid designs now reject map-shaped gRPC metadata, so `Map` and `MapStringSlice` are false after successful generation. `InitArgData` and `MetadataData` now contain a validation function, so they cannot be compared with `==` or used as map keys. | +| gRPC generation tools | `protoc-gen-go` and `protoc-gen-go-grpc` were discovered by `protoc`; the Makefile installed their latest releases | Install `protoc-gen-go v1.36.12` and `protoc-gen-go-grpc 1.6.2`. Goa resolves those programs before planning and rejects another reported version or an attempt to replace them through `Meta("protoc:cmd")`. The exact pair is the tool contract currently covered by generated-module tests; version text alone is not a general proof that another binary would produce different or compatible declarations. | +| `http/codegen` | `NewServicesData` and `NewJSONRPCServicesData` | Create HTTP plans with `NewPlans` or JSON-RPC HTTP plans with `NewJSONRPCPlans`. Both require the exact `*service.Plan` for the root. | +| `http/codegen` | `ClientFiles`, `ClientEncodeDecodeFile`, `ClientCLIFiles`, `ServerFiles`, `ServerEncodeDecodeFile`, `ServerTypeFiles`, `ClientTypeFiles`, `PathFiles`, and `WebsocketClientFile` | These released signatures remain available. They return files already built by the retained plan and reject a generated package argument that differs from the supplied `ServicesData`. New plugin code should prefer the linked HTTP plan methods. | +| `http/codegen` | `ExampleCLI`, `ExampleCLIFiles`, `ExampleServer`, and `ExampleServerFiles` | Create an HTTP `ExamplePlan` and call its `CLIFiles`, `ServerFiles`, or `CombinedServerFiles` methods. | +| `http/codegen` | `OpenAPIFiles(root)` | Call `NewOpenAPIPlan(root, exampleGenerator)`, then `Files`. | +| `http/codegen` | `CreateHTTPServices` testing helper | No direct replacement. Tests must construct, freeze, and link service and HTTP plans like production. | +| `http/codegen` | `SSEData.DataFieldTypeRef`; `ServiceData.ServerTypeNames`, `ClientTypeNames`, and `UnionTypes` | `DataFieldTypeRef` remains as a deprecated copy of `SSEData.Data.TypeRef` for an explicitly mapped data field. The three service-wide type lists are removed; read the generated type declarations supplied by the linked HTTP plan. `AttributeData` now contains a validation function, so it cannot be compared with `==` or used as a map key. | +| `jsonrpc/codegen` | `ClientFiles`, `ServerFiles`, and `ExampleServerFiles` | Create and link a JSON-RPC plan; call its `ClientFiles` and `ServerFiles` methods. Use `NewExamplePlan` for example files. | +| `jsonrpc/codegen` | `CreateJSONRPCServices` testing helper | Use `CreateJSONRPCPlan` when a test needs the linked production plan. | +| `jsonrpc` | `IDToString(id any) string` | Call `IDToString(id any) (string, error)` and handle the error. The function accepts decoded string and `json.Number` IDs and returns an error for every other Go type instead of silently returning an empty string. | +| `jsonrpc` | Positional literals for `RawRequest` and `RawResponse` | Use named fields. `RawRequest` adds `Invalid` and `HasMethod`; `RawResponse` adds `Invalid`, `HasResult`, `HasError`, and `HasID`. Generated clients and servers use these fields to distinguish a missing JSON member from a member whose value is empty or null. Existing named-field literals continue to compile. | +| `jsonrpc` | WebSocket `StreamConfig`, `StreamConfigOption`, `StreamErrorType`, `StreamErrorHandler`, `StreamErrorConnection`, `StreamErrorProtocol`, `StreamErrorParsing`, `StreamErrorOrphaned`, `StreamErrorTimeout`, `StreamErrorNotification`, `NewStreamConfig`, `WithRequestTimeout`, `WithConnectionTimeout`, `WithCloseTimeout`, `WithResultChannelBuffer`, `WithWebSocketBuffers`, `WithRetryConfig`, `WithCompression`, `WithPingInterval`, `WithErrorHandler`, and `(*StreamConfig).Validate` | No replacement. JSON-RPC WebSocket generation was removed. JSON-RPC supports unary HTTP calls and server streams through explicit server-sent events. | +| `codegen/service` | Unkeyed `UnionTypeData` and `UnionFieldData` literals; public union name strings | Use named fields. `UnionTypeData` adds `TypeDeclaration` and `KindDeclaration`. Each `UnionFieldData` adds `KindDeclaration` and `ConstructorDeclaration` for its generated constant and constructor. `FieldName` is the public branch spelling used by accessors; `StorageName` is the private struct field that stores the selected value. The released `Name`, `KindName`, and `KindConst` strings remain final snapshots for existing plugin templates. `Constructor` is a new final-name snapshot alongside the new declaration fields. | +| `http/codegen/openapi` | Process-global `Definitions`; `APISchema`, `GenerateServiceDefinition`, `ResultTypeRef`, `ResultTypeRefWithPrefix`, `TypeRef`, `TypeRefWithPrefix`, `GenerateResultTypeDefinition`, `GenerateTypeDefinition`, `GenerateTypeDefinitionWithName`, `TypeSchema`, `TypeSchemaWithPrefix`, `AttributeTypeSchema`, and `AttributeTypeSchemaWithPrefix` | The global definition cache and its mutating helpers have no replacement. For OpenAPI 2 attribute schemas, use `v2.BuildAttributeSchema(api, attribute, exampleGenerator)`; otherwise build a complete v2 or v3 document so definitions remain local to that build. | +| `http/codegen/openapi/v2` | `NewV2(root, host)` and `Files(root, path)` | These released signatures remain available and use the evaluated design's randomizer factory. Call `NewV2WithValues` or `FilesWithValues` when supplying translated values or a specific example generator. | +| `http/codegen/openapi/v3` | `New(root, version)` and `Files(root, version, path)` | These released signatures remain available and use the evaluated design's randomizer factory. Call `NewWithValues` or `FilesWithValues` when supplying translated values or a specific example generator. | +| `codegen`, `codegen/cli`, `codegen/example`, `codegen/service`, `expr`, `grpc/codegen`, and `http/codegen` | Positional literals for changed render-data structs | Use named fields or, preferably, the owning plan constructor. The exact affected types are listed below. Some now contain private state and cannot be fully constructed outside their package. | + +The following exported structs gained or replaced fields, so an unkeyed literal +that compiled with Goa v3 must be changed to named fields: + +- `codegen`: `AttributeContext`, `TransformAttrs`, `TransformFunctionData`, + `WrapDirective`. +- `codegen/cli`: `BuildFunctionData`, `CommandData`, `FlagArgData`, `FlagData`, + `InterceptorData`, `SubcommandData`. +- `codegen/example`: `Data`, `HandlerArg`. +- `codegen/service`: `EndpointMethodData`, `EndpointsData`, `ErrorInitData`, + `InitData`, `InterceptorData`, `MethodData`, `MethodInterceptorData`, + `ProjectedTypeData`, `ServicesData`, `StreamInterceptorData`, `UnionFieldData`, + `UnionTypeData`, `UserTypeData`, `ValidateData`, `ViewData`, and + `ViewedResultTypeData`. `UnionTypeData` adds `TypeDeclaration` and + `KindDeclaration`; `UnionFieldData` adds + `KindDeclaration`, `ConstructorDeclaration`, and `StorageName`. +- `expr`: `APIExpr`, `HTTPResponseExpr`, `ResultTypeExpr`, `SchemeExpr`, + `ServiceExpr`, and `UserTypeExpr`. +- `grpc/codegen`: `EndpointData`, `InitArgData`, `InitData`, + `LegacyDecodeData`, `MetadataData`, `RequestData`, `ResponseData`, + `ServiceData`, `ServicesData`, `StreamData`, and `ValidationData`. +- `http/codegen`: `AttributeData`, `CookieData`, `Element`, `EndpointData`, + `FileServerData`, `HeaderData`, `InitArgData`, `InitData`, + `MultipartData`, `ParamData`, `PayloadData`, `ResponseData`, `ServiceData`, + `ServicesData`, `SSEData`, `TypeData`, and `WebSocketData`. +- `jsonrpc`: `RawRequest` and `RawResponse`. + +`TransformAttrs`, `codegen/cli.FlagArgData`, `service.ValidateData`, +`grpc/codegen.InitArgData`, `grpc/codegen.MetadataData`, +`grpc/codegen.StreamData`, and `http/codegen.AttributeData` now contain maps, +slices, or functions. They can no longer be compared with `==` or used as map +keys. + +Several exported template-data structures now carry `*codegen.NameDeclaration` +records so every use reads the exact name chosen during planning. Goa keeps +public name fields when they can be copied from one real declaration after +names are final. This includes HTTP names, service constructors and validators, +CLI payload builders, and gRPC client methods and validators. For HTTP data, +plugins may edit ordinary render values and remove or reorder entries. +Goa's templates read the declaration field for each generated name rather than +its released string copy. A planning plugin may create a simple template value +for its own declaration, such as an HTTP constructor or gRPC constructor or +validator. The returned file must be in the generated package that reserved +that declaration. + +The preserved HTTP name fields are `ServiceData.ServerStruct`, +`MountPointStruct`, `ServerInit`, `MountServer`, and `ClientStruct`; +`EndpointData.MountHandler`, `HandlerInit`, `RequestDecoder`, +`ResponseEncoder`, `ErrorEncoder`, `ClientStruct`, `RequestEncoder`, +`ResponseDecoder`, and `BuildStreamPayload`; `MultipartData.FuncName` and +`InitName`; `SSEData.StructName`; `FileServerData.MountHandler`; +`WebSocketData.VarName`; `InitData.Name`; and `TypeData.VarName`, +`ValidatorName`, and `NestedValidatorName`. Existing templates that read these +fields continue to work. Copied JSON-RPC service data also keeps +`ServerStruct`, `ServerInit`, `MountServer`, and `ClientStruct`. Copied +JSON-RPC endpoint data keeps `HandlerInit`, `ClientStruct`, `RequestEncoder`, +`RequestDecoder`, and `ResponseDecoder`. Each string contains the final name +from its declaration. A plugin that creates a new package-level declaration +must use the factory API and declare it during `Plugin.Plan`. +Typed service, endpoint, and file values copied from Goa keep the wrappers and +extra mounts saved with them. Plugins may still remove the files or sections +that render those values. +Plugins should use their own template values for plugin-owned types rather than +constructing Goa transport data. + +HTTP package definitions and uses read the same planned declarations. This +includes WebSocket stream types, request builders and conversion functions, +body types, and their public and nested validators. The released `VarName`, +`Name`, `ValidatorName`, and `NestedValidatorName` strings mirror those +declarations, but Goa's templates do not use those copies as declaration +identity. A primitive or inline composite Go type such as `string` or +`[]string` has no package declaration; +the plan records that complete type expression instead. JSON-RPC receives a +copy of the same body declaration when one exists. Request builders and body +conversion functions are declared before names freeze, including constructors +for inline request bodies, so another generator claiming the preferred name +changes both the generated definition and every call. + +HTTP client command sections also keep the released `MultipartFuncName` and +`BuildStreamPayload` strings. They copy the matching declarations after names +are final, while Goa's templates read the declaration records directly. + +gRPC preserves the same name snapshots. `ServiceData.ServerStruct`, +`ClientStruct`, `ServerInit`, and `ClientInit`; `EndpointData.ServerStruct`, +`ClientStruct`, `ClientBuild`, `ClientEncode`, `ClientDecode`, `ServerHandler`, +`ServerDecode`, and `ServerEncode`; `StreamData.VarName`; and +`LegacyDecodeData.FuncName` each contain a final planned name. Goa's own gRPC +templates read the declaration saved for each role. Plugin templates may +continue to read the public snapshots, and plugin-owned source remains the +plugin's responsibility. + +Other affected data includes `service.EndpointsData`, `EndpointMethodData`, +`ErrorInitData`, view and interceptor data; gRPC service, method, request, +response, and transform data; and CLI parser and payload-builder data. Call +`Name()` only after the generation is frozen. Existing unkeyed literals for +changed exported data must become keyed literals or, preferably, be replaced +with the owning plan constructor. `NameScope.Unique` and a previously unseen +`HashedUnique` call now panic after that scope freezes. Use `NameScope.Fork` +only for private render-local helpers; package declarations must be collected +through their `GeneratedPackage`. + +### Protobuf tools + +Every gRPC generation run now requires these exact programs on `PATH`: + +```text +protoc-gen-go v1.36.12 +protoc-gen-go-grpc 1.6.2 +``` + +Install them with: + +```sh +go install google.golang.org/protobuf/cmd/protoc-gen-go@v1.36.12 +go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@v1.6.2 +``` + +Goa resolves each program to an absolute path and verifies its `--version` +output before planning protobuf names. `Meta("protoc:cmd", ...)` may still +select the protobuf compiler or add compiler arguments, but it may not replace +either required Go plugin with `--plugin`. A missing program, another version, +or a plugin override now stops generation before files are written. + +### Regenerated application source + +Regenerate all Goa-owned files together. Do not copy a subset of a new `gen` +tree over an old one: generated declarations and their callers use the same +frozen name records and are not designed to compile across generations. +`goa example` deliberately keeps existing starter files, so separately update +handwritten starter code and any application code that imports generated +transport packages. + +Generated command starters now run an endpoint and write its result instead of +returning `(goa.Endpoint, any, error)` to their caller. The private `doHTTP`, +`doGRPC`, and `doJSONRPC` functions accept a context and output writer, print +unary or streamed values, and return endpoint, stream receive, output, and +connection-close errors. Regenerate or update the whole command directory +together; an existing kept `main.go` cannot call a newly generated private +transport function with the old signature. HTTP and gRPC input-only or +bidirectional stream commands now return a clear unsupported-input error before +parsing an endpoint or opening a connection. + +Generated gRPC command flags for complete protobuf messages now decode +protobuf JSON instead of decoding the generated Go struct with the standard Go +JSON package. Scripts must use field names and value spellings accepted by +protobuf JSON; the generated example shows one accepted spelling. This also +gives enums, wrapper values, and protobuf aliases their defined JSON behavior. +Primitive command flag names and accepted text do not change. The exported +`BuildPayload` helper does change for a flag without an authored +default: its string argument becomes `*string`, where nil means the flag was +omitted and a pointer to an empty string means the caller supplied the empty or +zero value. Handwritten callers must pass pointers and handle required values +explicitly; Goa no longer inserts the text `REQUIRED` for an omitted required +flag. + +An error declared at API level is now a reusable definition, not an error that +every service and method returns. A service or method selects that definition +with a name-only `Error("busy")`; the reference keeps its type, validation, +defaults, description, and `Temporary`, `Timeout`, and `Fault` settings. A call +that supplies any additional error argument defines a separate local error. +Add an explicit name-only reference to every service or method that previously +relied on finding an API error without selecting it. + +An HTTP method that defines both an ordinary `Result` and a `StreamingResult` +uses the ordinary result for a normal HTTP response and the stream for its SSE +response. A regenerated example service now returns that ordinary result from +its method and uses the default result view when the service owns view +selection. Existing kept service starters for such a method must be updated; +regenerating `gen` alone does not rewrite them. + +Most generated declarations that have one clear released name keep that name, +including HTTP request and response types, validators, body constructors, +handler constructors, and mount functions. Names still change when preserving +the old spelling would preserve a false conflict or require two declarations +for one generated operation. A service method named `FooEndpoint` becomes +`Foo` when `Foo` no longer exists in that package. When two gRPC methods perform +the same conversion, their two method-based constructors become one constructor +named from the source and target types. Real name collisions receive stable +numeric suffixes instead of suffixes determined by discovery order. These are +Go source breaks for handwritten code that names the affected declarations. +They do not change the wire format by themselves. + +A relocated service type written to a package selected with `struct:pkg:path` +now uses the lowercase final path segment as its Go package clause. For +example, a path ending in `APIKeyService` now declares `package +apikeyservice`. The older mixed-case spelling could differ from the package +name used by the generated import. Handwritten imports that rely on the old +implicit qualifier must use the new lowercase name or add an explicit import +alias. Generated imports already carry the planned qualifier, and the file +path and exported type names do not change. + +Union declarations requested in another generated package now live in that +package's `unions.go` file. Their public family uses the exact `OneOf` name; Goa +never adds a numeric suffix to resolve a collision. Separately authored unions +that ask for the same family name now fail generation, even when their branches +are equal. Give each declaration a distinct `TypeName`. Compiler-created branch +types now use `Branch`, such as `ValueBranchText`, instead of the +old `` spelling. Authored branch type names remain unchanged. +Plugins and scripts that select generated files by filename must stop assuming +a union is written beside the service that first used it. + +HTTP union names now state which wire body owns them. A `Value` union becomes +`ValueRequestBody` in an ordinary request, `ValueStreamingBody` in a streaming +request, `ValueResponseBody` in a response, and `ValueDetailedResponseBody` in +the `detailed` response view. Handwritten transport code and plugins that name +the old union declaration must use the new exact name after regeneration. + +Generated union branch fields are now private so code cannot leave stale values +in branches that are no longer selected. Handwritten code must replace reads +such as `u.Text` with `u.AsText()` and writes such as `u.Text = value` with +`u.SetText(value)` or `NewValueText(value)`. Plugin templates use `FieldName` +for the public branch spelling and `StorageName` only when rendering the private +field inside the union implementation. JSON and protobuf data are unchanged. + +Generated interceptor implementations must also change their method +signatures. An argument such as `*LoggingInfo` is now the read-only +`LoggingInfo` interface. Goa supplies a private implementation for the exact +service method and for an endpoint call, stream send, or stream receive. +Handwritten interceptors should accept the interface and continue calling its +accessor methods. These known values are no longer stored in a public struct at +runtime. + +A handwritten multipart request decoder now fills the generated HTTP request +body instead of the service payload. For example, +`func(*multipart.Reader, **service.UploadPayload) error` becomes +`func(*multipart.Reader, *UploadRequestBody) error`. Goa validates that body +and then builds the service payload, just as it does for JSON requests. Array +and map bodies use pointers to the complete generated body value. Regenerate +first, then update each handwritten decoder to its new generated parameter +type. The multipart bytes on the network do not change. + +Some exported helpers disappear when Goa can prove that they do no work. For +example, Goa no longer writes an empty `ValidateUserTypeView` function. Code +that called an empty generated validator must remove that call. JSON-RPC +server-stream methods no longer receive a unary `DecodeResponse` +function because their generated endpoint returns a client stream and never +calls that decoder. Generated gRPC conversion constructors may also be renamed +or combined when several methods perform the same conversion. Handwritten code +should normally enter through the generated client, server, or endpoint +constructors instead of calling transport conversion helpers directly. + +Generated gRPC response encoders with mapped metadata now read the actual +result variable and use code specialized for the metadata type. Some older +combinations generated references to nonexistent `res` or `p` variables and +did not compile. Scalar text remains equivalent, but bytes now use their string +contents instead of Go's slice display: `[]byte{65, 66}` changes from +`"[65 66]"` to `"AB"`. Floating-point values use the exact width selected by +the design. A metadata consumer that compared the old text must update. For a +fixed-view result, generated gRPC encoders and decoders use the view selected in +the design instead of trusting a runtime `goa-view` metadata value. Valid peers +already send the designed view and keep the same result body. + +For caller-selected gRPC result views, Goa now generates one protobuf +conversion for each view. The server writes only the fields in the selected +view, and the client uses the matching constructor instead of applying the +default-view constructor to every response. A dynamic gRPC server stream sends +the selected view in its initial `goa-view` metadata before its first message; +the generated client reads that value before decoding the first message. The +protobuf schema does not change. Regenerate both sides of a dynamic viewed +stream together because an older server does not send this metadata and an +older client always decodes the default view. Regenerate both sides of any +viewed method whose selected view omits default-view fields: older generated +conversions can read an omitted field and fail. Fixed-view methods otherwise +keep their existing view choice. + +One generated command-line flag changes only for a design that used `domain` +for both a server variable and a URL variable. The URL variable is now +`-url-domain`; the old starter registered `-domain` twice and did not run. + +Generated examples also change because each example now uses the exact design +declaration that owns it rather than a value consumed earlier in a shared +random stream. Design-authored example inputs are unchanged, but generated +OpenAPI documents, CLI examples, array lengths, and decoding-error examples may +change because Goa now uses the authored value for that declaration. + +OpenAPI 3 documents may place generated examples under `examples.default` +instead of the single `example` field. Byte examples use base64 text in both +JSON and YAML documents. OpenAPI 2 security requirements without scopes use an +empty array instead of JSON `null`. OpenAPI 3.2 server-sent-event schemas mark +the event data field as required only when the selected stream field is +required. Snapshot consumers should regenerate and review the documents; the +service's accepted requests and returned results do not change from these +documentation-only differences. + +OpenAPI server variables now include the descriptions written in the design. +Reusable viewed-result schemas use the designed result type name in their +description instead of the generated HTTP response-body type name. These are +documentation text changes only. + +When examples are disabled, OpenAPI generation now omits examples written in +the design as well as examples computed by Goa. Security definitions used only +by excluded services or methods are also omitted. Each server variable now +uses only its own allowed values; an older document could accidentally copy +allowed values from the preceding variable. These changes affect generated +documents, not the service wire format. + +When several methods use one error type, the shared OpenAPI schema now keeps +the reusable type's description. Each OpenAPI 3 operation keeps the description +written for that method's error response. This changes generated API +documentation only; it does not change response data or status codes. + +A viewed-result constructor now returns a nonnil value carrying an unknown +requested view. Generated boundary validation can therefore report the precise +invalid view instead of receiving nil or panicking first. Valid view values and +their projected bodies are unchanged. + +Generated gRPC starters now print the designed method names directly instead of +asking the running gRPC server which methods it registered. Goa-designed +methods keep correct startup logs. A plugin that registers additional gRPC +methods at runtime must print its own log lines; those methods are not part of +Goa's generated service plan. + +A normalized output-path collision, conflicting same-file package or import, +conflicting keep-existing-file setting, or path that is absolute, escapes the +generation root, contains a backslash, or differs only by filesystem case now +fails during planning. Previously, one file could overwrite another or the +merge could produce invalid Go. Same-path contributions now keep every body +section even when two sections have the same diagnostic label, and every file +finalizer runs in contributor order. A plugin that relied on a duplicate label +to suppress output or on only the first finalizer running must remove that +assumption. + +### Designs that now stop generation + +These checks reject designs that Goa cannot generate faithfully. They run +before files are written, so they do not require a staged rollout or data +migration. Fix the design, then regenerate the complete generated tree. + +| Design | What now fails | How to migrate | +| --- | --- | --- | +| Authored default values | Defaults on declared types, result types, and errors are now checked through the complete nested value. Generation fails when a default has the wrong Go type, omits a required nested field, selects an unknown `OneOf` branch, or violates a nested length, range, enum, pattern, or format rule. Arrays, maps, and their elements and keys are checked too. | Change the default so it is a valid value of the designed type. If the rejected value is intentional, change the type or its validation rule instead of relying on Goa to emit an invalid default. | +| Relocated authored types | A declared type moved with `Meta("struct:pkg:path", ...)` now fails when it refers to another declared type that has no explicit generated package. Without that information, Goa cannot safely import the dependency and may create an import cycle. Compiler-created nested types remain with their owner and do not need this metadata. | Give each referenced declared type its own `struct:pkg:path`, usually the same path when the types belong together, or stop relocating the outer type. | +| Ordinary HTTP and JSON-RPC route overlap | One server cannot mount an ordinary HTTP route and a JSON-RPC route with the same HTTP method and matching path pattern. Parameter names do not make routes different: `POST /tasks/{taskID}` and `POST /tasks/{id}` accept the same requests. Released generation could let one handler replace the other. | Change one path, or mount the services on different servers. Keep JSON-RPC routes on `POST`. | +| JSON-RPC route, method, and request ID rules | Every JSON-RPC route must use `POST`, and a JSON-RPC method name cannot start with the reserved `rpc.` prefix. A request ID must be at most one direct string field in the payload. It cannot also appear in `params`, an HTTP parameter, header, cookie, or `Last-Event-ID`, and results and errors cannot define another request ID. When an optional ID has no default, its validation rules must accept the UUID that the generated client creates. | Change the route to `POST` and rename a method that starts with `rpc.`. Keep one direct string ID field or let Goa create the ID. Remove duplicate transport mappings. If an optional ID has rules that reject a UUID, make it required, give it a valid default, or change those rules. | +| JSON-RPC notifications | `Notification()` is only valid for a one-way method. The method may have a payload, but it cannot define a request ID, result, streaming payload, or streaming result. | Remove the response and stream from the notification, or remove `Notification()` and use an ordinary JSON-RPC request with a non-null ID. | +| Multipart requests | `MultipartRequest()` now requires a non-empty request `Body`. A multipart decoder fills that generated body before Goa builds the service payload, so there is no valid decoder contract without one. | Add an explicit `Body` mapping for the multipart fields, or remove `MultipartRequest()` and use the ordinary request decoder. | +| Server-sent-event field mappings | `SSEEventData` cannot select one field from a viewed streaming result because that would omit the view name needed to decode the value. When `SSERequestID` is used, its field cannot also have a `Header` mapping, and `Last-Event-ID` cannot map a different field because the server-sent-event transport uses that header for the selected request ID. | Remove `SSEEventData` so the complete viewed result is sent. Use only `SSERequestID` for `Last-Event-ID`, and remove any competing header mapping. | +| Other designs Goa cannot represent safely | Generation rejects duplicate `ConvertTo` or `CreateFrom` mappings for the same Goa and external type, non-primitive gRPC metadata, streaming HTTP success fields mapped to headers or cookies, JSON-RPC success or error fields mapped to HTTP response headers or cookies, and inherited HTTP or gRPC error mappings whose actual method error has a different type, validation, default, or metadata. A service and its methods also cannot reuse one standard error name with different settings or value definitions. gRPC and JSON-RPC reject a method with both `Result` and `StreamingResult`. Ordinary HTTP allows both only with `ServerSentEvents()`. JSON-RPC rejects client and bidirectional streams and requires `ServerSentEvents()` for a server stream. | Remove the duplicate mapping or unsupported transport mapping. Give different errors distinct names. Split methods that have two result contracts, and select a streaming form supported by the transport. JSON-RPC response values must stay inside `result` or `error.data`; request headers and cookies remain valid. | + +### Wire and runtime compatibility + +| Change | Mixed old and new programs | Rollback effect | +| --- | --- | --- | +| Required Goa `OneOf` validation | Generated service validators and HTTP and gRPC boundaries now reject a union with no selected branch. They also reject a selected message, bytes, or `Any` wrapper whose branch value is nil. The protobuf encoding is unchanged. Valid branches, including a selected empty message, work across versions. | Rolling back re-allows invalid union values; it requires no data migration. | +| Required singular protobuf primitive presence | Goa now writes `optional` on singular protobuf booleans, numbers, strings, enums, bytes, and their aliases so a validator can distinguish omission from an explicit zero or empty value. Generated `pb.go` fields for booleans, numbers, strings, enums, and their aliases become pointers. Bytes and bytes aliases remain `[]byte`; `Any` and other message fields remain pointers. Goa service types do not change, and protobuf field numbers and binary tags stay compatible. An old client works with a new server when it sends an explicit nonzero or nonempty value, but old generated code cannot mark a required zero or empty value as present, so the new server rejects that omission. A new client can send an explicit zero or empty value to an old server. Regenerate handwritten protobuf struct literals and replace direct scalar field reads with the generated accessors where needed. | Rolling back accepts omitted required zero and empty values again. Protobuf data needs no migration. | +| Protobuf defaults | While converting protobuf input into a service value, Goa applies an authored default only when the protobuf field is absent. Explicit zero values, empty bytes, and protobuf null remain explicit values. Conversion in the other direction never adds a default; it writes exactly what the service returned, including zero, nil, empty arrays, and empty maps. Released conversions could replace those explicit values with defaults. | No schema or stored-data migration is required. Callers that depended on explicit zero, empty, null, or nil values being silently replaced must use omission on input or return the desired default from service code. Rolling back restores the accidental replacement. | +| `ArrayOfRequired` for primitive values and primitive aliases in JSON bodies | Valid JSON is unchanged. Incoming JSON representations use pointer elements: server request bodies and client response bodies use values such as `[]*string`, then convert them to service value slices such as `[]string`. Outgoing client request bodies and server response bodies remain value slices. Incoming `[null]` is now rejected. Handwritten code that constructs incoming transport body values must supply pointers. gRPC repeated scalar fields and service arrays remain value slices. | Rolling back accepts `null` again; it requires no data migration. | +| Exclusive maximum validation | Generated primitive validators now reject values equal to or above an exclusive maximum. Older validators accidentally repeated the exclusive minimum check and could accept those values. | Rolling back accepts values that violate the designed maximum; it requires no data migration. | +| Command payload builders | Primitive command flag names and their text format stay the same. In generated Go, a `BuildPayload` string parameter for a flag without an authored default becomes `*string`. Nil means the caller omitted the flag; a pointer to `""` means the caller explicitly supplied the empty or zero value. Omitting a required value now returns an error instead of putting the text `REQUIRED` in the payload. | Update handwritten calls to pass a string pointer or nil. Scripts that invoke the generated command keep the same flag spelling. Rolling back restores the old string parameter and placeholder behavior. | +| Optional fields selected as request bodies | Generated HTTP clients omit the body when the selected service field is absent and keep an explicitly empty or zero value. Servers treat an empty HTTP body and JSON `null` as absence, while preserving path, query, header, and cookie fields decoded from the same request. When the selected field has a default, absence applies that default; explicit zero, empty array, and empty map values remain explicit. A defaulted scalar service field is a value and is always sent by a direct generated client. A nil defaulted collection is omitted and the server applies the default. Generated command clients use valid JSON text for collection defaults. JSON-RPC clients omit `params` for an absent direct object, map, array, or union, but keep present empty containers and selected empty-message union branches. Optional primitive JSON-RPC parameters remain positional: `[null]` means absent and an explicit zero remains present. Generated payload constructors for optional scalar bodies now take a pointer, and generated command builders use the same presence-aware constructor. | Regenerate clients, servers, and command packages together. Update handwritten calls to generated payload constructors for optional scalar bodies to pass a pointer or nil. Custom JSON-RPC peers must omit direct `params` for absence and must not send `params: null`; positional primitive `[null]` remains valid. Before this change, an absent selected body became the service zero value instead of an authored default, command clients formatted collection defaults as invalid JSON, and current development clients could emit invalid pointer operations for defaulted scalar aliases. Rolling back restores those behaviors. | +| Caller-selected JSON-RPC result views | A successful response is `{ "jsonrpc": "2.0", "id": ..., "result": { "view": "detailed", "body": ... } }`. The envelope is the method value inside JSON-RPC's standard `result` member. It is generated only when the caller chooses among views. The old result was the projected body alone; unary HTTP responses carried the view in the `goa-view` header, while stream messages had no reliable place for it. An old client and new server, or a new client and old server, are not compatible for these methods. Results without views and results whose view is fixed in the design keep their old body shape. The envelope is used consistently for unary and server-sent-event results. A configured response decoder now receives an HTTP response with status 200 instead of the previous zero status. JSON-RPC response fields must stay in `result` or `error.data`; design validation rejects response `Header` and `Cookie` mappings because one HTTP response may carry several JSON-RPC messages. | Deploy or roll back every client and server for a caller-selected view together. There is no dual decoder. A custom response decoder that inspected the synthetic status must accept 200. Move any JSON-RPC response header or cookie field into the result or error body before regenerating. Request headers and cookies are unchanged. This generic Goa envelope is valid JSON-RPC, but a method that belongs to another protocol layered on JSON-RPC must still use that protocol's required result schema. | +| Designed JSON-RPC errors | A designed service error uses its authored JSON-RPC code and keeps its generated message. API-level mappings now keep the code written in the JSON-RPC DSL; released generation could accidentally replace it with the API HTTP error status. The DSL accepts the five standard protocol codes and the server-error range from -32099 through -32000, but rejects other application codes in the reserved range from -32768 through -32000. The `error.data` value is now `{ "name": "busy", "body": ... }`, where `name` is the error name in the Goa design and `body` is the exact response body declared by `Body`. The name lets generated clients distinguish two errors that intentionally use the same code and prevents a protocol failure such as Internal Error from being mistaken for a designed error. A standard Parse Error or Invalid Request may use a null ID and remains a raw JSON-RPC error. If its code, name, and body instead identify a designed method error, a null ID is rejected as an invalid response because a designed endpoint error must repeat the request ID. Generated unary and server-sent-event clients also return the original JSON-RPC error when the data object is missing, malformed, names an error not allowed for that code, or uses an unknown code. Released servers put the raw service error in `data` and did not apply the planned response body conversion. | Regenerate generated clients and servers together. Custom clients must read the error name and decode the nested `body` instead of decoding `error.data` directly as the service error body. Custom servers must write the same object. Method- and service-level codes and all messages stay the same. An API-level mapping may now use its authored JSON-RPC code instead of the accidental HTTP status, so check custom peers that depended on that bug. No data migration is required. Rolling back requires rolling back both generated peers because released clients do not understand the new data object. | +| HTTP server-sent events | Event-write and flush failures are now returned instead of ignored, and clients decode retry values into the exact optional integer type. Clients ignore an empty retry field or one containing anything except ASCII digits; another valid retry field in the same event still applies. Clients remove an optional UTF-8 byte-order marker only from the first stream line. A valid event ID remains selected until another valid ID replaces it, an empty ID clears it, and an ID containing a NUL byte is ignored. Primitive and primitive-alias data use raw SSE text; an optional nil primitive omits the data line, while a present empty string writes an empty data line. The old optional-pointer server accidentally wrote JSON strings or `null`, so a new client reads the JSON quotes or `null` as part of the raw value; a new server remains readable by the old raw-text client. OpenAPI 3.2 now marks optional mapped data as optional and describes primitive data as raw text rather than JSON. For viewed results, the server writes one `goa-view` header, chooses the default when empty, rejects an unknown view before writing, and rejects changing the view after the first event. Generated servers reject IDs containing a NUL byte or line break and event names containing a line break because those values cannot be written safely. | Regenerate both sides for a stream with optional primitive data. Regenerate an OpenAPI 3.2 document if tooling relies on the event item schema. Other non-viewed event data keeps its shape, but code may now observe write errors and retry values. Do not mix versions for a variable-view stream. Invalid or partly written streams now fail instead of being accepted or followed by a second HTTP error response. | +| Viewed HTTP streams | HTTP SSE and WebSocket servers now reject an unknown requested view before encoding an event instead of writing a nil or `null` body. Valid fixed and selected views keep their existing body shape. | A new server can reject an invalid view that an old server accepted. No valid request needs a coordinated rollout. | +| Caller-selected views on HTTP client streams | Goa now rejects an HTTP WebSocket method that receives a `StreamingPayload` and also lets the caller choose the view of its `Result` or `StreamingResult`. This includes client-streaming and bidirectional methods. The request stream has no single request value from which the transport can select one response view. | Set a fixed `View` in the design before regenerating. Existing methods that already use a fixed view, and server-only streams where one request can select the view, are unchanged. Rolling back accepts the ambiguous design again. | +| Empty HTTP WebSocket streams | When a service returns successfully before sending or receiving a value, the generated server now upgrades the connection, sends a normal close frame, and closes it once. Released code returned success without completing the WebSocket handshake. Repeated `Close` calls on the generated server stream return the first result. The public stream methods and data frames do not change. | Clients observe a clean empty stream instead of a failed or hanging upgrade. No coordinated rollout or data migration is required. | +| JSON-RPC server-sent-event lifecycle | Each server `Send` accepts the streaming result directly and writes one JSON-RPC notification. Streaming methods are ordinary methods: the opening request requires a non-null ID, and the transport rejects a missing or null ID with Invalid Request before decoding parameters or calling the service. When the service method returns, the transport writes one terminal response: `result: null` for success or a JSON-RPC error for a returned error. Client `Recv(ctx)` becomes `Recv()` plus `RecvWithContext(ctx)`. Stream constructors return an interface that also implements the service client stream. After notifications, the client returns `io.EOF` or the terminal error. An optional primitive selected with `SSEEventData` writes `[null]` when nil and keeps explicit zero values. The client treats omitted params and `[null]` as absent and applies an authored default when present in the design. Empty retry fields and retry fields containing anything except ASCII digits are ignored, while a valid field in the same event still applies. The client now rejects an unknown server-sent-event name or a notification for another JSON-RPC method instead of silently skipping it. Body read and close failures are returned instead of discarded. Unary request reads and batch response delimiter writes now report failures through the server error handler. When an error code, designed-error name, or data object does not identify an allowed designed method error, generated clients return the original `*jsonrpc.RawErrorResponse` with its code, message, and data unchanged. Generated servers reject IDs containing a NUL byte or line break, event names containing a line break, and retry values that cannot be written as ASCII digits. | Generated service implementations must use the standard typed `Send`, `SendWithContext`, and `Close` methods. Client callers must use the new receive methods or service stream interface. The old JSON-RPC-only `StreamEvent`, `SendAndClose`, `SendError`, request ID, marker event, concrete client stream, and WebSocket service APIs are removed. Regenerate clients and servers together. Custom peers must send a non-null ID when opening a stream and must stop sending unknown event names or notifications for another method. Valid generated Goa clients already satisfy these rules. | +| JSON-RPC request and response rules | The method declaration now decides whether a call is a request or notification. An ordinary method requires a non-null `id`; a method declared with `Notification()` requires the `id` member to be absent. A mismatch receives Invalid Request before parameters are decoded or the service is called. Valid declared notifications run without producing a response. Empty string IDs and exact string or numeric IDs remain valid for ordinary methods. An invalid request object receives Invalid Request with `id: null`, including over server-sent events. A success with no value includes `"result": null`. Leading JSON whitespace no longer changes an array into a single request, `[]` returns one Invalid Request response, and valid and invalid batch members are handled independently. A batch cannot start a server stream: a valid streaming request receives Method Not Found with “Method is not available in a batch request.” In a server that has both ordinary and streaming methods, no `Accept` header or `*/*` permits each method's designed response format. A unary method requires an acceptable JSON media type, a streaming method requires an acceptable server-sent-event media type, and an unacceptable format returns HTTP 406. Media type spelling is case-insensitive and `q=0` rejects that format. Invalid method arguments map to Invalid Params; an undeclared service failure maps to Internal Error. | Regenerate clients and servers together. Custom peers must omit `id` only for methods declared as notifications and must send a non-null ID for every ordinary method, including streams. Valid ordinary requests and valid declared notifications keep their previous results. Clients that expected no error for an invalid object, one Parse Error for a mixed batch, an omitted `result`, an empty body for `[]`, or selection of a format the method cannot return must follow the generated contract and JSON-RPC 2.0 response rules. A custom caller of `RawRequest.UnmarshalJSON` must inspect `Invalid`: structurally invalid JSON-RPC objects and IDs other than strings, numbers, or null now set that field without returning a Go JSON decoding error. No data migration is needed. | +| HTTP and JSON-RPC response bodies | Generated clients close response bodies they fully consume and return read and close failures as decoding errors. When decoding and closing both fail, the returned error preserves both. A successful method that deliberately returns the raw body leaves it open for the caller. | Successful decoded responses are unchanged. Failure handling may now return a nonnil error, or `decoding_error` instead of a raw or request error, where older code discarded or mislabeled a read or close failure. | +| Nested HTTP validation paths | Generated validation errors now keep the complete field and array-index path while entering nested generated types instead of restarting the path at the nested value. | Invalid inputs may produce more precise error field names. Valid inputs and wire data are unchanged. | +| Maps stored in the complete HTTP query string | A payload map now decodes the raw query keys that generated clients send, such as `?a=1&b=2`. Released servers looked for bracketed keys such as `query[a]` even though released generated clients did not write that form. | Generated clients work with the new server without change. Custom clients that worked around the released decoder must send raw keys; otherwise `query[a]` becomes the literal map key. Rolling back restores the mismatched decoder. | +| HTTP float query text | Generated clients now use Go's shortest round-trip text for float32 and float64 query values. Ordinary values are unchanged, while very large or small values may use exponent form, such as `1e+100`, instead of a long decimal expansion. Servers decode the same number. | Systems that sign, cache, or compare the exact URL text must accept the compact form. Rolling back returns to the longer spelling. | +| gRPC metadata text | Generated metadata conversions are specialized for the designed type. Bytes now use their string contents, so `[]byte{65, 66}` is sent as `"AB"` instead of `"[65 66]"`. Floating-point values use the designed width. Other scalar text remains equivalent. | Regenerate both sides when a metadata consumer parses the old byte-slice display or depends on the old floating-point spelling. Rolling back restores the old text. | +| gRPC result views | Unary and streaming clients and servers now use the protobuf conversion and validation rules for the selected view. A client rejects a missing selected field before conversion instead of silently using a zero value or panicking; fields omitted by that view are not required. Dynamic server streams send the view in initial `goa-view` metadata, and dynamic clients require that metadata before decoding the first message. The protobuf schema is unchanged. An old stream server does not send this value, while an old stream client assumes the default view. Older conversions can also fail when a selected view omits a field used by the default conversion. A non-default view validator has a stable name such as `ValidateShowResponseTiny`; the default view keeps `ValidateShowResponse`. Identical top-level validators for one message and view share that name, so obsolete numeric duplicates such as `ValidateShowResponse2` disappear. Handwritten code that calls generated validators must use the validator for its selected view. | Regenerate and deploy both sides of a dynamic viewed gRPC stream together. Also regenerate both sides of any viewed method whose selected view omits default-view fields. Fixed-view methods otherwise keep their wire choice. | +| Nested gRPC validator names | Validators called directly by generated transport code remain exported as `Validate` or `Validate`. Validators used only for nested fields are private. Identical nested checks that receive the same argument and add the same error path share one function. Its ordinary name records the API, service, protobuf message, argument, and error context. If different checks would otherwise receive the same name, Goa also records the method, request or response role, selected view or error, and complete typed field path. Punctuation is written explicitly instead of being collapsed into the same Go spelling. Adding another field therefore does not renumber an existing helper. A root validator never shares a declaration with a nested helper. Validation checks and error paths are unchanged. | Regenerate the complete gRPC client and server packages. Handwritten code must call the exported top-level validator instead of a former nested helper such as `ValidateDetail` or `ValidateArrayOfString`. Plugins that write code into the generated package must use the planned validation declaration rather than rebuilding its name. No protobuf or stored data changes. | +| Protobuf wrappers around repeated and map values | Protobuf does not record whether a repeated or map field was omitted, so Goa no longer invents a missing-field error for the generated wrapper field. A nil wrapper used as a map value decodes as an empty collection instead of panicking. Authored rules still apply: for example, nil and present-empty wrappers both fail an authored minimum length of one, and item rules still check every present item. A wrapper with no authored rule no longer emits an otherwise empty exported validator. Handwritten code that called that unnecessary validator must remove the call. Generated function signatures and protobuf data are otherwise unchanged. | Rolling back can panic while converting a nil map-value wrapper and can reject an empty repeated value as missing. No data migration is needed. | +| Protobuf name collisions | Normal schemas keep their existing field encoding. A design whose protobuf declarations collide may receive stable numeric message, file, or generated Go suffixes instead of invalid source. Descriptor names, and a gRPC method path if its service or method itself needed a suffix, can therefore change for that formerly conflicting design. | Regenerate both sides from the same Goa version. A previously valid, collision-free schema needs no coordinated runtime rollout. | +| API error selection | API-level errors are reusable definitions. They no longer appear automatically on every service and method. A name-only `Error("busy")` on a service or method selects the parent definition and keeps all of its settings; supplying more arguments defines a separate local error. | Designs that relied on implicit API error lookup must add the explicit name-only selection before regenerating. Compiled peers and stored data do not need migration. Rolling back makes the API error visible through implicit lookup again. | + +Fresh factories make repeated and concurrent generation independent. The main +operational risks are an uncollected template symbol, an incomplete emitted +identity, or an inaccurate protoc family name. Focused catalog tests, reversed- +order tests, concurrent-run tests, real generated-module compilation, the +supported protoc toolchain, goa-ai generation, and full downstream regeneration are +the required proof. + +## Review gate + +Before changing generation lifecycle, names, roots, transports, plugins, or +file merging, trace one representative declaration through: + +1. the prepared root; +2. the retained service plan; +3. the selected transport or plugin plan; +4. the owning generated package and `NameDeclaration`; +5. stable collection and freeze; +6. every declaration and reference rendered from that record; +7. plugin contributions; and +8. the final merged file and compiled generated module. + +Also prove a valid counterexample at the next wider lifetime: two declarations +with one semantic ID but different origins, one origin with different request +and response wire contracts, two packages with the same basename, repeated +runs in one process, and concurrent runs. A service-only render test or a +source-text assertion is insufficient when the failure can appear in a +transport, protoc-generated family, plugin, or merged output. diff --git a/codegen/cli/cli.go b/codegen/cli/cli.go index f83ef6b082..3258956d0d 100644 --- a/codegen/cli/cli.go +++ b/codegen/cli/cli.go @@ -19,11 +19,18 @@ import ( type ( // CommandData contains the data needed to render a command. CommandData struct { + // ServiceName is the design service selected by this command. + ServiceName string + // UsageDeclaration is the package function that prints help for this command. + UsageDeclaration *codegen.NameDeclaration // Name of command e.g. "cellar-storage" Name string // VarName is the name of the command variable e.g. // "cellarStorage" VarName string + // FlagSetVar is the exact local variable that stores this command's flags + // in the generated endpoint parser. + FlagSetVar string // Description is the help text. Description string // Subcommands is the list of endpoint commands. @@ -31,8 +38,7 @@ type ( // Example is a valid command invocation, starting with the // command name. Example string - // PkgName is the service HTTP client package import name, - // e.g. "storagec". + // PkgName is the transport client package import name, e.g. "storagec". PkgName string // Interceptors contains the data for client interceptors if any. Interceptors *InterceptorData @@ -40,10 +46,17 @@ type ( // SubcommandData contains the data needed to render a sub-command. SubcommandData struct { + // MethodName is the design method selected by this command. + MethodName string + // UsageDeclaration is the package function that prints help for this subcommand. + UsageDeclaration *codegen.NameDeclaration // Name is the sub-command name e.g. "add" Name string // FullName is the sub-command full name e.g. "storageAdd" FullName string + // FlagSetVar is the exact local variable that stores this method's flags + // in the generated endpoint parser. + FlagSetVar string // Description is the help text. Description string // Flags is the list of flags supported by the subcommand. @@ -53,6 +66,12 @@ type ( // BuildFunction contains the data to generate a payload builder function // if any. Exclusive with Conversion. BuildFunction *BuildFunctionData + // ActualPointerVars lists the parser variables used by plugins that render + // the released BuildFunction call. + ActualPointerVars []string + // ActualArgs lists the exact expressions passed to BuildFunction. It is + // empty for plugins that use the released pointer-variable contract. + ActualArgs []string // Conversion contains the flag value to payload conversion function if // any. Exclusive with BuildFunction. Conversion string @@ -60,14 +79,23 @@ type ( Example string // Interceptors contains the data for client interceptors if any apply to the endpoint method. Interceptors *InterceptorData + // conversionFlag is the parsed flag converted directly into a primitive payload. + conversionFlag *FlagData } // InterceptorData contains the data needed to generate interceptor code. InterceptorData struct { // VarName is the name of the interceptor variable. VarName string + // ParserVar is the exact parameter name used by the generated endpoint parser. + ParserVar string // PkgName is the package name containing the interceptor type. PkgName string + // ClientInterceptorsDeclaration is the exact client interceptor interface. + ClientInterceptorsDeclaration *codegen.NameDeclaration + // ClientEndpointWrapperDeclaration is the exact wrapper applied to one + // client endpoint. It is nil for service-level command data. + ClientEndpointWrapperDeclaration *codegen.NameDeclaration } // FlagData contains the data needed to render a command-line flag. @@ -80,6 +108,17 @@ type ( Type string // FullName is the flag full name e.g. "storageAddVintage" FullName string + // PointerVar is the exact local variable that points to the parsed flag value. + PointerVar string + // HasDefault is true when the design supplies a default, including false, + // zero, or an empty string. + HasDefault bool + // TracksPresence is true when generated code must distinguish an omitted + // flag from a flag whose text is empty. + TracksPresence bool + // DefaultValue is the text passed to the standard flag package when the + // design supplies a default. + DefaultValue string // Description is the flag help text. Description string // Required is true if the flag is required. @@ -88,6 +127,8 @@ type ( Example string // Default returns the default value if any. Default any + // value describes how the flag text becomes a Go value. + value *flagValuePlan } // BuildFunctionData contains the data needed to generate a constructor @@ -103,6 +144,9 @@ type ( // FormalParams is the list of build function formal parameter // names. FormalParams []string + // FormalParamTypes lists the exact parameter types selected for generated + // builders. It is empty for plugins that use the released string contract. + FormalParamTypes []string // ServiceName is the name of the service. ServiceName string // MethodName is the name of the method. @@ -119,6 +163,20 @@ type ( CheckErr bool } + // ParserDeclarations contains every package function written to one command + // parser file. + ParserDeclarations struct { + // ParseEndpoint is the function that selects and builds an endpoint call. + ParseEndpoint *codegen.NameDeclaration + // UsageCommands is the function that lists available commands. + UsageCommands *codegen.NameDeclaration + // UsageExamples is the function that prints example commands. + UsageExamples *codegen.NameDeclaration + // PresenceFlagType is the private type that records whether the user set a + // command-line flag. + PresenceFlagType *codegen.NameDeclaration + } + // FlagArgData describes a payload initialization argument from which a // command-line flag and the code that loads the flag value into the // corresponding payload builder field are generated. @@ -128,8 +186,13 @@ type ( Name string // TypeName is the argument Go type name. TypeName string + // Plan contains the conversion and validation selected by Goa's transport + // generators. Plugins may continue to use TypeName and Validate. + Plan *FlagPlan // TypeRef is the reference to the argument type. TypeRef string + // Pointer reports whether JSON flag decoding must preserve a nil value. + Pointer bool // FieldName is the name of the payload field initialized with the // argument value if any. FieldName string @@ -141,13 +204,33 @@ type ( Example any // DefaultValue is the default value of the argument if any. DefaultValue any - // Validate contains the validation code for the argument value if any. + // Validate contains validation code kept for plugins built against the + // released CLI data. It cannot be used with Plan. + // + // Deprecated: Goa transport generators use Plan so checks receive the + // exact parsed value name. Plugins may continue to use Validate. Validate string // OmitField if true generates the flag without a corresponding payload // builder field. OmitField bool } + // FlagPlan contains the conversion and validation choices made by Goa's + // transport generators. + FlagPlan struct { + value *flagValuePlan + validation func(string) string + } + + // flagValuePlan records how command-line text becomes one generated Go value. + flagValuePlan struct { + kind expr.Kind + typeName string + typeRef string + alias bool + protobufMessage bool + } + // FieldData contains the data needed to generate the code that initializes a // field in the method payload type. FieldData struct { @@ -164,16 +247,20 @@ type ( // PayloadInitData contains the data needed to generate a constructor // function that initializes a service method payload type from the - // command-ling arguments. + // command-line arguments. PayloadInitData struct { // Code is the payload initialization code. Code string - // ReturnTypeAttribute if non-empty returns an attribute in the payload - // type that describes the shape of the method payload. + // ReturnTypeAttribute names the payload field initialized from the body. ReturnTypeAttribute string // ReturnTypeAttributePointer is true if the return type attribute - // generated struct field holds a pointer + // generated struct field holds a pointer. ReturnTypeAttributePointer bool + // ReturnTypeAttributeUnion reports whether the selected payload field is + // a union value. + ReturnTypeAttributeUnion bool + // OptionalBody reports whether the selected body flag may be omitted. + OptionalBody bool // ReturnIsStruct if true indicates that the method payload is an object. ReturnIsStruct bool // ReturnTypeName is the fully-qualified name of the payload. @@ -183,6 +270,29 @@ type ( // Args is the list of arguments for the constructor. Args []*codegen.InitArgData } + + // conversionData describes the Go value produced from command-line flag text. + conversionData struct { + code string + value string + declaresError bool + canError bool + } + + // conversionVariableNames contains local names used while parsing one flag. + conversionVariableNames struct { + error string + parsed string + converted string + } + + // parserFlagsData gives the shared flag template its commands and the fixed + // local names chosen for the surrounding endpoint parser. + parserFlagsData struct { + Commands []*CommandData + Variables *ParserVariablesData + PresenceFlagType string + } ) // BuildCommandData builds the data needed by CLI code generators to render the @@ -196,13 +306,15 @@ func BuildCommandData(data *service.Data) *CommandData { var interceptors *InterceptorData if len(data.ClientInterceptors) > 0 { interceptors = &InterceptorData{ - VarName: codegen.Goify(data.Name, false) + "Inter", - PkgName: data.PkgName, + VarName: codegen.Goify(data.Name, false) + "Inter", + PkgName: data.PkgName, + ClientInterceptorsDeclaration: data.ClientInterceptorsDeclaration, } } return &CommandData{ - Name: codegen.KebabCase(data.Name), + ServiceName: data.Name, + Name: codegen.KebabCase(data.PathName), VarName: codegen.Goify(data.Name, false), Description: description, PkgName: data.PkgName + "c", @@ -221,53 +333,30 @@ func BuildSubcommandData(data *service.Data, m *service.MethodData, buildFunctio description = fmt.Sprintf("Make request to the %q endpoint", m.Name) } - var conversion string + var conversionFlag *FlagData if m.Payload != "" && buildFunction == nil && len(flags) > 0 { - // No build function, just convert the arg to the body type - var convPre, convSuff string - target := "data" - if flagType(m.Payload) == "JSON" { - target = "val" - convPre = fmt.Sprintf("var val %s\n", m.Payload) - convSuff = "\ndata = val" - } - conv, _, check := conversionCode( - "*"+flags[0].FullName+"Flag", - target, - m.Payload, - false, - ) - conversion = convPre + conv + convSuff - if check { - conversion = "var err error\n" + conversion - conversion += "\nif err != nil {\n" - if flagType(m.Payload) == "JSON" { - conversion += fmt.Sprintf(`return nil, nil, fmt.Errorf("invalid JSON for %s, \nerror: %%s, \nexample of valid JSON:\n%%s", err, %q)`, - flags[0].FullName+"Flag", flags[0].Example) - } else { - conversion += fmt.Sprintf(`return nil, nil, fmt.Errorf("invalid value for %s, must be %s")`, - flags[0].FullName+"Flag", flags[0].Type) - } - conversion += "\n}" - } + conversionFlag = flags[0] } var interceptors *InterceptorData if len(m.ClientInterceptors) > 0 { interceptors = &InterceptorData{ - VarName: codegen.Goify(data.Name, false) + "Inter", - PkgName: data.PkgName, + VarName: codegen.Goify(data.Name, false) + "Inter", + PkgName: data.PkgName, + ClientInterceptorsDeclaration: data.ClientInterceptorsDeclaration, + ClientEndpointWrapperDeclaration: m.ClientEndpointWrapperDeclaration, } } sub := &SubcommandData{ - Name: name, - FullName: fullName, - Description: description, - Flags: flags, - MethodVarName: m.VarName, - BuildFunction: buildFunction, - Conversion: conversion, - Interceptors: interceptors, + MethodName: m.Name, + Name: name, + FullName: fullName, + Description: description, + Flags: flags, + MethodVarName: m.VarName, + BuildFunction: buildFunction, + conversionFlag: conversionFlag, + Interceptors: interceptors, } generateExample(sub, data.Name) @@ -282,12 +371,58 @@ func EndpointParserFile( specs []*codegen.ImportSpec, data []*CommandData, parseSection *codegen.SectionTemplate, +) *codegen.File { + return endpointParserFile(path, title, specs, data, parseSection, "", releasedUsageCommandsName, releasedUsageExamplesName) +} + +// EndpointParserFile returns a parser file that uses the function names chosen +// for this parser plan. +func (p *ParserPlan) EndpointParserFile( + path, title string, + specs []*codegen.ImportSpec, + data []*CommandData, + parseSection *codegen.SectionTemplate, +) *codegen.File { + presenceFlagType := "" + if p.Declarations.PresenceFlagType != nil { + presenceFlagType = p.Declarations.PresenceFlagType.Name() + } + return endpointParserFile( + path, + title, + specs, + data, + parseSection, + presenceFlagType, + p.Declarations.UsageCommands.Name, + p.Declarations.UsageExamples.Name, + ) +} + +// endpointParserFile assembles one parser file with the supplied help function +// names. +func endpointParserFile( + path, title string, + specs []*codegen.ImportSpec, + data []*CommandData, + parseSection *codegen.SectionTemplate, + presenceFlagType string, + usageCommandsName, usageExamplesName func() string, ) *codegen.File { sections := make([]*codegen.SectionTemplate, 0, 4+len(data)) + sections = append(sections, codegen.Header(title, "cli", specs)) + if presenceFlagType != "" && hasPresenceFlags(data) { + var declaration bytes.Buffer + if err := presenceFlagSection(presenceFlagType).Write(&declaration); err != nil { + panic(err) + } + plannedParse := *parseSection + plannedParse.Source = declaration.String() + "\n" + parseSection.Source + parseSection = &plannedParse + } sections = append(sections, - codegen.Header(title, "cli", specs), - UsageCommands(data), - UsageExamples(data), + usageCommands(data, usageCommandsName), + usageExamples(data, usageExamplesName), parseSection, ) for _, cmd := range data { @@ -310,26 +445,49 @@ func MakeFlags( pinit *PayloadInitData, ) ([]*FlagData, *BuildFunctionData) { var ( - fdata = make([]*FieldData, 0, len(args)) // preallocate - flags = make([]*FlagData, len(args)) - params = make([]string, len(args)) - check bool + fdata = make([]*FieldData, 0, len(args)) // preallocate + flags = make([]*FlagData, len(args)) + params = make([]string, len(args)) + paramTypes = make([]string, len(args)) + planned = true + check bool ) for i, arg := range args { - f := NewFlagData(svcn, m.Name, arg.Name, arg.TypeName, arg.Description, arg.Required, arg.Example, arg.DefaultValue) + value := (*flagValuePlan)(nil) + validation := func(string) string { + return arg.Validate + } + if arg.Plan == nil { + planned = false + value = legacyFlagValuePlan(arg.TypeName) + } else { + if arg.Validate != "" { + panic("CLI flag validation cannot use both Validate and Plan") + } + value = arg.Plan.value + validation = arg.Plan.validation + } + f := newFlagData(svcn, m.Name, arg.Name, value, arg.Description, arg.Required, arg.Example, arg.DefaultValue) + f.TracksPresence = arg.Plan != nil && !f.HasDefault flags[i] = f params[i] = f.FullName + paramTypes[i] = "string" + if f.TracksPresence { + paramTypes[i] = "*string" + } if arg.OmitField { continue } - code, chek := FieldLoadCode(f, arg.Name, arg.TypeName, arg.Validate, arg.DefaultValue, payload, payloadRef) + code, chek := fieldLoadCode(f, arg.Name, value, validation, arg.DefaultValue, payload, payloadRef) check = check || chek tn := arg.TypeRef - if f.Type == "JSON" { - // We need to declare the variable without - // a pointer to be able to unmarshal the JSON - // using its address. - tn = arg.TypeName + if value.isJSON() { + // JSON decoding uses the planned transport value. Optional + // pointer-shaped bodies keep a pointer so omission remains nil. + tn = value.typeName + if arg.Pointer { + tn = "*" + tn + } } fdata = append(fdata, &FieldData{ Name: arg.Name, @@ -338,17 +496,27 @@ func MakeFlags( Init: code, }) } + if !planned { + usesPresence := false + for _, flag := range flags { + usesPresence = usesPresence || flag.TracksPresence + } + if !usesPresence { + paramTypes = nil + } + } return flags, &BuildFunctionData{ - Name: "Build" + m.VarName + "Payload", - ActualParams: params, - FormalParams: params, - ServiceName: svcn, - MethodName: m.Name, - ResultType: payloadRef, - Fields: fdata, - PayloadInit: pinit, - CheckErr: check, + Name: "Build" + m.VarName + "Payload", + ActualParams: params, + FormalParams: params, + FormalParamTypes: paramTypes, + ServiceName: svcn, + MethodName: m.Name, + ResultType: payloadRef, + Fields: fdata, + PayloadInit: pinit, + CheckErr: check, } } @@ -369,6 +537,12 @@ func PayloadBuildersFile(path, title string, specs []*codegen.ImportSpec, data * // UsageCommands builds a section template that generates a help text showing // the list of allowed commands and sub-commands. func UsageCommands(data []*CommandData) *codegen.SectionTemplate { + return usageCommands(data, releasedUsageCommandsName) +} + +// usageCommands renders command help with the function name chosen for its +// generated package. +func usageCommands(data []*CommandData, name func() string) *codegen.SectionTemplate { usages := make([]string, len(data)) for i, cmd := range data { subs := make([]string, len(cmd.Subcommands)) @@ -383,12 +557,24 @@ func UsageCommands(data []*CommandData) *codegen.SectionTemplate { usages[i] = fmt.Sprintf("%s %s%s%s", cmd.Name, lp, strings.Join(subs, "|"), rp) } - return &codegen.SectionTemplate{Source: cliTemplates.Read(usageCommandsT), Data: usages} + return &codegen.SectionTemplate{ + Source: cliTemplates.Read(usageCommandsT), + Data: usages, + FuncMap: map[string]any{ + "usageName": name, + }, + } } // UsageExamples builds a section template that generates a help text showing // a valid invocation of the CLI tool. func UsageExamples(data []*CommandData) *codegen.SectionTemplate { + return usageExamples(data, releasedUsageExamplesName) +} + +// usageExamples renders example help with the function name chosen for its +// generated package. +func usageExamples(data []*CommandData, name func() string) *codegen.SectionTemplate { var examples []string for i, cmd := range data { if i < 5 { @@ -396,7 +582,25 @@ func UsageExamples(data []*CommandData) *codegen.SectionTemplate { } } - return &codegen.SectionTemplate{Source: cliTemplates.Read(usageExamplesT), Data: examples} + return &codegen.SectionTemplate{ + Source: cliTemplates.Read(usageExamplesT), + Data: examples, + FuncMap: map[string]any{ + "usageName": name, + }, + } +} + +// releasedUsageCommandsName returns the help function name used by the +// released parser helper. +func releasedUsageCommandsName() string { + return "UsageCommands" +} + +// releasedUsageExamplesName returns the example function name used by the +// released parser helper. +func releasedUsageExamplesName() string { + return "UsageExamples" } // FlagsCode returns a string containing the code that parses the command-line @@ -419,6 +623,38 @@ func FlagsCode(data []*CommandData) string { return flagsCode.String() } +// FlagsCode renders flag parsing with the exact local names chosen by this +// parser plan. +func (p *ParserPlan) FlagsCode(data []*CommandData) string { + if p.Variables == nil { + panic("CLI parser variables must be planned before rendering flags") + } + presenceFlagType := "" + usesPresence := hasPresenceFlags(data) + declaredPresence := p.Declarations.PresenceFlagType != nil + if usesPresence != declaredPresence { + panic("CLI flag presence declaration does not match the generated flags") + } + if usesPresence { + presenceFlagType = p.Declarations.PresenceFlagType.Name() + } + section := codegen.SectionTemplate{ + Name: "parse-endpoint-flags", + Source: cliTemplates.Read(parseFlagsPlannedT), + Data: &parserFlagsData{ + Commands: data, + Variables: p.Variables, + PresenceFlagType: presenceFlagType, + }, + FuncMap: map[string]any{"printDescription": printDescription}, + } + var flagsCode bytes.Buffer + if err := section.Write(&flagsCode); err != nil { + panic(err) + } + return flagsCode.String() +} + // CommandUsage builds the section templates that can be used to generate the // endpoint command usage code. func CommandUsage(data *CommandData) *codegen.SectionTemplate { @@ -438,12 +674,103 @@ func PayloadBuilderSection(buildFunction *BuildFunctionData) *codegen.SectionTem Source: cliTemplates.Read(buildPayloadT), Data: buildFunction, FuncMap: map[string]any{ - "fieldCode": fieldCode, + "fieldCode": fieldCode, + "formalParamType": formalParamType, }, } } -// NewFlagData creates a new FlagData from the given argument attributes. +// NewFlagPlan records the conversion and validation selected for one command- +// line flag. typeName is the concrete local type used for JSON values. typeRef +// is the concrete non-pointer reference used for primitive casts. +func NewFlagPlan(attribute *expr.AttributeExpr, typeName, typeRef string, validation func(string) string) *FlagPlan { + return &FlagPlan{ + value: newFlagValuePlan(attribute, typeName, typeRef), + validation: validation, + } +} + +// FlagImportPreferences returns the packages used to convert and validate one +// command-line flag. validation is true only when the generated flag code +// writes validation checks. +func FlagImportPreferences(attribute *expr.AttributeExpr, validation bool) []*codegen.ImportSpec { + value := newFlagValuePlan(attribute, "", "") + var imports []*codegen.ImportSpec + if value.isJSON() { + imports = append(imports, codegen.SimpleImport("encoding/json")) + } else { + switch value.kind { + case expr.BooleanKind, expr.IntKind, expr.Int32Kind, expr.Int64Kind, + expr.UIntKind, expr.UInt32Kind, expr.UInt64Kind, + expr.Float32Kind, expr.Float64Kind: + imports = append(imports, codegen.SimpleImport("strconv")) + } + } + if validation { + for _, preference := range codegen.ValidationRuntimeImports(attribute, codegen.GoLayoutPolicy{}) { + imports = append(imports, codegen.NewImport(preference.Name, preference.Path)) + } + } + return imports +} + +// FlagFieldImportPreferences returns the packages used when a payload builder +// loads one flag into a field. Required flags without defaults also use fmt to +// report omission, and conversions that can fail use fmt to report bad text. +func FlagFieldImportPreferences(attribute *expr.AttributeExpr, validation, required, hasDefault bool) []*codegen.ImportSpec { + imports := FlagImportPreferences(attribute, validation) + value := newFlagValuePlan(attribute, "", "") + if required && !hasDefault || value.isJSON() || flagValueParsesNumber(value) { + imports = append(imports, codegen.SimpleImport("fmt")) + } + return imports +} + +// newFlagValuePlan records how one flag's text becomes its concrete Go value. +func newFlagValuePlan(attribute *expr.AttributeExpr, typeName, typeRef string) *flagValuePlan { + kind := expr.AnyKind + alias := expr.IsAlias(attribute.Type) + if custom, _ := codegen.GetMetaType(attribute); custom == "" && expr.IsPrimitive(attribute.Type) { + dataType := attribute.Type + for { + userType, ok := dataType.(expr.UserType) + if !ok { + break + } + dataType = userType.Attribute().Type + } + kind = dataType.Kind() + } + return &flagValuePlan{ + kind: kind, + typeName: typeName, + typeRef: typeRef, + alias: alias, + } +} + +// flagValueParsesNumber reports whether flag text is parsed by strconv. +func flagValueParsesNumber(value *flagValuePlan) bool { + switch value.kind { + case expr.BooleanKind, expr.IntKind, expr.Int32Kind, expr.Int64Kind, + expr.UIntKind, expr.UInt32Kind, expr.UInt64Kind, + expr.Float32Kind, expr.Float64Kind: + return true + default: + return false + } +} + +// NewProtobufFlagPlan records a command-line flag whose JSON value is a +// protobuf message. The generated code uses protobuf's JSON decoder so the +// accepted field names and values match the message contract. +func NewProtobufFlagPlan(attribute *expr.AttributeExpr, typeName string) *FlagPlan { + plan := NewFlagPlan(attribute, typeName, typeName, nil) + plan.value.protobufMessage = true + return plan +} + +// NewFlagData creates flag data from the released string type description. // // svcn is the service name // en is the endpoint name @@ -453,134 +780,210 @@ func PayloadBuilderSection(buildFunction *BuildFunctionData) *codegen.SectionTem // required determines if the flag is required // example is an example value for the flag func NewFlagData(svcn, en, name, typeName, description string, required bool, example, def any) *FlagData { - ex := jsonExample(example) - fn := goifyTerms(svcn, en, name) - return &FlagData{ - Name: codegen.KebabCase(name), - VarName: codegen.Goify(name, false), - Type: flagType(typeName), - FullName: fn, - Description: description, - Required: required, - Example: ex, - Default: def, - } + return newFlagData(svcn, en, name, legacyFlagValuePlan(typeName), description, required, example, def) +} + +// NewFlagDataForPlan creates flag data from the given conversion and +// validation choices. +func NewFlagDataForPlan(svcn, en, name string, plan *FlagPlan, description string, required bool, example, def any) *FlagData { + flag := newFlagData(svcn, en, name, plan.value, description, required, example, def) + flag.TracksPresence = !flag.HasDefault + return flag } // FieldLoadCode returns the code used in the build payload function that // initializes one of the payload object fields. It returns the initialization // code and a boolean indicating whether the code requires an "err" variable. func FieldLoadCode(f *FlagData, argName, argTypeName, validate string, defaultValue any, payload expr.DataType, payloadRef string) (string, bool) { + var validation func(string) string + if validate != "" { + validation = func(string) string { + return validate + } + } + return fieldLoadCode(f, argName, legacyFlagValuePlan(argTypeName), validation, defaultValue, payload, payloadRef) +} + +// newFlagData creates flag data from the conversion selected during planning. +func newFlagData(svcn, en, name string, value *flagValuePlan, description string, required bool, example, def any) *FlagData { + ex := jsonExample(example) + fn := goifyTerms(svcn, en, name) + hasDefault := def != nil + defaultValue := "" + if hasDefault { + if value.isJSON() { + encoded, err := json.Marshal(def) + if err != nil { + panic(fmt.Sprintf("cannot encode the default for %s.%s.%s: %s", svcn, en, name, err)) // bug + } + defaultValue = string(encoded) + } else { + defaultValue = fmt.Sprint(def) + } + } + return &FlagData{ + Name: codegen.KebabCase(name), + VarName: codegen.Goify(name, false), + Type: value.flagType(), + FullName: fn, + Description: description, + Required: required, + Example: ex, + Default: def, + HasDefault: hasDefault, + DefaultValue: defaultValue, + value: value, + } +} + +// legacyFlagValuePlan reproduces the released string-based flag conversion. +func legacyFlagValuePlan(typeName string) *flagValuePlan { + kind := expr.AnyKind + switch typeName { + case codegen.GoNativeTypeName(expr.Boolean): + kind = expr.BooleanKind + case codegen.GoNativeTypeName(expr.Int): + kind = expr.IntKind + case codegen.GoNativeTypeName(expr.Int32): + kind = expr.Int32Kind + case codegen.GoNativeTypeName(expr.Int64): + kind = expr.Int64Kind + case codegen.GoNativeTypeName(expr.UInt): + kind = expr.UIntKind + case codegen.GoNativeTypeName(expr.UInt32): + kind = expr.UInt32Kind + case codegen.GoNativeTypeName(expr.UInt64): + kind = expr.UInt64Kind + case codegen.GoNativeTypeName(expr.Float32): + kind = expr.Float32Kind + case codegen.GoNativeTypeName(expr.Float64): + kind = expr.Float64Kind + case codegen.GoNativeTypeName(expr.String): + kind = expr.StringKind + case codegen.GoNativeTypeName(expr.Bytes): + kind = expr.BytesKind + } + return &flagValuePlan{ + kind: kind, + typeName: typeName, + typeRef: typeName, + } +} + +// fieldLoadCode writes a field conversion from its complete generation plan. +func fieldLoadCode( + f *FlagData, + argName string, + value *flagValuePlan, + validation func(string) string, + defaultValue any, + payload expr.DataType, + payloadRef string, +) (string, bool) { var ( - code string - declErr bool - startIf string - endIf string + code string + validationTarget string + declErr bool + startIf string + endIf string ) - if !f.Required { + from := f.FullName + if f.TracksPresence { + from = "*" + f.FullName + if f.Required { + zero := "nil" + declareZero := "" + if expr.IsPrimitive(payload) { + zero = "zero" + declareZero = fmt.Sprintf("\tvar zero %s\n", payloadRef) + } + startIf = fmt.Sprintf( + "if %s == nil {\n%s\treturn %s, fmt.Errorf(\"missing required flag --%s\")\n}\n", + f.FullName, + declareZero, + zero, + f.Name, + ) + } else { + startIf = fmt.Sprintf("if %s != nil {\n", f.FullName) + endIf = "\n}" + } + } else if !f.Required && !f.HasDefault { startIf = fmt.Sprintf("if %s != \"\" {\n", f.FullName) endIf = "\n}" } - if argTypeName == codegen.GoNativeTypeName(expr.String) { - ref := "&" - if f.Required || defaultValue != nil { - ref = "" - } - code = argName + " = " + ref + f.FullName - declErr = validate != "" + pointer := value.kind != expr.BytesKind && !value.isJSON() && !f.Required && defaultValue == nil + conversion := conversionData{} + if f.TracksPresence && pointer && value.kind == expr.StringKind && !value.alias { + conversion.code = fmt.Sprintf("%s = %s", argName, f.FullName) + conversion.value = from } else { - var checkErr bool - code, declErr, checkErr = conversionCode(f.FullName, argName, argTypeName, !f.Required && defaultValue == nil) - if checkErr { - code += "\nif err != nil {\n" - nilVal := "nil" - if expr.IsPrimitive(payload) { - code += fmt.Sprintf("var zero %s\n", payloadRef) - nilVal = "zero" - } - if flagType(argTypeName) == "JSON" { - code += fmt.Sprintf(`return %s, fmt.Errorf("invalid JSON for %s, \nerror: %%s, \nexample of valid JSON:\n%%s", err, %q)`, - nilVal, argName, f.Example) - } else { - code += fmt.Sprintf(`return %s, fmt.Errorf("invalid value for %s, must be %s")`, - nilVal, argName, f.Type) - } - code += "\n}" - } + conversion = conversionCode(from, argName, value, pointer, conversionVariableNames{ + error: "err", + parsed: "v", + converted: "val", + }) } - if validate != "" { - nilCheck := "if " + argName + " != nil {" - if strings.HasPrefix(validate, nilCheck) { - // hackety hack... the validation code is generated for the client and needs to - // account for the fact that the field could be nil in this case. We are reusing - // that code to validate a CLI flag which can never be nil. Lint tools complain - // about that so remove the if statements. Ideally we'd have a better way to do - // this but that requires a lot of changes and the added complexity might not be - // worth it. - var lines []string - ls := strings.Split(validate, "\n") - for i := 1; i < len(ls)-1; i++ { - if ls[i+1] == nilCheck { - i++ // skip both closing brace on previous line and check - continue - } - lines = append(lines, ls[i]) - } - validate = strings.Join(lines, "\n") - } - code += "\n" + validate + "\n" + code = conversion.code + validationTarget = conversion.value + declErr = conversion.declaresError + if conversion.canError { + code += "\nif err != nil {\n" nilVal := "nil" if expr.IsPrimitive(payload) { code += fmt.Sprintf("var zero %s\n", payloadRef) nilVal = "zero" } - code += fmt.Sprintf("if err != nil {\n\treturn %s, err\n}", nilVal) + if value.isJSON() { + code += fmt.Sprintf(`return %s, fmt.Errorf("invalid JSON for %s, \nerror: %%s, \nexample of valid JSON:\n%%s", err, %q)`, + nilVal, argName, f.Example) + } else { + code += fmt.Sprintf(`return %s, fmt.Errorf("invalid value for %s, must be %s")`, + nilVal, argName, f.Type) + } + code += "\n}" } - return fmt.Sprintf("%s%s%s", startIf, code, endIf), declErr -} - -// flagType calculates the type of a flag -func flagType(tname string) string { - switch tname { - case boolN, intN, int32N, int64N, uintN, uint32N, uint64N, float32N, float64N, stringN: - return strings.ToUpper(tname) - case bytesN: - return "STRING" - default: // Any, Array, Map, Object, User - return "JSON" + if validation != nil { + validate := validation(validationTarget) + if validate != "" { + declErr = true + code += "\n" + validate + "\n" + nilVal := "nil" + if expr.IsPrimitive(payload) { + code += fmt.Sprintf("var zero %s\n", payloadRef) + nilVal = "zero" + } + code += fmt.Sprintf("if err != nil {\n\treturn %s, err\n}", nilVal) + } } + return fmt.Sprintf("%s%s%s", startIf, code, endIf), declErr } -// jsonExample generates a json example +// jsonExample turns a generated value into the JSON text shown in CLI help and +// invalid-value errors. func jsonExample(v any) string { // In JSON, keys must be a string. But goa allows map keys to be anything. r := reflect.ValueOf(v) - if r.Kind() == reflect.Map { + if r.Kind() == reflect.Map && r.Type().Key().Kind() != reflect.String { keys := r.MapKeys() - if keys[0].Kind() != reflect.String { - a := make(map[string]any, len(keys)) - var kstr string - for _, k := range keys { - switch t := k.Interface().(type) { - case bool: - kstr = strconv.FormatBool(t) - case int32: - kstr = strconv.FormatInt(int64(t), 10) - case int64: - kstr = strconv.FormatInt(t, 10) - case int: - kstr = strconv.Itoa(t) - case float32: - kstr = strconv.FormatFloat(float64(t), 'f', -1, 32) - case float64: - kstr = strconv.FormatFloat(t, 'f', -1, 64) - default: - kstr = k.String() - } - a[kstr] = r.MapIndex(k).Interface() + a := make(map[string]any, len(keys)) + var kstr string + for _, k := range keys { + switch k.Kind() { + case reflect.Bool: + kstr = strconv.FormatBool(k.Bool()) + case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: + kstr = strconv.FormatInt(k.Int(), 10) + case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64, reflect.Uintptr: + kstr = strconv.FormatUint(k.Uint(), 10) + case reflect.Float32, reflect.Float64: + kstr = strconv.FormatFloat(k.Float(), 'f', -1, k.Type().Bits()) + default: + panic(fmt.Sprintf("unsupported CLI example map key kind %s", k.Kind())) } - v = a + a[kstr] = r.MapIndex(k).Interface() } + v = a } b, err := json.MarshalIndent(v, " ", " ") ex := "?" @@ -593,96 +996,221 @@ func jsonExample(v any) string { return ex } -var ( - boolN = codegen.GoNativeTypeName(expr.Boolean) - intN = codegen.GoNativeTypeName(expr.Int) - int32N = codegen.GoNativeTypeName(expr.Int32) - int64N = codegen.GoNativeTypeName(expr.Int64) - uintN = codegen.GoNativeTypeName(expr.UInt) - uint32N = codegen.GoNativeTypeName(expr.UInt32) - uint64N = codegen.GoNativeTypeName(expr.UInt64) - float32N = codegen.GoNativeTypeName(expr.Float32) - float64N = codegen.GoNativeTypeName(expr.Float64) - stringN = codegen.GoNativeTypeName(expr.String) - bytesN = codegen.GoNativeTypeName(expr.Bytes) -) - -// conversionCode produces the code that converts the string contained in the -// variable named from to the value stored in the variable "to" of type -// typeName. The second return value indicates whether the "err" variable must -// be declared prior to the conversion code being rendered. The last return -// value indicates whether the generated code can produce errors (i.e. -// initialize the err variable). -func conversionCode(from, to, typeName string, pointer bool) (string, bool, bool) { - var ( - parse string - cast string - - target = to - needCast = typeName != stringN && typeName != bytesN && flagType(typeName) != "JSON" - declErr = true - checkErr = true - decl = "" - ) - if needCast && pointer { - target = "val" - decl = ":" +// directPayloadConversion writes the primitive payload conversion after the +// parser has chosen the exact flag pointer variable used by this method. +func directPayloadConversion(flag *FlagData, variables *ParserVariablesData) string { + var prefix, suffix string + target := variables.Data + if flag.value.isJSON() { + target = variables.ConvertedValue + prefix = fmt.Sprintf("var %s %s\n", variables.ConvertedValue, flag.value.typeName) + suffix = fmt.Sprintf("\n%s = %s", variables.Data, variables.ConvertedValue) } - switch typeName { - case boolN: - if pointer { - parse = fmt.Sprintf("var %s bool\n", target) - } - parse += fmt.Sprintf("%s, err = strconv.ParseBool(%s)", target, from) - case intN: - parse = fmt.Sprintf("var v int64\nv, err = strconv.ParseInt(%s, 10, strconv.IntSize)", from) - cast = fmt.Sprintf("%s %s= int(v)", target, decl) - case int32N: - parse = fmt.Sprintf("var v int64\nv, err = strconv.ParseInt(%s, 10, 32)", from) - cast = fmt.Sprintf("%s %s= int32(v)", target, decl) - case int64N: - parse = fmt.Sprintf("%s, err %s= strconv.ParseInt(%s, 10, 64)", target, decl, from) - declErr = decl == "" - case uintN: - parse = fmt.Sprintf("var v uint64\nv, err = strconv.ParseUint(%s, 10, strconv.IntSize)", from) - cast = fmt.Sprintf("%s %s= uint(v)", target, decl) - case uint32N: - parse = fmt.Sprintf("var v uint64\nv, err = strconv.ParseUint(%s, 10, 32)", from) - cast = fmt.Sprintf("%s %s= uint32(v)", target, decl) - case uint64N: - parse = fmt.Sprintf("%s, err %s= strconv.ParseUint(%s, 10, 64)", target, decl, from) - declErr = decl == "" - case float32N: - parse = fmt.Sprintf("var v float64\nv, err = strconv.ParseFloat(%s, 32)", from) - cast = fmt.Sprintf("%s %s= float32(v)", target, decl) - case float64N: - parse = fmt.Sprintf("%s, err %s= strconv.ParseFloat(%s, 64)", target, decl, from) - declErr = decl == "" - case stringN: - parse = fmt.Sprintf("%s %s= %s", target, decl, from) - declErr = false - checkErr = false - case bytesN: - parse = fmt.Sprintf("%s %s= []byte(%s)", target, decl, from) - declErr = false - checkErr = false + from := "*" + flag.PointerVar + presenceCheck := "" + if flag.TracksPresence { + from += ".value" + if flag.Required { + presenceCheck = fmt.Sprintf( + "if %s.value == nil {\n\treturn nil, nil, fmt.Errorf(\"missing required flag --%s\")\n}\n", + flag.PointerVar, + flag.Name, + ) + } + } + converted := conversionCode(from, target, flag.value, false, conversionVariableNames{ + error: variables.Error, + parsed: variables.ParsedValue, + converted: variables.ConvertedValue, + }) + code := presenceCheck + prefix + converted.code + suffix + if !converted.canError { + return code + } + code = fmt.Sprintf("var %s error\n%s\nif %s != nil {\n", variables.Error, code, variables.Error) + if flag.value.isJSON() { + code += fmt.Sprintf(`return nil, nil, fmt.Errorf("invalid JSON for %s, \nerror: %%s, \nexample of valid JSON:\n%%s", %s, %q)`, + flag.PointerVar, variables.Error, flag.Example) + } else { + code += fmt.Sprintf(`return nil, nil, fmt.Errorf("invalid value for %s, must be %s")`, + flag.PointerVar, flag.Type) + } + return code + "\n}" +} + +// conversionCode describes the code and concrete Go value produced from the +// flag text in from. The result also reports how the conversion uses err. +func conversionCode(from, to string, value *flagValuePlan, pointer bool, variables conversionVariableNames) conversionData { + var parse string + switch value.kind { + case expr.BooleanKind: + if !value.alias { + return directParsedConversion(to, value.typeRef, fmt.Sprintf("strconv.ParseBool(%s)", from), pointer, variables) + } + parse = fmt.Sprintf("var %s bool\n%s, %s = strconv.ParseBool(%s)", variables.parsed, variables.parsed, variables.error, from) + case expr.IntKind, expr.Int32Kind, expr.Int64Kind: + bits := "64" + if value.kind == expr.IntKind { + bits = "strconv.IntSize" + } else if value.kind == expr.Int32Kind { + bits = "32" + } + if value.kind == expr.Int64Kind && !value.alias { + return directParsedConversion(to, value.typeRef, fmt.Sprintf("strconv.ParseInt(%s, 10, 64)", from), pointer, variables) + } + parse = fmt.Sprintf("var %s int64\n%s, %s = strconv.ParseInt(%s, 10, %s)", variables.parsed, variables.parsed, variables.error, from, bits) + case expr.UIntKind, expr.UInt32Kind, expr.UInt64Kind: + bits := "64" + if value.kind == expr.UIntKind { + bits = "strconv.IntSize" + } else if value.kind == expr.UInt32Kind { + bits = "32" + } + if value.kind == expr.UInt64Kind && !value.alias { + return directParsedConversion(to, value.typeRef, fmt.Sprintf("strconv.ParseUint(%s, 10, 64)", from), pointer, variables) + } + parse = fmt.Sprintf("var %s uint64\n%s, %s = strconv.ParseUint(%s, 10, %s)", variables.parsed, variables.parsed, variables.error, from, bits) + case expr.Float32Kind, expr.Float64Kind: + bits := "64" + if value.kind == expr.Float32Kind { + bits = "32" + } + if value.kind == expr.Float64Kind && !value.alias { + return directParsedConversion(to, value.typeRef, fmt.Sprintf("strconv.ParseFloat(%s, 64)", from), pointer, variables) + } + parse = fmt.Sprintf("var %s float64\n%s, %s = strconv.ParseFloat(%s, %s)", variables.parsed, variables.parsed, variables.error, from, bits) + case expr.StringKind: + converted := from + if value.alias { + converted = fmt.Sprintf("%s(%s)", value.typeRef, from) + } + if pointer && !value.alias { + return conversionData{code: fmt.Sprintf("%s = &%s", to, from), value: from} + } + code, target := assignConvertedValue(to, converted, pointer, variables.converted) + return conversionData{code: code, value: target} + case expr.BytesKind: + converted := fmt.Sprintf("[]byte(%s)", from) + if value.alias { + converted = fmt.Sprintf("%s(%s)", value.typeRef, from) + } + return conversionData{code: fmt.Sprintf("%s = %s", to, converted), value: to} default: - parse = fmt.Sprintf("err = json.Unmarshal([]byte(%s), &%s)", from, target) + if value.protobufMessage { + parse = fmt.Sprintf("%s = protojson.Unmarshal([]byte(%s), &%s)", variables.error, from, to) + } else { + parse = fmt.Sprintf("%s = json.Unmarshal([]byte(%s), &%s)", variables.error, from, to) + } + return conversionData{code: parse, value: to, declaresError: true, canError: true} + } + converted := fmt.Sprintf("%s(%s)", value.typeRef, variables.parsed) + assignment, target := assignConvertedValue(to, converted, pointer, variables.converted) + return conversionData{ + code: parse + "\n" + assignment, + value: target, + declaresError: true, + canError: true, + } +} + +// directParsedConversion writes a parser result directly into its final value +// when the parser already returns the generated Go type. +func directParsedConversion(target, typeRef, parser string, pointer bool, variables conversionVariableNames) conversionData { + if !pointer { + return conversionData{ + code: fmt.Sprintf("%s, %s = %s", target, variables.error, parser), + value: target, + declaresError: true, + canError: true, + } } - if !needCast { - return parse, declErr, checkErr + return conversionData{ + code: fmt.Sprintf("var %s %s\n%s, %s = %s\n%s = &%s", variables.converted, typeRef, variables.converted, variables.error, parser, target, variables.converted), + value: variables.converted, + declaresError: true, + canError: true, } - if cast != "" { - parse = parse + "\n" + cast +} + +// assignConvertedValue writes a converted scalar into its final local and +// returns the concrete value expression used by validation. +func assignConvertedValue(target, converted string, pointer bool, valueVariable string) (string, string) { + if !pointer { + return fmt.Sprintf("%s = %s", target, converted), target } - if to != target { - ref := "" - if pointer { - ref = "&" + return fmt.Sprintf("%s := %s\n%s = &%s", valueVariable, converted, target, valueVariable), valueVariable +} + +// flagType returns the command-line type shown in help and conversion errors. +func (p *flagValuePlan) flagType() string { + switch p.kind { + case expr.BooleanKind: + return "BOOL" + case expr.IntKind: + return "INT" + case expr.Int32Kind: + return "INT32" + case expr.Int64Kind: + return "INT64" + case expr.UIntKind: + return "UINT" + case expr.UInt32Kind: + return "UINT32" + case expr.UInt64Kind: + return "UINT64" + case expr.Float32Kind: + return "FLOAT32" + case expr.Float64Kind: + return "FLOAT64" + case expr.StringKind, expr.BytesKind: + return "STRING" + default: + return "JSON" + } +} + +// isJSON reports whether flag text uses JSON decoding. +func (p *flagValuePlan) isJSON() bool { + return p.kind != expr.BooleanKind && p.kind != expr.IntKind && + p.kind != expr.Int32Kind && p.kind != expr.Int64Kind && + p.kind != expr.UIntKind && p.kind != expr.UInt32Kind && + p.kind != expr.UInt64Kind && p.kind != expr.Float32Kind && + p.kind != expr.Float64Kind && p.kind != expr.StringKind && + p.kind != expr.BytesKind +} + +// hasPresenceFlags reports whether the parser needs to remember if any flag +// was supplied. Defaults do not need this because they always have a value. +func hasPresenceFlags(data []*CommandData) bool { + for _, command := range data { + for _, subcommand := range command.Subcommands { + for _, flag := range subcommand.Flags { + if flag.TracksPresence { + return true + } + } } - parse += fmt.Sprintf("\n%s = %s%s", to, ref, target) } - return parse, declErr, checkErr + return false +} + +// presenceFlagSection writes the private flag type used to keep omitted flags +// distinct from flags whose text is empty. +func presenceFlagSection(typeName string) *codegen.SectionTemplate { + return &codegen.SectionTemplate{ + Name: "cli-presence-flag", + Source: cliTemplates.Read(presenceFlagT), + Data: typeName, + } +} + +// formalParamType returns the planned builder parameter type. Plugin data made +// with the released API has no type list and continues to use string. +func formalParamType(data *BuildFunctionData, index int) string { + if len(data.FormalParamTypes) == 0 { + return "string" + } + return data.FormalParamTypes[index] } // goifyTerms makes valid go identifiers out of the supplied terms @@ -697,10 +1225,19 @@ func goifyTerms(terms ...string) string { return res } +// printDescription indents each line embedded in generated Go code while +// keeping blank lines free of whitespace. func printDescription(desc string) string { - res := strings.ReplaceAll(desc, "`", "`+\"`\"+`") - res = strings.ReplaceAll(res, "\n", "\n\t") - return res + desc = strings.TrimRight(desc, " \t\r\n") + lines := strings.Split(strings.ReplaceAll(desc, "`", "`+\"`\"+`"), "\n") + for i := 1; i < len(lines); i++ { + if strings.TrimSpace(lines[i]) == "" { + lines[i] = "" + continue + } + lines[i] = "\t" + lines[i] + } + return strings.Join(lines, "\n") } func generateExample(sub *SubcommandData, svc string) { diff --git a/codegen/cli/cli_test.go b/codegen/cli/cli_test.go new file mode 100644 index 0000000000..dffd0491f4 --- /dev/null +++ b/codegen/cli/cli_test.go @@ -0,0 +1,804 @@ +// This file verifies command-line payload builders validate the concrete Go +// values produced from flag text. The tests catch regressions where validation +// is generated for a pointer and then edited as source text. +package cli + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/require" + + "goa.design/goa/v3/codegen" + "goa.design/goa/v3/codegen/service" + "goa.design/goa/v3/expr" +) + +// TestParserVariablesKeepCollidingMethodsDistinct catches generated parsers +// that rebuild local variable names from method text. The two command names are +// different, but both become StatusUpdate when converted to a Go identifier. +func TestParserVariablesKeepCollidingMethodsDistinct(t *testing.T) { + generation, err := codegen.NewGeneration("generated.local/gen", nil) + require.NoError(t, err) + pkg, err := generation.ClaimPackage("generated.local/gen/jsonrpc/cli/server") + require.NoError(t, err) + parser, err := DeclareParser(pkg, "jsonrpc", "api", "server", []CommandDeclarationInput{ + {Service: "notifications", Methods: []string{"status_update", "status+update"}}, + }) + require.NoError(t, err) + require.NoError(t, generation.Freeze()) + + const preferred = "notificationsStatusUpdate" + value := NewFlagPlan(&expr.AttributeExpr{Type: expr.String}, "string", "string", nil).value + firstFlag := &FlagData{Name: "body", FullName: preferred + "Body", Type: "STRING", value: value} + secondFlag := &FlagData{Name: "body", FullName: preferred + "Body", Type: "STRING", value: value} + builder := &BuildFunctionData{ + ActualParams: []string{preferred + "Body"}, + FormalParams: []string{preferred + "Body"}, + } + command := &CommandData{ + ServiceName: "notifications", + Name: "notifications", + VarName: "notifications", + UsageDeclaration: parser.Commands["notifications"].Usage, + Subcommands: []*SubcommandData{ + { + MethodName: "status_update", + Name: "status-update", + FullName: preferred, + UsageDeclaration: parser.Commands["notifications"].Methods["status_update"], + Flags: []*FlagData{firstFlag}, + conversionFlag: firstFlag, + }, + { + MethodName: "status+update", + Name: "status+update", + FullName: preferred, + UsageDeclaration: parser.Commands["notifications"].Methods["status+update"], + Flags: []*FlagData{secondFlag}, + BuildFunction: builder, + }, + }, + } + + parser.PlanVariables([]*CommandData{command}, nil) + generated := parser.FlagsCode([]*CommandData{command}) + + require.Equal(t, "notificationsFlags", command.FlagSetVar) + require.Equal(t, preferred+"Flags2", command.Subcommands[0].FlagSetVar) + require.Equal(t, preferred+"Flags", command.Subcommands[1].FlagSetVar) + require.Equal(t, preferred+"BodyFlag2", firstFlag.PointerVar) + require.Equal(t, preferred+"BodyFlag", secondFlag.PointerVar) + require.Equal(t, "data = *"+preferred+"BodyFlag2", command.Subcommands[0].Conversion) + require.Equal(t, []string{preferred + "Body"}, builder.ActualParams) + require.Equal(t, []string{preferred + "BodyFlag"}, command.Subcommands[1].ActualPointerVars) + require.Equal(t, 4, strings.Count(generated, preferred+"Flags2")) + require.Equal(t, 1, strings.Count(generated, preferred+"BodyFlag2 =")) + + reversedPlus := &FlagData{Name: "body", FullName: preferred + "Body"} + reversedUnderscore := &FlagData{Name: "body", FullName: preferred + "Body"} + reversed := &CommandData{ + ServiceName: "notifications", + VarName: "notifications", + Subcommands: []*SubcommandData{ + {MethodName: "status+update", FullName: preferred, Flags: []*FlagData{reversedPlus}}, + {MethodName: "status_update", FullName: preferred, Flags: []*FlagData{reversedUnderscore}}, + }, + } + parser.PlanVariables([]*CommandData{reversed}, nil) + require.Equal(t, preferred+"Flags", reversed.Subcommands[0].FlagSetVar) + require.Equal(t, preferred+"Flags2", reversed.Subcommands[1].FlagSetVar) + require.Equal(t, preferred+"BodyFlag", reversedPlus.PointerVar) + require.Equal(t, preferred+"BodyFlag2", reversedUnderscore.PointerVar) +} + +// TestBuildCommandDataUsesPlannedServicePath checks that the public command +// name matches the unique path chosen while service packages are planned. +func TestBuildCommandDataUsesPlannedServicePath(t *testing.T) { + tests := []struct { + name string + service *service.Data + command string + }{ + { + name: "ordinary service", + service: &service.Data{Name: "Calculator", PathName: "calculator"}, + command: "calculator", + }, + { + name: "first colliding service", + service: &service.Data{Name: "mcp_read_value", PathName: "mcp_read_value"}, + command: "mcp-read-value", + }, + { + name: "second colliding service", + service: &service.Data{Name: "mcp-read-value", PathName: "mcp_read_value2"}, + command: "mcp-read-value2", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + command := BuildCommandData(test.service) + require.Equal(t, test.command, command.Name) + }) + } +} + +func TestFieldLoadCodeValidationTarget(t *testing.T) { + minimum := 1.0 + minLength := 1 + cases := []struct { + name string + flag *FlagData + argument string + attribute *expr.AttributeExpr + typeName string + typeRef string + wantTarget string + wantCondition string + wantError string + avoidValidation string + }{ + { + name: "optional integer", + flag: &FlagData{FullName: "serviceMethodCount", Type: "INT"}, + argument: "count", + typeName: "int", + typeRef: "int", + attribute: &expr.AttributeExpr{Type: expr.Int, Validation: &expr.ValidationExpr{Minimum: &minimum}}, + wantTarget: "val", + wantCondition: "if val < 1 {", + wantError: `goa.InvalidRangeError("count", val, 1, true)`, + avoidValidation: "if count != nil", + }, + { + name: "optional string", + flag: &FlagData{FullName: "serviceMethodState", Type: "STRING"}, + argument: "state", + typeName: "string", + typeRef: "string", + attribute: &expr.AttributeExpr{Type: expr.String, Validation: &expr.ValidationExpr{Values: []any{"ready"}}}, + wantTarget: "serviceMethodState", + wantCondition: `if !(serviceMethodState == "ready") {`, + wantError: `goa.InvalidEnumValueError("state", serviceMethodState, []any{"ready"})`, + avoidValidation: "if state != nil", + }, + { + name: "optional JSON array", + flag: &FlagData{FullName: "serviceMethodItems", Type: "JSON", Example: "[]"}, + argument: "items", + typeName: "[]string", + typeRef: "[]string", + attribute: &expr.AttributeExpr{Type: &expr.Array{ElemType: &expr.AttributeExpr{Type: expr.String}}, Validation: &expr.ValidationExpr{MinLength: &minLength}}, + wantTarget: "items", + wantCondition: "if len(items) < 1 {", + wantError: `goa.InvalidLengthError("items", items, len(items), 1, true)`, + avoidValidation: "if items != nil", + }, + } + + for _, test := range cases { + t.Run(test.name, func(t *testing.T) { + context := codegen.NewAttributeContext(false, false, true, "", codegen.NewNameScope()) + var target string + validation := func(value string) string { + target = value + return codegen.AttributeValidationCode( + test.attribute, + nil, + context, + true, + false, + value, + test.argument, + ) + } + value := NewFlagPlan(test.attribute, test.typeName, test.typeRef, nil).value + + generated, declaresError := fieldLoadCode( + test.flag, + test.argument, + value, + validation, + nil, + &expr.Object{}, + "*Payload", + ) + + require.Equal(t, test.wantTarget, target) + require.Contains(t, generated, test.wantCondition) + require.Contains(t, generated, test.wantError) + require.NotContains(t, generated, test.avoidValidation) + require.True(t, declaresError) + }) + } +} + +// TestFlagArgumentTypeNameMatchesValuePlan checks the released type-name field +// against the value description used to generate the flag conversion. +func TestFlagArgumentTypeNameMatchesValuePlan(t *testing.T) { + plan := NewFlagPlan(&expr.AttributeExpr{Type: expr.String}, "Label", "Label", nil) + argument := &FlagArgData{Plan: plan, TypeName: plan.value.typeName} + require.Equal(t, plan.value.typeName, argument.TypeName) +} + +// TestLegacyFlagValidationIsEmitted verifies that plugins using the released +// Validate field still write their validation code into payload builders. +func TestLegacyFlagValidationIsEmitted(t *testing.T) { + _, builder := MakeFlags( + "Service", + &service.MethodData{Name: "Method", VarName: "Method"}, + []*FlagArgData{{ + Name: "value", + TypeName: "string", + TypeRef: "string", + Required: true, + Validate: "if value == \"\" {\n\terr = goa.MissingFieldError(\"value\", \"payload\")\n}", + }}, + &expr.Object{}, + "*Payload", + nil, + ) + + require.Contains(t, builder.Fields[0].Init, "if value == \"\"") + require.Contains(t, builder.Fields[0].Init, "goa.MissingFieldError") + require.Equal(t, "BuildMethodPayload", builder.Name) + require.True(t, builder.CheckErr) +} + +// TestFlagValidationFormsAreExclusive catches generators that silently choose +// one validation form when a plugin supplies both forms. +func TestFlagValidationFormsAreExclusive(t *testing.T) { + plan := NewFlagPlan(&expr.AttributeExpr{Type: expr.String}, "string", "string", func(string) string { return "typed" }) + require.PanicsWithValue(t, "CLI flag validation cannot use both Validate and Plan", func() { + MakeFlags( + "Service", + &service.MethodData{Name: "Method"}, + []*FlagArgData{{ + Name: "value", + Plan: plan, + TypeRef: "string", + Required: true, + Validate: "legacy", + }}, + &expr.Object{}, + "*Payload", + nil, + ) + }) +} + +// TestUsageSectionsKeepReleasedData checks that plugins still receive a string +// slice when they replace the source of a generated help section. +func TestUsageSectionsKeepReleasedData(t *testing.T) { + command := &CommandData{Name: "calc", Subcommands: []*SubcommandData{{Name: "add"}}, Example: "calc add"} + require.IsType(t, []string{}, UsageCommands([]*CommandData{command}).Data) + require.IsType(t, []string{}, UsageExamples([]*CommandData{command}).Data) +} + +// TestPrintDescriptionLeavesBlankLinesEmpty catches generated help text that +// puts tabs on otherwise empty lines. +func TestPrintDescriptionLeavesBlankLinesEmpty(t *testing.T) { + require.Equal(t, "First line.\n\n\tSecond paragraph.", printDescription("First line.\n\nSecond paragraph.\n\t\n")) +} + +// TestReleasedFunctionSignaturesCompile keeps the public CLI helper calls used +// by plugins source compatible. +func TestReleasedFunctionSignaturesCompile(t *testing.T) { + var buildCommand func(*service.Data) *CommandData = BuildCommandData + var endpointFile func(string, string, []*codegen.ImportSpec, []*CommandData, *codegen.SectionTemplate) *codegen.File = EndpointParserFile + var usageCommands func([]*CommandData) *codegen.SectionTemplate = UsageCommands + var usageExamples func([]*CommandData) *codegen.SectionTemplate = UsageExamples + var flagsCode func([]*CommandData) string = FlagsCode + var newFlag func(string, string, string, string, string, bool, any, any) *FlagData = NewFlagData + var fieldLoad func(*FlagData, string, string, string, any, expr.DataType, string) (string, bool) = FieldLoadCode + + require.NotNil(t, buildCommand) + require.NotNil(t, endpointFile) + require.NotNil(t, usageCommands) + require.NotNil(t, usageExamples) + require.NotNil(t, flagsCode) + require.NotNil(t, newFlag) + require.NotNil(t, fieldLoad) +} + +// TestReleasedFlagHelpersGenerateConversions checks the string-based flag +// helpers still produce the conversion and validation requested by plugins. +func TestReleasedFlagHelpersGenerateConversions(t *testing.T) { + flag := NewFlagData("Service", "Method", "count", "int32", "", true, int32(1), nil) + generated, declaresError := FieldLoadCode( + flag, + "count", + "int32", + "if count < 1 {\n\terr = goa.InvalidRangeError(\"count\", count, 1, true)\n}", + nil, + &expr.Object{}, + "*Payload", + ) + + require.Equal(t, "INT32", flag.Type) + require.Contains(t, generated, "strconv.ParseInt(serviceMethodCount, 10, 32)") + require.Contains(t, generated, "if count < 1") + require.True(t, declaresError) +} + +// TestReleasedFlagsCodeUsesReleasedTemplateData checks that plugins can still +// render flags without creating a parser plan. +func TestReleasedFlagsCodeUsesReleasedTemplateData(t *testing.T) { + command := &CommandData{ + Name: "calc", + VarName: "calc", + Subcommands: []*SubcommandData{{ + Name: "add", + FullName: "calcAdd", + Flags: []*FlagData{{ + Name: "value", + FullName: "calcAddValue", + }}, + }}, + } + generated := FlagsCode([]*CommandData{command}) + require.Contains(t, generated, `calcFlags = flag.NewFlagSet("calc"`) + require.Contains(t, generated, `calcAddValueFlag = calcAddFlags.String("value"`) +} + +func TestFieldLoadCodeRequiredIntegerValidationUsesLoadedValue(t *testing.T) { + maximum := 42.0 + attribute := &expr.AttributeExpr{ + Type: expr.Int32, + Validation: &expr.ValidationExpr{Maximum: &maximum}, + } + context := codegen.NewAttributeContext(false, false, true, "", codegen.NewNameScope()) + var target string + validation := func(value string) string { + target = value + return codegen.AttributeValidationCode(attribute, nil, context, true, false, value, "count") + } + + generated, declaresError := fieldLoadCode( + &FlagData{FullName: "serviceMethodCount", Type: "INT32", Required: true}, + "count", + NewFlagPlan(attribute, "int32", "int32", nil).value, + validation, + nil, + &expr.Object{}, + "*Payload", + ) + + require.Equal(t, "count", target) + require.Contains(t, generated, "if count > 42") + require.True(t, declaresError) +} + +func TestPrimitiveAliasFlagPlan(t *testing.T) { + alias := &expr.UserTypeExpr{ + TypeName: "Count", + AttributeExpr: &expr.AttributeExpr{ + Type: expr.Int32, + }, + } + attribute := &expr.AttributeExpr{Type: alias} + value := NewFlagPlan(attribute, "Count", "service.Count", nil).value + flag := newFlagData("Service", "Method", "count", value, "", false, int32(3), nil) + + generated, declaresError := fieldLoadCode( + flag, + "count", + value, + nil, + nil, + &expr.Object{}, + "*Payload", + ) + + require.Equal(t, "INT32", flag.Type) + require.Contains(t, generated, "strconv.ParseInt(serviceMethodCount, 10, 32)") + require.Contains(t, generated, "val := service.Count(v)") + require.Contains(t, generated, "count = &val") + require.NotContains(t, generated, "json.Unmarshal") + require.True(t, declaresError) +} + +func TestCompositeFlagPlanUsesJSON(t *testing.T) { + attribute := &expr.AttributeExpr{ + Type: &expr.Array{ElemType: &expr.AttributeExpr{Type: expr.String}}, + } + value := NewFlagPlan(attribute, "[]string", "[]string", nil).value + flag := newFlagData("Service", "Method", "items", value, "", false, []string{"one"}, nil) + + generated, declaresError := fieldLoadCode( + flag, + "items", + value, + nil, + nil, + &expr.Object{}, + "*Payload", + ) + + require.Equal(t, "JSON", flag.Type) + require.Contains(t, generated, "json.Unmarshal([]byte(serviceMethodItems), &items)") + require.True(t, declaresError) +} + +// TestFlagImportPreferencesMatchGeneratedCode verifies that import planning +// follows the conversion and validation selected for each flag. +func TestFlagImportPreferencesMatchGeneratedCode(t *testing.T) { + minimum := 2 + validated := &expr.AttributeExpr{ + Type: expr.String, + Validation: &expr.ValidationExpr{MinLength: &minimum}, + } + cases := []struct { + name string + attribute *expr.AttributeExpr + validation bool + want []string + }{ + {name: "string", attribute: &expr.AttributeExpr{Type: expr.String}}, + {name: "number", attribute: &expr.AttributeExpr{Type: expr.Int32}, want: []string{"strconv"}}, + {name: "JSON", attribute: &expr.AttributeExpr{Type: &expr.Array{ElemType: &expr.AttributeExpr{Type: expr.String}}}, want: []string{"encoding/json"}}, + {name: "validated UTF-8 string", attribute: validated, validation: true, want: []string{"unicode/utf8", codegen.GoaImport("").Path}}, + } + + for _, test := range cases { + t.Run(test.name, func(t *testing.T) { + preferences := FlagImportPreferences(test.attribute, test.validation) + paths := make([]string, len(preferences)) + for index, preference := range preferences { + paths[index] = preference.Path + } + require.ElementsMatch(t, test.want, paths) + }) + } +} + +func TestStringAndBytesAliasesUseStringFlags(t *testing.T) { + cases := []struct { + name string + primitive expr.DataType + typeName string + typeRef string + wantGenerated string + }{ + { + name: "string alias", + primitive: expr.String, + typeName: "Label", + typeRef: "service.Label", + wantGenerated: "val := service.Label(serviceMethodValue)\nvalue = &val", + }, + { + name: "bytes alias", + primitive: expr.Bytes, + typeName: "Blob", + typeRef: "service.Blob", + wantGenerated: "value = service.Blob(serviceMethodValue)", + }, + } + + for _, test := range cases { + t.Run(test.name, func(t *testing.T) { + alias := &expr.UserTypeExpr{ + TypeName: test.typeName, + AttributeExpr: &expr.AttributeExpr{Type: test.primitive}, + } + attribute := &expr.AttributeExpr{Type: alias} + value := NewFlagPlan(attribute, test.typeName, test.typeRef, nil).value + flag := newFlagData("Service", "Method", "value", value, "", false, "value", nil) + + generated, _ := fieldLoadCode( + flag, + "value", + value, + nil, + nil, + &expr.Object{}, + "*Payload", + ) + + require.Equal(t, "STRING", flag.Type) + require.Contains(t, generated, test.wantGenerated) + require.NotContains(t, generated, "json.Unmarshal") + }) + } +} + +func TestCustomGoTypeFlagPlanUsesJSON(t *testing.T) { + attribute := &expr.AttributeExpr{ + Type: expr.String, + Meta: expr.MetaExpr{ + "struct:field:type": []string{"time.Time", "time"}, + }, + } + value := NewFlagPlan(attribute, "time.Time", "time.Time", nil).value + flag := newFlagData("Service", "Method", "at", value, "", true, "2026-08-22T00:00:00Z", nil) + + generated, declaresError := fieldLoadCode( + flag, + "at", + value, + nil, + nil, + &expr.Object{}, + "*Payload", + ) + + require.Equal(t, "JSON", flag.Type) + require.Contains(t, generated, "json.Unmarshal([]byte(serviceMethodAt), &at)") + require.True(t, declaresError) +} + +// TestPlannedFlagsPreservePresence checks that generated parsers keep an +// omitted flag distinct from a flag whose text is empty. +func TestPlannedFlagsPreservePresence(t *testing.T) { + generation, err := codegen.NewGeneration("generated.local/gen", nil) + require.NoError(t, err) + pkg, err := generation.ClaimPackage("generated.local/gen/http/cli/server") + require.NoError(t, err) + parser, err := DeclareParser(pkg, "http", "api", "server", []CommandDeclarationInput{ + {Service: "calc", Methods: []string{"add"}, NeedsFlagPresence: true}, + }) + require.NoError(t, err) + require.NoError(t, generation.Freeze()) + + plan := NewFlagPlan(&expr.AttributeExpr{Type: expr.String}, "string", "string", nil) + flag := NewFlagDataForPlan("calc", "add", "label", plan, "label to add", false, "sample", nil) + command := &CommandData{ + ServiceName: "calc", + Name: "calc", + VarName: "calc", + Subcommands: []*SubcommandData{{ + MethodName: "add", + Name: "add", + FullName: "calcAdd", + Flags: []*FlagData{flag}, + }}, + } + command.UsageDeclaration = parser.Commands["calc"].Usage + command.Subcommands[0].UsageDeclaration = parser.Commands["calc"].Methods["add"] + parser.PlanVariables([]*CommandData{command}, nil) + + generated := parser.FlagsCode([]*CommandData{command}) + require.Contains(t, generated, "new(cliStringFlag)") + require.Contains(t, generated, `.Var(calcAddLabelFlag, "label", "label to add")`) + require.NotContains(t, generated, `.String("label"`) +} + +// TestFieldLoadCodePreservesPlannedFlagPresence checks the generated builder +// branches on flag presence, not on whether the flag text is empty. +func TestFieldLoadCodePreservesPlannedFlagPresence(t *testing.T) { + plan := NewFlagPlan(&expr.AttributeExpr{Type: expr.String}, "string", "string", nil) + tests := []struct { + name string + required bool + want []string + doesNotWant []string + }{ + { + name: "required", + required: true, + want: []string{ + `if serviceMethodLabel == nil {`, + `fmt.Errorf("missing required flag --label")`, + `label = *serviceMethodLabel`, + }, + doesNotWant: []string{`serviceMethodLabel != ""`}, + }, + { + name: "optional", + required: false, + want: []string{ + `if serviceMethodLabel != nil {`, + `label = serviceMethodLabel`, + }, + doesNotWant: []string{`serviceMethodLabel != ""`}, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + flag := NewFlagDataForPlan( + "Service", + "Method", + "label", + plan, + "", + test.required, + "sample", + nil, + ) + generated, _ := fieldLoadCode( + flag, + "label", + plan.value, + nil, + nil, + &expr.Object{}, + "*Payload", + ) + + for _, want := range test.want { + require.Contains(t, generated, want) + } + for _, unwanted := range test.doesNotWant { + require.NotContains(t, generated, unwanted) + } + }) + } +} + +// TestFlagDefaultsUseTheirText checks that zero-valued defaults are emitted as +// real defaults instead of the placeholder used for missing required flags. +func TestFlagDefaultsUseTheirText(t *testing.T) { + tests := []struct { + name string + plan *FlagPlan + def any + want string + }{ + { + name: "empty string", + plan: NewFlagPlan(&expr.AttributeExpr{Type: expr.String}, "string", "string", nil), + def: "", + want: `.String("value", "", "")`, + }, + { + name: "false", + plan: NewFlagPlan(&expr.AttributeExpr{Type: expr.Boolean}, "bool", "bool", nil), + def: false, + want: `.String("value", "false", "")`, + }, + { + name: "zero", + plan: NewFlagPlan(&expr.AttributeExpr{Type: expr.Int}, "int", "int", nil), + def: 0, + want: `.String("value", "0", "")`, + }, + { + name: "array JSON", + plan: NewFlagPlan(&expr.AttributeExpr{Type: &expr.Array{ElemType: &expr.AttributeExpr{Type: expr.String}}}, "[]string", "[]string", nil), + def: []string{"one", "two"}, + want: `.String("value", "[\"one\",\"two\"]", "")`, + }, + { + name: "map JSON", + plan: NewFlagPlan(&expr.AttributeExpr{Type: &expr.Map{KeyType: &expr.AttributeExpr{Type: expr.String}, ElemType: &expr.AttributeExpr{Type: expr.Int}}}, "map[string]int", "map[string]int", nil), + def: map[string]int{"one": 1}, + want: `.String("value", "{\"one\":1}", "")`, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + flag := NewFlagDataForPlan("calc", "add", "value", test.plan, "", true, "sample", test.def) + command := &CommandData{ + ServiceName: "calc", + Name: "calc", + VarName: "calc", + Subcommands: []*SubcommandData{{ + MethodName: "add", + Name: "add", + FullName: "calcAdd", + Flags: []*FlagData{flag}, + }}, + } + + generated := FlagsCode([]*CommandData{command}) + require.Contains(t, generated, test.want) + require.NotContains(t, generated, `"REQUIRED"`) + }) + } +} + +// TestMakeFlagsPlansPresenceBeforeRendering checks that builders and parser +// calls receive exact types and expressions without inspecting values at run +// time. +func TestMakeFlagsPlansPresenceBeforeRendering(t *testing.T) { + stringPlan := NewFlagPlan(&expr.AttributeExpr{Type: expr.String}, "string", "string", nil) + boolPlan := NewFlagPlan(&expr.AttributeExpr{Type: expr.Boolean}, "bool", "bool", nil) + intPlan := NewFlagPlan(&expr.AttributeExpr{Type: expr.Int}, "int", "int", nil) + flags, builder := MakeFlags( + "Service", + &service.MethodData{Name: "Method", VarName: "Method"}, + []*FlagArgData{ + { + Name: "required", + TypeName: "string", + Plan: stringPlan, + TypeRef: "string", + Required: true, + Example: "value", + }, + { + Name: "optional", + TypeName: "string", + Plan: stringPlan, + TypeRef: "*string", + Example: "value", + }, + { + Name: "emptyDefault", + TypeName: "string", + Plan: stringPlan, + TypeRef: "string", + Example: "value", + DefaultValue: "", + }, + { + Name: "falseDefault", + TypeName: "bool", + Plan: boolPlan, + TypeRef: "bool", + Example: true, + DefaultValue: false, + }, + { + Name: "zeroDefault", + TypeName: "int", + Plan: intPlan, + TypeRef: "int", + Example: 1, + DefaultValue: 0, + }, + }, + &expr.Object{}, + "*Payload", + nil, + ) + + require.Equal(t, []string{"*string", "*string", "string", "string", "string"}, builder.FormalParamTypes) + require.True(t, flags[0].TracksPresence) + require.True(t, flags[1].TracksPresence) + for _, flag := range flags[2:] { + require.False(t, flag.TracksPresence) + require.True(t, flag.HasDefault) + } + require.Contains(t, builder.Fields[0].Init, `if serviceMethodRequired == nil {`) + require.Contains(t, builder.Fields[0].Init, `required = *serviceMethodRequired`) + require.Contains(t, builder.Fields[1].Init, `if serviceMethodOptional != nil {`) + require.Contains(t, builder.Fields[1].Init, `optional = serviceMethodOptional`) + require.NotContains(t, builder.Fields[2].Init, "if ") + require.Contains(t, builder.Fields[2].Init, `emptyDefault = serviceMethodEmptyDefault`) + require.Contains(t, builder.Fields[3].Init, `strconv.ParseBool(serviceMethodFalseDefault)`) + require.Contains(t, builder.Fields[4].Init, `strconv.ParseInt(serviceMethodZeroDefault, 10, strconv.IntSize)`) + var builderCode strings.Builder + require.NoError(t, PayloadBuilderSection(builder).Write(&builderCode)) + require.Contains(t, builderCode.String(), "serviceMethodRequired *string") + require.Contains(t, builderCode.String(), "serviceMethodOptional *string") + require.Contains(t, builderCode.String(), "serviceMethodEmptyDefault string") + + generation, err := codegen.NewGeneration("generated.local/gen", nil) + require.NoError(t, err) + pkg, err := generation.ClaimPackage("generated.local/gen/http/cli/server") + require.NoError(t, err) + parser, err := DeclareParser(pkg, "http", "api", "server", []CommandDeclarationInput{ + {Service: "Service", Methods: []string{"Method"}, NeedsFlagPresence: true}, + }) + require.NoError(t, err) + require.NoError(t, generation.Freeze()) + subcommand := &SubcommandData{ + MethodName: "Method", + FullName: "serviceMethod", + Flags: flags, + BuildFunction: builder, + } + parser.PlanVariables([]*CommandData{{ + ServiceName: "Service", + VarName: "service", + Subcommands: []*SubcommandData{subcommand}, + }}, nil) + require.Equal(t, []string{ + "serviceMethodRequiredFlag.value", + "serviceMethodOptionalFlag.value", + "*serviceMethodEmptyDefaultFlag", + "*serviceMethodFalseDefaultFlag", + "*serviceMethodZeroDefaultFlag", + }, subcommand.ActualArgs) + + var rendered strings.Builder + require.NoError(t, presenceFlagSection(parser.Declarations.PresenceFlagType.Name()).Write(&rendered)) + require.Contains(t, rendered.String(), "type cliStringFlag struct") + require.Contains(t, rendered.String(), "f.value = &value") +} diff --git a/codegen/cli/json_example_test.go b/codegen/cli/json_example_test.go new file mode 100644 index 0000000000..876882220b --- /dev/null +++ b/codegen/cli/json_example_test.go @@ -0,0 +1,42 @@ +// This file checks that CLI examples turn every supported Goa map key into a +// distinct JSON object key. +package cli + +import ( + "fmt" + "strings" + "testing" + + "goa.design/goa/v3/codegen/testutil" +) + +// TestJSONExampleFormatsPrimitiveMapKeys catches map entries being merged when +// generated help turns primitive and primitive-alias keys into JSON text. +func TestJSONExampleFormatsPrimitiveMapKeys(t *testing.T) { + type ( + exampleBool bool + exampleInt int32 + exampleUint uint64 + exampleFloat float64 + exampleString string + ) + tests := []struct { + name string + value any + }{ + {"boolean alias", map[exampleBool]string{false: "disabled", true: "enabled"}}, + {"signed alias", map[exampleInt]string{-2: "negative", 10: "positive"}}, + {"unsigned", map[uint32]string{7: "seven", 42: "forty-two"}}, + {"unsigned alias", map[exampleUint]string{9: "nine", 11: "eleven"}}, + {"floating-point alias", map[exampleFloat]string{1.25: "one", 2.5: "two"}}, + {"string", map[string]string{"first": "one", "second": "two"}}, + {"string alias", map[exampleString]string{"left": "one", "right": "two"}}, + {"empty object", map[string]any{}}, + } + + var actual strings.Builder + for _, test := range tests { + fmt.Fprintf(&actual, "%s:\n%s\n", test.name, jsonExample(test.value)) + } + testutil.AssertString(t, "testdata/golden/json_example_primitive_map_keys.golden", actual.String()) +} diff --git a/codegen/cli/symbols.go b/codegen/cli/symbols.go new file mode 100644 index 0000000000..afb004007a --- /dev/null +++ b/codegen/cli/symbols.go @@ -0,0 +1,539 @@ +// This file assigns the function and local variable names used by command-line +// client files. HTTP and gRPC planning completes these names before rendering +// the shared CLI templates. +package cli + +import ( + "bytes" + "cmp" + "crypto/sha256" + "encoding/binary" + "slices" + "sort" + + "goa.design/goa/v3/codegen" +) + +type ( + // CommandDeclarationInput names one service command and each method command + // written to a parser file. + CommandDeclarationInput struct { + // Service is the design service name used to identify this command. + Service string + // Methods lists the design methods accepted by this service command. + Methods []string + // NeedsFlagPresence is true when any method has a command-line flag + // without a design default. + NeedsFlagPresence bool + } + + // ParserPlan contains the names written to one command parser package. + ParserPlan struct { + // Declarations contains the three functions shared by the parser file. + Declarations *ParserDeclarations + // Commands contains the help function names for each design service. + Commands map[string]*CommandPlan + // Variables contains the exact parameter and local names used by ParseEndpoint. + Variables *ParserVariablesData + family string + variables *codegen.NameScope + imports []string + variableNames map[parserVariableIdentity]parserVariableName + planned bool + } + + // ParserVariablesData contains the exact names of parameters and local + // values written directly by the shared HTTP and gRPC parser templates. + ParserVariablesData struct { + // ServiceName stores the selected service command name. + ServiceName string + // ServiceFlags stores the flag set for the selected service. + ServiceFlags string + // MethodName stores the selected method command name. + MethodName string + // MethodFlags stores the flag set for the selected method. + MethodFlags string + // Data stores the payload passed to the selected endpoint. + Data string + // Endpoint stores the selected Goa endpoint. + Endpoint string + // Error stores an error returned while building the payload. + Error string + // Client stores the generated transport client. + Client string + // Scheme is the HTTP URL scheme parameter. + Scheme string + // Host is the HTTP server address parameter. + Host string + // Doer is the HTTP request executor parameter. + Doer string + // Encoder is the HTTP request encoder parameter. + Encoder string + // Decoder is the HTTP response decoder parameter. + Decoder string + // Restore is the HTTP response-body restore parameter. + Restore string + // Dialer is the WebSocket dialer parameter. + Dialer string + // Connection is the gRPC connection parameter. + Connection string + // Options is the gRPC call option parameter. + Options string + // ParsedValue stores a primitive value returned by a string parser. + ParsedValue string + // ConvertedValue stores a value before it is assigned through a pointer. + ConvertedValue string + } + + // ParserLocalData describes one transport-specific parameter written in the + // generated endpoint parser. PlanVariables fills VarName before rendering. + ParserLocalData struct { + // ServiceName is the exact design service that uses this parameter. + ServiceName string + // MethodName is the exact design method that uses this parameter. It is + // empty for a service-wide parameter. + MethodName string + // Use distinguishes parameters that serve different purposes in one method. + Use string + // PreferredName is the Go name used when it does not conflict with another local. + PreferredName string + // VarName is the exact Go name written by the parameter and every use. + VarName string + } + + // CommandPlan contains the help function names for one service command. + CommandPlan struct { + // Usage is the service help function. + Usage *codegen.NameDeclaration + // Methods contains help functions indexed by design method name. + Methods map[string]*codegen.NameDeclaration + } + + // symbolOrder identifies one shared CLI function by the design names that + // select its output file and contents. + symbolOrder struct { + family string + root string + server string + service string + method string + role symbolRole + commands [sha256.Size]byte + } + + // symbolRole lists the package functions emitted by shared CLI templates. + symbolRole uint8 + + // parserVariableCandidate records one local definition and the data field + // that receives its exact Go name. + parserVariableCandidate struct { + identity parserVariableIdentity + preferred string + command *CommandData + subcommand *SubcommandData + flag *FlagData + interceptor *InterceptorData + local *ParserLocalData + } + + // parserVariableIdentity orders local definitions by their exact design + // names, so reversing input slices does not change collision suffixes. + parserVariableIdentity struct { + service string + method string + flag string + use string + role parserVariableRole + } + + // parserVariableName stores the preferred and exact name selected for one local. + parserVariableName struct { + preferred string + name string + } + + // parserVariableRole distinguishes local definitions with the same design names. + parserVariableRole uint8 +) + +const ( + parseEndpointRole symbolRole = iota + 1 + usageCommandsRole + usageExamplesRole + commandUsageRole + methodUsageRole + payloadBuilderRole + presenceFlagTypeRole +) + +const ( + serviceFlagSetVariable parserVariableRole = iota + 1 + methodFlagSetVariable + flagPointerVariable + interceptorVariable + transportVariable +) + +// DeclareParser submits every function written to one parser package. family +// is "http", "jsonrpc", or "grpc"; root and server distinguish files from +// separate designs; commands supplies the service and method help names. +func DeclareParser(pkg *codegen.GeneratedPackage, family, root, server string, commands []CommandDeclarationInput) (*ParserPlan, error) { + commandNames := commandDeclarationNames(commands) + declare := func(preferred string, role symbolRole, service, method string) (*codegen.NameDeclaration, error) { + visibility := codegen.ExportedName + if role == commandUsageRole || role == methodUsageRole { + visibility = codegen.UnexportedName + } + declaration := codegen.NewPreferredName( + codegen.NameFunction, + preferred, + visibility, + symbolOrder{family: family, root: root, server: server, service: service, method: method, role: role, commands: commandNames}, + ) + if err := pkg.DeclareName(declaration); err != nil { + return nil, err + } + return declaration, nil + } + parseEndpoint, err := declare("ParseEndpoint", parseEndpointRole, "", "") + if err != nil { + return nil, err + } + usageCommands, err := declare("UsageCommands", usageCommandsRole, "", "") + if err != nil { + return nil, err + } + usageExamples, err := declare("UsageExamples", usageExamplesRole, "", "") + if err != nil { + return nil, err + } + var presenceFlagType *codegen.NameDeclaration + for _, command := range commands { + if command.NeedsFlagPresence { + presenceFlagType = codegen.NewPreferredName( + codegen.NameType, + "cliStringFlag", + codegen.UnexportedName, + symbolOrder{family: family, root: root, server: server, role: presenceFlagTypeRole, commands: commandNames}, + ) + if err := pkg.DeclareName(presenceFlagType); err != nil { + return nil, err + } + break + } + } + plan := &ParserPlan{ + Declarations: &ParserDeclarations{ + ParseEndpoint: parseEndpoint, + UsageCommands: usageCommands, + UsageExamples: usageExamples, + PresenceFlagType: presenceFlagType, + }, + Commands: make(map[string]*CommandPlan, len(commands)), + family: family, + variables: codegen.NewNameScope(), + variableNames: make(map[parserVariableIdentity]parserVariableName), + } + for _, command := range commands { + usage, err := declare(goifyTerms(command.Service)+"Usage", commandUsageRole, command.Service, "") + if err != nil { + return nil, err + } + commandPlan := &CommandPlan{ + Usage: usage, + Methods: make(map[string]*codegen.NameDeclaration, len(command.Methods)), + } + for _, method := range command.Methods { + methodUsage, err := declare(goifyTerms(command.Service, method)+"Usage", methodUsageRole, command.Service, method) + if err != nil { + return nil, err + } + commandPlan.Methods[method] = methodUsage + } + plan.Commands[command.Service] = commandPlan + } + return plan, nil +} + +// PlanVariables chooses every local Go name written by one endpoint parser. +// data contains the shared service, method, flag, and interceptor values; +// locals contains transport-specific parameters such as multipart encoders. +func (p *ParserPlan) PlanVariables(data []*CommandData, locals []*ParserLocalData) { + candidates := parserVariableCandidates(data, locals) + sort.Slice(candidates, func(i, j int) bool { + return compareParserVariable(candidates[i], candidates[j]) < 0 + }) + if !p.planned { + p.imports = parserImportQualifiers(p.family, data) + for _, qualifier := range p.imports { + p.variables.Unique(qualifier) + } + p.Variables = planParserVariables(p.variables, p.family) + for _, candidate := range candidates { + if _, exists := p.variableNames[candidate.identity]; exists { + panic("CLI parser contains the same local variable more than once") + } + name := p.variables.Unique(candidate.preferred) + p.variableNames[candidate.identity] = parserVariableName{ + preferred: candidate.preferred, + name: name, + } + candidate.assign(name) + } + p.variables.Freeze() + p.planned = true + } else { + if !slices.Equal(p.imports, parserImportQualifiers(p.family, data)) { + panic("CLI parser imports changed after local variables were planned") + } + if len(candidates) != len(p.variableNames) { + panic("CLI parser local variables changed after planning") + } + for _, candidate := range candidates { + planned, exists := p.variableNames[candidate.identity] + if !exists || planned.preferred != candidate.preferred { + panic("CLI parser local variable changed after planning") + } + candidate.assign(planned.name) + } + } + for _, command := range data { + for _, subcommand := range command.Subcommands { + if subcommand.Interceptors != nil { + subcommand.Interceptors.ParserVar = command.Interceptors.ParserVar + } + if subcommand.BuildFunction != nil { + count := len(subcommand.BuildFunction.ActualParams) + subcommand.ActualPointerVars = make([]string, count) + subcommand.ActualArgs = make([]string, count) + for index := range count { + flag := subcommand.Flags[index] + subcommand.ActualPointerVars[index] = flag.PointerVar + subcommand.ActualArgs[index] = "*" + flag.PointerVar + if flag.TracksPresence { + subcommand.ActualArgs[index] = flag.PointerVar + ".value" + } + } + } + if subcommand.conversionFlag != nil { + subcommand.Conversion = directPayloadConversion(subcommand.conversionFlag, p.Variables) + } + } + } +} + +// DeclarePayloadBuilder submits the function that builds one method payload +// from command-line flags and returns the record used by its definition and +// calls. +func DeclarePayloadBuilder(pkg *codegen.GeneratedPackage, family, root, service, method, preferred string) (*codegen.NameDeclaration, error) { + declaration := codegen.NewPreferredName( + codegen.NameFunction, + preferred, + codegen.ExportedName, + symbolOrder{family: family, root: root, service: service, method: method, role: payloadBuilderRole}, + ) + if err := pkg.DeclareName(declaration); err != nil { + return nil, err + } + return declaration, nil +} + +// ComparePackageName orders CLI functions by the design and output file that +// writes them, so reversing input designs does not change their final names. +func (order symbolOrder) ComparePackageName(other codegen.PackageNameOrder) int { + right := other.(symbolOrder) + for _, compared := range []int{ + cmp.Compare(order.family, right.family), + cmp.Compare(order.root, right.root), + cmp.Compare(order.server, right.server), + cmp.Compare(order.service, right.service), + cmp.Compare(order.method, right.method), + cmp.Compare(order.role, right.role), + } { + if compared != 0 { + return compared + } + } + return bytes.Compare(order.commands[:], right.commands[:]) +} + +// parserVariableCandidates collects each local definition before any name is +// chosen, including transport parameters supplied by the caller. +func parserVariableCandidates(data []*CommandData, locals []*ParserLocalData) []*parserVariableCandidate { + var candidates []*parserVariableCandidate + for _, command := range data { + candidates = append(candidates, &parserVariableCandidate{ + identity: parserVariableIdentity{ + service: command.ServiceName, + role: serviceFlagSetVariable, + }, + preferred: command.VarName + "Flags", + command: command, + }) + if command.Interceptors != nil { + candidates = append(candidates, &parserVariableCandidate{ + identity: parserVariableIdentity{ + service: command.ServiceName, + role: interceptorVariable, + }, + preferred: command.Interceptors.VarName, + interceptor: command.Interceptors, + }) + } + for _, subcommand := range command.Subcommands { + candidates = append(candidates, &parserVariableCandidate{ + identity: parserVariableIdentity{ + service: command.ServiceName, + method: subcommand.MethodName, + role: methodFlagSetVariable, + }, + preferred: subcommand.FullName + "Flags", + subcommand: subcommand, + }) + for _, flag := range subcommand.Flags { + candidates = append(candidates, &parserVariableCandidate{ + identity: parserVariableIdentity{ + service: command.ServiceName, + method: subcommand.MethodName, + flag: flag.Name, + role: flagPointerVariable, + }, + preferred: flag.FullName + "Flag", + flag: flag, + }) + } + } + } + for _, local := range locals { + candidates = append(candidates, &parserVariableCandidate{ + identity: parserVariableIdentity{ + service: local.ServiceName, + method: local.MethodName, + use: local.Use, + role: transportVariable, + }, + preferred: local.PreferredName, + local: local, + }) + } + return candidates +} + +// parserImportQualifiers returns every package name referenced by ParseEndpoint. +// Reserving them first prevents a local variable from hiding an imported package. +func parserImportQualifiers(family string, data []*CommandData) []string { + qualifiers := map[string]struct{}{ + "flag": {}, + "fmt": {}, + "goa": {}, + "os": {}, + } + switch family { + case "grpc": + qualifiers["grpc"] = struct{}{} + qualifiers["json"] = struct{}{} + qualifiers["strconv"] = struct{}{} + qualifiers["utf8"] = struct{}{} + case "http", "jsonrpc": + qualifiers["goahttp"] = struct{}{} + qualifiers["http"] = struct{}{} + qualifiers["json"] = struct{}{} + qualifiers["strconv"] = struct{}{} + qualifiers["utf8"] = struct{}{} + } + for _, command := range data { + if command.PkgName != "" { + qualifiers[command.PkgName] = struct{}{} + } + if command.Interceptors != nil && command.Interceptors.PkgName != "" { + qualifiers[command.Interceptors.PkgName] = struct{}{} + } + } + result := make([]string, 0, len(qualifiers)) + for qualifier := range qualifiers { + result = append(result, qualifier) + } + sort.Strings(result) + return result +} + +// planParserVariables chooses names for parameters and local values written by +// the parser templates after all imported package names are reserved. +func planParserVariables(scope *codegen.NameScope, family string) *ParserVariablesData { + variables := &ParserVariablesData{ + ServiceName: scope.Unique("svcn"), + ServiceFlags: scope.Unique("svcf"), + MethodName: scope.Unique("epn"), + MethodFlags: scope.Unique("epf"), + Data: scope.Unique("data"), + Endpoint: scope.Unique("endpoint"), + Error: scope.Unique("err"), + Client: scope.Unique("c"), + ParsedValue: scope.Unique("v"), + ConvertedValue: scope.Unique("val"), + } + switch family { + case "grpc": + variables.Connection = scope.Unique("cc") + variables.Options = scope.Unique("opts") + case "http", "jsonrpc": + variables.Scheme = scope.Unique("scheme") + variables.Host = scope.Unique("host") + variables.Doer = scope.Unique("doer") + variables.Encoder = scope.Unique("enc") + variables.Decoder = scope.Unique("dec") + variables.Restore = scope.Unique("restore") + variables.Dialer = scope.Unique("dialer") + } + return variables +} + +// compareParserVariable orders exact design identities before the preferred Go +// spelling, so the same design always receives the same suffix. +func compareParserVariable(left, right *parserVariableCandidate) int { + for _, compared := range []int{ + cmp.Compare(left.identity.service, right.identity.service), + cmp.Compare(left.identity.method, right.identity.method), + cmp.Compare(left.identity.flag, right.identity.flag), + cmp.Compare(left.identity.use, right.identity.use), + cmp.Compare(left.identity.role, right.identity.role), + cmp.Compare(left.preferred, right.preferred), + } { + if compared != 0 { + return compared + } + } + return 0 +} + +// assign stores one exact name on the data read by its definition and uses. +func (candidate *parserVariableCandidate) assign(name string) { + switch { + case candidate.command != nil: + candidate.command.FlagSetVar = name + case candidate.subcommand != nil: + candidate.subcommand.FlagSetVar = name + case candidate.flag != nil: + candidate.flag.PointerVar = name + case candidate.interceptor != nil: + candidate.interceptor.ParserVar = name + case candidate.local != nil: + candidate.local.VarName = name + } +} + +// commandDeclarationNames returns fixed-size bytes derived from every service +// and method name written into one parser file. +func commandDeclarationNames(commands []CommandDeclarationInput) [sha256.Size]byte { + var encoded []byte + for _, command := range commands { + encoded = binary.AppendUvarint(encoded, uint64(len(command.Service))) + encoded = append(encoded, command.Service...) + encoded = binary.AppendUvarint(encoded, uint64(len(command.Methods))) + for _, method := range command.Methods { + encoded = binary.AppendUvarint(encoded, uint64(len(method))) + encoded = append(encoded, method...) + } + } + return sha256.Sum256(encoded) +} diff --git a/codegen/cli/templates.go b/codegen/cli/templates.go index 51525e204a..def5c31b17 100644 --- a/codegen/cli/templates.go +++ b/codegen/cli/templates.go @@ -8,11 +8,13 @@ import ( // Template constants const ( - usageCommandsT = "usage_commands" - usageExamplesT = "usage_examples" - parseFlagsT = "parse_flags" - commandUsageT = "command_usage" - buildPayloadT = "build_payload" + usageCommandsT = "usage_commands" + usageExamplesT = "usage_examples" + parseFlagsT = "parse_flags" + parseFlagsPlannedT = "parse_flags_planned" + presenceFlagT = "presence_flag" + commandUsageT = "command_usage" + buildPayloadT = "build_payload" ) //go:embed templates/*.go.tpl diff --git a/codegen/cli/templates/build_payload.go.tpl b/codegen/cli/templates/build_payload.go.tpl index 4de3b35885..848f8c8dcc 100644 --- a/codegen/cli/templates/build_payload.go.tpl +++ b/codegen/cli/templates/build_payload.go.tpl @@ -1,5 +1,5 @@ {{ printf "%s builds the payload for the %s %s endpoint from CLI flags." .Name .ServiceName .MethodName | comment }} -func {{ .Name }}({{ range .FormalParams }}{{ . }} string, {{ end }}) ({{ .ResultType }}, error) { +func {{ .Name }}({{ range $index, $param := .FormalParams }}{{ $param }} {{ formalParamType $ $index }}, {{ end }}) ({{ .ResultType }}, error) { {{- if .CheckErr }} var err error {{- end }} @@ -12,6 +12,13 @@ func {{ .Name }}({{ range .FormalParams }}{{ . }} string, {{ end }}) ({{ .Result {{- end }} {{- end }} {{- with .PayloadInit }} + {{- if .OptionalBody }} + res := &{{ .ReturnTypeName }}{} + if body != nil { + {{ .Code }} + res.{{ .ReturnTypeAttribute }} = {{ if .ReturnTypeAttributePointer }}&{{ else if .ReturnTypeAttributeUnion }}*{{ end }}v + } + {{- else }} {{- if .Code }} {{ .Code }} {{- if .ReturnTypeAttribute }} @@ -26,6 +33,10 @@ func {{ .Name }}({{ range .FormalParams }}{{ . }} string, {{ end }}) ({{ .Result {{- end }} {{ fieldCode . }} {{- end }} + {{- end }} + {{- if and .OptionalBody .ReturnIsStruct }} + {{ fieldCode . }} + {{- end }} return {{ if .ReturnTypeAttribute }}res{{ else }}v{{ end }}, nil {{- end }} } diff --git a/codegen/cli/templates/command_usage.go.tpl b/codegen/cli/templates/command_usage.go.tpl index 9832a1425d..7436e45b39 100644 --- a/codegen/cli/templates/command_usage.go.tpl +++ b/codegen/cli/templates/command_usage.go.tpl @@ -1,5 +1,5 @@ -{{ printf "%sUsage displays the usage of the %s command and its subcommands." .VarName .Name | comment }} -func {{ .VarName }}Usage() { +{{ printf "%s displays the usage of the %s command and its subcommands." .UsageDeclaration.Name .Name | comment }} +func {{ .UsageDeclaration.Name }}() { fmt.Fprintln(os.Stderr, `{{ printDescription .Description }}`) fmt.Fprintf(os.Stderr, "Usage:\n %s [globalflags] {{ .Name }} COMMAND [flags]\n\n", os.Args[0]) fmt.Fprintln(os.Stderr, "COMMAND:") @@ -12,7 +12,7 @@ func {{ .VarName }}Usage() { } {{- range .Subcommands }} -func {{ .FullName }}Usage() { +func {{ .UsageDeclaration.Name }}() { // Header with flags fmt.Fprintf(os.Stderr, "%s [flags] {{ $.Name }} {{ .Name }}", os.Args[0]) {{- range .Flags }} diff --git a/codegen/cli/templates/parse_flags.go.tpl b/codegen/cli/templates/parse_flags.go.tpl index f8617fff3c..a15b2d0a4b 100644 --- a/codegen/cli/templates/parse_flags.go.tpl +++ b/codegen/cli/templates/parse_flags.go.tpl @@ -5,7 +5,7 @@ var ( {{ .FullName }}Flags = flag.NewFlagSet("{{ .Name }}", flag.ExitOnError) {{- $sub := . }} {{- range .Flags }} - {{ .FullName }}Flag = {{ $sub.FullName }}Flags.String("{{ .Name }}", "{{ if .Default }}{{ .Default }}{{ else if .Required }}REQUIRED{{ end }}", {{ printf "%q" .Description }}) + {{ .FullName }}Flag = {{ $sub.FullName }}Flags.String("{{ .Name }}", {{ if .HasDefault }}{{ printf "%q" .DefaultValue }}{{ else if .Required }}"REQUIRED"{{ else }}""{{ end }}, {{ printf "%q" .Description }}) {{- end }} {{ end }} {{- end }} diff --git a/codegen/cli/templates/parse_flags_planned.go.tpl b/codegen/cli/templates/parse_flags_planned.go.tpl new file mode 100644 index 0000000000..42f0718dfb --- /dev/null +++ b/codegen/cli/templates/parse_flags_planned.go.tpl @@ -0,0 +1,90 @@ +var ( + {{- range .Commands }} + {{ .FlagSetVar }} = flag.NewFlagSet("{{ .Name }}", flag.ContinueOnError) + {{ range .Subcommands }} + {{ .FlagSetVar }} = flag.NewFlagSet("{{ .Name }}", flag.ExitOnError) + {{- $sub := . }} + {{- range .Flags }} + {{- if .TracksPresence }} + {{ .PointerVar }} = new({{ $.PresenceFlagType }}) + {{- else }} + {{ .PointerVar }} = {{ $sub.FlagSetVar }}.String("{{ .Name }}", {{ printf "%q" .DefaultValue }}, {{ printf "%q" .Description }}) + {{- end }} + {{- end }} + {{ end }} + {{- end }} + ) + {{- if .PresenceFlagType }} + {{ range .Commands -}} + {{ range .Subcommands -}} + {{ $sub := . -}} + {{ range .Flags -}} + {{ if .TracksPresence -}} + {{ $sub.FlagSetVar }}.Var({{ .PointerVar }}, "{{ .Name }}", {{ printf "%q" .Description }}) + {{ end -}} + {{ end -}} + {{ end -}} + {{ end }} + {{- end }} + {{ range .Commands -}} + {{ $cmd := . -}} + {{ .FlagSetVar }}.Usage = {{ .UsageDeclaration.Name }} + {{ range .Subcommands -}} + {{ .FlagSetVar }}.Usage = {{ .UsageDeclaration.Name }} + {{ end }} + {{ end }} + if {{ .Variables.Error }} := flag.CommandLine.Parse(os.Args[1:]); {{ .Variables.Error }} != nil { + return nil, nil, {{ .Variables.Error }} + } + + if flag.NArg() < 2 { // two non flag args are required: SERVICE and ENDPOINT (aka COMMAND) + return nil, nil, fmt.Errorf("not enough arguments") + } + + var ( + {{ .Variables.ServiceName }} string + {{ .Variables.ServiceFlags }} *flag.FlagSet + ) + { + {{ .Variables.ServiceName }} = flag.Arg(0) + switch {{ .Variables.ServiceName }} { + {{- range .Commands }} + case "{{ .Name }}": + {{ $.Variables.ServiceFlags }} = {{ .FlagSetVar }} + {{- end }} + default: + return nil, nil, fmt.Errorf("unknown service %q", {{ .Variables.ServiceName }}) + } + } + if {{ .Variables.Error }} := {{ .Variables.ServiceFlags }}.Parse(flag.Args()[1:]); {{ .Variables.Error }} != nil { + return nil, nil, {{ .Variables.Error }} + } + + var ( + {{ .Variables.MethodName }} string + {{ .Variables.MethodFlags }} *flag.FlagSet + ) + { + {{ .Variables.MethodName }} = {{ .Variables.ServiceFlags }}.Arg(0) + switch {{ .Variables.ServiceName }} { + {{- range .Commands }} + case "{{ .Name }}": + switch {{ $.Variables.MethodName }} { + {{- range .Subcommands }} + case "{{ .Name }}": + {{ $.Variables.MethodFlags }} = {{ .FlagSetVar }} + {{ end }} + } + {{ end }} + } + } + if {{ .Variables.MethodFlags }} == nil { + return nil, nil, fmt.Errorf("unknown %q endpoint %q", {{ .Variables.ServiceName }}, {{ .Variables.MethodName }}) + } + + // Parse endpoint flags if any + if {{ .Variables.ServiceFlags }}.NArg() > 1 { + if {{ .Variables.Error }} := {{ .Variables.MethodFlags }}.Parse({{ .Variables.ServiceFlags }}.Args()[1:]); {{ .Variables.Error }} != nil { + return nil, nil, {{ .Variables.Error }} + } + } diff --git a/codegen/cli/templates/presence_flag.go.tpl b/codegen/cli/templates/presence_flag.go.tpl new file mode 100644 index 0000000000..455ba2f51a --- /dev/null +++ b/codegen/cli/templates/presence_flag.go.tpl @@ -0,0 +1,18 @@ +// {{ . }} keeps an omitted command-line flag distinct from an explicitly empty flag. +type {{ . }} struct { + value *string +} + +// String returns the flag text shown by the standard flag package. +func (f *{{ . }}) String() string { + if f.value == nil { + return "" + } + return *f.value +} + +// Set records that the user supplied the flag, even when value is empty. +func (f *{{ . }}) Set(value string) error { + f.value = &value + return nil +} diff --git a/codegen/cli/templates/usage_commands.go.tpl b/codegen/cli/templates/usage_commands.go.tpl index b63c271756..3e452fe302 100644 --- a/codegen/cli/templates/usage_commands.go.tpl +++ b/codegen/cli/templates/usage_commands.go.tpl @@ -2,7 +2,7 @@ // // command (subcommand1|subcommand2|...) // -func UsageCommands() []string { +func {{ usageName }}() []string { return []string{ {{- range . }} "{{ . }}", diff --git a/codegen/cli/templates/usage_examples.go.tpl b/codegen/cli/templates/usage_examples.go.tpl index 2fb7d84239..63ea9afb3a 100644 --- a/codegen/cli/templates/usage_examples.go.tpl +++ b/codegen/cli/templates/usage_examples.go.tpl @@ -1,5 +1,5 @@ // UsageExamples produces an example of a valid invocation of the CLI tool. -func UsageExamples() string { +func {{ usageName }}() string { return {{ range . }}os.Args[0] + " " + {{ printf "%q" . }} + "\n" + {{ end }}"" } diff --git a/codegen/cli/testdata/golden/json_example_primitive_map_keys.golden b/codegen/cli/testdata/golden/json_example_primitive_map_keys.golden new file mode 100644 index 0000000000..21e1cea84f --- /dev/null +++ b/codegen/cli/testdata/golden/json_example_primitive_map_keys.golden @@ -0,0 +1,37 @@ +boolean alias: +'{ + "false": "disabled", + "true": "enabled" + }' +signed alias: +'{ + "-2": "negative", + "10": "positive" + }' +unsigned: +'{ + "42": "forty-two", + "7": "seven" + }' +unsigned alias: +'{ + "11": "eleven", + "9": "nine" + }' +floating-point alias: +'{ + "1.25": "one", + "2.5": "two" + }' +string: +'{ + "first": "one", + "second": "two" + }' +string alias: +'{ + "left": "one", + "right": "two" + }' +empty object: +{} diff --git a/codegen/example/example_client.go b/codegen/example/example_client.go index 9750d255b5..23aabc542b 100644 --- a/codegen/example/example_client.go +++ b/codegen/example/example_client.go @@ -1,84 +1,109 @@ +// This file writes example command-line programs from copied server data and +// the package names already chosen for this generation. package example import ( - "os" - "path/filepath" "strings" "goa.design/goa/v3/codegen" - "goa.design/goa/v3/expr" ) -// CLIFiles returns example client tool main implementation for each server -// expression in the design. -func CLIFiles(genpkg string, root *expr.RootExpr) []*codegen.File { +type ( + // clientMainData contains all design values selected before one example + // command-line client is rendered. + clientMainData struct { + // APIName is the API name written in help text. + APIName string + // Server contains the copied host, transport, and URL variable settings. + Server *clientMainServerData + // HasJSONRPC reports whether the client includes JSON-RPC commands. + HasJSONRPC bool + // HasHTTP reports whether the client includes ordinary HTTP commands. + HasHTTP bool + // UsageCommands is the sorted command list written in help text. + UsageCommands []string + // JSONRPCOnly lists commands handled only by the JSON-RPC client. + JSONRPCOnly []*jsonRPCServiceData + // WritesEndpointResult reports whether a command returns one result. + WritesEndpointResult bool + // WritesStreamResults reports whether a command receives server results. + WritesStreamResults bool + } + + // clientMainServerData contains the URL variables planned for one client + // main and the hosts that use them. + clientMainServerData struct { + *Data + // Transports lists only transports with commands that this client can call. + Transports []*TransportData + // Schemes lists only URL schemes accepted by Transports. + Schemes []string + // HelpURL is the literal default URL shown for the URL override flag. + HelpURL string + // Variables lists every URL variable with its client flag names. + Variables []*mainVariableData + // Hosts lists each host with the same planned URL variables. + Hosts []*clientMainHostData + } + + // clientMainHostData contains one host and its planned URL variables. + clientMainHostData struct { + *HostData + // Variables lists the URL variables used by this host. + Variables []*mainVariableData + } +) + +// CLIFiles returns one example command-line program for each copied server. +func CLIFiles(root *Root) []*codegen.File { var fw []*codegen.File - for _, svr := range root.API.Servers { - if m := exampleCLIMain(genpkg, root, svr); m != nil { + for _, svr := range root.Servers { + if m := exampleCLIMain(root, svr); m != nil { fw = append(fw, m) } } return fw } -// exampleCLIMain returns an example client tool main implementation for the -// given server expression. -func exampleCLIMain(_ string, root *expr.RootExpr, svr *expr.ServerExpr) *codegen.File { - svrdata := Servers.Get(svr, root) - - // Skip CLI generation for servers with no transports (e.g., agent-only services) - if svrdata.DefaultTransport() == nil { +// exampleCLIMain writes the command-line program for server. +func exampleCLIMain(root *Root, server *Data) *codegen.File { + // A server with no callable method has no client to run. + if server.DefaultClientTransport() == nil { return nil } - path := filepath.Join("cmd", svrdata.Dir+"-cli", "main.go") - if _, err := os.Stat(path); !os.IsNotExist(err) { - return nil // file already exists, skip it. - } - specs := []*codegen.ImportSpec{ - {Path: "context"}, - {Path: "encoding/json"}, - {Path: "errors"}, - {Path: "flag"}, - {Path: "fmt"}, - {Path: "net/url"}, - {Path: "os"}, - {Path: "sort"}, - {Path: "slices"}, - {Path: "strings"}, - codegen.GoaImport(""), + path := server.clientMainPath + main := &clientMainData{ + APIName: root.APIName, + Server: planClientMainServer(server), + HasJSONRPC: server.clientHasJSONRPC, + HasHTTP: server.clientHasHTTP, + UsageCommands: server.usageCommands, + JSONRPCOnly: server.jsonRPCOnly, + WritesEndpointResult: server.writesEndpointResult, + WritesStreamResults: server.writesStreamResults, } + specs := packageImports(server.clientPackage, clientMainFixedImports(server)) sections := []*codegen.SectionTemplate{ codegen.Header("", "main", specs), { Name: "cli-main-start", Source: exampleTemplates.Read(clientStartT), - Data: map[string]any{ - "Server": svrdata, - "HasJSONRPC": hasJSONRPC(root, svr), - "HasHTTP": hasHTTP(root, svr), - }, + Data: main, FuncMap: map[string]any{ "join": strings.Join, }, }, { Name: "cli-main-var-init", Source: exampleTemplates.Read(clientVarInitT), - Data: map[string]any{ - "Server": svrdata, - }, + Data: main, FuncMap: map[string]any{ "join": strings.Join, }, }, { Name: "cli-main-endpoint-init", Source: exampleTemplates.Read(clientEndpointInitT), - Data: map[string]any{ - "Server": svrdata, - "Root": root, - "HasJSONRPC": hasJSONRPC(root, svr), - "HasHTTP": hasHTTP(root, svr), - }, + Data: main, FuncMap: map[string]any{ "join": strings.Join, "toUpper": strings.ToUpper, @@ -86,15 +111,11 @@ func exampleCLIMain(_ string, root *expr.RootExpr, svr *expr.ServerExpr) *codege }, { Name: "cli-main-end", Source: exampleTemplates.Read(clientEndT), + Data: main, }, { Name: "cli-main-usage", Source: exampleTemplates.Read(clientUsageT), - Data: map[string]any{ - "APIName": root.API.Name, - "Server": svrdata, - "HasJSONRPC": hasJSONRPC(root, svr), - "HasHTTP": hasHTTP(root, svr), - }, + Data: main, FuncMap: map[string]any{ "toUpper": strings.ToUpper, "join": strings.Join, @@ -104,22 +125,97 @@ func exampleCLIMain(_ string, root *expr.RootExpr, svr *expr.ServerExpr) *codege return &codegen.File{Path: path, SectionTemplates: sections, SkipExist: true} } -// hasJSONRPC returns true if the server expression has a JSON-RPC server. -func hasJSONRPC(root *expr.RootExpr, svr *expr.ServerExpr) bool { - for _, s := range svr.Services { - if root.API.JSONRPC.Service(s) != nil { - return true +// clientMainFixedImports lists packages whose names are written directly by +// the command-line client templates. +func clientMainFixedImports(server *Data) []*codegen.ImportSpec { + specs := []*codegen.ImportSpec{ + {Path: "context"}, + {Path: "errors"}, + {Path: "flag"}, + {Path: "fmt"}, + {Path: "net/url"}, + {Path: "os"}, + {Path: "strings"}, + } + if server.writesEndpointResult || server.writesStreamResults { + specs = append(specs, + &codegen.ImportSpec{Path: "encoding/json"}, + &codegen.ImportSpec{Path: "io"}, + ) + } + if server.writesEndpointResult { + specs = append(specs, codegen.GoaImport("")) + } + return specs +} + +// planClientMainServer selects URL flag names that are distinct from the +// built-in client flags. +func planClientMainServer(server *Data) *clientMainServerData { + fixedFlags := []string{"host", "url", "timeout", "verbose", "v"} + if server.clientHasJSONRPC { + fixedFlags = append(fixedFlags, "jsonrpc", "j") + } + variables := planMainVariables(server.Variables, fixedFlags) + schemes := clientSchemes(server) + // Replace each placeholder with the default value used by its generated flag. + defaultHost := server.DefaultHost() + helpURL := defaultHost.DefaultURL(server.DefaultClientTransport().Type) + for _, variable := range defaultHost.Variables { + helpURL = strings.ReplaceAll(helpURL, "{"+variable.Name+"}", variables.byName[variable.Name].DefaultValue) + } + planned := &clientMainServerData{ + Data: server, + Transports: server.clientTransports, + Schemes: schemes, + HelpURL: helpURL, + Variables: variables.all, + Hosts: make([]*clientMainHostData, len(server.Hosts)), + } + for index, host := range server.Hosts { + plannedHost := &clientMainHostData{ + HostData: host, + Variables: make([]*mainVariableData, len(host.Variables)), + } + for variableIndex, variable := range host.Variables { + plannedHost.Variables[variableIndex] = variables.byName[variable.Name] } + planned.Hosts[index] = plannedHost } - return false + return planned } -// hasHTTP returns true if the server expression has an HTTP server. -func hasHTTP(root *expr.RootExpr, svr *expr.ServerExpr) bool { - for _, s := range svr.Services { - if root.API.HTTP.Service(s) != nil { - return true +// DefaultTransport returns the transport selected when the caller does not +// choose one. Only transports with generated commands are considered. +func (s *clientMainServerData) DefaultTransport() *TransportData { + return defaultTransport(s.Transports) +} + +// clientSchemes returns the server URL schemes that lead to a generated +// client command. +func clientSchemes(server *Data) []string { + selected := make(map[Transport]struct{}, len(server.clientTransports)) + for _, transport := range server.clientTransports { + selected[transport.Type] = struct{}{} + } + var schemes []string + for _, scheme := range server.Schemes { + if _, ok := selected[transportForScheme(scheme)]; ok { + schemes = append(schemes, scheme) } } - return false + return schemes +} + +// transportForScheme returns the transport used by one validated server URL +// scheme. +func transportForScheme(scheme string) Transport { + switch scheme { + case "http", "https": + return TransportHTTP + case "grpc", "grpcs": + return TransportGRPC + default: + panic("unsupported server scheme " + scheme) + } } diff --git a/codegen/example/example_client_test.go b/codegen/example/example_client_test.go index f89cc823e0..c0832f5576 100644 --- a/codegen/example/example_client_test.go +++ b/codegen/example/example_client_test.go @@ -1,33 +1,57 @@ +// This file verifies that common example CLI entrypoints render without a +// second generated-module path input. package example import ( "bytes" "path/filepath" + "strings" "testing" "github.com/stretchr/testify/require" "goa.design/goa/v3/codegen" "goa.design/goa/v3/codegen/example/testdata" + "goa.design/goa/v3/codegen/service" + dsl "goa.design/goa/v3/dsl" + "goa.design/goa/v3/eval" + "goa.design/goa/v3/expr" ) func TestExampleCLIFiles(t *testing.T) { cases := []struct { - Name string - DSL func() + Name string + DSL func() + HasEndpointResults bool + HasStreamResults bool }{ - {"no-server", testdata.NoServerDSL}, - {"single-server-single-host", testdata.SingleServerSingleHostDSL}, - {"single-server-single-host-with-variables", testdata.SingleServerSingleHostWithVariablesDSL}, - {"single-server-multiple-hosts", testdata.SingleServerMultipleHostsDSL}, - {"single-server-multiple-hosts-with-variables", testdata.SingleServerMultipleHostsWithVariablesDSL}, + {"no-server", testdata.NoServerDSL, true, false}, + {"single-server-single-host", testdata.SingleServerSingleHostDSL, true, false}, + {"single-server-single-host-with-variables", testdata.SingleServerSingleHostWithVariablesDSL, true, false}, + {"single-server-multiple-hosts", testdata.SingleServerMultipleHostsDSL, true, false}, + {"single-server-multiple-hosts-with-variables", testdata.SingleServerMultipleHostsWithVariablesDSL, true, false}, + {"server-stream", serverStreamClientDSL, false, true}, + {"input-stream", inputStreamClientDSL, false, false}, + {"mixed-results", mixedResultClientDSL, true, false}, + {"files-with-grpc", filesWithGRPCClientDSL, true, false}, + {"https-only", httpsOnlyClientDSL, true, false}, + {"grpcs-only", grpcsOnlyClientDSL, true, false}, + {"https-before-http", httpsBeforeHTTPClientDSL, true, false}, } for _, c := range cases { t.Run(c.Name, func(t *testing.T) { - // reset global variable - Servers = make(ServersData) root := codegen.RunDSL(t, c.DSL) - fs := CLIFiles("", root) + generation, err := codegen.NewGeneration("goa.design/goa/example", []eval.Root{root}) + require.NoError(t, err) + servicePlan, err := service.NewPlan(root, generation, expr.NewExampleGenerator(root.API.RandomizerFactory)) + require.NoError(t, err) + plan, err := NewPlan(generation, servicePlan) + require.NoError(t, err) + require.NoError(t, generation.Freeze()) + require.NoError(t, servicePlan.Link()) + rootData, ok := plan.Root(servicePlan) + require.True(t, ok) + fs := CLIFiles(rootData) require.Len(t, fs, 1) require.Greater(t, len(fs[0].SectionTemplates), 0) var buf bytes.Buffer @@ -35,8 +59,293 @@ func TestExampleCLIFiles(t *testing.T) { require.NoError(t, s.Write(&buf)) } code := codegen.FormatTestCode(t, "package foo\n"+buf.String()) + require.Equal(t, c.HasEndpointResults, strings.Contains(code, "func writeEndpointResult(")) + require.Equal(t, c.HasStreamResults, strings.Contains(code, "func writeStreamResults[")) + require.Equal(t, c.HasEndpointResults || c.HasStreamResults, strings.Contains(code, "func writeJSON(")) golden := filepath.Join("testdata", "client-"+c.Name+".golden") compareOrUpdateGolden(t, code, golden) }) } } + +func TestExampleCLIOmitsServerWithoutCallableMethods(t *testing.T) { + plannedRoot := plannedExampleRoot(t, func() { + dsl.Service("assets", func() { + dsl.Files("/index.html", "index.html") + }) + }) + + require.True(t, plannedRoot.Servers[0].HasHTTP) + require.NotNil(t, plannedRoot.Servers[0].DefaultTransport()) + require.Nil(t, plannedRoot.Servers[0].DefaultClientTransport()) + require.Empty(t, CLIFiles(plannedRoot)) +} + +func TestExampleCLIUsesOnlyTransportsWithCallableMethods(t *testing.T) { + plannedRoot := plannedExampleRoot(t, filesWithGRPCClientDSL) + + server := plannedRoot.Servers[0] + require.True(t, server.HasTransport(TransportHTTP)) + require.True(t, server.HasTransport(TransportGRPC)) + require.Equal(t, TransportHTTP, server.DefaultTransport().Type) + require.Equal(t, Transport(TransportGRPC), server.DefaultClientTransport().Type) + files := CLIFiles(plannedRoot) + require.Len(t, files, 1) + code := renderExampleSections(t, files[0]) + require.Contains(t, code, `case "grpc", "grpcs":`) + require.NotContains(t, code, `case "http", "https":`) + require.NotContains(t, code, "doHTTP(") + require.Contains(t, code, "valid schemes: grpc") +} + +func TestExampleCLIPlansLiteralHelpURL(t *testing.T) { + plannedRoot := plannedExampleRoot(t, httpsBeforeHTTPClientDSL) + plannedServer := planClientMainServer(plannedRoot.Servers[0]) + + require.Equal(t, "https://west.example.com:9443/base/v1/status", plannedServer.HelpURL) +} + +// plannedExampleRoot evaluates design and returns the server data used by +// generated example programs. +func plannedExampleRoot(t *testing.T, design func()) *Root { + t.Helper() + root := codegen.RunDSL(t, design) + generation, err := codegen.NewGeneration("example.local/gen", []eval.Root{root}) + require.NoError(t, err) + servicePlan, err := service.NewPlan(root, generation, expr.NewExampleGenerator(root.API.RandomizerFactory)) + require.NoError(t, err) + plan, err := NewPlan(generation, servicePlan) + require.NoError(t, err) + require.NoError(t, generation.Freeze()) + require.NoError(t, servicePlan.Link()) + plannedRoot, ok := plan.Root(servicePlan) + require.True(t, ok) + return plannedRoot +} + +var filesWithGRPCClientDSL = func() { + dsl.Service("assets", func() { + dsl.Files("/index.html", "index.html") + dsl.Method("read_status", func() { + dsl.Result(dsl.String) + dsl.GRPC(func() {}) + }) + }) +} + +var httpsOnlyClientDSL = func() { + dsl.API("secure HTTP", func() { + dsl.Server("secure", func() { + dsl.Services("status") + dsl.Host("local", func() { + dsl.URI("https://localhost:8443") + }) + }) + }) + dsl.Service("status", func() { + dsl.Method("read", func() { + dsl.Result(dsl.String) + dsl.HTTP(func() { + dsl.GET("/status") + }) + }) + }) +} + +var grpcsOnlyClientDSL = func() { + dsl.API("secure gRPC", func() { + dsl.Server("secure", func() { + dsl.Services("status") + dsl.Host("local", func() { + dsl.URI("grpcs://localhost:8443") + }) + }) + }) + dsl.Service("status", func() { + dsl.Method("read", func() { + dsl.Result(dsl.String) + dsl.GRPC(func() {}) + }) + }) +} + +var httpsBeforeHTTPClientDSL = func() { + dsl.API("ordered HTTP", func() { + dsl.Server("ordered", func() { + dsl.Services("status") + dsl.Host("public", func() { + dsl.URI("https://{region}.example.com:9443/base/{version}/status") + dsl.URI("http://localhost:8080") + dsl.Variable("region", dsl.String, func() { + dsl.Default("west") + }) + dsl.Variable("version", dsl.String, func() { + dsl.Enum("v1", "v2") + }) + }) + }) + }) + dsl.Service("status", func() { + dsl.Method("read", func() { + dsl.Result(dsl.String) + dsl.HTTP(func() { + dsl.GET("/status") + }) + }) + }) +} + +var serverStreamClientDSL = func() { + dsl.Service("events", func() { + dsl.Method("watch", func() { + dsl.StreamingResult(dsl.String) + dsl.GRPC(func() {}) + }) + }) +} + +var inputStreamClientDSL = func() { + dsl.Service("events", func() { + dsl.Method("upload", func() { + dsl.StreamingPayload(dsl.String) + dsl.Result(dsl.String) + dsl.HTTP(func() { + dsl.POST("/upload") + }) + dsl.GRPC(func() {}) + }) + }) +} + +var mixedResultClientDSL = func() { + dsl.Service("events", func() { + dsl.Method("create", func() { + dsl.Result(dsl.String) + dsl.StreamingResult(dsl.Int) + dsl.HTTP(func() { + dsl.POST("/create") + dsl.ServerSentEvents() + }) + }) + }) +} + +func TestMixedClientRoutesJSONRPCCommandsFromPlannedEndpoints(t *testing.T) { + root := codegen.RunDSL(t, mixedClientRoutingDSL) + generation, err := codegen.NewGeneration("example.local/gen", []eval.Root{root}) + require.NoError(t, err) + servicePlan, err := service.NewPlan(root, generation, expr.NewExampleGenerator(root.API.RandomizerFactory)) + require.NoError(t, err) + plan, err := NewPlan(generation, servicePlan) + require.NoError(t, err) + require.NoError(t, generation.Freeze()) + require.NoError(t, servicePlan.Link()) + plannedRoot, ok := plan.Root(servicePlan) + require.True(t, ok) + + files := CLIFiles(plannedRoot) + require.Len(t, files, 1) + code := renderExampleSections(t, files[0]) + require.NotContains(t, code, "strings.HasPrefix(err.Error()") + require.Contains(t, code, `case "catalog":`) + require.Contains(t, code, `case "watch":`) + require.Contains(t, code, "err = doJSONRPC(context.Background(), scheme, host, timeout, debug, os.Stdout)") + require.Contains(t, code, `usageCommands := []string{`) + require.NotContains(t, code, "sort.Strings(usageCommands)") + require.NotContains(t, code, "slices.Compact(usageCommands)") + first := strings.Index(code, `"catalog read"`) + second := strings.Index(code, `"catalog watch"`) + require.GreaterOrEqual(t, first, 0) + require.Greater(t, second, first) +} + +func TestMixedClientRoutesCollidingServicesByPlannedPaths(t *testing.T) { + root := codegen.RunDSL(t, mixedClientCollidingServicesDSL) + generation, err := codegen.NewGeneration("example.local/gen", []eval.Root{root}) + require.NoError(t, err) + servicePlan, err := service.NewPlan(root, generation, expr.NewExampleGenerator(root.API.RandomizerFactory)) + require.NoError(t, err) + plan, err := NewPlan(generation, servicePlan) + require.NoError(t, err) + require.NoError(t, generation.Freeze()) + require.NoError(t, servicePlan.Link()) + plannedRoot, ok := plan.Root(servicePlan) + require.True(t, ok) + + first := servicePlan.Services().Get("read_value").PathName + second := servicePlan.Services().Get("read-value").PathName + require.NotEqual(t, first, second) + code := renderExampleSections(t, CLIFiles(plannedRoot)[0]) + require.Contains(t, code, `case "`+codegen.KebabCase(first)+`":`) + require.Contains(t, code, `case "`+codegen.KebabCase(second)+`":`) + require.Contains(t, code, `"`+codegen.KebabCase(first)+` read"`) + require.Contains(t, code, `"`+codegen.KebabCase(first)+` watch"`) + require.Contains(t, code, `"`+codegen.KebabCase(second)+` read"`) + require.Contains(t, code, `"`+codegen.KebabCase(second)+` watch"`) +} + +func TestClientHostVariableValidationEmitsFixedCases(t *testing.T) { + root := codegen.RunDSL(t, testdata.SingleServerMultipleHostsWithVariablesDSL) + generation, err := codegen.NewGeneration("example.local/gen", []eval.Root{root}) + require.NoError(t, err) + servicePlan, err := service.NewPlan(root, generation, expr.NewExampleGenerator(root.API.RandomizerFactory)) + require.NoError(t, err) + plan, err := NewPlan(generation, servicePlan) + require.NoError(t, err) + require.NoError(t, generation.Freeze()) + require.NoError(t, servicePlan.Link()) + plannedRoot, ok := plan.Root(servicePlan) + require.True(t, ok) + + code := renderExampleSections(t, CLIFiles(plannedRoot)[0]) + require.Contains(t, code, `switch *versionF`) + require.Contains(t, code, `case "v1", "v2":`) + require.NotContains(t, code, "for _, v := range []string") +} + +var mixedClientRoutingDSL = func() { + dsl.API("mixed client", func() { + dsl.Server("public", func() { + dsl.Services("catalog") + dsl.Host("development", func() { + dsl.URI("http://localhost:8080") + }) + }) + }) + dsl.Service("catalog", func() { + dsl.JSONRPC(func() { + dsl.POST("/rpc") + }) + dsl.Method("read", func() { + dsl.HTTP(func() { + dsl.GET("/catalog") + }) + }) + dsl.Method("watch", func() { + dsl.JSONRPC(func() {}) + }) + }) +} + +var mixedClientCollidingServicesDSL = func() { + dsl.API("mixed client collisions", func() { + dsl.Server("public", func() { + dsl.Services("read_value", "read-value") + }) + }) + for _, name := range []string{"read_value", "read-value"} { + dsl.Service(name, func() { + dsl.JSONRPC(func() { + dsl.POST("/" + name) + }) + dsl.Method("read", func() { + dsl.HTTP(func() { + dsl.GET("/" + name) + }) + }) + dsl.Method("watch", func() { + dsl.JSONRPC(func() {}) + }) + }) + } +} diff --git a/codegen/example/example_server.go b/codegen/example/example_server.go index fa58ff3f0c..f8aa049527 100644 --- a/codegen/example/example_server.go +++ b/codegen/example/example_server.go @@ -1,158 +1,172 @@ +// This file writes the shared example server from copied server data and the +// package names already chosen for this generation. package example import ( - "os" "path" - "path/filepath" + "sort" "strings" "goa.design/goa/v3/codegen" "goa.design/goa/v3/codegen/service" - "goa.design/goa/v3/expr" ) -// ServerFiles returns an example server main implementation for every server -// expression in the service design. -func ServerFiles(genpkg string, root *expr.RootExpr, services *service.ServicesData) []*codegen.File { +type ( + // serverMainData contains every import, declaration, and local name used by + // one generated server main. + serverMainData struct { + // Server contains the listener settings and handler arguments written to the main. + Server *serverMainServerData + // Services lists the generated declarations and local names for each service. + Services []*serverMainServiceData + // APIPkg is the package name used for starter service constructors. + APIPkg string + // InterPkg is the package name used for starter interceptor constructors. + InterPkg string + // HasServices reports whether the main initializes any service endpoints. + HasServices bool + // HasInterceptors reports whether the main initializes server interceptors. + HasInterceptors bool + } + + // serverMainServerData keeps the server settings and handler arguments used + // by one generated main. + serverMainServerData struct { + *Data + // Variables lists every URL variable with its server flag names. + Variables []*mainVariableData + // Hosts contains each host and the arguments passed to its handlers. + Hosts []*serverMainHostData + } + + // serverMainHostData keeps one host and the arguments passed to each of its + // generated handlers. + serverMainHostData struct { + *HostData + // Variables lists the URL variables used by this host. + Variables []*mainVariableData + // URIs lists the host URLs and the arguments passed to their handlers. + URIs []*URIData + } + + // serverMainServiceData contains exact generated declarations and the local + // names used to connect one service to its handlers. + serverMainServiceData struct { + // Name is the design service name used to connect handler arguments. + Name string + // PkgName is the package name used for the generated service package. + PkgName string + // ServiceVar is the local variable holding the starter service. + ServiceVar string + // EndpointsVar is the local variable holding the service endpoints. + EndpointsVar string + // InterceptorsVar is the local variable holding server interceptors. + InterceptorsVar string + // HasMethods reports whether the service needs a service and endpoint value. + HasMethods bool + // HasServerInterceptors reports whether NewEndpoints takes interceptors. + HasServerInterceptors bool + // ServiceDeclaration is the exact generated service interface. + ServiceDeclaration *codegen.NameDeclaration + // EndpointsDeclaration is the exact generated endpoint collection type. + EndpointsDeclaration *codegen.NameDeclaration + // NewEndpointsDeclaration is the exact generated endpoint constructor. + NewEndpointsDeclaration *codegen.NameDeclaration + // ServerInterceptorsDeclaration is the exact generated interceptor interface. + ServerInterceptorsDeclaration *codegen.NameDeclaration + // ExampleConstructorDeclaration is the exact starter service constructor. + ExampleConstructorDeclaration *codegen.NameDeclaration + // ExampleInterceptorsConstructor is the exact starter server interceptor + // constructor. + ExampleInterceptorsConstructor *codegen.NameDeclaration + } +) + +// ServerFiles returns one example main program for each copied server. +func ServerFiles(root *Root, services *service.ServicesData) []*codegen.File { var fw []*codegen.File - for _, svr := range root.API.Servers { - if m := exampleSvrMain(genpkg, root, svr, services); m != nil { + for _, svr := range root.Servers { + if m := exampleSvrMain(svr, services); m != nil { fw = append(fw, m) } } return fw } -// APIPkg returns a unique package name for the example API implementation -// package derived from the API name. The name is registered with the given -// scope so subsequent calls return distinct names. -func APIPkg(root *expr.RootExpr, scope *codegen.NameScope) string { - return scope.Unique(strings.ToLower(codegen.Goify(root.API.Name, false)), "api") -} - -// RootPath returns the Go import path of the project root computed from the -// generated code package import path genpkg. It returns "." if genpkg has no -// parent path. +// RootPath returns the project import path that contains genpkg. func RootPath(genpkg string) string { - // genpkg is created by path.Join so the separator is / regardless of operating system - if idx := strings.LastIndex(genpkg, "/"); idx > 0 { - return genpkg[:idx] - } - return "." + return path.Dir(genpkg) } -// exampleSvrMain returns the default main function for the given server -// expression. -func exampleSvrMain(genpkg string, root *expr.RootExpr, svr *expr.ServerExpr, services *service.ServicesData) *codegen.File { - svrdata := Servers.Get(svr, root) - mainPath := filepath.Join("cmd", svrdata.Dir, "main.go") - if _, err := os.Stat(mainPath); !os.IsNotExist(err) { - return nil // file already exists, skip it. - } - specs := []*codegen.ImportSpec{ - {Path: "context"}, - {Path: "flag"}, - {Path: "fmt"}, - {Path: "net"}, - {Path: "net/url"}, - {Path: "os"}, - {Path: "os/signal"}, - {Path: "strings"}, - {Path: "sync"}, - {Path: "syscall"}, - {Path: "time"}, - {Path: "goa.design/clue/debug"}, - {Path: "goa.design/clue/log"}, - } +// exampleSvrMain writes the main program for server. +func exampleSvrMain(server *Data, services *service.ServicesData) *codegen.File { + mainPath := server.serverMainPath + outputPackage := server.serverPackage.ImportPath() + specs := packageImports(server.serverPackage, serverMainFixedImports()) - // Iterate through services listed in the server expression. - svcData := make([]*service.Data, len(svr.Services)) - scope := codegen.NewNameScope() + // Load the generated information for each service hosted by this server. + svcData := make([]*service.Data, len(server.Services)) hasInterceptors := false - for i, svc := range svr.Services { + serviceImports := make(map[string]struct{}, len(server.Services)) + servicePackages := make(map[string]string, len(server.Services)) + for i, svc := range server.Services { sd := services.Get(svc) svcData[i] = sd - specs = append(specs, &codegen.ImportSpec{ - Path: path.Join(genpkg, sd.PathName), - Name: scope.Unique(sd.PkgName, "svc"), - }) + serviceImport := services.ServiceImport(outputPackage, svc) + servicePackages[svc] = serviceImport.Name + if _, exists := serviceImports[serviceImport.Path]; !exists { + specs = append(specs, serviceImport) + serviceImports[serviceImport.Path] = struct{}{} + } hasInterceptors = hasInterceptors || len(sd.ServerInterceptors) > 0 } - interPkg := scope.Unique("interceptors", "ex") - - rootPath := RootPath(genpkg) - apiPkg := APIPkg(root, scope) - specs = append(specs, &codegen.ImportSpec{Path: rootPath, Name: apiPkg}) + rootPath := path.Dir(services.GenPkg()) + apiImport := services.PackageImport(outputPackage, rootPath) + apiPkg := apiImport.Name + specs = append(specs, apiImport) + var interPkg string if hasInterceptors { - specs = append(specs, &codegen.ImportSpec{Path: path.Join(rootPath, "interceptors"), Name: interPkg}) + interceptorImport := services.PackageImport(outputPackage, rootPath+"/interceptors") + interPkg = interceptorImport.Name + specs = append(specs, interceptorImport) } + main := planServerMain(server, svcData, servicePackages, apiPkg, interPkg) sections := []*codegen.SectionTemplate{ codegen.Header("", "main", specs), { Name: "server-main-start", Source: exampleTemplates.Read(serverStartT), - Data: map[string]any{ - "Server": svrdata, - }, + Data: main, FuncMap: map[string]any{ "join": strings.Join, }, }, { Name: "server-main-logger", Source: exampleTemplates.Read(serverLoggerT), - Data: map[string]any{ - "APIPkg": apiPkg, - "Server": svrdata, - }, + Data: main, }, { Name: "server-main-services", Source: exampleTemplates.Read(serverServicesT), - Data: map[string]any{ - "APIPkg": apiPkg, - "Services": svcData, - }, - FuncMap: map[string]any{ - "mustInitServices": mustInitServices, - }, + Data: main, }, { Name: "server-main-interceptors", Source: exampleTemplates.Read(serverInterceptorsT), - Data: map[string]any{ - "APIPkg": apiPkg, - "InterPkg": interPkg, - "Services": svcData, - "HasInterceptors": hasInterceptors, - }, - FuncMap: map[string]any{ - "mustInitServices": mustInitServices, - }, + Data: main, }, { Name: "server-main-endpoints", Source: exampleTemplates.Read(serverEndpointsT), - Data: map[string]any{ - "Services": svcData, - }, - FuncMap: map[string]any{ - "mustInitServices": mustInitServices, - }, + Data: main, }, { Name: "server-main-interrupts", Source: exampleTemplates.Read(serverInterruptsT), }, { Name: "server-main-handler", Source: exampleTemplates.Read(serverHandlerT), - Data: map[string]any{ - "Server": svrdata, - "Services": svcData, - }, + Data: main, FuncMap: map[string]any{ - "goify": codegen.Goify, "join": strings.Join, "toUpper": strings.ToUpper, - "hasJSONRPCEndpoints": func(svcData *service.Data) bool { - return hasJSONRPCEndpoints(root, svcData) - }, }, }, { @@ -164,23 +178,131 @@ func exampleSvrMain(genpkg string, root *expr.RootExpr, svr *expr.ServerExpr, se return &codegen.File{Path: mainPath, SectionTemplates: sections, SkipExist: true} } -// mustInitServices returns true if at least one of the services defines methods. -// It is used by the template to initialize service variables. -func mustInitServices(data []*service.Data) bool { - for _, svc := range data { - if len(svc.Methods) > 0 { - return true +// serverMainFixedImports lists packages whose names are written directly by +// the server main templates. +func serverMainFixedImports() []*codegen.ImportSpec { + return []*codegen.ImportSpec{ + {Path: "context"}, + {Path: "flag"}, + {Path: "fmt"}, + {Path: "net"}, + {Path: "net/url"}, + {Path: "os"}, + {Path: "os/signal"}, + {Path: "strings"}, + {Path: "sync"}, + {Path: "syscall"}, + {Path: "time"}, + {Path: "goa.design/clue/debug"}, + {Path: "goa.design/clue/log"}, + } +} + +// planServerMain chooses every local name once and connects each handler +// argument to the matching service variable. +func planServerMain( + server *Data, + services []*service.Data, + packages map[string]string, + apiPkg, interPkg string, +) *serverMainData { + scope := codegen.NewNameScope() + importNames := map[string]struct{}{apiPkg: {}} + if interPkg != "" { + importNames[interPkg] = struct{}{} + } + for _, packageName := range packages { + importNames[packageName] = struct{}{} + } + orderedImports := make([]string, 0, len(importNames)) + for name := range importNames { + orderedImports = append(orderedImports, name) + } + sort.Strings(orderedImports) + for _, name := range orderedImports { + scope.Unique(name) + } + for _, name := range []string{ + "addr", "c", "cancel", "context", "ctx", "debug", "err", "errc", + "flag", "fmt", "format", "h", "log", "net", "os", "signal", "strings", + "sync", "syscall", "time", "u", "url", "wg", + } { + scope.Unique(name) + } + byName := make(map[string]*serverMainServiceData, len(services)) + main := &serverMainData{ + APIPkg: apiPkg, + InterPkg: interPkg, + HasInterceptors: interPkg != "", + } + for _, serviceData := range services { + planned := &serverMainServiceData{ + Name: serviceData.Name, + PkgName: packages[serviceData.Name], + HasMethods: len(serviceData.Methods) > 0, + HasServerInterceptors: len(serviceData.ServerInterceptors) > 0, + ServiceDeclaration: serviceData.ServiceDeclaration, + EndpointsDeclaration: serviceData.EndpointsDeclaration, + NewEndpointsDeclaration: serviceData.NewEndpointsDeclaration, + ServerInterceptorsDeclaration: serviceData.ServerInterceptorsDeclaration, + ExampleConstructorDeclaration: serviceData.ExampleConstructorDeclaration, + ExampleInterceptorsConstructor: serviceData.ExampleServerInterceptorsConstructorDeclaration, + } + if planned.HasMethods { + base := codegen.Goify(serviceData.Name, false) + planned.ServiceVar = scope.Unique(base + "Svc") + planned.EndpointsVar = scope.Unique(base + "Endpoints") + if planned.HasServerInterceptors { + planned.InterceptorsVar = scope.Unique(base + "Interceptors") + } + main.HasServices = true } + main.Services = append(main.Services, planned) + byName[planned.Name] = planned } - return false + main.Server = planServerMainHandlers(server, byName) + return main } -// hasJSONRPCEndpoints returns true if the service has JSON-RPC endpoints. -func hasJSONRPCEndpoints(root *expr.RootExpr, data *service.Data) bool { - for _, svc := range root.API.JSONRPC.Services { - if svc.Name() == data.Name { - return true +// planServerMainHandlers copies each host and replaces service names with the +// local variables chosen for this main function. +func planServerMainHandlers(server *Data, services map[string]*serverMainServiceData) *serverMainServerData { + fixedFlags := make([]string, 0, 4+len(server.Transports)) + fixedFlags = append(fixedFlags, "host", "domain", "secure", "debug") + for _, transport := range server.Transports { + fixedFlags = append(fixedFlags, string(transport.Type)+"-port") + } + variables := planMainVariables(server.Variables, fixedFlags) + planned := &serverMainServerData{ + Data: server, + Variables: variables.all, + Hosts: make([]*serverMainHostData, len(server.Hosts)), + } + for hostIndex, host := range server.Hosts { + plannedHost := &serverMainHostData{ + HostData: host, + Variables: make([]*mainVariableData, len(host.Variables)), + URIs: make([]*URIData, len(host.URIs)), + } + for variableIndex, variable := range host.Variables { + plannedHost.Variables[variableIndex] = variables.byName[variable.Name] + } + for uriIndex, uri := range host.URIs { + plannedURI := *uri + plannedURI.HandlerArgs = make([]HandlerArg, len(uri.HandlerArgs)) + for argIndex, arg := range uri.HandlerArgs { + plannedArg := arg + service := services[arg.Service] + if arg.Endpoint { + plannedArg.Variable = service.EndpointsVar + } else { + plannedArg.Variable = service.ServiceVar + } + plannedURI.HandlerArgs[argIndex] = plannedArg + } + plannedHost.URIs[uriIndex] = &plannedURI } + planned.Hosts[hostIndex] = plannedHost } - return false + return planned } diff --git a/codegen/example/example_server_test.go b/codegen/example/example_server_test.go index 59f2242683..b347eb0b33 100644 --- a/codegen/example/example_server_test.go +++ b/codegen/example/example_server_test.go @@ -1,3 +1,5 @@ +// This file verifies that generated example servers and command-line programs +// contain the service and transport wiring required by representative designs. package example import ( @@ -14,6 +16,9 @@ import ( "goa.design/goa/v3/codegen" "goa.design/goa/v3/codegen/example/testdata" "goa.design/goa/v3/codegen/service" + dsl "goa.design/goa/v3/dsl" + "goa.design/goa/v3/eval" + "goa.design/goa/v3/expr" ) // updateGolden is true when -w is passed to `go test`, e.g. `go test ./... -w` @@ -59,10 +64,19 @@ func TestExampleServerFiles(t *testing.T) { } for _, c := range cases { t.Run(c.Name, func(t *testing.T) { - Servers = make(ServersData) root := codegen.RunDSL(t, c.DSL) - services := service.NewServicesData(root) - fs := ServerFiles("", root, services) + generation, err := codegen.NewGeneration("goa.design/goa/example", []eval.Root{root}) + require.NoError(t, err) + servicePlan, err := service.NewPlan(root, generation, expr.NewExampleGenerator(root.API.RandomizerFactory)) + require.NoError(t, err) + examplePlan, err := NewPlan(generation, servicePlan) + require.NoError(t, err) + rootData, ok := examplePlan.Root(servicePlan) + require.True(t, ok) + require.NoError(t, generation.Freeze()) + require.NoError(t, servicePlan.Link()) + services := servicePlan.Services() + fs := ServerFiles(rootData, services) require.Len(t, fs, 1) require.Greater(t, len(fs[0].SectionTemplates), 0) var buf bytes.Buffer @@ -75,3 +89,131 @@ func TestExampleServerFiles(t *testing.T) { }) } } + +func TestGRPCOnlyServerLoggerDoesNotUseHTTPPort(t *testing.T) { + root := codegen.RunDSL(t, testdata.ServiceForOnlyGRPCDSL) + generation, err := codegen.NewGeneration("goa.design/goa/example", []eval.Root{root}) + require.NoError(t, err) + servicePlan, err := service.NewPlan(root, generation, expr.NewExampleGenerator(root.API.RandomizerFactory)) + require.NoError(t, err) + examplePlan, err := NewPlan(generation, servicePlan) + require.NoError(t, err) + rootData, ok := examplePlan.Root(servicePlan) + require.True(t, ok) + require.NoError(t, generation.Freeze()) + require.NoError(t, servicePlan.Link()) + files := ServerFiles(rootData, servicePlan.Services()) + require.Len(t, files, 1) + + var code bytes.Buffer + for _, section := range files[0].SectionTemplates { + require.NoError(t, section.Write(&code)) + } + require.NotContains(t, code.String(), "httpPortF") + require.Contains(t, code.String(), `log.KV{K: "grpc-port", V: *grpcPortF}`) +} + +func TestServerMainUsesPlannedDeclarationsAndDistinctLocals(t *testing.T) { + root := codegen.RunDSL(t, collidingServerServicesDSL) + generation, err := codegen.NewGeneration("example.local/gen", []eval.Root{root}) + require.NoError(t, err) + servicePlan, err := service.NewPlan(root, generation, expr.NewExampleGenerator(root.API.RandomizerFactory)) + require.NoError(t, err) + examplePlan, err := NewPlan(generation, servicePlan) + require.NoError(t, err) + require.NoError(t, generation.Freeze()) + require.NoError(t, servicePlan.Link()) + plannedRoot, ok := examplePlan.Root(servicePlan) + require.True(t, ok) + services := servicePlan.Services() + files := ServerFiles(plannedRoot, services) + require.Len(t, files, 1) + code := renderExampleSections(t, files[0]) + + first := services.Get("foo-bar") + second := services.Get("foo bar") + outputPackage := plannedRoot.Servers[0].serverPackage.ImportPath() + serviceImport := services.ServiceImport(outputPackage, first.Name) + require.Contains(t, code, serviceImport.Name+` "`+serviceImport.Path+`"`) + require.Contains(t, code, serviceImport.Name+"."+first.ServiceDeclaration.Name()) + require.Contains(t, code, serviceImport.Name+"."+second.ServiceDeclaration.Name()) + require.Contains(t, code, "."+first.ExampleConstructorDeclaration.Name()+"()") + require.Contains(t, code, "."+second.ExampleConstructorDeclaration.Name()+"()") + require.Contains(t, code, "."+first.NewEndpointsDeclaration.Name()+"(") + require.Contains(t, code, "."+second.NewEndpointsDeclaration.Name()+"(") + require.Contains(t, code, "."+first.ServerInterceptorsDeclaration.Name()) + require.Contains(t, code, "."+second.ServerInterceptorsDeclaration.Name()) + require.Contains(t, code, "."+first.ExampleServerInterceptorsConstructorDeclaration.Name()+"()") + require.Contains(t, code, "."+second.ExampleServerInterceptorsConstructorDeclaration.Name()+"()") + require.Contains(t, code, "fooBarSvc2") + require.Contains(t, code, "fooBarEndpoints2") + require.Contains(t, code, "fooBarInterceptors2") +} + +// TestServerMainUsesItsOutputPackageImportNames checks that clue/log keeps the +// name log in cmd/public/main.go. The application receives log2, and every +// generated call uses that selected name. +func TestServerMainUsesItsOutputPackageImportNames(t *testing.T) { + root := codegen.RunDSL(t, func() { + dsl.API("log", func() { + dsl.Server("public", func() { + dsl.Services("status") + dsl.Host("development", func() { + dsl.URI("http://localhost:8080") + }) + }) + }) + dsl.Service("status", func() { + dsl.Method("read", func() { + dsl.HTTP(func() { + dsl.GET("/status") + }) + }) + }) + }) + generation, err := codegen.NewGeneration("example.local/gen", []eval.Root{root}) + require.NoError(t, err) + servicePlan, err := service.NewPlan(root, generation, expr.NewExampleGenerator(root.API.RandomizerFactory)) + require.NoError(t, err) + examplePlan, err := NewPlan(generation, servicePlan) + require.NoError(t, err) + require.NoError(t, generation.Freeze()) + require.NoError(t, servicePlan.Link()) + plannedRoot, ok := examplePlan.Root(servicePlan) + require.True(t, ok) + + files := ServerFiles(plannedRoot, servicePlan.Services()) + require.Len(t, files, 1) + code := renderExampleSections(t, files[0]) + require.Contains(t, code, `log2 "example.local"`) + require.Contains(t, code, `"goa.design/clue/log"`) + require.Contains(t, code, "log2.NewStatus()") +} + +var collidingServerServicesDSL = func() { + trace := dsl.Interceptor("trace") + dsl.API("colliding server", func() { + dsl.Server("public", func() { + dsl.Services("foo-bar", "foo bar") + dsl.Host("development", func() { + dsl.URI("http://localhost:8080") + }) + }) + }) + dsl.Service("foo-bar", func() { + dsl.ServerInterceptor(trace) + dsl.Method("first", func() { + dsl.HTTP(func() { + dsl.GET("/first") + }) + }) + }) + dsl.Service("foo bar", func() { + dsl.ServerInterceptor(trace) + dsl.Method("second", func() { + dsl.HTTP(func() { + dsl.GET("/second") + }) + }) + }) +} diff --git a/codegen/example/plan.go b/codegen/example/plan.go new file mode 100644 index 0000000000..cade016fc8 --- /dev/null +++ b/codegen/example/plan.go @@ -0,0 +1,140 @@ +// This file copies the server information used by generated examples and +// records every package imported by their server and client programs. +package example + +import ( + "path" + "path/filepath" + + "goa.design/goa/v3/codegen" + "goa.design/goa/v3/codegen/service" +) + +type ( + // Plan stores copied API, service, server, and JSON-RPC names for one + // example generation. + Plan struct { + rootByService map[*service.Plan]*Root + } + + // Root stores the API name, service names, and server descriptions copied + // from one design. + Root struct { + // APIName is the design API name written in example help text. + APIName string + // Services lists every design service in declaration order. + Services []string + // Servers lists the copied server values in declaration order. + Servers []*Data + } +) + +// NewPlan copies the server information and final service paths from each +// service plan, then records the imports used by every generated server and +// command-line client. +func NewPlan(generation *codegen.Generation, services ...*service.Plan) (*Plan, error) { + plan := &Plan{rootByService: make(map[*service.Plan]*Root, len(services))} + for _, servicePlan := range services { + design := servicePlan.Root() + serviceCommands := make(map[string]string, len(design.Services)) + plannedRoot := &Root{ + APIName: design.API.Name, + Services: make([]string, len(design.Services)), + Servers: make([]*Data, len(design.API.Servers)), + } + for i, service := range design.Services { + plannedRoot.Services[i] = service.Name + serviceImport, _, err := servicePlan.ServicePackageImports(service) + if err != nil { + return nil, err + } + serviceCommands[service.Name] = codegen.KebabCase(path.Base(serviceImport.Path)) + } + for i, server := range design.API.Servers { + planned := buildServerData(server, design, serviceCommands) + if err := planMainPackages(generation, servicePlan, planned); err != nil { + return nil, err + } + plannedRoot.Servers[i] = planned + } + plan.rootByService[servicePlan] = plannedRoot + } + return plan, nil +} + +// Root returns the copied design description for servicePlan. The second +// result is false when servicePlan was not used to create this plan. +func (p *Plan) Root(servicePlan *service.Plan) (*Root, bool) { + root, ok := p.rootByService[servicePlan] + return root, ok +} + +// planMainPackages records the imports used by one generated server and its +// command-line client before generation chooses their Go names. +func planMainPackages(generation *codegen.Generation, servicePlan *service.Plan, server *Data) error { + rootPath := RootPath(generation.GenPkg()) + serverPath := path.Join(rootPath, "cmd", server.Dir) + serverPackage, err := generation.ClaimOutputPackage(serverPath, path.Dir(filepath.ToSlash(server.serverMainPath))) + if err != nil { + return err + } + server.serverPackage = serverPackage + generated := make([]*codegen.ImportSpec, 0, len(server.Services)+2) + for _, serviceName := range server.Services { + serviceImport, _, err := servicePlan.ServicePackageImports(servicePlan.Root().Service(serviceName)) + if err != nil { + return err + } + generated = append(generated, serviceImport) + } + hasInterceptors := false + for _, serviceName := range server.Services { + hasInterceptors = hasInterceptors || len(servicePlan.Root().Service(serviceName).ServerInterceptors) > 0 + } + for _, spec := range servicePlan.ExampleImports() { + if spec.Path == path.Join(rootPath, "interceptors") && !hasInterceptors { + continue + } + generated = append(generated, spec) + } + if err := registerPackageImports(serverPackage, serverMainFixedImports(), generated); err != nil { + return err + } + + if server.DefaultClientTransport() == nil { + return nil + } + clientPath := path.Join(rootPath, "cmd", server.Dir+"-cli") + clientPackage, err := generation.ClaimOutputPackage(clientPath, path.Dir(filepath.ToSlash(server.clientMainPath))) + if err != nil { + return err + } + server.clientPackage = clientPackage + return registerPackageImports(clientPackage, clientMainFixedImports(server), nil) +} + +// registerPackageImports records names written directly in templates first. +// Generated packages receive another name when a template already uses theirs. +func registerPackageImports(owner *codegen.GeneratedPackage, fixed, generated []*codegen.ImportSpec) error { + for _, spec := range fixed { + if err := owner.RequireImport(spec); err != nil { + return err + } + } + for _, spec := range generated { + if err := owner.ReserveGeneratedImport(spec); err != nil { + return err + } + } + return nil +} + +// packageImports returns the import declarations chosen for one generated +// file after generation has made every package name final. +func packageImports(owner *codegen.GeneratedPackage, planned []*codegen.ImportSpec) []*codegen.ImportSpec { + imports := make([]*codegen.ImportSpec, len(planned)) + for index, spec := range planned { + imports[index] = owner.Import(spec.Path) + } + return imports +} diff --git a/codegen/example/plan_test.go b/codegen/example/plan_test.go new file mode 100644 index 0000000000..2ce27d0422 --- /dev/null +++ b/codegen/example/plan_test.go @@ -0,0 +1,283 @@ +// This file checks that each example generation keeps its copied server data +// separate from every other generation. +package example + +import ( + "bytes" + "reflect" + "sync" + "testing" + + "github.com/stretchr/testify/require" + + "goa.design/goa/v3/codegen" + "goa.design/goa/v3/codegen/example/testdata" + "goa.design/goa/v3/codegen/service" + dsl "goa.design/goa/v3/dsl" + "goa.design/goa/v3/eval" + "goa.design/goa/v3/expr" +) + +func TestPlansKeepSameNamedServersSeparate(t *testing.T) { + httpRoot := codegen.RunDSL(t, testdata.ServiceForOnlyHTTPDSL) + grpcRoot := codegen.RunDSL(t, testdata.ServiceForOnlyGRPCDSL) + + httpGeneration, err := codegen.NewGeneration("example.local/http/gen", []eval.Root{httpRoot}) + require.NoError(t, err) + grpcGeneration, err := codegen.NewGeneration("example.local/grpc/gen", []eval.Root{grpcRoot}) + require.NoError(t, err) + + httpService, err := service.NewPlan(httpRoot, httpGeneration, expr.NewExampleGenerator(httpRoot.API.RandomizerFactory)) + require.NoError(t, err) + httpPlan, err := NewPlan(httpGeneration, httpService) + require.NoError(t, err) + httpData, ok := httpPlan.Root(httpService) + require.True(t, ok) + httpServer := httpData.Servers[0] + require.True(t, httpServer.HasHTTP) + require.False(t, httpServer.HasTransport(TransportGRPC)) + + grpcService, err := service.NewPlan(grpcRoot, grpcGeneration, expr.NewExampleGenerator(grpcRoot.API.RandomizerFactory)) + require.NoError(t, err) + grpcPlan, err := NewPlan(grpcGeneration, grpcService) + require.NoError(t, err) + grpcData, ok := grpcPlan.Root(grpcService) + require.True(t, ok) + grpcServer := grpcData.Servers[0] + require.False(t, grpcServer.HasHTTP) + require.True(t, grpcServer.HasTransport(TransportGRPC)) + + require.True(t, httpServer.HasHTTP) + require.False(t, httpServer.HasTransport(TransportGRPC)) +} + +func TestPlanKeepsHostVariableDefaultAndAllowedValues(t *testing.T) { + root := codegen.RunDSL(t, func() { + dsl.API("host variables", func() { + dsl.Server("public", func() { + dsl.Services("status") + dsl.Host("production", func() { + dsl.URI("https://{region}.example.com") + dsl.Variable("region", dsl.String, func() { + dsl.Default("west") + dsl.Enum("west", "east") + }) + }) + }) + }) + dsl.Service("status", func() { + dsl.Method("read", func() { + dsl.HTTP(func() { + dsl.GET("/status") + }) + }) + }) + }) + generation, err := codegen.NewGeneration("example.local/gen", []eval.Root{root}) + require.NoError(t, err) + servicePlan, err := service.NewPlan(root, generation, expr.NewExampleGenerator(root.API.RandomizerFactory)) + require.NoError(t, err) + plan, err := NewPlan(generation, servicePlan) + require.NoError(t, err) + plannedRoot, ok := plan.Root(servicePlan) + require.True(t, ok) + + variable := plannedRoot.Servers[0].Hosts[0].Variables[0] + require.Equal(t, "west", variable.DefaultValue) + require.Equal(t, []string{"west", "east"}, variable.Values) +} + +// TestPlanUsesURLRoleWhenAHostVariableMatchesABuiltInFlag checks that a +// generated flag says what it configures instead of receiving a number. +func TestPlanUsesURLRoleWhenAHostVariableMatchesABuiltInFlag(t *testing.T) { + root := codegen.RunDSL(t, func() { + dsl.API("host variable collision", func() { + dsl.Server("public", func() { + dsl.Services("status") + dsl.Host("production", func() { + dsl.URI("https://{host}.example.com") + dsl.Variable("host", dsl.String, func() { + dsl.Default("west") + dsl.Enum("west", "east") + }) + }) + }) + }) + dsl.Service("status", func() { + dsl.Method("read", func() { + dsl.HTTP(func() { + dsl.GET("/status") + }) + }) + }) + }) + generation, err := codegen.NewGeneration("example.local/gen", []eval.Root{root}) + require.NoError(t, err) + servicePlan, err := service.NewPlan(root, generation, expr.NewExampleGenerator(root.API.RandomizerFactory)) + require.NoError(t, err) + plan, err := NewPlan(generation, servicePlan) + require.NoError(t, err) + plannedRoot, ok := plan.Root(servicePlan) + require.True(t, ok) + + plannedClient := planClientMainServer(plannedRoot.Servers[0]) + variable := plannedClient.Variables[0] + require.Equal(t, "url-host", variable.FlagName) + require.Equal(t, "urlHostF", variable.VarName) + require.Same(t, variable, plannedClient.Hosts[0].Variables[0]) + + plannedServer := planMainVariables(plannedRoot.Servers[0].Variables, []string{"host"}) + require.Equal(t, "url-host", plannedServer.all[0].FlagName) + require.Equal(t, "urlHostF", plannedServer.all[0].VarName) +} + +// TestPlanFindsRootForExactServicePlan checks that copied server data belongs +// only to the service plan from which it was built. +func TestPlanFindsRootForExactServicePlan(t *testing.T) { + firstRoot := codegen.RunDSL(t, testdata.ServiceForOnlyHTTPDSL) + secondRoot := codegen.RunDSL(t, testdata.ServiceForOnlyHTTPDSL) + firstGeneration, err := codegen.NewGeneration("example.local/first/gen", []eval.Root{firstRoot}) + require.NoError(t, err) + firstService, err := service.NewPlan(firstRoot, firstGeneration, expr.NewExampleGenerator(firstRoot.API.RandomizerFactory)) + require.NoError(t, err) + secondGeneration, err := codegen.NewGeneration("example.local/second/gen", []eval.Root{secondRoot}) + require.NoError(t, err) + secondService, err := service.NewPlan(secondRoot, secondGeneration, expr.NewExampleGenerator(secondRoot.API.RandomizerFactory)) + require.NoError(t, err) + plan, err := NewPlan(firstGeneration, firstService) + require.NoError(t, err) + + root, ok := plan.Root(firstService) + require.True(t, ok) + require.Equal(t, firstRoot.API.Name, root.APIName) + _, ok = plan.Root(secondService) + require.False(t, ok) +} + +func TestPlansBuildConcurrently(t *testing.T) { + httpRoot := codegen.RunDSL(t, testdata.ServiceForOnlyHTTPDSL) + grpcRoot := codegen.RunDSL(t, testdata.ServiceForOnlyGRPCDSL) + httpGeneration, err := codegen.NewGeneration("example.local/http/gen", []eval.Root{httpRoot}) + require.NoError(t, err) + grpcGeneration, err := codegen.NewGeneration("example.local/grpc/gen", []eval.Root{grpcRoot}) + require.NoError(t, err) + httpService, err := service.NewPlan(httpRoot, httpGeneration, expr.NewExampleGenerator(httpRoot.API.RandomizerFactory)) + require.NoError(t, err) + grpcService, err := service.NewPlan(grpcRoot, grpcGeneration, expr.NewExampleGenerator(grpcRoot.API.RandomizerFactory)) + require.NoError(t, err) + + start := make(chan struct{}) + var ( + plans [2]*Plan + errs [2]error + ready sync.WaitGroup + wait sync.WaitGroup + ) + build := func(index int, generation *codegen.Generation, servicePlan *service.Plan) { + defer wait.Done() + ready.Done() + <-start + plans[index], errs[index] = NewPlan(generation, servicePlan) + } + ready.Add(2) + wait.Add(2) + go build(0, httpGeneration, httpService) + go build(1, grpcGeneration, grpcService) + ready.Wait() + close(start) + wait.Wait() + + require.NoError(t, errs[0]) + require.NoError(t, errs[1]) + httpData, ok := plans[0].Root(httpService) + require.True(t, ok) + grpcData, ok := plans[1].Root(grpcService) + require.True(t, ok) + require.True(t, httpData.Servers[0].HasHTTP) + require.False(t, grpcData.Servers[0].HasHTTP) +} + +// TestPlanCopiesEveryServerValue checks that example output does not keep a +// path back to the design values it copied. +func TestPlanCopiesEveryServerValue(t *testing.T) { + root := codegen.RunDSL(t, testdata.ServiceForOnlyHTTPDSL) + generation, err := codegen.NewGeneration("example.local/http/gen", []eval.Root{root}) + require.NoError(t, err) + servicePlan, err := service.NewPlan(root, generation, expr.NewExampleGenerator(root.API.RandomizerFactory)) + require.NoError(t, err) + plan, err := NewPlan(generation, servicePlan) + require.NoError(t, err) + require.NoError(t, generation.Freeze()) + require.NoError(t, servicePlan.Link()) + + copied, ok := plan.Root(servicePlan) + require.True(t, ok) + server := root.API.Servers[0] + require.False(t, pointsToDesignValue(reflect.ValueOf(copied), map[uintptr]struct{}{ + reflect.ValueOf(root).Pointer(): {}, + reflect.ValueOf(server).Pointer(): {}, + }, make(map[uintptr]struct{}))) + + files := CLIFiles(copied) + require.NotEmpty(t, files) + before := renderExampleSections(t, files[0]) + root.API.Name = "changed api" + server.Name = "changed server" + server.Description = "changed description" + server.Services = nil + server.Hosts = nil + require.Equal(t, before, renderExampleSections(t, files[0])) +} + +// renderExampleSections writes the complete file without touching the file +// system so a test can compare the exact generated text. +func renderExampleSections(t *testing.T, file *codegen.File) string { + t.Helper() + var output bytes.Buffer + for _, section := range file.SectionTemplates { + require.NoError(t, section.Write(&output)) + } + return output.String() +} + +// pointsToDesignValue reports whether value contains one of the original +// design pointers. visited prevents loops in linked values. +func pointsToDesignValue(value reflect.Value, targets, visited map[uintptr]struct{}) bool { + if !value.IsValid() { + return false + } + switch value.Kind() { + case reflect.Interface: + return pointsToDesignValue(value.Elem(), targets, visited) + case reflect.Pointer: + pointer := value.Pointer() + if _, ok := targets[pointer]; ok { + return true + } + if _, ok := visited[pointer]; ok { + return false + } + visited[pointer] = struct{}{} + return pointsToDesignValue(value.Elem(), targets, visited) + case reflect.Map: + for iterator := value.MapRange(); iterator.Next(); { + if pointsToDesignValue(iterator.Key(), targets, visited) || + pointsToDesignValue(iterator.Value(), targets, visited) { + return true + } + } + case reflect.Slice, reflect.Array: + for index := 0; index < value.Len(); index++ { + if pointsToDesignValue(value.Index(index), targets, visited) { + return true + } + } + case reflect.Struct: + for index := 0; index < value.NumField(); index++ { + if pointsToDesignValue(value.Field(index), targets, visited) { + return true + } + } + } + return false +} diff --git a/codegen/example/public_api_test.go b/codegen/example/public_api_test.go new file mode 100644 index 0000000000..cf86b23650 --- /dev/null +++ b/codegen/example/public_api_test.go @@ -0,0 +1,15 @@ +// This file protects small released helpers that plugins and generator tools +// can use without rebuilding example-generation data. +package example + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +// TestRootPathReturnsProjectImportPath checks the released project path helper. +func TestRootPathReturnsProjectImportPath(t *testing.T) { + require.Equal(t, "goa.design/calc", RootPath("goa.design/calc/gen")) + require.Equal(t, ".", RootPath("gen")) +} diff --git a/codegen/example/server_data.go b/codegen/example/server_data.go index 830a637bfd..aad1a93d08 100644 --- a/codegen/example/server_data.go +++ b/codegen/example/server_data.go @@ -1,7 +1,12 @@ +// This file copies server, host, URL, and transport values used to write +// example programs. package example import ( "fmt" + "path/filepath" + "slices" + "sort" "strconv" "strings" @@ -9,15 +14,7 @@ import ( "goa.design/goa/v3/expr" ) -// Servers holds the server data needed to generate the example service and -// client. It is computed from the Server expressions in the service design. -var Servers = make(ServersData) - type ( - // ServersData holds the server data from the service design indexed by - // server name. - ServersData map[string]*Data - // Data contains the data about a single server. Data struct { // Name is the server name. @@ -35,7 +32,32 @@ type ( // Transports is the list of transports defined in the server. Transports []*TransportData // Dir is the directory name for the generated client and server examples. - Dir string + Dir string + serverMainPath string + clientMainPath string + // serverPackage stores the import names selected for cmd/ files. + serverPackage *codegen.GeneratedPackage + // clientPackage stores the import names selected for cmd/-cli files. + clientPackage *codegen.GeneratedPackage + // HasHTTP reports whether the server exposes an ordinary HTTP service. + HasHTTP bool + // HasJSONRPC reports whether the server exposes a JSON-RPC service. + HasJSONRPC bool + clientTransports []*TransportData + clientHasHTTP bool + clientHasJSONRPC bool + writesEndpointResult bool + writesStreamResults bool + usageCommands []string + jsonRPCOnly []*jsonRPCServiceData + } + + // jsonRPCServiceData lists the JSON-RPC-only endpoints for one service. + jsonRPCServiceData struct { + // Service is the command-line service name. + Service string + // Endpoints lists command-line endpoint names. + Endpoints []string } // HostData contains the data about a single host in a server. @@ -44,8 +66,7 @@ type ( Name string // Description is the host description. Description string - // Schemes is the list of schemes supported by the host. It is computed - // from the URI expressions defined in the Host. + // Schemes lists the protocols used by the host URLs. // Possible values are http, https, grpc, grpcs. Schemes []string // URIs is the list of URLs defined in the host. @@ -60,19 +81,31 @@ type ( Name string // Description is the variable description. Description string - // VarName is the variable name used in generating flag variables. - VarName string - // DefaultValue is the default value for the variable. It is set to the - // default value defined in the variable attribute if exists, or else set - // to the first value in the enum expression. + // DefaultValue is the configured default, or the first allowed value when + // no default was configured. DefaultValue string - // Values is the list of allowed values for the variable. The values can - // only be primitives. We convert the primitives into string type so that - // we could use them to replace the URL variables in the example - // generation. + // Values lists the allowed values as text so the generated program can + // replace variables in a URL. Values []string } + // mainVariableData contains the exact command-line and Go names selected + // for one URL variable in one generated main program. + mainVariableData struct { + *VariableData + // FlagName is the exact command-line flag name. + FlagName string + // VarName is the exact Go variable name holding the flag value. + VarName string + } + + // mainVariables contains every planned URL variable and provides the same + // planned value to each host that uses it. + mainVariables struct { + all []*mainVariableData + byName map[string]*mainVariableData + } + // URIData contains the data about a URL. URIData struct { // URL is the underlying URL. @@ -84,17 +117,20 @@ type ( Port string // Transport is the transport type for the URL. Transport *TransportData - // HandlerArgs are the precomputed handler arguments for this URI used by - // the example server template. Each entry may contain an Endpoint and/or - // Service argument name to be passed to the handler in order. + // HandlerArgs lists the service values passed to the generated handler in + // call order. The generated main adds each local variable name later. HandlerArgs []HandlerArg } - // HandlerArg represents one argument slot to the handler call in the example - // server. Only one of Endpoint or Service may be set for each entry. + // HandlerArg identifies one service or endpoint value passed to a generated + // transport handler. HandlerArg struct { - Endpoint string - Service string + // Service is the design service name. + Service string + // Endpoint is true when the handler receives the service's endpoint collection. + Endpoint bool + // Variable is the local variable passed by the generated main. + Variable string } // TransportData contains the data about a transport (http or grpc). @@ -118,18 +154,7 @@ const ( TransportGRPC = "grpc" ) -// Get returns the server data for the given server expression. It builds the -// server data if the server name does not exist in the map. -func (d ServersData) Get(svr *expr.ServerExpr, root *expr.RootExpr) *Data { - if data, ok := d[svr.Name]; ok { - return data - } - sd := buildServerData(svr, root) - d[svr.Name] = sd - return sd -} - -// DefaultHost returns the first host defined in the server expression. +// DefaultHost returns the server's first host. func (s *Data) DefaultHost() *HostData { if len(s.Hosts) == 0 { return nil @@ -149,15 +174,13 @@ func (s *Data) AvailableHosts() []string { // DefaultTransport returns the default transport for the given server. // If multiple transports are defined, HTTP transport is used as the default. func (s *Data) DefaultTransport() *TransportData { - if len(s.Transports) == 1 { - return s.Transports[0] - } - for _, t := range s.Transports { - if t.Type == TransportHTTP { - return t - } - } - return nil // bug + return defaultTransport(s.Transports) +} + +// DefaultClientTransport returns the transport selected by the generated +// command-line client. It returns nil when the server has no callable method. +func (s *Data) DefaultClientTransport() *TransportData { + return defaultTransport(s.clientTransports) } // HasTransport checks if the server supports the given transport. @@ -170,6 +193,20 @@ func (s *Data) HasTransport(transport Transport) bool { return false } +// HandlerArgs returns the ordered service values accepted by the handler for +// transport. Every host using the same transport has the same arguments. It +// panics when the server does not use transport. +func (s *Data) HandlerArgs(transport Transport) []HandlerArg { + for _, host := range s.Hosts { + for _, uri := range host.URIs { + if uri.Transport.Type == transport { + return uri.HandlerArgs + } + } + } + panic(fmt.Sprintf("server %q does not use the %s transport", s.Name, transport)) +} + // DefaultURL returns the first URL defined for the given transport in a host. func (h *HostData) DefaultURL(transport Transport) string { for _, u := range h.URIs { @@ -180,8 +217,10 @@ func (h *HostData) DefaultURL(transport Transport) string { return "" } -// buildServerData builds the server data for the given server expression. -func buildServerData(svr *expr.ServerExpr, root *expr.RootExpr) *Data { +// buildServerData copies one server's service names, generated command names, +// hosts, URL variables, transports, and handler arguments for the example +// templates. +func buildServerData(svr *expr.ServerExpr, root *expr.RootExpr, serviceCommands map[string]string) *Data { hosts := make([]*HostData, 0, len(svr.Hosts)) for _, h := range svr.Hosts { hosts = append(hosts, buildHostData(h)) @@ -192,7 +231,7 @@ func buildServerData(svr *expr.ServerExpr, root *expr.RootExpr) *Data { foundVars = make(map[string]struct{}) ) - // collect all the URL variables defined in host expressions + // List each URL variable once even when several hosts use it. for _, h := range hosts { for _, v := range h.Variables { if _, ok := foundVars[v.Name]; ok { @@ -204,35 +243,40 @@ func buildServerData(svr *expr.ServerExpr, root *expr.RootExpr) *Data { } var ( - transports []*TransportData - httpServices []string - grpcServices []string - - foundTrans = make(map[Transport]struct{}) + transports []*TransportData + httpServices []string + grpcServices []string + hasHTTP bool + hasJSONRPC bool + clientHTTP bool + clientJSONRPC bool + + clientTransports []*TransportData ) for _, svc := range svr.Services { - _, seenHTTP := foundTrans[TransportHTTP] - _, seenGRPC := foundTrans[TransportGRPC] - if root.API.HTTP.Service(svc) != nil { + if service := root.API.HTTP.Service(svc); service != nil { + hasHTTP = true httpServices = append(httpServices, svc) - if !seenHTTP { - transports = append(transports, newHTTPTransport()) - foundTrans[TransportHTTP] = struct{}{} + transports = appendTransport(transports, newHTTPTransport()) + if len(service.HTTPEndpoints) > 0 { + clientHTTP = true + clientTransports = appendTransport(clientTransports, newHTTPTransport()) } - seenHTTP = true } - if root.API.JSONRPC.Service(svc) != nil { - // JSON-RPC implies HTTP transport; ensure HTTP transport exists - if !seenHTTP { - transports = append(transports, newHTTPTransport()) - foundTrans[TransportHTTP] = struct{}{} + if service := root.API.JSONRPC.Service(svc); service != nil { + hasJSONRPC = true + // JSON-RPC runs over HTTP, so both use the same server listener. + transports = appendTransport(transports, newHTTPTransport()) + if len(service.HTTPEndpoints) > 0 { + clientJSONRPC = true + clientTransports = appendTransport(clientTransports, newHTTPTransport()) } } - if root.API.GRPC.Service(svc) != nil { + if service := root.API.GRPC.Service(svc); service != nil { grpcServices = append(grpcServices, svc) - if !seenGRPC { - transports = append(transports, newGRPCTransport()) - foundTrans[TransportGRPC] = struct{}{} + transports = appendTransport(transports, newGRPCTransport()) + if len(service.GRPCEndpoints) > 0 { + clientTransports = appendTransport(clientTransports, newGRPCTransport()) } } } @@ -244,26 +288,179 @@ func buildServerData(svr *expr.ServerExpr, root *expr.RootExpr) *Data { transport.Services = grpcServices } } + dir := codegen.SnakeCase(codegen.Goify(svr.Name, true)) sd := &Data{ - Name: svr.Name, - Description: svr.Description, - Services: svr.Services, - Schemes: svr.Schemes(), - Hosts: hosts, - Variables: variables, - Transports: transports, - Dir: codegen.SnakeCase(codegen.Goify(svr.Name, true)), - } - // Precompute handler args for each URI of each host + Name: svr.Name, + Description: svr.Description, + Services: append([]string(nil), svr.Services...), + Schemes: svr.Schemes(), + Hosts: hosts, + Variables: variables, + Transports: transports, + Dir: dir, + serverMainPath: filepath.Join("cmd", dir, "main.go"), + clientMainPath: filepath.Join("cmd", dir+"-cli", "main.go"), + HasHTTP: hasHTTP, + HasJSONRPC: hasJSONRPC, + clientTransports: clientTransports, + clientHasHTTP: clientHTTP, + clientHasJSONRPC: clientJSONRPC, + usageCommands: usageCommands(svr, root, serviceCommands), + jsonRPCOnly: jsonRPCOnlyCommands(svr, root, serviceCommands), + } + sd.writesEndpointResult, sd.writesStreamResults = clientResultWriters(svr, root) + // Keep the handler argument order while the complete design is still available. for _, h := range sd.Hosts { for _, u := range h.URIs { - u.HandlerArgs = computeHandlerArgsForURI(u, sd, root) + u.HandlerArgs = planHandlerArgsForURI(u, sd, root) } } return sd } -// buildHostData builds the host data for the given host expression. +// defaultTransport returns the only available transport, or HTTP when both +// HTTP and gRPC are available. It returns nil when no transport is available. +func defaultTransport(transports []*TransportData) *TransportData { + if len(transports) == 1 { + return transports[0] + } + for _, transport := range transports { + if transport.Type == TransportHTTP { + return transport + } + } + return nil +} + +// appendTransport adds candidate unless the list already contains the same +// transport type. +func appendTransport(transports []*TransportData, candidate *TransportData) []*TransportData { + for _, transport := range transports { + if transport.Type == candidate.Type { + return transports + } + } + return append(transports, candidate) +} + +// clientResultWriters reports which result helpers the server's example +// client calls. Commands that need streamed input are rejected before they +// invoke an endpoint and therefore need neither helper. +func clientResultWriters(server *expr.ServerExpr, root *expr.RootExpr) (endpoint, stream bool) { + addMethod := func(method *expr.MethodExpr, mixedUsesEndpoint bool) { + if method.IsPayloadStreaming() { + return + } + if method.IsResultStreaming() && !(mixedUsesEndpoint && method.HasMixedResults()) { + stream = true + return + } + endpoint = true + } + for _, serviceName := range server.Services { + if service := root.API.HTTP.Service(serviceName); service != nil { + for _, transportEndpoint := range service.HTTPEndpoints { + addMethod(transportEndpoint.MethodExpr, true) + } + } + if service := root.API.JSONRPC.Service(serviceName); service != nil { + for _, transportEndpoint := range service.HTTPEndpoints { + addMethod(transportEndpoint.MethodExpr, false) + } + } + if service := root.API.GRPC.Service(serviceName); service != nil { + for _, transportEndpoint := range service.GRPCEndpoints { + addMethod(transportEndpoint.MethodExpr, false) + } + } + } + return +} + +// usageCommands returns the complete help list for one server. Each transport +// contributes the commands accepted by its generated client. +func usageCommands(server *expr.ServerExpr, root *expr.RootExpr, serviceCommands map[string]string) []string { + var commands []string + for _, serviceName := range server.Services { + if service := root.API.HTTP.Service(serviceName); service != nil { + commands = appendUsageCommand(commands, serviceCommands[serviceName], httpEndpointNames(service.HTTPEndpoints)) + } + if service := root.API.JSONRPC.Service(serviceName); service != nil { + commands = appendUsageCommand(commands, serviceCommands[serviceName], httpEndpointNames(service.HTTPEndpoints)) + } + if service := root.API.GRPC.Service(serviceName); service != nil { + endpoints := make([]string, len(service.GRPCEndpoints)) + for i, endpoint := range service.GRPCEndpoints { + endpoints[i] = codegen.KebabCase(endpoint.Name()) + } + commands = appendUsageCommand(commands, serviceCommands[serviceName], endpoints) + } + } + sort.Strings(commands) + return slices.Compact(commands) +} + +// jsonRPCOnlyCommands returns the service and endpoint pairs handled only by +// the JSON-RPC client. +func jsonRPCOnlyCommands(server *expr.ServerExpr, root *expr.RootExpr, serviceCommands map[string]string) []*jsonRPCServiceData { + var services []*jsonRPCServiceData + for _, serviceName := range server.Services { + jsonRPC := root.API.JSONRPC.Service(serviceName) + if jsonRPC == nil { + continue + } + httpMethods := make(map[string]struct{}) + if httpService := root.API.HTTP.Service(serviceName); httpService != nil { + for _, endpoint := range httpService.HTTPEndpoints { + httpMethods[endpoint.MethodExpr.Name] = struct{}{} + } + } + var endpoints []string + for _, endpoint := range jsonRPC.HTTPEndpoints { + if _, alsoHTTP := httpMethods[endpoint.MethodExpr.Name]; !alsoHTTP { + endpoints = append(endpoints, codegen.KebabCase(endpoint.Name())) + } + } + if len(endpoints) > 0 { + services = append(services, &jsonRPCServiceData{ + Service: serviceCommands[serviceName], + Endpoints: endpoints, + }) + } + } + return services +} + +// httpEndpointNames returns the command-line names for endpoints in design +// order. +func httpEndpointNames(endpoints []*expr.HTTPEndpointExpr) []string { + names := make([]string, len(endpoints)) + for i, endpoint := range endpoints { + names[i] = codegen.KebabCase(endpoint.Name()) + } + return names +} + +// appendUsageCommand adds one client's help entry when it has endpoints. +func appendUsageCommand(commands []string, serviceCommand string, endpoints []string) []string { + if len(endpoints) == 0 { + return commands + } + var left, right string + if len(endpoints) > 1 { + left, right = "(", ")" + } + return append(commands, fmt.Sprintf( + "%s %s%s%s", + serviceCommand, + left, + strings.Join(endpoints, "|"), + right, + )) +} + +// buildHostData copies one host's name, description, URLs, and URL variables +// for the example templates. func buildHostData(host *expr.HostExpr) *HostData { uris := make([]*URIData, len(host.URIs)) for i, uv := range host.URIs { @@ -315,16 +512,15 @@ func buildHostData(host *expr.HostExpr) *HostData { for i, v := range *vars { def := v.Attribute.DefaultValue var values []string + if v.Attribute.Validation != nil && len(v.Attribute.Validation.Values) > 0 { + values = convertToString(v.Attribute.Validation.Values...) + } if def == nil { def = v.Attribute.Validation.Values[0] - // DSL ensures v.Attribute has either a - // default value or an enum validation - values = convertToString(v.Attribute.Validation.Values...) } variables[i] = &VariableData{ Name: v.Name, Description: v.Attribute.Description, - VarName: codegen.Goify(v.Name, false), DefaultValue: convertToString(def)[0], Values: values, } @@ -339,6 +535,38 @@ func buildHostData(host *expr.HostExpr) *HostData { } } +// planMainVariables chooses command-line and Go names that do not collide +// with the flags already emitted by one main program. +func planMainVariables(variables []*VariableData, fixedFlags []string) *mainVariables { + flagScope := codegen.NewNameScope() + localScope := codegen.NewNameScope() + fixed := make(map[string]struct{}, len(fixedFlags)) + for _, flagName := range fixedFlags { + fixed[flagName] = struct{}{} + flagScope.Unique(flagName) + localScope.Unique(codegen.Goify(flagName, false) + "F") + } + planned := &mainVariables{ + all: make([]*mainVariableData, len(variables)), + byName: make(map[string]*mainVariableData, len(variables)), + } + for index, variable := range variables { + preferred := variable.Name + if _, conflicts := fixed[preferred]; conflicts { + preferred = "url-" + preferred + } + flagName := flagScope.Unique(preferred) + value := &mainVariableData{ + VariableData: variable, + FlagName: flagName, + VarName: localScope.Unique(codegen.Goify(flagName, false) + "F"), + } + planned.all[index] = value + planned.byName[variable.Name] = value + } + return planned +} + // convertToString converts primitive type to a string. func convertToString(vals ...any) []string { str := make([]string, len(vals)) @@ -379,12 +607,10 @@ func newGRPCTransport() *TransportData { return &TransportData{Type: TransportGRPC, Name: "gRPC"} } -// computeHandlerArgsForURI returns the ordered handler arguments for the given URI. -// For HTTP URIs that serve both HTTP and JSON-RPC services, the order is: -// - HTTP service endpoints (for services in the HTTP transport list) -// - JSON-RPC service interfaces (in JSONRPC.Services order) -// - JSON-RPC service endpoints (for services not already added as HTTP endpoints) -func computeHandlerArgsForURI(uri *URIData, server *Data, root *expr.RootExpr) []HandlerArg { +// planHandlerArgsForURI lists the services passed to one generated handler. +// HTTP endpoints come first, followed by JSON-RPC services and any remaining +// JSON-RPC endpoints. +func planHandlerArgsForURI(uri *URIData, server *Data, root *expr.RootExpr) []HandlerArg { capHint := len(server.Services) grpcSvcNames := make([]string, 0, capHint) for _, t := range server.Transports { @@ -395,14 +621,15 @@ func computeHandlerArgsForURI(uri *URIData, server *Data, root *expr.RootExpr) [ if uri.Transport.Type == TransportGRPC { out := make([]HandlerArg, 0, len(grpcSvcNames)) for _, name := range grpcSvcNames { - out = append(out, HandlerArg{Endpoint: codegen.Goify(name, false) + "Endpoints"}) + out = append(out, HandlerArg{Service: name, Endpoint: true}) } return out } - var jsonrpcServices []*expr.HTTPServiceExpr - if root.API != nil && root.API.JSONRPC != nil { - jsonrpcServices = root.API.JSONRPC.Services + jsonrpcServices := root.API.JSONRPC.Services + hostedServices := make(map[string]struct{}, len(server.Services)) + for _, name := range server.Services { + hostedServices[name] = struct{}{} } httpSvcSet := make(map[string]struct{}, len(server.Services)) @@ -430,53 +657,32 @@ func computeHandlerArgsForURI(uri *URIData, server *Data, root *expr.RootExpr) [ return false } - // Build set of services that are in $.Services for the template. - // The template data depends on whether there are HTTP services: - // - If there are HTTP services: $.Services = HTTP services only - // - If there are NO HTTP services: $.Services = all JSON-RPC services + // The HTTP helper receives ordinary HTTP endpoints first. servicesInTemplate := make(map[string]struct{}) - hasHTTPServices := false - if root.API != nil && root.API.HTTP != nil && len(root.API.HTTP.Services) > 0 { - hasHTTPServices = true - for _, hs := range root.API.HTTP.Services { - if hs.ServiceExpr != nil { - servicesInTemplate[hs.ServiceExpr.Name] = struct{}{} - } - } - } - // If no HTTP services, JSON-RPC services populate $.Services - if !hasHTTPServices && root.API != nil && root.API.JSONRPC != nil { - for _, js := range root.API.JSONRPC.Services { - if js.ServiceExpr != nil { - servicesInTemplate[js.ServiceExpr.Name] = struct{}{} - } - } + for _, hs := range root.API.HTTP.Services { + servicesInTemplate[hs.ServiceExpr.Name] = struct{}{} } addedEndpoints := make(map[string]bool, len(server.Services)) - // Step 1: Add endpoint pointers for services in server.Services that are also in $.Services. - // This matches the template's first loop: {{ range $.Services }}{{ if .Service.Methods }} - // where $.Services includes both HTTP and JSON-RPC services. + // Add endpoint variables for the services passed first. for _, svcName := range server.Services { if _, inTemplate := servicesInTemplate[svcName]; inTemplate && serviceHasHandlers(svcName) { - out = append(out, HandlerArg{Endpoint: codegen.Goify(svcName, false) + "Endpoints"}) + out = append(out, HandlerArg{Service: svcName, Endpoint: true}) addedEndpoints[svcName] = true } } - // Step 2: For each JSON-RPC service, add service interface, then endpoint (if not HTTP). - // This matches the template's second loop: {{ range $.JSONRPCServices }} - // where each iteration adds the service, checks if it's in $.Services, and conditionally - // adds the endpoint - all in the same iteration (not separate loops). + // Add each JSON-RPC service variable followed by its endpoint variable when + // that endpoint was not already added above. for _, jsvc := range jsonrpcServices { name := jsvc.ServiceExpr.Name - // Add service interface - out = append(out, HandlerArg{Service: codegen.Goify(name, false) + "Svc"}) - // Add endpoint if this service doesn't have HTTP transport - // (i.e., wasn't added in Step 1) + if _, hosted := hostedServices[name]; !hosted { + continue + } + out = append(out, HandlerArg{Service: name}) if !addedEndpoints[name] && serviceHasHandlers(name) { - out = append(out, HandlerArg{Endpoint: codegen.Goify(name, false) + "Endpoints"}) + out = append(out, HandlerArg{Service: name, Endpoint: true}) addedEndpoints[name] = true } } diff --git a/codegen/example/server_data_test.go b/codegen/example/server_data_test.go index faf015c837..21861ff28c 100644 --- a/codegen/example/server_data_test.go +++ b/codegen/example/server_data_test.go @@ -3,6 +3,8 @@ package example import ( "testing" + "github.com/stretchr/testify/require" + "goa.design/goa/v3/expr" ) @@ -24,6 +26,13 @@ func TestComputeHandlerArgsForURI_JSONRPCOrdering(t *testing.T) { }, HTTPEndpoints: []*expr.HTTPEndpointExpr{{MethodExpr: mcpMethod}}, } + jsonrpcUnhosted := &expr.HTTPServiceExpr{ + ServiceExpr: &expr.ServiceExpr{ + Name: "unhosted", + Methods: []*expr.MethodExpr{{Name: "Ignore"}}, + }, + HTTPEndpoints: []*expr.HTTPEndpointExpr{{MethodExpr: &expr.MethodExpr{Name: "Ignore"}}}, + } root := &expr.RootExpr{ API: &expr.APIExpr{ HTTP: &expr.HTTPExpr{ @@ -31,13 +40,14 @@ func TestComputeHandlerArgsForURI_JSONRPCOrdering(t *testing.T) { }, JSONRPC: &expr.JSONRPCExpr{ HTTPExpr: expr.HTTPExpr{ - Services: []*expr.HTTPServiceExpr{jsonrpcOrchestrator, jsonrpcMCPAssistant}, + Services: []*expr.HTTPServiceExpr{jsonrpcOrchestrator, jsonrpcMCPAssistant, jsonrpcUnhosted}, }, }, }, Services: []*expr.ServiceExpr{ {Name: "orchestrator", Methods: []*expr.MethodExpr{method}}, {Name: "mcp_assistant", Methods: []*expr.MethodExpr{mcpMethod}}, + {Name: "unhosted", Methods: []*expr.MethodExpr{{Name: "Ignore"}}}, }, } server := &Data{ @@ -48,13 +58,13 @@ func TestComputeHandlerArgsForURI_JSONRPCOrdering(t *testing.T) { } uri := &URIData{Transport: &TransportData{Type: TransportHTTP}} - args := computeHandlerArgsForURI(uri, server, root) + args := planHandlerArgsForURI(uri, server, root) want := []HandlerArg{ - {Endpoint: "orchestratorEndpoints"}, - {Service: "orchestratorSvc"}, - {Service: "mcpAssistantSvc"}, - {Endpoint: "mcpAssistantEndpoints"}, + {Service: "orchestrator", Endpoint: true}, + {Service: "orchestrator"}, + {Service: "mcp_assistant"}, + {Service: "mcp_assistant", Endpoint: true}, } if len(args) != len(want) { t.Fatalf("expected %d handler args, got %d (%v)", len(want), len(args), args) @@ -65,3 +75,42 @@ func TestComputeHandlerArgsForURI_JSONRPCOrdering(t *testing.T) { } } } + +// TestPlanHandlerArgsForJSONRPCOnlyServer checks that the generated main and +// HTTP helper can use the same service-by-service argument order. +func TestPlanHandlerArgsForJSONRPCOnlyServer(t *testing.T) { + firstMethod := &expr.MethodExpr{Name: "First"} + secondMethod := &expr.MethodExpr{Name: "Second"} + first := &expr.ServiceExpr{Name: "first", Methods: []*expr.MethodExpr{firstMethod}} + second := &expr.ServiceExpr{Name: "second", Methods: []*expr.MethodExpr{secondMethod}} + root := &expr.RootExpr{ + API: &expr.APIExpr{ + HTTP: &expr.HTTPExpr{}, + JSONRPC: &expr.JSONRPCExpr{HTTPExpr: expr.HTTPExpr{Services: []*expr.HTTPServiceExpr{ + { + ServiceExpr: first, + HTTPEndpoints: []*expr.HTTPEndpointExpr{{MethodExpr: firstMethod}}, + }, + { + ServiceExpr: second, + HTTPEndpoints: []*expr.HTTPEndpointExpr{{MethodExpr: secondMethod}}, + }, + }}}, + }, + Services: []*expr.ServiceExpr{first, second}, + } + server := &Data{ + Services: []string{"first", "second"}, + Transports: []*TransportData{{ + Type: TransportHTTP, + }}, + } + uri := &URIData{Transport: &TransportData{Type: TransportHTTP}} + + require.Equal(t, []HandlerArg{ + {Service: "first"}, + {Service: "first", Endpoint: true}, + {Service: "second"}, + {Service: "second", Endpoint: true}, + }, planHandlerArgsForURI(uri, server, root)) +} diff --git a/codegen/example/templates/client_end.go.tpl b/codegen/example/templates/client_end.go.tpl index 21b99bc20b..6e78d3386a 100644 --- a/codegen/example/templates/client_end.go.tpl +++ b/codegen/example/templates/client_end.go.tpl @@ -1,13 +1,48 @@ +} - data, err := endpoint(context.Background(), payload) +{{- if .WritesEndpointResult }} +// writeEndpointResult calls one normal endpoint and writes its result as JSON. +func writeEndpointResult(ctx context.Context, stdout io.Writer, endpoint goa.Endpoint, payload any) error { + data, err := endpoint(ctx, payload) if err != nil { - fmt.Fprintln(os.Stderr, err.Error()) - os.Exit(1) + return err + } + return writeJSON(stdout, data) +} +{{- end }} + +{{- if .WritesStreamResults }} +// writeStreamResults writes each server result until the server ends the stream. +func writeStreamResults[T any](ctx context.Context, stdout io.Writer, recv func(context.Context) (T, error)) error { + for { + data, err := recv(ctx) + if err == io.EOF { + return nil + } + if err != nil { + return fmt.Errorf("receive result: %w", err) + } + if err := writeJSON(stdout, data); err != nil { + return err + } } +} +{{- end }} - if data != nil { - m, _ := json.MarshalIndent(data, "", " ") - fmt.Println(string(m)) +{{- if or .WritesEndpointResult .WritesStreamResults }} +// writeJSON writes one indented JSON value followed by a newline. +func writeJSON(stdout io.Writer, data any) error { + if data == nil { + return nil + } + encoded, err := json.MarshalIndent(data, "", " ") + if err != nil { + return fmt.Errorf("encode result: %w", err) + } + if _, err := fmt.Fprintln(stdout, string(encoded)); err != nil { + return fmt.Errorf("write result: %w", err) } + return nil } +{{- end }} diff --git a/codegen/example/templates/client_endpoint_init.go.tpl b/codegen/example/templates/client_endpoint_init.go.tpl index 02353b8c76..b1ef3de424 100644 --- a/codegen/example/templates/client_endpoint_init.go.tpl +++ b/codegen/example/templates/client_endpoint_init.go.tpl @@ -1,7 +1,5 @@ var ( - endpoint goa.Endpoint - payload any err error ) { @@ -11,18 +9,29 @@ {{- if and (eq $t.Type "http") $.HasJSONRPC }} {{- if $.HasHTTP }} if *jsonrpcF || *jF { - endpoint, payload, err = doJSONRPC(scheme, host, timeout, debug) + err = doJSONRPC(context.Background(), scheme, host, timeout, debug, os.Stdout) } else { - endpoint, payload, err = doHTTP(scheme, host, timeout, debug) - if err != nil && strings.HasPrefix(err.Error(), "unknown") { - endpoint, payload, err = doJSONRPC(scheme, host, timeout, debug) + switch flag.Arg(0) { + {{- range $.JSONRPCOnly }} + case {{ printf "%q" .Service }}: + switch flag.Arg(1) { + {{- range .Endpoints }} + case {{ printf "%q" . }}: + err = doJSONRPC(context.Background(), scheme, host, timeout, debug, os.Stdout) + {{- end }} + default: + err = doHTTP(context.Background(), scheme, host, timeout, debug, os.Stdout) + } + {{- end }} + default: + err = doHTTP(context.Background(), scheme, host, timeout, debug, os.Stdout) } } {{- else }} - endpoint, payload, err = doJSONRPC(scheme, host, timeout, debug) + err = doJSONRPC(context.Background(), scheme, host, timeout, debug, os.Stdout) {{- end }} {{- else }} - endpoint, payload, err = do{{ toUpper $t.Name }}(scheme, host, timeout, debug) + err = do{{ toUpper $t.Name }}(context.Background(), scheme, host, timeout, debug, os.Stdout) {{- end }} {{- end }} default: diff --git a/codegen/example/templates/client_start.go.tpl b/codegen/example/templates/client_start.go.tpl index b668793bdc..2054095416 100644 --- a/codegen/example/templates/client_start.go.tpl +++ b/codegen/example/templates/client_start.go.tpl @@ -4,7 +4,7 @@ func main() { hostF = flag.String("host", {{ printf "%q" .Server.DefaultHost.Name }}, "Server host (valid values: {{ (join .Server.AvailableHosts ", ") }})") addrF = flag.String("url", "", "URL to service host") {{- range .Server.Variables }} - {{ .VarName }}F = flag.String({{ printf "%q" .Name }}, {{ printf "%q" .DefaultValue }}, {{ printf "%q" .Description }}) + {{ .VarName }} = flag.String({{ printf "%q" .FlagName }}, {{ printf "%q" .DefaultValue }}, {{ printf "%q" .Description }}) {{- end }} {{- if and .HasJSONRPC .HasHTTP }} jsonrpcF = flag.Bool("jsonrpc", false, "Force JSON-RPC transport") diff --git a/codegen/example/templates/client_usage.go.tpl b/codegen/example/templates/client_usage.go.tpl index e5ee17de74..931851d3bf 100644 --- a/codegen/example/templates/client_usage.go.tpl +++ b/codegen/example/templates/client_usage.go.tpl @@ -1,31 +1,25 @@ func usage() { - var usageCommands []string -{{- range .Server.Transports }} - {{- if and (eq .Type "http") $.HasHTTP }} - usageCommands = append(usageCommands, {{ .Type }}UsageCommands()...) + usageCommands := []string{ + {{- range .UsageCommands }} + {{ printf "%q" . }}, {{- end }} -{{- end }} -{{- if .HasJSONRPC }} - usageCommands = append(usageCommands, jsonrpcUsageCommands()...) -{{- end }} - sort.Strings(usageCommands) - usageCommands = slices.Compact(usageCommands) + } fmt.Fprintf(os.Stderr, `%s is a command line client for the {{ .APIName }} API. Usage: - %s [-host HOST][-url URL][-timeout SECONDS][-verbose|-v]{{ range .Server.Variables }}[-{{ .Name }} {{ toUpper .Name }}]{{ end }} SERVICE ENDPOINT [flags] + %s [-host HOST][-url URL][-timeout SECONDS][-verbose|-v]{{ range .Server.Variables }}[-{{ .FlagName }} {{ toUpper .Name }}]{{ end }} SERVICE ENDPOINT [flags] -host HOST: server host ({{ .Server.DefaultHost.Name }}). valid values: {{ (join .Server.AvailableHosts ", ") }} - -url URL: specify service URL overriding host URL (http://localhost:8080) + -url URL: specify service URL overriding host URL ({{ .Server.HelpURL }}) {{- if and .HasJSONRPC .HasHTTP }} -jsonrpc|-j: force JSON-RPC (false) {{- end }} -timeout: maximum number of seconds to wait for response (30) -verbose|-v: print request and response details (false) {{- range .Server.Variables }} - -{{ .Name }}: {{ .Description }} ({{ .DefaultValue }}) + -{{ .FlagName }}: {{ .Description }} ({{ .DefaultValue }}) {{- end }} Commands: diff --git a/codegen/example/templates/client_var_init.go.tpl b/codegen/example/templates/client_var_init.go.tpl index 65f0430c95..dae738f250 100644 --- a/codegen/example/templates/client_var_init.go.tpl +++ b/codegen/example/templates/client_var_init.go.tpl @@ -10,24 +10,17 @@ var ( switch *hostF { {{- range $h := .Server.Hosts }} case {{ printf "%q" $h.Name }}: - addr = {{ printf "%q" ($h.DefaultURL $.Server.DefaultTransport.Type) }} + addr = {{ printf "%q" ($h.DefaultURL $.Server.DefaultTransport.Type) }} {{- range $h.Variables }} {{- if .Values }} - var {{ .VarName }}Seen bool - { - for _, v := range []string{ {{ range $v := .Values }}"{{ $v }}",{{ end }} } { - if v == *{{ .VarName }}F { - {{ .VarName }}Seen = true - break - } - } - } - if !{{ .VarName }}Seen { - fmt.Fprintf(os.Stderr, "invalid value for URL '{{ .Name }}' variable: %q (valid values: {{ join .Values "," }})\n", *{{ .VarName }}F) + switch *{{ .VarName }} { + case {{ range $index, $value := .Values }}{{ if $index }}, {{ end }}{{ printf "%q" $value }}{{ end }}: + default: + fmt.Fprintf(os.Stderr, "invalid value for URL '{{ .Name }}' variable: %q (valid values: {{ join .Values "," }})\n", *{{ .VarName }}) os.Exit(1) } {{- end }} - addr = strings.ReplaceAll(addr, "{{ printf "{%s}" .Name }}", *{{ .VarName }}F) + addr = strings.ReplaceAll(addr, "{{ printf "{%s}" .Name }}", *{{ .VarName }}) {{- end }} {{- end }} default: diff --git a/codegen/example/templates/server_endpoints.go.tpl b/codegen/example/templates/server_endpoints.go.tpl index e0587e27ee..8a47b2a743 100644 --- a/codegen/example/templates/server_endpoints.go.tpl +++ b/codegen/example/templates/server_endpoints.go.tpl @@ -1,19 +1,19 @@ -{{- if mustInitServices .Services }} +{{- if .HasServices }} {{ comment "Wrap the services in endpoints that can be invoked from other services potentially running in different processes." }} var ( {{- range .Services }} - {{- if .Methods }} - {{ .VarName }}Endpoints *{{ .PkgName }}.Endpoints + {{- if .HasMethods }} + {{ .EndpointsVar }} *{{ .PkgName }}.{{ .EndpointsDeclaration.Name }} {{- end }} {{- end }} ) { {{- range .Services }} - {{- if .Methods }} - {{ .VarName }}Endpoints = {{ .PkgName }}.NewEndpoints({{ .VarName }}Svc{{ if .ServerInterceptors }}, {{ .VarName }}Interceptors{{ end }}) - {{ .VarName }}Endpoints.Use(debug.LogPayloads()) - {{ .VarName }}Endpoints.Use(log.Endpoint) + {{- if .HasMethods }} + {{ .EndpointsVar }} = {{ .PkgName }}.{{ .NewEndpointsDeclaration.Name }}({{ .ServiceVar }}{{ if .HasServerInterceptors }}, {{ .InterceptorsVar }}{{ end }}) + {{ .EndpointsVar }}.Use(debug.LogPayloads()) + {{ .EndpointsVar }}.Use(log.Endpoint) {{- end }} {{- end }} } diff --git a/codegen/example/templates/server_handler.go.tpl b/codegen/example/templates/server_handler.go.tpl index 13d2f03c09..93427fff80 100644 --- a/codegen/example/templates/server_handler.go.tpl +++ b/codegen/example/templates/server_handler.go.tpl @@ -10,20 +10,13 @@ addr := {{ printf "%q" $u.URL }} {{- range $h.Variables }} {{- if .Values }} - var {{ .VarName }}Seen bool - { - for _, v := range []string{ {{ range $v := .Values }}"{{ $v }}",{{ end }} } { - if v == *{{ .VarName }}F { - {{ .VarName }}Seen = true - break - } - } - } - if !{{ .VarName }}Seen { - log.Fatal(ctx, fmt.Errorf("invalid value for URL '{{ .Name }}' variable: %q (valid values: {{ join .Values "," }})\n", *{{ .VarName }}F)) + switch *{{ .VarName }} { + case {{ range $index, $value := .Values }}{{ if $index }}, {{ end }}{{ printf "%q" $value }}{{ end }}: + default: + log.Fatal(ctx, fmt.Errorf("invalid value for URL '{{ .Name }}' variable: %q (valid values: {{ join .Values "," }})\n", *{{ .VarName }})) } {{- end }} - addr = strings.ReplaceAll(addr, "{{ printf "{%s}" .Name }}", *{{ .VarName }}F) + addr = strings.ReplaceAll(addr, "{{ printf "{%s}" .Name }}", *{{ .VarName }}) {{- end }} u, err := url.Parse(addr) if err != nil { @@ -44,7 +37,7 @@ } else if u.Port() == "" { u.Host = net.JoinHostPort(u.Host, "{{ $u.Port }}") } - handle{{ toUpper $u.Transport.Name }}Server(ctx, u{{- range $u.HandlerArgs }}{{- if .Endpoint }}, {{ .Endpoint }}{{- end }}{{- if .Service }}, {{ .Service }}{{- end }}{{- end }}, &wg, errc, *dbgF) + handle{{ toUpper $u.Transport.Name }}Server(ctx, u{{- range $u.HandlerArgs }}, {{ .Variable }}{{- end }}, &wg, errc, *dbgF) } {{- end }} {{ end }} diff --git a/codegen/example/templates/server_interceptors.go.tpl b/codegen/example/templates/server_interceptors.go.tpl index cdb973dcce..d1d23b0c4f 100644 --- a/codegen/example/templates/server_interceptors.go.tpl +++ b/codegen/example/templates/server_interceptors.go.tpl @@ -1,17 +1,17 @@ -{{- if mustInitServices .Services }} +{{- if .HasServices }} {{- if .HasInterceptors }} {{ comment "Initialize the interceptors." }} var ( {{- range .Services }} - {{- if and .Methods .ServerInterceptors }} - {{ .VarName }}Interceptors {{ .PkgName }}.ServerInterceptors + {{- if and .HasMethods .HasServerInterceptors }} + {{ .InterceptorsVar }} {{ .PkgName }}.{{ .ServerInterceptorsDeclaration.Name }} {{- end }} {{- end }} ) { {{- range .Services }} - {{- if and .Methods .ServerInterceptors }} - {{ .VarName }}Interceptors = {{ $.InterPkg }}.New{{ .StructName }}ServerInterceptors() + {{- if and .HasMethods .HasServerInterceptors }} + {{ .InterceptorsVar }} = {{ $.InterPkg }}.{{ .ExampleInterceptorsConstructor.Name }}() {{- end }} {{- end }} } diff --git a/codegen/example/templates/server_logger.go.tpl b/codegen/example/templates/server_logger.go.tpl index 642c082687..89569a2665 100644 --- a/codegen/example/templates/server_logger.go.tpl +++ b/codegen/example/templates/server_logger.go.tpl @@ -10,6 +10,6 @@ ctx = log.Context(ctx, log.WithDebug()) log.Debugf(ctx, "debug logs enabled") } -{{- if .Server.Transports }} - log.Print(ctx, log.KV{K: "http-port", V: *httpPortF}) +{{- range .Server.Transports }} + log.Print(ctx, log.KV{K: "{{ .Type }}-port", V: *{{ .Type }}PortF}) {{- end }} diff --git a/codegen/example/templates/server_services.go.tpl b/codegen/example/templates/server_services.go.tpl index 2de5109753..231a040aa9 100644 --- a/codegen/example/templates/server_services.go.tpl +++ b/codegen/example/templates/server_services.go.tpl @@ -1,17 +1,17 @@ -{{- if mustInitServices .Services }} +{{- if .HasServices }} {{ comment "Initialize the services." }} var ( {{- range .Services }} - {{- if .Methods }} - {{ .VarName }}Svc {{ .PkgName }}.Service + {{- if .HasMethods }} + {{ .ServiceVar }} {{ .PkgName }}.{{ .ServiceDeclaration.Name }} {{- end }} {{- end }} ) { {{- range .Services }} - {{- if .Methods }} - {{ .VarName }}Svc = {{ $.APIPkg }}.New{{ .StructName }}() + {{- if .HasMethods }} + {{ .ServiceVar }} = {{ $.APIPkg }}.{{ .ExampleConstructorDeclaration.Name }}() {{- end }} {{- end }} } diff --git a/codegen/example/templates/server_start.go.tpl b/codegen/example/templates/server_start.go.tpl index 0e722ae8eb..cf246267e9 100644 --- a/codegen/example/templates/server_start.go.tpl +++ b/codegen/example/templates/server_start.go.tpl @@ -8,7 +8,7 @@ func main() { {{ .Type }}PortF = flag.String("{{ .Type }}-port", "", "{{ .Name }} port (overrides host {{ .Name }} port specified in service design)") {{- end }} {{- range .Server.Variables }} - {{ .VarName }}F = flag.String({{ printf "%q" .Name }}, {{ printf "%q" .DefaultValue }}, "{{ .Description }}{{ if .Values }} (valid values: {{ join .Values ", " }}){{ end }}") + {{ .VarName }} = flag.String({{ printf "%q" .FlagName }}, {{ printf "%q" .DefaultValue }}, "{{ .Description }}{{ if .Values }} (valid values: {{ join .Values ", " }}){{ end }}") {{- end }} secureF = flag.Bool("secure", false, "Use secure scheme (https or grpcs)") dbgF = flag.Bool("debug", false, "Log request and response bodies") diff --git a/codegen/example/testdata/client-files-with-grpc.golden b/codegen/example/testdata/client-files-with-grpc.golden new file mode 100644 index 0000000000..51a33eb1f8 --- /dev/null +++ b/codegen/example/testdata/client-files-with-grpc.golden @@ -0,0 +1,123 @@ +func main() { + var ( + hostF = flag.String("host", "localhost", "Server host (valid values: localhost)") + addrF = flag.String("url", "", "URL to service host") + + verboseF = flag.Bool("verbose", false, "Print request and response details") + vF = flag.Bool("v", false, "Print request and response details") + timeoutF = flag.Int("timeout", 30, "Maximum number of seconds to wait for response") + ) + flag.Usage = usage + flag.Parse() + + var ( + addr string + timeout int + debug bool + ) + { + addr = *addrF + if addr == "" { + switch *hostF { + case "localhost": + addr = "grpc://localhost:8080" + default: + fmt.Fprintf(os.Stderr, "invalid host argument: %q (valid hosts: localhost)\n", *hostF) + os.Exit(1) + } + } + timeout = *timeoutF + debug = *verboseF || *vF + } + + var ( + scheme string + host string + ) + { + u, err := url.Parse(addr) + if err != nil { + fmt.Fprintf(os.Stderr, "invalid URL %#v: %s\n", addr, err) + os.Exit(1) + } + scheme = u.Scheme + host = u.Host + } + + var ( + err error + ) + { + switch scheme { + case "grpc", "grpcs": + err = doGRPC(context.Background(), scheme, host, timeout, debug, os.Stdout) + default: + fmt.Fprintf(os.Stderr, "invalid scheme: %q (valid schemes: grpc)\n", scheme) + os.Exit(1) + } + } + if err != nil { + if errors.Is(err, flag.ErrHelp) { + os.Exit(0) + } + fmt.Fprintln(os.Stderr, err.Error()) + fmt.Fprintln(os.Stderr, "run '"+os.Args[0]+" --help' for detailed usage.") + os.Exit(1) + } + +} + +// writeEndpointResult calls one normal endpoint and writes its result as JSON. +func writeEndpointResult(ctx context.Context, stdout io.Writer, endpoint goa.Endpoint, payload any) error { + data, err := endpoint(ctx, payload) + if err != nil { + return err + } + return writeJSON(stdout, data) +} + +// writeJSON writes one indented JSON value followed by a newline. +func writeJSON(stdout io.Writer, data any) error { + if data == nil { + return nil + } + encoded, err := json.MarshalIndent(data, "", " ") + if err != nil { + return fmt.Errorf("encode result: %w", err) + } + if _, err := fmt.Fprintln(stdout, string(encoded)); err != nil { + return fmt.Errorf("write result: %w", err) + } + return nil +} + +func usage() { + usageCommands := []string{ + "assets read-status", + } + fmt.Fprintf(os.Stderr, `%s is a command line client for the test api API. + +Usage: + %s [-host HOST][-url URL][-timeout SECONDS][-verbose|-v] SERVICE ENDPOINT [flags] + + -host HOST: server host (localhost). valid values: localhost + -url URL: specify service URL overriding host URL (grpc://localhost:8080) + -timeout: maximum number of seconds to wait for response (30) + -verbose|-v: print request and response details (false) + +Commands: +%s +Additional help: + %s SERVICE [ENDPOINT] --help + +Example: +%s +`, os.Args[0], os.Args[0], indent(strings.Join(usageCommands, "\n")), os.Args[0], indent(grpcUsageExamples())) +} + +func indent(s string) string { + if s == "" { + return "" + } + return " " + strings.ReplaceAll(s, "\n", "\n ") +} diff --git a/codegen/example/testdata/client-grpcs-only.golden b/codegen/example/testdata/client-grpcs-only.golden new file mode 100644 index 0000000000..b13ed247c9 --- /dev/null +++ b/codegen/example/testdata/client-grpcs-only.golden @@ -0,0 +1,123 @@ +func main() { + var ( + hostF = flag.String("host", "local", "Server host (valid values: local)") + addrF = flag.String("url", "", "URL to service host") + + verboseF = flag.Bool("verbose", false, "Print request and response details") + vF = flag.Bool("v", false, "Print request and response details") + timeoutF = flag.Int("timeout", 30, "Maximum number of seconds to wait for response") + ) + flag.Usage = usage + flag.Parse() + + var ( + addr string + timeout int + debug bool + ) + { + addr = *addrF + if addr == "" { + switch *hostF { + case "local": + addr = "grpcs://localhost:8443" + default: + fmt.Fprintf(os.Stderr, "invalid host argument: %q (valid hosts: local)\n", *hostF) + os.Exit(1) + } + } + timeout = *timeoutF + debug = *verboseF || *vF + } + + var ( + scheme string + host string + ) + { + u, err := url.Parse(addr) + if err != nil { + fmt.Fprintf(os.Stderr, "invalid URL %#v: %s\n", addr, err) + os.Exit(1) + } + scheme = u.Scheme + host = u.Host + } + + var ( + err error + ) + { + switch scheme { + case "grpc", "grpcs": + err = doGRPC(context.Background(), scheme, host, timeout, debug, os.Stdout) + default: + fmt.Fprintf(os.Stderr, "invalid scheme: %q (valid schemes: grpcs)\n", scheme) + os.Exit(1) + } + } + if err != nil { + if errors.Is(err, flag.ErrHelp) { + os.Exit(0) + } + fmt.Fprintln(os.Stderr, err.Error()) + fmt.Fprintln(os.Stderr, "run '"+os.Args[0]+" --help' for detailed usage.") + os.Exit(1) + } + +} + +// writeEndpointResult calls one normal endpoint and writes its result as JSON. +func writeEndpointResult(ctx context.Context, stdout io.Writer, endpoint goa.Endpoint, payload any) error { + data, err := endpoint(ctx, payload) + if err != nil { + return err + } + return writeJSON(stdout, data) +} + +// writeJSON writes one indented JSON value followed by a newline. +func writeJSON(stdout io.Writer, data any) error { + if data == nil { + return nil + } + encoded, err := json.MarshalIndent(data, "", " ") + if err != nil { + return fmt.Errorf("encode result: %w", err) + } + if _, err := fmt.Fprintln(stdout, string(encoded)); err != nil { + return fmt.Errorf("write result: %w", err) + } + return nil +} + +func usage() { + usageCommands := []string{ + "status read", + } + fmt.Fprintf(os.Stderr, `%s is a command line client for the secure gRPC API. + +Usage: + %s [-host HOST][-url URL][-timeout SECONDS][-verbose|-v] SERVICE ENDPOINT [flags] + + -host HOST: server host (local). valid values: local + -url URL: specify service URL overriding host URL (grpcs://localhost:8443) + -timeout: maximum number of seconds to wait for response (30) + -verbose|-v: print request and response details (false) + +Commands: +%s +Additional help: + %s SERVICE [ENDPOINT] --help + +Example: +%s +`, os.Args[0], os.Args[0], indent(strings.Join(usageCommands, "\n")), os.Args[0], indent(grpcUsageExamples())) +} + +func indent(s string) string { + if s == "" { + return "" + } + return " " + strings.ReplaceAll(s, "\n", "\n ") +} diff --git a/codegen/example/testdata/client-https-before-http.golden b/codegen/example/testdata/client-https-before-http.golden new file mode 100644 index 0000000000..279d691223 --- /dev/null +++ b/codegen/example/testdata/client-https-before-http.golden @@ -0,0 +1,135 @@ +func main() { + var ( + hostF = flag.String("host", "public", "Server host (valid values: public)") + addrF = flag.String("url", "", "URL to service host") + regionF = flag.String("region", "west", "") + versionF = flag.String("version", "v1", "") + + verboseF = flag.Bool("verbose", false, "Print request and response details") + vF = flag.Bool("v", false, "Print request and response details") + timeoutF = flag.Int("timeout", 30, "Maximum number of seconds to wait for response") + ) + flag.Usage = usage + flag.Parse() + + var ( + addr string + timeout int + debug bool + ) + { + addr = *addrF + if addr == "" { + switch *hostF { + case "public": + addr = "https://{region}.example.com:9443/base/{version}/status" + addr = strings.ReplaceAll(addr, "{region}", *regionF) + switch *versionF { + case "v1", "v2": + default: + fmt.Fprintf(os.Stderr, "invalid value for URL 'version' variable: %q (valid values: v1,v2)\n", *versionF) + os.Exit(1) + } + addr = strings.ReplaceAll(addr, "{version}", *versionF) + default: + fmt.Fprintf(os.Stderr, "invalid host argument: %q (valid hosts: public)\n", *hostF) + os.Exit(1) + } + } + timeout = *timeoutF + debug = *verboseF || *vF + } + + var ( + scheme string + host string + ) + { + u, err := url.Parse(addr) + if err != nil { + fmt.Fprintf(os.Stderr, "invalid URL %#v: %s\n", addr, err) + os.Exit(1) + } + scheme = u.Scheme + host = u.Host + } + + var ( + err error + ) + { + switch scheme { + case "http", "https": + err = doHTTP(context.Background(), scheme, host, timeout, debug, os.Stdout) + default: + fmt.Fprintf(os.Stderr, "invalid scheme: %q (valid schemes: http|https)\n", scheme) + os.Exit(1) + } + } + if err != nil { + if errors.Is(err, flag.ErrHelp) { + os.Exit(0) + } + fmt.Fprintln(os.Stderr, err.Error()) + fmt.Fprintln(os.Stderr, "run '"+os.Args[0]+" --help' for detailed usage.") + os.Exit(1) + } + +} + +// writeEndpointResult calls one normal endpoint and writes its result as JSON. +func writeEndpointResult(ctx context.Context, stdout io.Writer, endpoint goa.Endpoint, payload any) error { + data, err := endpoint(ctx, payload) + if err != nil { + return err + } + return writeJSON(stdout, data) +} + +// writeJSON writes one indented JSON value followed by a newline. +func writeJSON(stdout io.Writer, data any) error { + if data == nil { + return nil + } + encoded, err := json.MarshalIndent(data, "", " ") + if err != nil { + return fmt.Errorf("encode result: %w", err) + } + if _, err := fmt.Fprintln(stdout, string(encoded)); err != nil { + return fmt.Errorf("write result: %w", err) + } + return nil +} + +func usage() { + usageCommands := []string{ + "status read", + } + fmt.Fprintf(os.Stderr, `%s is a command line client for the ordered HTTP API. + +Usage: + %s [-host HOST][-url URL][-timeout SECONDS][-verbose|-v][-region REGION][-version VERSION] SERVICE ENDPOINT [flags] + + -host HOST: server host (public). valid values: public + -url URL: specify service URL overriding host URL (https://west.example.com:9443/base/v1/status) + -timeout: maximum number of seconds to wait for response (30) + -verbose|-v: print request and response details (false) + -region: (west) + -version: (v1) + +Commands: +%s +Additional help: + %s SERVICE [ENDPOINT] --help + +Example: +%s +`, os.Args[0], os.Args[0], indent(strings.Join(usageCommands, "\n")), os.Args[0], indent(httpUsageExamples())) +} + +func indent(s string) string { + if s == "" { + return "" + } + return " " + strings.ReplaceAll(s, "\n", "\n ") +} diff --git a/codegen/example/testdata/client-https-only.golden b/codegen/example/testdata/client-https-only.golden new file mode 100644 index 0000000000..7d3b491d69 --- /dev/null +++ b/codegen/example/testdata/client-https-only.golden @@ -0,0 +1,123 @@ +func main() { + var ( + hostF = flag.String("host", "local", "Server host (valid values: local)") + addrF = flag.String("url", "", "URL to service host") + + verboseF = flag.Bool("verbose", false, "Print request and response details") + vF = flag.Bool("v", false, "Print request and response details") + timeoutF = flag.Int("timeout", 30, "Maximum number of seconds to wait for response") + ) + flag.Usage = usage + flag.Parse() + + var ( + addr string + timeout int + debug bool + ) + { + addr = *addrF + if addr == "" { + switch *hostF { + case "local": + addr = "https://localhost:8443" + default: + fmt.Fprintf(os.Stderr, "invalid host argument: %q (valid hosts: local)\n", *hostF) + os.Exit(1) + } + } + timeout = *timeoutF + debug = *verboseF || *vF + } + + var ( + scheme string + host string + ) + { + u, err := url.Parse(addr) + if err != nil { + fmt.Fprintf(os.Stderr, "invalid URL %#v: %s\n", addr, err) + os.Exit(1) + } + scheme = u.Scheme + host = u.Host + } + + var ( + err error + ) + { + switch scheme { + case "http", "https": + err = doHTTP(context.Background(), scheme, host, timeout, debug, os.Stdout) + default: + fmt.Fprintf(os.Stderr, "invalid scheme: %q (valid schemes: https)\n", scheme) + os.Exit(1) + } + } + if err != nil { + if errors.Is(err, flag.ErrHelp) { + os.Exit(0) + } + fmt.Fprintln(os.Stderr, err.Error()) + fmt.Fprintln(os.Stderr, "run '"+os.Args[0]+" --help' for detailed usage.") + os.Exit(1) + } + +} + +// writeEndpointResult calls one normal endpoint and writes its result as JSON. +func writeEndpointResult(ctx context.Context, stdout io.Writer, endpoint goa.Endpoint, payload any) error { + data, err := endpoint(ctx, payload) + if err != nil { + return err + } + return writeJSON(stdout, data) +} + +// writeJSON writes one indented JSON value followed by a newline. +func writeJSON(stdout io.Writer, data any) error { + if data == nil { + return nil + } + encoded, err := json.MarshalIndent(data, "", " ") + if err != nil { + return fmt.Errorf("encode result: %w", err) + } + if _, err := fmt.Fprintln(stdout, string(encoded)); err != nil { + return fmt.Errorf("write result: %w", err) + } + return nil +} + +func usage() { + usageCommands := []string{ + "status read", + } + fmt.Fprintf(os.Stderr, `%s is a command line client for the secure HTTP API. + +Usage: + %s [-host HOST][-url URL][-timeout SECONDS][-verbose|-v] SERVICE ENDPOINT [flags] + + -host HOST: server host (local). valid values: local + -url URL: specify service URL overriding host URL (https://localhost:8443) + -timeout: maximum number of seconds to wait for response (30) + -verbose|-v: print request and response details (false) + +Commands: +%s +Additional help: + %s SERVICE [ENDPOINT] --help + +Example: +%s +`, os.Args[0], os.Args[0], indent(strings.Join(usageCommands, "\n")), os.Args[0], indent(httpUsageExamples())) +} + +func indent(s string) string { + if s == "" { + return "" + } + return " " + strings.ReplaceAll(s, "\n", "\n ") +} diff --git a/codegen/example/testdata/client-input-stream.golden b/codegen/example/testdata/client-input-stream.golden new file mode 100644 index 0000000000..289389cecf --- /dev/null +++ b/codegen/example/testdata/client-input-stream.golden @@ -0,0 +1,101 @@ +func main() { + var ( + hostF = flag.String("host", "localhost", "Server host (valid values: localhost)") + addrF = flag.String("url", "", "URL to service host") + + verboseF = flag.Bool("verbose", false, "Print request and response details") + vF = flag.Bool("v", false, "Print request and response details") + timeoutF = flag.Int("timeout", 30, "Maximum number of seconds to wait for response") + ) + flag.Usage = usage + flag.Parse() + + var ( + addr string + timeout int + debug bool + ) + { + addr = *addrF + if addr == "" { + switch *hostF { + case "localhost": + addr = "http://localhost:80" + default: + fmt.Fprintf(os.Stderr, "invalid host argument: %q (valid hosts: localhost)\n", *hostF) + os.Exit(1) + } + } + timeout = *timeoutF + debug = *verboseF || *vF + } + + var ( + scheme string + host string + ) + { + u, err := url.Parse(addr) + if err != nil { + fmt.Fprintf(os.Stderr, "invalid URL %#v: %s\n", addr, err) + os.Exit(1) + } + scheme = u.Scheme + host = u.Host + } + + var ( + err error + ) + { + switch scheme { + case "http", "https": + err = doHTTP(context.Background(), scheme, host, timeout, debug, os.Stdout) + case "grpc", "grpcs": + err = doGRPC(context.Background(), scheme, host, timeout, debug, os.Stdout) + default: + fmt.Fprintf(os.Stderr, "invalid scheme: %q (valid schemes: grpc|http)\n", scheme) + os.Exit(1) + } + } + if err != nil { + if errors.Is(err, flag.ErrHelp) { + os.Exit(0) + } + fmt.Fprintln(os.Stderr, err.Error()) + fmt.Fprintln(os.Stderr, "run '"+os.Args[0]+" --help' for detailed usage.") + os.Exit(1) + } + +} + +func usage() { + usageCommands := []string{ + "events upload", + } + fmt.Fprintf(os.Stderr, `%s is a command line client for the test api API. + +Usage: + %s [-host HOST][-url URL][-timeout SECONDS][-verbose|-v] SERVICE ENDPOINT [flags] + + -host HOST: server host (localhost). valid values: localhost + -url URL: specify service URL overriding host URL (http://localhost:80) + -timeout: maximum number of seconds to wait for response (30) + -verbose|-v: print request and response details (false) + +Commands: +%s +Additional help: + %s SERVICE [ENDPOINT] --help + +Example: +%s +`, os.Args[0], os.Args[0], indent(strings.Join(usageCommands, "\n")), os.Args[0], indent(httpUsageExamples())) +} + +func indent(s string) string { + if s == "" { + return "" + } + return " " + strings.ReplaceAll(s, "\n", "\n ") +} diff --git a/codegen/example/testdata/client-mixed-results.golden b/codegen/example/testdata/client-mixed-results.golden new file mode 100644 index 0000000000..4a90b8fd44 --- /dev/null +++ b/codegen/example/testdata/client-mixed-results.golden @@ -0,0 +1,123 @@ +func main() { + var ( + hostF = flag.String("host", "localhost", "Server host (valid values: localhost)") + addrF = flag.String("url", "", "URL to service host") + + verboseF = flag.Bool("verbose", false, "Print request and response details") + vF = flag.Bool("v", false, "Print request and response details") + timeoutF = flag.Int("timeout", 30, "Maximum number of seconds to wait for response") + ) + flag.Usage = usage + flag.Parse() + + var ( + addr string + timeout int + debug bool + ) + { + addr = *addrF + if addr == "" { + switch *hostF { + case "localhost": + addr = "http://localhost:80" + default: + fmt.Fprintf(os.Stderr, "invalid host argument: %q (valid hosts: localhost)\n", *hostF) + os.Exit(1) + } + } + timeout = *timeoutF + debug = *verboseF || *vF + } + + var ( + scheme string + host string + ) + { + u, err := url.Parse(addr) + if err != nil { + fmt.Fprintf(os.Stderr, "invalid URL %#v: %s\n", addr, err) + os.Exit(1) + } + scheme = u.Scheme + host = u.Host + } + + var ( + err error + ) + { + switch scheme { + case "http", "https": + err = doHTTP(context.Background(), scheme, host, timeout, debug, os.Stdout) + default: + fmt.Fprintf(os.Stderr, "invalid scheme: %q (valid schemes: http)\n", scheme) + os.Exit(1) + } + } + if err != nil { + if errors.Is(err, flag.ErrHelp) { + os.Exit(0) + } + fmt.Fprintln(os.Stderr, err.Error()) + fmt.Fprintln(os.Stderr, "run '"+os.Args[0]+" --help' for detailed usage.") + os.Exit(1) + } + +} + +// writeEndpointResult calls one normal endpoint and writes its result as JSON. +func writeEndpointResult(ctx context.Context, stdout io.Writer, endpoint goa.Endpoint, payload any) error { + data, err := endpoint(ctx, payload) + if err != nil { + return err + } + return writeJSON(stdout, data) +} + +// writeJSON writes one indented JSON value followed by a newline. +func writeJSON(stdout io.Writer, data any) error { + if data == nil { + return nil + } + encoded, err := json.MarshalIndent(data, "", " ") + if err != nil { + return fmt.Errorf("encode result: %w", err) + } + if _, err := fmt.Fprintln(stdout, string(encoded)); err != nil { + return fmt.Errorf("write result: %w", err) + } + return nil +} + +func usage() { + usageCommands := []string{ + "events create", + } + fmt.Fprintf(os.Stderr, `%s is a command line client for the test api API. + +Usage: + %s [-host HOST][-url URL][-timeout SECONDS][-verbose|-v] SERVICE ENDPOINT [flags] + + -host HOST: server host (localhost). valid values: localhost + -url URL: specify service URL overriding host URL (http://localhost:80) + -timeout: maximum number of seconds to wait for response (30) + -verbose|-v: print request and response details (false) + +Commands: +%s +Additional help: + %s SERVICE [ENDPOINT] --help + +Example: +%s +`, os.Args[0], os.Args[0], indent(strings.Join(usageCommands, "\n")), os.Args[0], indent(httpUsageExamples())) +} + +func indent(s string) string { + if s == "" { + return "" + } + return " " + strings.ReplaceAll(s, "\n", "\n ") +} diff --git a/codegen/example/testdata/client-no-server.golden b/codegen/example/testdata/client-no-server.golden index 4bed82edee..cde69b0a66 100644 --- a/codegen/example/testdata/client-no-server.golden +++ b/codegen/example/testdata/client-no-server.golden @@ -45,16 +45,14 @@ func main() { } var ( - endpoint goa.Endpoint - payload any - err error + err error ) { switch scheme { case "http", "https": - endpoint, payload, err = doHTTP(scheme, host, timeout, debug) + err = doHTTP(context.Background(), scheme, host, timeout, debug, os.Stdout) case "grpc", "grpcs": - endpoint, payload, err = doGRPC(scheme, host, timeout, debug) + err = doGRPC(context.Background(), scheme, host, timeout, debug, os.Stdout) default: fmt.Fprintf(os.Stderr, "invalid scheme: %q (valid schemes: grpc|http)\n", scheme) os.Exit(1) @@ -69,30 +67,43 @@ func main() { os.Exit(1) } - data, err := endpoint(context.Background(), payload) +} + +// writeEndpointResult calls one normal endpoint and writes its result as JSON. +func writeEndpointResult(ctx context.Context, stdout io.Writer, endpoint goa.Endpoint, payload any) error { + data, err := endpoint(ctx, payload) if err != nil { - fmt.Fprintln(os.Stderr, err.Error()) - os.Exit(1) + return err } + return writeJSON(stdout, data) +} - if data != nil { - m, _ := json.MarshalIndent(data, "", " ") - fmt.Println(string(m)) +// writeJSON writes one indented JSON value followed by a newline. +func writeJSON(stdout io.Writer, data any) error { + if data == nil { + return nil } + encoded, err := json.MarshalIndent(data, "", " ") + if err != nil { + return fmt.Errorf("encode result: %w", err) + } + if _, err := fmt.Fprintln(stdout, string(encoded)); err != nil { + return fmt.Errorf("write result: %w", err) + } + return nil } func usage() { - var usageCommands []string - usageCommands = append(usageCommands, httpUsageCommands()...) - sort.Strings(usageCommands) - usageCommands = slices.Compact(usageCommands) + usageCommands := []string{ + "service method", + } fmt.Fprintf(os.Stderr, `%s is a command line client for the test api API. Usage: %s [-host HOST][-url URL][-timeout SECONDS][-verbose|-v] SERVICE ENDPOINT [flags] -host HOST: server host (localhost). valid values: localhost - -url URL: specify service URL overriding host URL (http://localhost:8080) + -url URL: specify service URL overriding host URL (http://localhost:80) -timeout: maximum number of seconds to wait for response (30) -verbose|-v: print request and response details (false) diff --git a/codegen/example/testdata/client-server-stream.golden b/codegen/example/testdata/client-server-stream.golden new file mode 100644 index 0000000000..d2d2ce75d6 --- /dev/null +++ b/codegen/example/testdata/client-server-stream.golden @@ -0,0 +1,130 @@ +func main() { + var ( + hostF = flag.String("host", "localhost", "Server host (valid values: localhost)") + addrF = flag.String("url", "", "URL to service host") + + verboseF = flag.Bool("verbose", false, "Print request and response details") + vF = flag.Bool("v", false, "Print request and response details") + timeoutF = flag.Int("timeout", 30, "Maximum number of seconds to wait for response") + ) + flag.Usage = usage + flag.Parse() + + var ( + addr string + timeout int + debug bool + ) + { + addr = *addrF + if addr == "" { + switch *hostF { + case "localhost": + addr = "grpc://localhost:8080" + default: + fmt.Fprintf(os.Stderr, "invalid host argument: %q (valid hosts: localhost)\n", *hostF) + os.Exit(1) + } + } + timeout = *timeoutF + debug = *verboseF || *vF + } + + var ( + scheme string + host string + ) + { + u, err := url.Parse(addr) + if err != nil { + fmt.Fprintf(os.Stderr, "invalid URL %#v: %s\n", addr, err) + os.Exit(1) + } + scheme = u.Scheme + host = u.Host + } + + var ( + err error + ) + { + switch scheme { + case "grpc", "grpcs": + err = doGRPC(context.Background(), scheme, host, timeout, debug, os.Stdout) + default: + fmt.Fprintf(os.Stderr, "invalid scheme: %q (valid schemes: grpc)\n", scheme) + os.Exit(1) + } + } + if err != nil { + if errors.Is(err, flag.ErrHelp) { + os.Exit(0) + } + fmt.Fprintln(os.Stderr, err.Error()) + fmt.Fprintln(os.Stderr, "run '"+os.Args[0]+" --help' for detailed usage.") + os.Exit(1) + } + +} + +// writeStreamResults writes each server result until the server ends the stream. +func writeStreamResults[T any](ctx context.Context, stdout io.Writer, recv func(context.Context) (T, error)) error { + for { + data, err := recv(ctx) + if err == io.EOF { + return nil + } + if err != nil { + return fmt.Errorf("receive result: %w", err) + } + if err := writeJSON(stdout, data); err != nil { + return err + } + } +} + +// writeJSON writes one indented JSON value followed by a newline. +func writeJSON(stdout io.Writer, data any) error { + if data == nil { + return nil + } + encoded, err := json.MarshalIndent(data, "", " ") + if err != nil { + return fmt.Errorf("encode result: %w", err) + } + if _, err := fmt.Fprintln(stdout, string(encoded)); err != nil { + return fmt.Errorf("write result: %w", err) + } + return nil +} + +func usage() { + usageCommands := []string{ + "events watch", + } + fmt.Fprintf(os.Stderr, `%s is a command line client for the test api API. + +Usage: + %s [-host HOST][-url URL][-timeout SECONDS][-verbose|-v] SERVICE ENDPOINT [flags] + + -host HOST: server host (localhost). valid values: localhost + -url URL: specify service URL overriding host URL (grpc://localhost:8080) + -timeout: maximum number of seconds to wait for response (30) + -verbose|-v: print request and response details (false) + +Commands: +%s +Additional help: + %s SERVICE [ENDPOINT] --help + +Example: +%s +`, os.Args[0], os.Args[0], indent(strings.Join(usageCommands, "\n")), os.Args[0], indent(grpcUsageExamples())) +} + +func indent(s string) string { + if s == "" { + return "" + } + return " " + strings.ReplaceAll(s, "\n", "\n ") +} diff --git a/codegen/example/testdata/client-single-server-multiple-hosts-with-variables.golden b/codegen/example/testdata/client-single-server-multiple-hosts-with-variables.golden index bb0df699fc..a88f2a09bb 100644 --- a/codegen/example/testdata/client-single-server-multiple-hosts-with-variables.golden +++ b/codegen/example/testdata/client-single-server-multiple-hosts-with-variables.golden @@ -24,16 +24,9 @@ func main() { switch *hostF { case "dev": addr = "http://example-{version}:8090" - var versionSeen bool - { - for _, v := range []string{"v1", "v2"} { - if v == *versionF { - versionSeen = true - break - } - } - } - if !versionSeen { + switch *versionF { + case "v1", "v2": + default: fmt.Fprintf(os.Stderr, "invalid value for URL 'version' variable: %q (valid values: v1,v2)\n", *versionF) os.Exit(1) } @@ -66,14 +59,12 @@ func main() { } var ( - endpoint goa.Endpoint - payload any - err error + err error ) { switch scheme { case "http", "https": - endpoint, payload, err = doHTTP(scheme, host, timeout, debug) + err = doHTTP(context.Background(), scheme, host, timeout, debug, os.Stdout) default: fmt.Fprintf(os.Stderr, "invalid scheme: %q (valid schemes: http|https)\n", scheme) os.Exit(1) @@ -88,30 +79,43 @@ func main() { os.Exit(1) } - data, err := endpoint(context.Background(), payload) +} + +// writeEndpointResult calls one normal endpoint and writes its result as JSON. +func writeEndpointResult(ctx context.Context, stdout io.Writer, endpoint goa.Endpoint, payload any) error { + data, err := endpoint(ctx, payload) if err != nil { - fmt.Fprintln(os.Stderr, err.Error()) - os.Exit(1) + return err } + return writeJSON(stdout, data) +} - if data != nil { - m, _ := json.MarshalIndent(data, "", " ") - fmt.Println(string(m)) +// writeJSON writes one indented JSON value followed by a newline. +func writeJSON(stdout io.Writer, data any) error { + if data == nil { + return nil + } + encoded, err := json.MarshalIndent(data, "", " ") + if err != nil { + return fmt.Errorf("encode result: %w", err) } + if _, err := fmt.Fprintln(stdout, string(encoded)); err != nil { + return fmt.Errorf("write result: %w", err) + } + return nil } func usage() { - var usageCommands []string - usageCommands = append(usageCommands, httpUsageCommands()...) - sort.Strings(usageCommands) - usageCommands = slices.Compact(usageCommands) + usageCommands := []string{ + "service method", + } fmt.Fprintf(os.Stderr, `%s is a command line client for the SingleServerMultipleHostsWithVariables API. Usage: %s [-host HOST][-url URL][-timeout SECONDS][-verbose|-v][-version VERSION][-domain DOMAIN][-port PORT] SERVICE ENDPOINT [flags] -host HOST: server host (dev). valid values: dev, stage - -url URL: specify service URL overriding host URL (http://localhost:8080) + -url URL: specify service URL overriding host URL (http://example-v1:8090) -timeout: maximum number of seconds to wait for response (30) -verbose|-v: print request and response details (false) -version: Version (v1) diff --git a/codegen/example/testdata/client-single-server-multiple-hosts.golden b/codegen/example/testdata/client-single-server-multiple-hosts.golden index efb2085ef0..fedd5a7b54 100644 --- a/codegen/example/testdata/client-single-server-multiple-hosts.golden +++ b/codegen/example/testdata/client-single-server-multiple-hosts.golden @@ -47,14 +47,12 @@ func main() { } var ( - endpoint goa.Endpoint - payload any - err error + err error ) { switch scheme { case "http", "https": - endpoint, payload, err = doHTTP(scheme, host, timeout, debug) + err = doHTTP(context.Background(), scheme, host, timeout, debug, os.Stdout) default: fmt.Fprintf(os.Stderr, "invalid scheme: %q (valid schemes: http|https)\n", scheme) os.Exit(1) @@ -69,30 +67,43 @@ func main() { os.Exit(1) } - data, err := endpoint(context.Background(), payload) +} + +// writeEndpointResult calls one normal endpoint and writes its result as JSON. +func writeEndpointResult(ctx context.Context, stdout io.Writer, endpoint goa.Endpoint, payload any) error { + data, err := endpoint(ctx, payload) if err != nil { - fmt.Fprintln(os.Stderr, err.Error()) - os.Exit(1) + return err } + return writeJSON(stdout, data) +} - if data != nil { - m, _ := json.MarshalIndent(data, "", " ") - fmt.Println(string(m)) +// writeJSON writes one indented JSON value followed by a newline. +func writeJSON(stdout io.Writer, data any) error { + if data == nil { + return nil } + encoded, err := json.MarshalIndent(data, "", " ") + if err != nil { + return fmt.Errorf("encode result: %w", err) + } + if _, err := fmt.Fprintln(stdout, string(encoded)); err != nil { + return fmt.Errorf("write result: %w", err) + } + return nil } func usage() { - var usageCommands []string - usageCommands = append(usageCommands, httpUsageCommands()...) - sort.Strings(usageCommands) - usageCommands = slices.Compact(usageCommands) + usageCommands := []string{ + "service method", + } fmt.Fprintf(os.Stderr, `%s is a command line client for the SingleServerMultipleHosts API. Usage: %s [-host HOST][-url URL][-timeout SECONDS][-verbose|-v] SERVICE ENDPOINT [flags] -host HOST: server host (dev). valid values: dev, stage - -url URL: specify service URL overriding host URL (http://localhost:8080) + -url URL: specify service URL overriding host URL (http://example:8090) -timeout: maximum number of seconds to wait for response (30) -verbose|-v: print request and response details (false) diff --git a/codegen/example/testdata/client-single-server-single-host-with-variables.golden b/codegen/example/testdata/client-single-server-single-host-with-variables.golden index 4fd6755bc6..76ac892cb3 100644 --- a/codegen/example/testdata/client-single-server-single-host-with-variables.golden +++ b/codegen/example/testdata/client-single-server-single-host-with-variables.golden @@ -63,14 +63,12 @@ func main() { } var ( - endpoint goa.Endpoint - payload any - err error + err error ) { switch scheme { case "http", "https": - endpoint, payload, err = doHTTP(scheme, host, timeout, debug) + err = doHTTP(context.Background(), scheme, host, timeout, debug, os.Stdout) default: fmt.Fprintf(os.Stderr, "invalid scheme: %q (valid schemes: http|https)\n", scheme) os.Exit(1) @@ -85,30 +83,43 @@ func main() { os.Exit(1) } - data, err := endpoint(context.Background(), payload) +} + +// writeEndpointResult calls one normal endpoint and writes its result as JSON. +func writeEndpointResult(ctx context.Context, stdout io.Writer, endpoint goa.Endpoint, payload any) error { + data, err := endpoint(ctx, payload) if err != nil { - fmt.Fprintln(os.Stderr, err.Error()) - os.Exit(1) + return err } + return writeJSON(stdout, data) +} - if data != nil { - m, _ := json.MarshalIndent(data, "", " ") - fmt.Println(string(m)) +// writeJSON writes one indented JSON value followed by a newline. +func writeJSON(stdout io.Writer, data any) error { + if data == nil { + return nil } + encoded, err := json.MarshalIndent(data, "", " ") + if err != nil { + return fmt.Errorf("encode result: %w", err) + } + if _, err := fmt.Fprintln(stdout, string(encoded)); err != nil { + return fmt.Errorf("write result: %w", err) + } + return nil } func usage() { - var usageCommands []string - usageCommands = append(usageCommands, httpUsageCommands()...) - sort.Strings(usageCommands) - usageCommands = slices.Compact(usageCommands) + usageCommands := []string{ + "service method", + } fmt.Fprintf(os.Stderr, `%s is a command line client for the SingleServerSingleHostWithVariables API. Usage: %s [-host HOST][-url URL][-timeout SECONDS][-verbose|-v][-int INT][-uint UINT][-float32 FLOAT32][-int32 INT32][-int64 INT64][-uint32 UINT32][-uint64 UINT64][-float64 FLOAT64][-bool BOOL] SERVICE ENDPOINT [flags] -host HOST: server host (dev). valid values: dev - -url URL: specify service URL overriding host URL (http://localhost:8080) + -url URL: specify service URL overriding host URL (http://example-1-1-1.1:8090) -timeout: maximum number of seconds to wait for response (30) -verbose|-v: print request and response details (false) -int: (1) diff --git a/codegen/example/testdata/client-single-server-single-host.golden b/codegen/example/testdata/client-single-server-single-host.golden index fadb54b2b9..d516a48dc8 100644 --- a/codegen/example/testdata/client-single-server-single-host.golden +++ b/codegen/example/testdata/client-single-server-single-host.golden @@ -45,16 +45,14 @@ func main() { } var ( - endpoint goa.Endpoint - payload any - err error + err error ) { switch scheme { case "http", "https": - endpoint, payload, err = doHTTP(scheme, host, timeout, debug) + err = doHTTP(context.Background(), scheme, host, timeout, debug, os.Stdout) case "grpc", "grpcs": - endpoint, payload, err = doGRPC(scheme, host, timeout, debug) + err = doGRPC(context.Background(), scheme, host, timeout, debug, os.Stdout) default: fmt.Fprintf(os.Stderr, "invalid scheme: %q (valid schemes: grpc|http|https)\n", scheme) os.Exit(1) @@ -69,30 +67,43 @@ func main() { os.Exit(1) } - data, err := endpoint(context.Background(), payload) +} + +// writeEndpointResult calls one normal endpoint and writes its result as JSON. +func writeEndpointResult(ctx context.Context, stdout io.Writer, endpoint goa.Endpoint, payload any) error { + data, err := endpoint(ctx, payload) if err != nil { - fmt.Fprintln(os.Stderr, err.Error()) - os.Exit(1) + return err } + return writeJSON(stdout, data) +} - if data != nil { - m, _ := json.MarshalIndent(data, "", " ") - fmt.Println(string(m)) +// writeJSON writes one indented JSON value followed by a newline. +func writeJSON(stdout io.Writer, data any) error { + if data == nil { + return nil } + encoded, err := json.MarshalIndent(data, "", " ") + if err != nil { + return fmt.Errorf("encode result: %w", err) + } + if _, err := fmt.Fprintln(stdout, string(encoded)); err != nil { + return fmt.Errorf("write result: %w", err) + } + return nil } func usage() { - var usageCommands []string - usageCommands = append(usageCommands, httpUsageCommands()...) - sort.Strings(usageCommands) - usageCommands = slices.Compact(usageCommands) + usageCommands := []string{ + "service method", + } fmt.Fprintf(os.Stderr, `%s is a command line client for the SingleServerSingleHost API. Usage: %s [-host HOST][-url URL][-timeout SECONDS][-verbose|-v] SERVICE ENDPOINT [flags] -host HOST: server host (dev). valid values: dev - -url URL: specify service URL overriding host URL (http://localhost:8080) + -url URL: specify service URL overriding host URL (http://example:8090) -timeout: maximum number of seconds to wait for response (30) -verbose|-v: print request and response details (false) diff --git a/codegen/example/testdata/server-no-server.golden b/codegen/example/testdata/server-no-server.golden index 8d6ed8fba7..55490aa714 100644 --- a/codegen/example/testdata/server-no-server.golden +++ b/codegen/example/testdata/server-no-server.golden @@ -22,6 +22,7 @@ func main() { log.Debugf(ctx, "debug logs enabled") } log.Print(ctx, log.KV{K: "http-port", V: *httpPortF}) + log.Print(ctx, log.KV{K: "grpc-port", V: *grpcPortF}) // Initialize the services. var ( diff --git a/codegen/example/testdata/server-same-api-service-name.golden b/codegen/example/testdata/server-same-api-service-name.golden index 30462fadc0..db02a0b490 100644 --- a/codegen/example/testdata/server-same-api-service-name.golden +++ b/codegen/example/testdata/server-same-api-service-name.golden @@ -22,6 +22,7 @@ func main() { log.Debugf(ctx, "debug logs enabled") } log.Print(ctx, log.KV{K: "http-port", V: *httpPortF}) + log.Print(ctx, log.KV{K: "grpc-port", V: *grpcPortF}) // Initialize the services. var ( diff --git a/codegen/example/testdata/server-sercice-for-only-grpc.golden b/codegen/example/testdata/server-sercice-for-only-grpc.golden index 37d920f66a..1e62a57815 100644 --- a/codegen/example/testdata/server-sercice-for-only-grpc.golden +++ b/codegen/example/testdata/server-sercice-for-only-grpc.golden @@ -20,7 +20,7 @@ func main() { ctx = log.Context(ctx, log.WithDebug()) log.Debugf(ctx, "debug logs enabled") } - log.Print(ctx, log.KV{K: "http-port", V: *httpPortF}) + log.Print(ctx, log.KV{K: "grpc-port", V: *grpcPortF}) // Initialize the services. var ( diff --git a/codegen/example/testdata/server-server-hosting-multiple-services.golden b/codegen/example/testdata/server-server-hosting-multiple-services.golden index 50ff33ea91..ac3f603fae 100644 --- a/codegen/example/testdata/server-server-hosting-multiple-services.golden +++ b/codegen/example/testdata/server-server-hosting-multiple-services.golden @@ -22,6 +22,7 @@ func main() { log.Debugf(ctx, "debug logs enabled") } log.Print(ctx, log.KV{K: "http-port", V: *httpPortF}) + log.Print(ctx, log.KV{K: "grpc-port", V: *grpcPortF}) // Initialize the services. var ( diff --git a/codegen/example/testdata/server-server-hosting-service-subset.golden b/codegen/example/testdata/server-server-hosting-service-subset.golden index ee3a68a010..78071cfbcd 100644 --- a/codegen/example/testdata/server-server-hosting-service-subset.golden +++ b/codegen/example/testdata/server-server-hosting-service-subset.golden @@ -22,6 +22,7 @@ func main() { log.Debugf(ctx, "debug logs enabled") } log.Print(ctx, log.KV{K: "http-port", V: *httpPortF}) + log.Print(ctx, log.KV{K: "grpc-port", V: *grpcPortF}) // Initialize the services. var ( diff --git a/codegen/example/testdata/server-service-for-http-and-part-of-grpc.golden b/codegen/example/testdata/server-service-for-http-and-part-of-grpc.golden index 9d052dacb3..2639ec0d88 100644 --- a/codegen/example/testdata/server-service-for-http-and-part-of-grpc.golden +++ b/codegen/example/testdata/server-service-for-http-and-part-of-grpc.golden @@ -22,6 +22,7 @@ func main() { log.Debugf(ctx, "debug logs enabled") } log.Print(ctx, log.KV{K: "http-port", V: *httpPortF}) + log.Print(ctx, log.KV{K: "grpc-port", V: *grpcPortF}) // Initialize the services. var ( diff --git a/codegen/example/testdata/server-service-name-with-spaces.golden b/codegen/example/testdata/server-service-name-with-spaces.golden index cd3eeee20e..384db71363 100644 --- a/codegen/example/testdata/server-service-name-with-spaces.golden +++ b/codegen/example/testdata/server-service-name-with-spaces.golden @@ -22,6 +22,7 @@ func main() { log.Debugf(ctx, "debug logs enabled") } log.Print(ctx, log.KV{K: "http-port", V: *httpPortF}) + log.Print(ctx, log.KV{K: "grpc-port", V: *grpcPortF}) // Initialize the services. var ( diff --git a/codegen/example/testdata/server-single-server-multiple-hosts-with-variables.golden b/codegen/example/testdata/server-single-server-multiple-hosts-with-variables.golden index dd0fea4a7d..d10843b2e1 100644 --- a/codegen/example/testdata/server-single-server-multiple-hosts-with-variables.golden +++ b/codegen/example/testdata/server-single-server-multiple-hosts-with-variables.golden @@ -2,14 +2,14 @@ func main() { // Define command line flags, add any other flag required to configure the // service. var ( - hostF = flag.String("host", "dev", "Server host (valid values: dev, stage)") - domainF = flag.String("domain", "", "Host domain name (overrides host domain specified in service design)") - httpPortF = flag.String("http-port", "", "HTTP port (overrides host HTTP port specified in service design)") - versionF = flag.String("version", "v1", "Version (valid values: v1, v2)") - domainF = flag.String("domain", "test", "Domain") - portF = flag.String("port", "8080", "Port") - secureF = flag.Bool("secure", false, "Use secure scheme (https or grpcs)") - dbgF = flag.Bool("debug", false, "Log request and response bodies") + hostF = flag.String("host", "dev", "Server host (valid values: dev, stage)") + domainF = flag.String("domain", "", "Host domain name (overrides host domain specified in service design)") + httpPortF = flag.String("http-port", "", "HTTP port (overrides host HTTP port specified in service design)") + versionF = flag.String("version", "v1", "Version (valid values: v1, v2)") + urlDomainF = flag.String("url-domain", "test", "Domain") + portF = flag.String("port", "8080", "Port") + secureF = flag.Bool("secure", false, "Use secure scheme (https or grpcs)") + dbgF = flag.Bool("debug", false, "Log request and response bodies") ) flag.Parse() @@ -64,16 +64,9 @@ func main() { case "dev": { addr := "http://example-{version}:8090" - var versionSeen bool - { - for _, v := range []string{"v1", "v2"} { - if v == *versionF { - versionSeen = true - break - } - } - } - if !versionSeen { + switch *versionF { + case "v1", "v2": + default: log.Fatal(ctx, fmt.Errorf("invalid value for URL 'version' variable: %q (valid values: v1,v2)\n", *versionF)) } addr = strings.ReplaceAll(addr, "{version}", *versionF) @@ -102,7 +95,7 @@ func main() { case "stage": { addr := "https://example-{domain}:{port}" - addr = strings.ReplaceAll(addr, "{domain}", *domainF) + addr = strings.ReplaceAll(addr, "{domain}", *urlDomainF) addr = strings.ReplaceAll(addr, "{port}", *portF) u, err := url.Parse(addr) if err != nil { diff --git a/codegen/example/testdata/server-single-server-single-host.golden b/codegen/example/testdata/server-single-server-single-host.golden index 57c0d3f245..f7847d537a 100644 --- a/codegen/example/testdata/server-single-server-single-host.golden +++ b/codegen/example/testdata/server-single-server-single-host.golden @@ -22,6 +22,7 @@ func main() { log.Debugf(ctx, "debug logs enabled") } log.Print(ctx, log.KV{K: "http-port", V: *httpPortF}) + log.Print(ctx, log.KV{K: "grpc-port", V: *grpcPortF}) // Initialize the services. var ( diff --git a/codegen/funcs.go b/codegen/funcs.go index e42762ecbe..d266c928fd 100644 --- a/codegen/funcs.go +++ b/codegen/funcs.go @@ -1,3 +1,6 @@ +// This file contains helpers shared by Goa's code generators. The helpers +// receive names and types chosen while a generated file is planned and return +// the Go source inserted into that file. package codegen import ( @@ -8,6 +11,7 @@ import ( "unicode" "goa.design/goa/v3/expr" + "goa.design/goa/v3/internal/codegenname" ) type ( @@ -28,6 +32,9 @@ type ( FieldPointer bool // FieldType is the type of the field in the struct. FieldType expr.DataType + // FieldTypeRef is the field's final Go type name in the generated file. + // Callers set it when converting an argument to a named primitive type. + FieldTypeRef string } ) @@ -39,7 +46,7 @@ func TemplateFuncs() map[string]any { } } -// CommandLine return the command used to run this process. +// CommandLine returns the command used to run this process. func CommandLine() string { cmdl := "goa" for _, arg := range os.Args { @@ -115,86 +122,10 @@ func CamelCase(name string, firstUpper, acronym bool) string { operation: "camel", } return globalStringCache.getCached(key, func() string { - return camelCaseUncached(name, firstUpper, acronym) + return codegenname.CamelCase(name, firstUpper, acronym) }) } -// camelCaseUncached is the original implementation without caching. -func camelCaseUncached(name string, firstUpper, acronym bool) string { - runes := []rune(name) - // remove trailing invalid identifiers (makes code below simpler) - runes = removeTrailingInvalid(runes) - - // all characters are invalid - if len(runes) == 0 { - return "" - } - - w, i := 0, 0 // index of start of word, scan - for i+1 <= len(runes) { - eow := false // whether we hit the end of a word - - // remove leading invalid identifiers - runes = removeInvalidAtIndex(i, runes) - - switch { - case i+1 == len(runes): - eow = true - case !validIdentifier(runes[i]): - // get rid of it - runes = append(runes[:i], runes[i+1:]...) - case runes[i+1] == '_': - // underscore; shift the remainder forward over any run of underscores - eow = true - n := 1 - for i+n+1 < len(runes) && runes[i+n+1] == '_' { - n++ - } - copy(runes[i+1:], runes[i+n+1:]) - runes = runes[:len(runes)-n] - case isLower(runes[i]) && !isLower(runes[i+1]): - // lower->non-lower - eow = true - } - i++ - if !eow { - continue - } - - // [w,i] is a word. - word := string(runes[w:i]) - // is it one of our initialisms? - if u := strings.ToUpper(word); commonInitialisms[u] { - switch { - case firstUpper && acronym: - // u is already in upper case. Nothing to do here. - case firstUpper && !acronym: - u = expr.Title(strings.ToLower(u)) - case w > 0 && !acronym: - u = expr.Title(strings.ToLower(u)) - case w == 0: - u = strings.ToLower(u) - } - - // All the common initialisms are ASCII, - // so we can replace the bytes exactly. - copy(runes[w:], []rune(u)) - } else if w > 0 && strings.ToLower(word) == word { - // already all lowercase, and not the first word, so uppercase the first character. - runes[w] = unicode.ToUpper(runes[w]) - } else if w == 0 && strings.ToLower(word) == word && firstUpper { - runes[w] = unicode.ToUpper(runes[w]) - } - if w == 0 && !firstUpper { - runes[w] = unicode.ToLower(runes[w]) - } - // advance to next word - w = i - } - - return string(runes) -} - // SnakeCase produces the snake_case version of the given CamelCase string. // News => news // OldNews => old_news @@ -304,11 +235,10 @@ func InitStructFields(args []*InitArgData, targetVar, sourcePkg, targetPkg strin code += fmt.Sprintf("%s.%s = %s%s\n", targetVar, arg.FieldName, deref, arg.Name) case expr.IsPrimitive(arg.FieldType): // aliased primitive type - pkg := targetPkg - if loc := UserTypeLocation(arg.FieldType); loc != nil { - pkg = loc.PackageName() + if arg.FieldTypeRef == "" { + return "", helpers, fmt.Errorf("initialize field %q: missing final field type reference", arg.FieldName) } - t := scope.GoFullTypeRef(&expr.AttributeExpr{Type: arg.FieldType}, pkg) + t := arg.FieldTypeRef cast := fmt.Sprintf("%s(%s)", t, arg.Name) if arg.Pointer { code += "if " + arg.Name + " != nil {\n" @@ -370,78 +300,3 @@ func runeSpacePos(r []rune) int { } return len(r) } - -// isLower returns true if the character is considered a lower case character -// when transforming word into CamelCase. -func isLower(r rune) bool { - return unicode.IsDigit(r) || unicode.IsLower(r) -} - -// validIdentifier returns true if the rune is a letter or number -func validIdentifier(r rune) bool { - return unicode.IsLetter(r) || unicode.IsDigit(r) -} - -// removeTrailingInvalid removes trailing invalid identifiers from runes. -func removeTrailingInvalid(runes []rune) []rune { - valid := len(runes) - 1 - for ; valid >= 0 && !validIdentifier(runes[valid]); valid-- { - } - - return runes[0 : valid+1] -} - -// removeInvalidAtIndex removes consecutive invalid identifiers from runes starting at index i. -func removeInvalidAtIndex(i int, runes []rune) []rune { - valid := i - for ; valid < len(runes) && !validIdentifier(runes[valid]); valid++ { - } - - return append(runes[:i], runes[valid:]...) -} - -var ( - // common words who need to keep their - commonInitialisms = map[string]bool{ - "API": true, - "ASCII": true, - "CPU": true, - "CSS": true, - "DNS": true, - "EOF": true, - "GUID": true, - "HTML": true, - "HTTP": true, - "HTTPS": true, - "ID": true, - "IP": true, - "JMES": true, - "JSON": true, - "JWT": true, - "LHS": true, - "OK": true, - "QPS": true, - "RAM": true, - "RHS": true, - "RPC": true, - "SDK": true, - "SLA": true, - "SMTP": true, - "SQL": true, - "SSH": true, - "TCP": true, - "TLS": true, - "TTL": true, - "UDP": true, - "UI": true, - "UID": true, - "UUID": true, - "URI": true, - "URL": true, - "UTF8": true, - "VM": true, - "XML": true, - "XSRF": true, - "XSS": true, - } -) diff --git a/codegen/funcs_test.go b/codegen/funcs_test.go index 1fd4152d79..91876f6e5f 100644 --- a/codegen/funcs_test.go +++ b/codegen/funcs_test.go @@ -2,8 +2,53 @@ package codegen import ( "testing" + + "github.com/stretchr/testify/require" + + "goa.design/goa/v3/expr" ) +// TestInitStructFieldsUsesFinalFieldTypeRef verifies that primitive alias +// casts use the exact type name selected during package planning. +func TestInitStructFieldsUsesFinalFieldTypeRef(t *testing.T) { + alias := &expr.UserTypeExpr{ + TypeName: "Token", + AttributeExpr: &expr.AttributeExpr{ + Type: expr.String, + Meta: expr.MetaExpr{"struct:pkg:path": {"types"}}, + }, + } + code, _, err := InitStructFields([]*InitArgData{{ + Name: "token", + Type: expr.String, + FieldName: "Token", + FieldType: alias, + FieldTypeRef: "types2.Token", + FieldPointer: false, + Pointer: false, + }}, "payload", "", "service") + require.NoError(t, err) + require.Equal(t, "payload.Token = types2.Token(token)\n", code) +} + +// TestInitStructFieldsRequiresFinalFieldTypeRef verifies that plugin data +// cannot make the shared initializer guess a package name for a named field. +func TestInitStructFieldsRequiresFinalFieldTypeRef(t *testing.T) { + alias := &expr.UserTypeExpr{ + TypeName: "Token", + AttributeExpr: &expr.AttributeExpr{ + Type: expr.String, + }, + } + _, _, err := InitStructFields([]*InitArgData{{ + Name: "token", + Type: expr.String, + FieldName: "Token", + FieldType: alias, + }}, "payload", "", "service") + require.EqualError(t, err, `initialize field "Token": missing final field type reference`) +} + func TestSnakeCase(t *testing.T) { cases := map[string]struct { str string @@ -72,6 +117,56 @@ func TestCamelCase(t *testing.T) { } } +func TestProtobufNames(t *testing.T) { + tests := []struct { + name string + source string + want string + }{ + {name: "empty", want: "Val"}, + {name: "leading digits", source: "123_message", want: "_123Message"}, + {name: "acronym", source: "api_message", want: "APIMessage"}, + {name: "mixed Unicode", source: "café_message", want: "CafMessage"}, + {name: "only Unicode", source: "東京", want: "Val"}, + {name: "field keyword is a legal declaration", source: "string", want: "String"}, + {name: "invalid characters", source: "---", want: "Val"}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + actual := ProtobufName(test.source) + if actual != test.want { + t.Errorf("got %q, expected %q", actual, test.want) + } + }) + } +} + +func TestProtobufFieldNames(t *testing.T) { + tests := []struct { + name string + source string + want string + }{ + {name: "empty", want: "val"}, + {name: "leading digits", source: "123Field", want: "_123_field"}, + {name: "acronym", source: "HTTPServer", want: "http_server"}, + {name: "mixed Unicode", source: "caféField", want: "caf_field"}, + {name: "only Unicode", source: "東京", want: "val"}, + {name: "reserved word", source: "string", want: "string_"}, + {name: "invalid characters", source: "---", want: "val"}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + actual := ProtobufFieldName(test.source) + if actual != test.want { + t.Errorf("got %q, expected %q", actual, test.want) + } + }) + } +} + func TestKebabCase(t *testing.T) { cases := map[string]struct { str string diff --git a/codegen/generated_import_plan.go b/codegen/generated_import_plan.go new file mode 100644 index 0000000000..326bdd815a --- /dev/null +++ b/codegen/generated_import_plan.go @@ -0,0 +1,226 @@ +// This file records the packages named by one generated source contribution. +// A generator records complete paths before names are chosen, then reads the +// final import declarations after the generation is frozen. Contributions +// that share a file merge their completed import declarations. +package codegen + +import ( + "fmt" + "path" + "sort" + "strings" + + "goa.design/goa/v3/expr" +) + +type ( + // GeneratedImportPlan stores the packages named by one source contribution. + // Every package path must be added before Generation.Freeze. + GeneratedImportPlan struct { + output *GeneratedPackage + paths map[string]struct{} + imports []*ImportSpec + linked bool + } +) + +// NewGeneratedImportPlan creates an empty import plan for source written in +// output. +func NewGeneratedImportPlan(output *GeneratedPackage) *GeneratedImportPlan { + if output == nil { + panic("generated import plan requires an output package") + } + return &GeneratedImportPlan{ + output: output, + paths: make(map[string]struct{}), + } +} + +// Require adds packages whose names are written directly by generated source. +func (p *GeneratedImportPlan) Require(specs ...*ImportSpec) error { + return p.add(specs, p.output.RequireImport) +} + +// AddGenerated adds generated packages referenced by this contribution. +func (p *GeneratedImportPlan) AddGenerated(specs ...*ImportSpec) error { + return p.add(specs, p.output.ReserveGeneratedImport) +} + +// AddDesign adds packages named by design metadata referenced by this +// contribution. +func (p *GeneratedImportPlan) AddDesign(specs ...*ImportSpec) error { + return p.add(specs, p.output.DeclareImport) +} + +// AddTypeExpressions adds custom and relocated packages visible in written Go +// type expressions. A named type adds its package but not packages used only +// by its separately written fields. +func (p *GeneratedImportPlan) AddTypeExpressions(attributes ...*expr.AttributeExpr) error { + if err := p.ensurePlanning(); err != nil { + return err + } + for _, attribute := range attributes { + if err := walkAttributeImports(p.output, attribute, false, nil, p.addAttributeImport); err != nil { + return err + } + } + return nil +} + +// AddRecursiveTypeReferences adds packages named while generated code reads, +// converts, or validates nested fields. It descends into named types because +// those fields are referenced by the emitted code. +func (p *GeneratedImportPlan) AddRecursiveTypeReferences(attributes ...*expr.AttributeExpr) error { + if err := p.ensurePlanning(); err != nil { + return err + } + seen := make(map[expr.UserType]struct{}) + for _, attribute := range attributes { + if err := walkAttributeImports(p.output, attribute, true, seen, p.addAttributeImport); err != nil { + return err + } + } + return nil +} + +// Paths returns independent copies of the complete package paths used by this +// contribution. It can compare plans before their package names are fixed. +func (p *GeneratedImportPlan) Paths() []string { + paths := make([]string, 0, len(p.paths)) + for importPath := range p.paths { + paths = append(paths, importPath) + } + sort.Strings(paths) + return paths +} + +// Link resolves every recorded path to the package name selected by +// Generation.Freeze. +func (p *GeneratedImportPlan) Link() error { + if p.linked { + return fmt.Errorf("generated import plan for %q linked more than once", p.output.path) + } + if !p.output.frozen { + return fmt.Errorf("generated import plan for %q linked before freeze", p.output.path) + } + paths := p.Paths() + p.imports = make([]*ImportSpec, len(paths)) + for index, importPath := range paths { + p.imports[index] = p.output.Import(importPath) + } + p.linked = true + return nil +} + +// Imports returns independent copies of the final import declarations. It +// panics when Link has not completed. +func (p *GeneratedImportPlan) Imports() []*ImportSpec { + if !p.linked { + panic("generated imports requested before link") + } + imports := make([]*ImportSpec, len(p.imports)) + for index, spec := range p.imports { + copy := *spec + imports[index] = © + } + return imports +} + +// add registers each import preference on the output package and saves its +// complete path for this contribution. +func (p *GeneratedImportPlan) add(specs []*ImportSpec, register func(*ImportSpec) error) error { + if err := p.ensurePlanning(); err != nil { + return err + } + for _, spec := range specs { + if spec == nil || spec.Path == "" || spec.Path == p.output.path { + continue + } + if err := register(spec); err != nil { + return err + } + p.paths[spec.Path] = struct{}{} + } + return nil +} + +// ensurePlanning rejects changes after package and import names are fixed. +func (p *GeneratedImportPlan) ensurePlanning() error { + if p.output.frozen { + return fmt.Errorf("generated import plan for %q changed after freeze", p.output.path) + } + return nil +} + +// addAttributeImport registers one package found in a generated type shape. +func (p *GeneratedImportPlan) addAttributeImport(spec *ImportSpec, generated bool) error { + if generated { + return p.AddGenerated(spec) + } + return p.AddDesign(spec) +} + +// walkAttributeImports visits packages named by one generated type shape. +func walkAttributeImports( + output *GeneratedPackage, + attribute *expr.AttributeExpr, + expandNamed bool, + seen map[expr.UserType]struct{}, + visit func(*ImportSpec, bool) error, +) error { + if attribute == nil || attribute.Type == nil || attribute.Type == expr.Empty { + return nil + } + if _, spec := GetMetaType(attribute); spec != nil && spec.Path != output.path { + if err := visit(spec, false); err != nil { + return err + } + } + switch actual := attribute.Type.(type) { + case expr.UserType: + if expr.IsErrorResult(actual) { + return visit(GoaImport(""), false) + } + if location := UserTypeLocation(actual); location != nil { + importPath := path.Join(output.genpkg, location.RelImportPath) + if importPath != output.path { + if err := visit(NewImport( + strings.ToLower(Goify(path.Base(importPath), false)), + importPath, + ), true); err != nil { + return err + } + } + } + if !expandNamed { + return nil + } + origin := actual.Origin() + if _, exists := seen[origin]; exists { + return nil + } + seen[origin] = struct{}{} + defer delete(seen, origin) + return walkAttributeImports(output, actual.Attribute(), true, seen, visit) + case *expr.Object: + for _, field := range *actual { + if err := walkAttributeImports(output, field.Attribute, expandNamed, seen, visit); err != nil { + return err + } + } + case *expr.Array: + return walkAttributeImports(output, actual.ElemType, expandNamed, seen, visit) + case *expr.Map: + if err := walkAttributeImports(output, actual.KeyType, expandNamed, seen, visit); err != nil { + return err + } + return walkAttributeImports(output, actual.ElemType, expandNamed, seen, visit) + case *expr.Union: + for _, branch := range actual.Values { + if err := walkAttributeImports(output, branch.Attribute, expandNamed, seen, visit); err != nil { + return err + } + } + } + return nil +} diff --git a/codegen/generated_import_plan_test.go b/codegen/generated_import_plan_test.go new file mode 100644 index 0000000000..0216940144 --- /dev/null +++ b/codegen/generated_import_plan_test.go @@ -0,0 +1,155 @@ +package codegen + +import ( + "testing" + + "github.com/stretchr/testify/require" + + "goa.design/goa/v3/expr" +) + +// TestGeneratedImportPlanLinkExactAliases verifies that one contribution records all +// package paths before freeze and reads the same selected names afterward. +func TestGeneratedImportPlanLinkExactAliases(t *testing.T) { + generation := mustTestGeneration(t, "generated.local/gen", nil) + pkg := mustClaimTestPackage(t, generation, "generated.local/gen/service") + imports := NewGeneratedImportPlan(pkg) + require.NoError(t, imports.Require(SimpleImport("strconv"))) + + custom := &expr.AttributeExpr{ + Type: expr.String, + Meta: expr.MetaExpr{ + "struct:field:type": {"strconv.Token", "generated.local/custom/strconv", "strconv"}, + }, + } + relocated := &expr.UserTypeExpr{ + TypeName: "Record", + AttributeExpr: &expr.AttributeExpr{ + Type: expr.String, + Meta: expr.MetaExpr{"struct:pkg:path": {"types"}}, + }, + } + references := &expr.AttributeExpr{Type: &expr.Object{ + {Name: "custom", Attribute: custom}, + {Name: "relocated", Attribute: &expr.AttributeExpr{Type: relocated}}, + }} + require.NoError(t, imports.AddRecursiveTypeReferences(references)) + require.Equal(t, []string{ + "generated.local/custom/strconv", + "generated.local/gen/types", + "strconv", + }, imports.Paths()) + require.NoError(t, generation.Freeze()) + require.NoError(t, imports.Link()) + + require.Equal(t, []*ImportSpec{ + {Name: "strconv2", Path: "generated.local/custom/strconv"}, + {Name: "types", Path: "generated.local/gen/types"}, + {Path: "strconv"}, + }, imports.Imports()) + require.Equal(t, "strconv2.Token", pkg.Scope().GoTypeName(custom)) + require.Equal(t, "types", pkg.ImportName("generated.local/gen/types")) + + copy := imports.Imports() + copy[0].Path = "changed" + require.Equal(t, "generated.local/custom/strconv", imports.Imports()[0].Path) + paths := imports.Paths() + paths[0] = "changed" + require.Equal(t, "generated.local/custom/strconv", imports.Paths()[0]) +} + +// TestGeneratedImportPlanRejectsEarlyLink verifies that aliases cannot be +// read while another import can still change their names. +func TestGeneratedImportPlanRejectsEarlyLink(t *testing.T) { + generation := mustTestGeneration(t, "generated.local/gen", nil) + pkg := mustClaimTestPackage(t, generation, "generated.local/gen/service") + imports := NewGeneratedImportPlan(pkg) + require.NoError(t, imports.Require(SimpleImport("fmt"))) + require.EqualError(t, imports.Link(), "generated import plan for \"generated.local/gen/service\" linked before freeze") +} + +// TestGeneratedImportPlanSeparatesWrittenTypesFromNestedFields verifies that +// signatures do not import packages used only inside a named type. +func TestGeneratedImportPlanSeparatesWrittenTypesFromNestedFields(t *testing.T) { + generation := mustTestGeneration(t, "generated.local/gen", nil) + pkg := mustClaimTestPackage(t, generation, "generated.local/gen/service") + named := &expr.UserTypeExpr{ + TypeName: "Record", + AttributeExpr: &expr.AttributeExpr{ + Type: &expr.Object{{ + Name: "token", + Attribute: &expr.AttributeExpr{ + Type: expr.String, + Meta: expr.MetaExpr{ + "struct:field:type": {"wire.Token", "generated.local/wire", "wire"}, + }, + }, + }}, + Meta: expr.MetaExpr{"struct:pkg:path": {"types"}}, + }, + } + attribute := &expr.AttributeExpr{Type: named} + shallow := NewGeneratedImportPlan(pkg) + recursive := NewGeneratedImportPlan(pkg) + require.NoError(t, shallow.AddTypeExpressions(attribute)) + require.NoError(t, recursive.AddRecursiveTypeReferences(attribute)) + require.Equal(t, []string{"generated.local/gen/types"}, shallow.Paths()) + require.Equal(t, []string{"generated.local/gen/types", "generated.local/wire"}, recursive.Paths()) +} + +// TestGeneratedImportPlanTraversesEveryTypeCopy verifies that copies of one +// authored type can name different packages in their nested generated fields. +func TestGeneratedImportPlanTraversesEveryTypeCopy(t *testing.T) { + generation := mustTestGeneration(t, "generated.local/gen", nil) + pkg := mustClaimTestPackage(t, generation, "generated.local/gen/service") + origin := &expr.UserTypeExpr{ + TypeName: "Record", + AttributeExpr: &expr.AttributeExpr{Type: &expr.Object{{ + Name: "token", + Attribute: &expr.AttributeExpr{Type: expr.String}, + }}}, + } + copyWithPackage := func(name, importPath string) expr.UserType { + attribute := expr.DupAtt(origin.Attribute()) + expr.AsObject(attribute.Type).Attribute("token").AddMeta( + "struct:field:type", + name+".Token", + importPath, + name, + ) + return origin.Dup(attribute) + } + attribute := &expr.AttributeExpr{Type: &expr.Object{ + {Name: "first", Attribute: &expr.AttributeExpr{Type: copyWithPackage("first", "generated.local/first")}}, + {Name: "second", Attribute: &expr.AttributeExpr{Type: copyWithPackage("second", "generated.local/second")}}, + }} + imports := NewGeneratedImportPlan(pkg) + require.NoError(t, imports.AddRecursiveTypeReferences(attribute)) + require.Equal(t, []string{"generated.local/first", "generated.local/second"}, imports.Paths()) +} + +// TestGeneratedImportPlanIncludesBuiltInServiceError verifies that signatures +// using Goa's standard error reserve the package that defines ServiceError. +func TestGeneratedImportPlanIncludesBuiltInServiceError(t *testing.T) { + generation := mustTestGeneration(t, "generated.local/gen", nil) + pkg := mustClaimTestPackage(t, generation, "generated.local/gen/service") + imports := NewGeneratedImportPlan(pkg) + attribute := expr.DupAtt(&expr.AttributeExpr{Type: expr.ErrorResult}) + + require.NoError(t, imports.AddTypeExpressions(attribute)) + require.Equal(t, []string{GoaImport("").Path}, imports.Paths()) +} + +// TestGeneratedImportPlanRejectsLateChangesAndRepeatedLink verifies that the +// planning and linking phases each run exactly once. +func TestGeneratedImportPlanRejectsLateChangesAndRepeatedLink(t *testing.T) { + generation := mustTestGeneration(t, "generated.local/gen", nil) + pkg := mustClaimTestPackage(t, generation, "generated.local/gen/service") + imports := NewGeneratedImportPlan(pkg) + require.NoError(t, imports.Require(SimpleImport("fmt"))) + require.NoError(t, generation.Freeze()) + require.EqualError(t, imports.Require(), "generated import plan for \"generated.local/gen/service\" changed after freeze") + require.EqualError(t, imports.AddTypeExpressions(), "generated import plan for \"generated.local/gen/service\" changed after freeze") + require.NoError(t, imports.Link()) + require.EqualError(t, imports.Link(), "generated import plan for \"generated.local/gen/service\" linked more than once") +} diff --git a/codegen/generated_types.go b/codegen/generated_types.go new file mode 100644 index 0000000000..2f2076cb63 --- /dev/null +++ b/codegen/generated_types.go @@ -0,0 +1,977 @@ +// This file chooses every package-level Go name before source files are +// written. Generators record the names they need, then use the chosen names +// when writing code. +package codegen + +import ( + "fmt" + "reflect" + "slices" + "strings" + + "goa.design/goa/v3/expr" +) + +type ( + // GeneratedPackage stores declarations, final names, and the output location + // for one generated Go package. + GeneratedPackage struct { + generation *generationOwner + genpkg string + claim string + path string + outputDir string + scope *NameScope + names []*NameDeclaration + exactNames map[string]*NameDeclaration + nameBindings map[string]*NameDeclaration + importPlan *importAliasPlan + imports map[string]importAliasBinding + userTypes map[expr.UserType]*TypeDeclaration + typeBindings map[expr.UserType]*TypeDeclaration + derivedTypes map[DerivedTypeID]*TypeDeclaration + unions map[UnionDeclarationID]*unionDeclaration + frozen bool + } + + // DerivedTypeID identifies a type Goa generated from a source type, such as a + // view, method payload, or method result. + DerivedTypeID struct { + origin expr.UserType + kind derivedTypeKind + } + + // MethodTypeIdentity records the API, Go wrapper name, whether it holds a + // payload or result, the key used to repeat its examples, and the source type + // for one service method. + MethodTypeIdentity struct { + api string + name string + kind derivedTypeKind + exampleIdentity expr.ExampleIdentity + origin expr.UserType + } + + // TypeDeclaration stores the final name and package path of one generated Go + // type. + TypeDeclaration struct { + declaration *NameDeclaration + } + + // UnionDeclaration stores the generated union type name and its kind type + // name. + UnionDeclaration struct { + declaration *NameDeclaration + kindDeclaration *NameDeclaration + } + + // UnionBranchDeclaration stores the constant, constructor, and optional type + // generated for one union branch. + UnionBranchDeclaration struct { + kindDeclaration *NameDeclaration + constructorDeclaration *NameDeclaration + branchType *TypeDeclaration + } + + // unionDeclaration stores a union expression and the names generated for the + // union and each branch. + unionDeclaration struct { + union *expr.Union + declaration *UnionDeclaration + branches map[unionBranchID]*UnionBranchDeclaration + } + + // unionBranchID selects a generated union branch by its design name. + unionBranchID struct { + name string + } + + // derivedTypeKind records whether Goa is generating a view, viewed result, + // method payload, or method result. + derivedTypeKind uint + + // derivedTypeOrder sorts generated view types from copied strings and numbers + // so pointer addresses and visit order cannot change their suffixes. + derivedTypeOrder struct { + kind derivedTypeKind + name string + sourceName string + sourceID string + api string + } +) + +const ( + projectedTypeKind derivedTypeKind = iota + 1 + viewedResultTypeKind + methodPayloadTypeKind + methodStreamingPayloadTypeKind + methodResultTypeKind + methodStreamingResultTypeKind +) + +// NewProjectedTypeID returns the key used to find the view-specific copy of +// source whose fields use pointers in the generated service views package. +func NewProjectedTypeID(source expr.UserType) DerivedTypeID { + return newDerivedTypeID(source, projectedTypeKind) +} + +// NewViewedResultTypeID returns the key used to find the viewed-result wrapper +// generated from source in a service views package. +func NewViewedResultTypeID(source expr.UserType) DerivedTypeID { + return newDerivedTypeID(source, viewedResultTypeKind) +} + +// Name returns the Go wrapper name assigned while preparing the method. +func (i MethodTypeIdentity) Name() string { + return i.name +} + +// UID returns the stable example key stored when the method value was prepared. +func (i MethodTypeIdentity) UID() string { + return "generated:" + i.exampleIdentity.Seed() +} + +// Name returns the Go type name without a package qualifier. It panics until +// Generation.Freeze chooses every declaration name because another +// declaration may still change this one. +func (d *TypeDeclaration) Name() string { + return d.declaration.Name() +} + +// PackagePath returns the import path of the package that declares the type. +func (d *TypeDeclaration) PackagePath() string { + return d.declaration.packagePath() +} + +// Declaration returns the NameDeclaration used for this generated type. +func (d *TypeDeclaration) Declaration() *NameDeclaration { + return d.declaration +} + +// Name returns the Go union type name without a package qualifier. It panics +// until Generation.Freeze chooses every declaration name. +func (d *UnionDeclaration) Name() string { + return d.declaration.Name() +} + +// KindName returns the Go union kind type name without a package qualifier. It +// panics until Generation.Freeze chooses every declaration name. +func (d *UnionDeclaration) KindName() string { + return d.kindDeclaration.Name() +} + +// PackagePath returns the import path of the package that declares the union. +func (d *UnionDeclaration) PackagePath() string { + return d.declaration.packagePath() +} + +// Declaration returns the NameDeclaration used for the union type. +func (d *UnionDeclaration) Declaration() *NameDeclaration { + return d.declaration +} + +// KindDeclaration returns the NameDeclaration used for the union kind type. +func (d *UnionDeclaration) KindDeclaration() *NameDeclaration { + return d.kindDeclaration +} + +// KindConst returns the branch kind constant without a package qualifier. +func (d *UnionBranchDeclaration) KindConst() string { + return d.kindDeclaration.Name() +} + +// Constructor returns the branch constructor name without a package qualifier. +func (d *UnionBranchDeclaration) Constructor() string { + return d.constructorDeclaration.Name() +} + +// KindDeclaration returns the NameDeclaration used for the branch kind +// constant. +func (d *UnionBranchDeclaration) KindDeclaration() *NameDeclaration { + return d.kindDeclaration +} + +// ConstructorDeclaration returns the NameDeclaration used for the branch +// constructor. +func (d *UnionBranchDeclaration) ConstructorDeclaration() *NameDeclaration { + return d.constructorDeclaration +} + +// Type returns the generated branch type and true when the branch has one. +func (d *UnionBranchDeclaration) Type() (*TypeDeclaration, bool) { + return d.branchType, d.branchType != nil +} + +// Ref returns the Go type reference for dataType, including the pointer chosen +// by Goa for named objects, unions, and aliases. +func (d *TypeDeclaration) Ref(dataType expr.DataType) string { + return goTypeRef(d.Name(), dataType) +} + +// DeclareName records one package-level Go name. Each supplied key will return +// that same name during type formatting. Repeating the same name and keys has +// no effect. It returns an error if the name belongs to another package, cannot +// be ordered, or a key already selects another name. +func (p *GeneratedPackage) DeclareName(declaration *NameDeclaration, keys ...Hasher) error { + if p.frozen { + return fmt.Errorf("generated package %q is frozen", p.path) + } + if err := validateNameDeclaration(declaration); err != nil { + return err + } + if err := p.validateNameBindings(declaration, keys); err != nil { + return err + } + if declaration.owner != nil { + if declaration.owner == p { + return p.recordNameBindings(declaration, keys) + } + return fmt.Errorf( + "package name %q already belongs to generated package %q", + declaration.preferredName(), + declaration.owner.path, + ) + } + if declaration.base != nil { + switch { + case declaration.base.owner == nil: + return fmt.Errorf( + "generated package %q cannot declare preferred %s %q: base declaration is not owned", + p.path, + declaration.kind, + declaration.preferredName(), + ) + case declaration.base.owner != p && (p.generation == nil || declaration.base.owner.generation != p.generation): + return fmt.Errorf( + "generated package %q cannot declare preferred %s %q: base declaration belongs to another generation in package %q", + p.path, + declaration.kind, + declaration.preferredName(), + declaration.base.owner.path, + ) + } + } + if declaration.exact { + if existing, ok := p.exactNames[declaration.preferred]; ok { + return fmt.Errorf( + "generated package %q cannot declare exact %s %q: already declared by exact %s", + p.path, + declaration.kind, + declaration.preferred, + existing.kind, + ) + } + p.exactNames[declaration.preferred] = declaration + } else { + if err := validatePackageNameOrder(declaration.order); err != nil { + return fmt.Errorf( + "generated package %q cannot declare preferred %s %q: %w", + p.path, + declaration.kind, + declaration.preferredName(), + err, + ) + } + for _, existing := range p.names { + if existing.exact || reflect.TypeOf(existing.order) != reflect.TypeOf(declaration.order) { + continue + } + if existing.order.ComparePackageName(declaration.order) == 0 { + return fmt.Errorf( + "generated package %q cannot deterministically order preferred %s %q", + p.path, + declaration.kind, + declaration.preferredName(), + ) + } + } + } + declaration.owner = p + p.names = append(p.names, declaration) + return p.recordNameBindings(declaration, keys) +} + +// DeclareDependentName adds a generated declaration named by placing prefix and +// suffix around base's final name. base must belong to the same generation run. +func (p *GeneratedPackage) DeclareDependentName(kind PackageNameKind, base *NameDeclaration, prefix, suffix string, order PackageNameOrder) (*NameDeclaration, error) { + declaration := newDependentName(kind, base, prefix, suffix, order) + if err := p.DeclareName(declaration); err != nil { + return nil, err + } + return declaration, nil +} + +// DeclareGeneratedType adds a type produced by a generator plugin. Goa assigns +// a stable final name but does not associate the declaration with an authored +// Goa type. +func (p *GeneratedPackage) DeclareGeneratedType(preferredName string, order PackageNameOrder) (*TypeDeclaration, error) { + declaration := NewPreferredName(NameType, Goify(preferredName, true), ExportedName, order) + if err := p.DeclareName(declaration); err != nil { + return nil, fmt.Errorf("declare generated type %q: %w", preferredName, err) + } + return &TypeDeclaration{declaration: declaration}, nil +} + +// BindGeneratedType makes userType and copies made from it use declaration +// when Goa writes type definitions and references. The declaration must belong +// to this package. Repeating the same binding has no effect. +func (p *GeneratedPackage) BindGeneratedType(userType expr.UserType, declaration *TypeDeclaration) error { + if p.frozen { + return fmt.Errorf("generated package %q is frozen", p.path) + } + userTypeValue := reflect.ValueOf(userType) + if userType == nil || (userTypeValue.Kind() == reflect.Ptr && userTypeValue.IsNil()) { + return fmt.Errorf("generated package %q cannot bind a nil user type", p.path) + } + if declaration == nil || declaration.declaration == nil { + return fmt.Errorf("generated package %q cannot bind a nil type declaration", p.path) + } + if declaration.declaration.owner == nil { + return fmt.Errorf( + "generated package %q cannot bind type %q: declaration is not owned", + p.path, + userType.Name(), + ) + } + if declaration.declaration.owner != p { + return fmt.Errorf( + "generated package %q cannot bind type %q: declaration belongs to generated package %q", + p.path, + userType.Name(), + declaration.declaration.owner.path, + ) + } + return p.bindType(userType.Origin(), declaration) +} + +// DeclareUserType adds userType with its exact exported Go name and returns the +// generated declaration. Repeated calls for the same source type return the +// same declaration. +func (p *GeneratedPackage) DeclareUserType(userType expr.UserType) (*TypeDeclaration, error) { + if p.frozen { + return nil, fmt.Errorf("generated package %q is frozen", p.path) + } + origin := userType.Origin() + name := Goify(userType.Name(), true) + if declaration, ok := p.userTypes[origin]; ok { + if declaration.declaration.preferred != name { + return nil, fmt.Errorf( + "user type origin %q cannot declare both %q and %q in generated package %q", + origin.Name(), + declaration.declaration.preferred, + name, + p.path, + ) + } + return declaration, nil + } + + nameDeclaration := NewExactName(NameType, name) + if err := p.DeclareName(nameDeclaration); err != nil { + return nil, fmt.Errorf("declare user type %q: %w", userType.Name(), err) + } + declaration := &TypeDeclaration{declaration: nameDeclaration} + if err := p.bindType(origin, declaration); err != nil { + return nil, err + } + p.bindName(nameDeclaration, userType) + p.userTypes[origin] = declaration + return declaration, nil +} + +// DeclareDerivedType adds one generated form of a source type. Repeated calls +// with the same DerivedTypeID return the same declaration. +func (p *GeneratedPackage) DeclareDerivedType(identity DerivedTypeID, name string) (*TypeDeclaration, error) { + return p.declareDerivedType(identity, name, "") +} + +// declareDerivedType adds one generated form and uses api only to order method +// wrappers contributed by different APIs to the same Go package. +func (p *GeneratedPackage) declareDerivedType(identity DerivedTypeID, name, api string) (*TypeDeclaration, error) { + if p.frozen { + return nil, fmt.Errorf("generated package %q is frozen", p.path) + } + canonicalName := Goify(name, true) + if declaration, ok := p.derivedTypes[identity]; ok { + if declaration.declaration.preferred != canonicalName { + return nil, fmt.Errorf( + "derived type from %q cannot declare both %q and %q in generated package %q", + identity.origin.Name(), + declaration.declaration.preferred, + canonicalName, + p.path, + ) + } + return declaration, nil + } + order := newDerivedTypeOrder(identity, canonicalName, api) + nameDeclaration := NewPreferredName(NameType, canonicalName, ExportedName, order) + if err := p.DeclareName(nameDeclaration); err != nil { + return nil, err + } + declaration := &TypeDeclaration{declaration: nameDeclaration} + if identity.kind.isMethodType() { + if err := p.bindType(identity.origin, declaration); err != nil { + return nil, err + } + } + p.derivedTypes[identity] = declaration + return declaration, nil +} + +// DeclareMethodType adds the wrapper described by the MethodTypeIdentity value +// for source. It returns the declaration and DerivedTypeID used by later +// lookups. +func (p *GeneratedPackage) DeclareMethodType(identity MethodTypeIdentity, source expr.UserType) (*TypeDeclaration, DerivedTypeID, error) { + if identity.origin == nil || identity.origin != source.Origin() { + return nil, DerivedTypeID{}, fmt.Errorf( + "user type %q is not the compiler-owned method wrapper %q", + source.Name(), + identity.UID(), + ) + } + derived := newDerivedTypeID(source, identity.kind) + declaration, err := p.declareDerivedType(derived, identity.Name(), identity.api) + return declaration, derived, err +} + +// DeclareUnion adds the generated union type, kind type, branch constants, and +// branch constructors for the OneOf stored in attribute. Copies of the same +// authored OneOf and emitted definition return the same declaration. Reading +// generated names panics until Generation.Freeze chooses every declaration +// name. +func (p *GeneratedPackage) DeclareUnion(attribute *expr.AttributeExpr) (*UnionDeclaration, error) { + if p.frozen { + return nil, fmt.Errorf("generated package %q is frozen", p.path) + } + union, ok := attribute.Type.(*expr.Union) + if !ok { + return nil, fmt.Errorf("generated package %q can only declare a OneOf attribute", p.path) + } + if err := validateUnionBranchGoNames(union); err != nil { + return nil, err + } + identity := NewUnionDeclarationID(attribute) + if planned, ok := p.unions[identity]; ok { + return planned.declaration, nil + } + + name := Goify(union.Name(), true) + nameDeclaration := NewExactName(NameType, name) + kindDeclaration := NewExactName(NameType, name+"Kind") + if err := p.declareUnionName(union, nameDeclaration); err != nil { + return nil, err + } + if err := p.declareUnionName(union, kindDeclaration); err != nil { + return nil, err + } + declaration := &UnionDeclaration{ + declaration: nameDeclaration, + kindDeclaration: kindDeclaration, + } + branches := make(map[unionBranchID]*UnionBranchDeclaration, len(union.Values)) + for _, branch := range union.Values { + branchIdentity := unionBranchID{name: branch.Name} + if _, ok := branches[branchIdentity]; ok { + return nil, fmt.Errorf("union %q declares branch %q more than once", union.Name(), branch.Name) + } + branchName := Goify(branch.Name, true) + kindDeclaration := NewExactName(NameConstant, name+"Kind"+branchName) + constructorDeclaration := NewExactName(NameFunction, "New"+name+branchName) + if err := p.declareUnionName(union, kindDeclaration); err != nil { + return nil, err + } + if err := p.declareUnionName(union, constructorDeclaration); err != nil { + return nil, err + } + branches[branchIdentity] = &UnionBranchDeclaration{ + kindDeclaration: kindDeclaration, + constructorDeclaration: constructorDeclaration, + } + } + p.unions[identity] = &unionDeclaration{ + union: union, + declaration: declaration, + branches: branches, + } + return declaration, nil +} + +// DeclareUnionBranchType adds the generated type used by branchName in the +// OneOf stored in attribute. Copies of that authored OneOf share the branch +// declaration. Types written directly in the DSL must use DeclareUserType +// instead. +func (p *GeneratedPackage) DeclareUnionBranchType(attribute *expr.AttributeExpr, branchName string, userType expr.UserType) (*TypeDeclaration, error) { + if p.frozen { + return nil, fmt.Errorf("generated package %q is frozen", p.path) + } + union, ok := attribute.Type.(*expr.Union) + if !ok { + return nil, fmt.Errorf("generated package %q can only use a OneOf attribute", p.path) + } + identity := NewUnionDeclarationID(attribute) + planned, ok := p.unions[identity] + if !ok { + return nil, fmt.Errorf("union %q is not declared in generated package %q", union.Name(), p.path) + } + if !unionHasBranchType(union, branchName, userType) { + return nil, fmt.Errorf("user type %q is not branch %q of union %q", userType.Name(), branchName, union.Name()) + } + + branchIdentity := unionBranchID{name: branchName} + branch, ok := planned.branches[branchIdentity] + if !ok { + return nil, fmt.Errorf("branch %q of union %q is not declared in generated package %q", branchName, union.Name(), p.path) + } + if branch.branchType != nil { + name := unionBranchTypeName(union, branchName) + if branch.branchType.declaration.preferred != name { + return nil, fmt.Errorf( + "branch %q of union %q cannot declare both %q and %q", + branchName, + union.Name(), + branch.branchType.declaration.preferred, + name, + ) + } + if err := p.bindType(userType.Origin(), branch.branchType); err != nil { + return nil, err + } + return branch.branchType, nil + } + typeName := unionBranchTypeName(union, branchName) + nameDeclaration := NewExactName(NameType, typeName) + if err := p.declareUnionName(union, nameDeclaration); err != nil { + return nil, err + } + declaration := &TypeDeclaration{declaration: nameDeclaration} + origin := userType.Origin() + if err := p.bindType(origin, declaration); err != nil { + return nil, err + } + branch.branchType = declaration + return declaration, nil +} + +// UserType returns the declaration previously added for userType. It does not +// add a name. +func (p *GeneratedPackage) UserType(userType expr.UserType) (*TypeDeclaration, error) { + if declaration, ok := p.userTypes[userType.Origin()]; ok { + return declaration, nil + } + return nil, fmt.Errorf("user type %q is not declared in generated package %q", userType.Name(), p.path) +} + +// Type returns the exact user type declaration or generated union branch type +// previously associated with userType's source declaration. +func (p *GeneratedPackage) Type(userType expr.UserType) (*TypeDeclaration, error) { + if declaration, ok := p.typeBindings[userType.Origin()]; ok { + return declaration, nil + } + return nil, fmt.Errorf("user type %q has no declaration in generated package %q", userType.Name(), p.path) +} + +// DerivedType returns the generated view type previously added for the supplied +// DerivedTypeID. +func (p *GeneratedPackage) DerivedType(identity DerivedTypeID) (*TypeDeclaration, error) { + if declaration, ok := p.derivedTypes[identity]; ok { + return declaration, nil + } + return nil, fmt.Errorf( + "derived type from %q is not declared in generated package %q", + identity.origin.Name(), + p.path, + ) +} + +// UnionBranch returns the constant, constructor, and optional type previously +// added for branchName. It does not add names. +func (p *GeneratedPackage) UnionBranch(attribute *expr.AttributeExpr, branchName string) (*UnionBranchDeclaration, error) { + union, ok := attribute.Type.(*expr.Union) + if !ok { + return nil, fmt.Errorf("generated package %q can only use a OneOf attribute", p.path) + } + planned, ok := p.unions[NewUnionDeclarationID(attribute)] + if !ok { + return nil, fmt.Errorf("union %q is not declared in generated package %q", union.Name(), p.path) + } + branch, ok := planned.branches[unionBranchID{name: branchName}] + if !ok { + return nil, fmt.Errorf("branch %q of union %q is not declared in generated package %q", branchName, union.Name(), p.path) + } + return branch, nil +} + +// Union returns the declaration previously added for union. It does not add a +// name. +func (p *GeneratedPackage) Union(attribute *expr.AttributeExpr) (*UnionDeclaration, error) { + union, ok := attribute.Type.(*expr.Union) + if !ok { + return nil, fmt.Errorf("generated package %q can only use a OneOf attribute", p.path) + } + if planned, ok := p.unions[NewUnionDeclarationID(attribute)]; ok { + return planned.declaration, nil + } + return nil, fmt.Errorf("union %q is not declared in generated package %q", union.Name(), p.path) +} + +// UnionBranchType returns the generated type previously added for branchName. +// It returns an error when the branch does not generate a type. +func (p *GeneratedPackage) UnionBranchType(attribute *expr.AttributeExpr, branchName string) (*TypeDeclaration, error) { + union, ok := attribute.Type.(*expr.Union) + if !ok { + return nil, fmt.Errorf("generated package %q can only use a OneOf attribute", p.path) + } + branch, err := p.UnionBranch(attribute, branchName) + if err != nil { + return nil, err + } + if branch.branchType == nil { + return nil, fmt.Errorf("branch %q of union %q has no generated type in package %q", branchName, union.Name(), p.path) + } + return branch.branchType, nil +} + +// Scope returns the package's NameScope after all names are final. It panics +// until Generation.Freeze chooses every declaration name. +func (p *GeneratedPackage) Scope() *NameScope { + if !p.frozen { + panic(fmt.Sprintf("generated package %q scope requested before freeze", p.path)) + } + return p.scope +} + +// ComparePackageName sorts two generated service type names. +func (o derivedTypeOrder) ComparePackageName(other PackageNameOrder) int { + return compareDerivedTypeOrder(o, other.(derivedTypeOrder)) +} + +// newGeneratedPackage returns an empty package record for path and outputDir. +func newGeneratedPackage(claim, path, outputDir string) *GeneratedPackage { + return &GeneratedPackage{ + claim: claim, + path: path, + outputDir: outputDir, + scope: NewNameScope(), + exactNames: make(map[string]*NameDeclaration), + nameBindings: make(map[string]*NameDeclaration), + importPlan: &importAliasPlan{ + candidates: make(map[string]*importAliasCandidate), + }, + userTypes: make(map[expr.UserType]*TypeDeclaration), + typeBindings: make(map[expr.UserType]*TypeDeclaration), + derivedTypes: make(map[DerivedTypeID]*TypeDeclaration), + unions: make(map[UnionDeclarationID]*unionDeclaration), + } +} + +// freeze chooses every name in this standalone package and then rejects later +// changes. +func (p *GeneratedPackage) freeze() error { + return freezeGeneratedPackages([]*GeneratedPackage{p}) +} + +// freezeIndependentNames chooses names that do not depend on another generated +// declaration and returns the names that still need their base to be chosen. +func (p *GeneratedPackage) freezeIndependentNames() ([]*NameDeclaration, error) { + exact := make([]*NameDeclaration, 0, len(p.names)) + preferred := make([]*NameDeclaration, 0, len(p.names)) + dependent := make([]*NameDeclaration, 0, len(p.names)) + for _, declaration := range p.names { + switch { + case declaration.exact: + exact = append(exact, declaration) + case declaration.base == nil: + preferred = append(preferred, declaration) + default: + dependent = append(dependent, declaration) + } + } + slices.SortFunc(exact, func(left, right *NameDeclaration) int { + return strings.Compare(left.preferred, right.preferred) + }) + for _, declaration := range exact { + declaration.final = p.scope.Unique(declaration.preferred) + if declaration.final != declaration.preferred { + return nil, fmt.Errorf( + "generated package %q cannot preserve exact %s name %q", + p.path, + declaration.kind, + declaration.preferred, + ) + } + declaration.frozen = true + } + if err := p.freezeImports(); err != nil { + return nil, err + } + slices.SortFunc(preferred, comparePackageNames) + for _, declaration := range preferred { + declaration.final = p.scope.Unique(declaration.preferred) + declaration.frozen = true + } + return dependent, nil +} + +// finishFreeze records the chosen names in the package lookup tables and +// prevents later code from changing the package. +func (p *GeneratedPackage) finishFreeze() { + for _, declaration := range p.names { + for _, hash := range declaration.hashes { + p.scope.bind(hash, declaration.final) + } + } + for origin, declaration := range p.typeBindings { + p.scope.bindUserType(origin, declaration.declaration.final) + } + for identity, union := range p.unions { + p.scope.bindUnion(identity, union.declaration.declaration.final) + } + p.scope.Freeze() + p.frozen = true +} + +// freezeGeneratedPackages chooses independent names in every package before it +// resolves names based on declarations elsewhere in the same generation run. +func freezeGeneratedPackages(packages []*GeneratedPackage) error { + slices.SortFunc(packages, func(left, right *GeneratedPackage) int { + return strings.Compare(left.path, right.path) + }) + var dependent []*NameDeclaration + for _, generatedPackage := range packages { + remaining, err := generatedPackage.freezeIndependentNames() + if err != nil { + return err + } + dependent = append(dependent, remaining...) + } + for len(dependent) > 0 { + ready := dependent[:0] + waiting := make([]*NameDeclaration, 0, len(dependent)) + for _, declaration := range dependent { + if declaration.base.frozen { + ready = append(ready, declaration) + } else { + waiting = append(waiting, declaration) + } + } + if len(ready) == 0 { + return fmt.Errorf("generated packages contain a package-name dependency cycle") + } + slices.SortFunc(ready, func(left, right *NameDeclaration) int { + if compared := strings.Compare(left.owner.path, right.owner.path); compared != 0 { + return compared + } + return comparePackageNames(left, right) + }) + for _, declaration := range ready { + declaration.final = declaration.owner.scope.Unique(declaration.preferredName()) + declaration.frozen = true + } + dependent = waiting + } + for _, generatedPackage := range packages { + generatedPackage.finishFreeze() + } + return nil +} + +// declareUnionName records one exact public name owned by union. A collision +// tells the design author how to choose another complete union name. +func (p *GeneratedPackage) declareUnionName(union *expr.Union, declaration *NameDeclaration) error { + if err := p.DeclareName(declaration); err != nil { + return fmt.Errorf( + "declare OneOf %q in generated package %q: %w; set TypeName on the OneOf to a unique name", + union.Name(), + p.path, + err, + ) + } + return nil +} + +// validateUnionBranchGoNames rejects branch spellings that would create the +// same exported Go identifier and therefore the same public union functions. +func validateUnionBranchGoNames(union *expr.Union) error { + branches := make(map[string]string, len(union.Values)) + for _, branch := range union.Values { + goName := Goify(branch.Name, true) + if existing, ok := branches[goName]; ok { + return fmt.Errorf( + "OneOf %q branches %q and %q both generate Go name %q; rename one of the branches", + union.Name(), + existing, + branch.Name, + goName, + ) + } + branches[goName] = branch.Name + } + return nil +} + +// bindName makes lookups for hash return declaration's chosen Go name. +func (p *GeneratedPackage) bindName(declaration *NameDeclaration, hash Hasher) { + if err := p.recordNameBindings(declaration, []Hasher{hash}); err != nil { + panic(err) + } +} + +// validateNameBindings rejects nil lookup keys and keys that already return a +// different Go declaration name. +func (p *GeneratedPackage) validateNameBindings(declaration *NameDeclaration, keys []Hasher) error { + for _, key := range keys { + if key == nil { + return fmt.Errorf("generated package %q cannot declare a nil lookup key", p.path) + } + hash := key.Hash() + if existing := p.nameBindings[hash]; existing != nil && existing != declaration { + return fmt.Errorf( + "generated package %q lookup key %q already belongs to %s %q", + p.path, + hash, + existing.kind, + existing.preferredName(), + ) + } + } + return nil +} + +// recordNameBindings makes each lookup key return declaration's chosen Go +// name. +func (p *GeneratedPackage) recordNameBindings(declaration *NameDeclaration, keys []Hasher) error { + if err := p.validateNameBindings(declaration, keys); err != nil { + return err + } + for _, key := range keys { + hash := key.Hash() + if p.nameBindings[hash] == declaration { + continue + } + p.nameBindings[hash] = declaration + declaration.hashes = append(declaration.hashes, key) + } + return nil +} + +// bindType associates one source type with one generated declaration. Repeating +// the same association has no effect; using a different declaration returns an +// error. +func (p *GeneratedPackage) bindType(origin expr.UserType, declaration *TypeDeclaration) error { + if existing, ok := p.typeBindings[origin]; ok { + if existing == declaration { + return nil + } + return fmt.Errorf( + "user type %q is already bound to another declaration in generated package %q", + origin.Name(), + p.path, + ) + } + p.typeBindings[origin] = declaration + return nil +} + +// newDerivedTypeID identifies one generated view, payload, or result type by +// the source type it came from. It panics when that source is unknown. +func newDerivedTypeID(source expr.UserType, kind derivedTypeKind) DerivedTypeID { + if source == nil || source.Origin() == nil { + panic("derived type source has no declaration origin") + } + return DerivedTypeID{origin: source.Origin(), kind: kind} +} + +// newMethodTypeIdentity records the API, generated wrapper name, whether it +// holds a payload or result, and the key used to repeat examples for one +// service method value. +func newMethodTypeIdentity(apiName, methodName string, kind derivedTypeKind, exampleIdentity expr.ExampleIdentity) MethodTypeIdentity { + if !kind.isMethodType() { + panic("method type identity requires a method role") + } + return MethodTypeIdentity{ + api: apiName, + name: Goify(methodName, true) + kind.methodSuffix(), + kind: kind, + exampleIdentity: exampleIdentity, + } +} + +// bind records the source type wrapped for one method. Goa uses the pointer only +// while preparing this run; it does not change generated names or identifiers. +func (i MethodTypeIdentity) bind(source expr.UserType) MethodTypeIdentity { + i.origin = source.Origin() + return i +} + +// methodSuffix returns the Go name suffix for a method payload or result +// wrapper. +func (k derivedTypeKind) methodSuffix() string { + switch k { + case methodPayloadTypeKind: + return "Payload" + case methodStreamingPayloadTypeKind: + return "StreamingPayload" + case methodResultTypeKind: + return "Result" + case methodStreamingResultTypeKind: + return "StreamingResult" + default: + panic("derived type kind is not a method role") + } +} + +// isMethodType reports whether this kind is a service method payload or result +// wrapper. +func (k derivedTypeKind) isMethodType() bool { + return k >= methodPayloadTypeKind && k <= methodStreamingResultTypeKind +} + +// newDerivedTypeOrder copies the values used to sort generated type names so +// expression pointer addresses cannot affect their suffixes. +func newDerivedTypeOrder(identity DerivedTypeID, name, api string) derivedTypeOrder { + return derivedTypeOrder{ + kind: identity.kind, + name: name, + sourceName: identity.origin.Name(), + sourceID: identity.origin.ID(), + api: api, + } +} + +// compareDerivedTypeOrder sorts generated view types by kind, requested name, +// source name, source ID, and API name. +func compareDerivedTypeOrder(left, right derivedTypeOrder) int { + if left.kind != right.kind { + return int(left.kind) - int(right.kind) + } + for _, values := range [][2]string{ + {left.name, right.name}, + {left.sourceName, right.sourceName}, + {left.sourceID, right.sourceID}, + {left.api, right.api}, + } { + if compared := strings.Compare(values[0], values[1]); compared != 0 { + return compared + } + } + return 0 +} + +// unionHasBranchType reports whether branchName in this OneOf occurrence uses +// userType. The caller has already found the authored OneOf declaration. +func unionHasBranchType(union *expr.Union, branchName string, userType expr.UserType) bool { + for _, branch := range union.Values { + if branch.Name == branchName && branch.Attribute.Type == userType { + return true + } + } + return false +} + +// unionBranchTypeName separates a compiler-created branch type from the union +// kind type and branch constants in the same Go package. +func unionBranchTypeName(union *expr.Union, branchName string) string { + return Goify(union.Name(), true) + "Branch" + Goify(branchName, true) +} diff --git a/codegen/generated_types_test.go b/codegen/generated_types_test.go new file mode 100644 index 0000000000..3a6b288f5f --- /dev/null +++ b/codegen/generated_types_test.go @@ -0,0 +1,1521 @@ +// This file verifies that one generated package owns the public names of every +// relocated declaration planned into it. +package codegen + +import ( + "fmt" + "strings" + "testing" + + "github.com/stretchr/testify/require" + + "goa.design/goa/v3/dsl" + "goa.design/goa/v3/eval" + "goa.design/goa/v3/expr" +) + +type ( + // testNameOrder supplies stable typed ordering facts to package-name tests. + testNameOrder struct { + value string + } + + // alphaTestNameOrder and omegaTestNameOrder are unrelated order families + // whose comparers reject foreign values. + alphaTestNameOrder string + omegaTestNameOrder string + + // unstableTestNameOrder is invalid because its value may change after + // declaration collection. + unstableTestNameOrder []string + + // indirectTestNameOrder is invalid because it contains pointer identity. + indirectTestNameOrder struct { + value *string + } + + // testNameKey supplies the lookup key used by generated type formatters. + testNameKey string +) + +// ComparePackageName orders declarations from the same test family. +func (o testNameOrder) ComparePackageName(other PackageNameOrder) int { + return strings.Compare(o.value, other.(testNameOrder).value) +} + +// ComparePackageName orders declarations from the alpha test family. +func (o alphaTestNameOrder) ComparePackageName(other PackageNameOrder) int { + return strings.Compare(string(o), string(other.(alphaTestNameOrder))) +} + +// ComparePackageName orders declarations from the omega test family. +func (o omegaTestNameOrder) ComparePackageName(other PackageNameOrder) int { + return strings.Compare(string(o), string(other.(omegaTestNameOrder))) +} + +// ComparePackageName orders an invalid mutable test value. +func (o unstableTestNameOrder) ComparePackageName(other PackageNameOrder) int { + return strings.Compare(strings.Join(o, "/"), strings.Join(other.(unstableTestNameOrder), "/")) +} + +// ComparePackageName orders an invalid pointer-backed test value. +func (o indirectTestNameOrder) ComparePackageName(other PackageNameOrder) int { + return strings.Compare(*o.value, *other.(indirectTestNameOrder).value) +} + +// Hash returns the lookup key used by a generated package. +func (k testNameKey) Hash() string { + return string(k) +} + +// TestDeclareNameBindsLookupKeys checks that a plugin can declare a name and +// use the same final name through its normal typed lookup. +func TestDeclareNameBindsLookupKeys(t *testing.T) { + generation := mustTestGeneration(t, "generated.local/gen", nil) + pkg := mustClaimTestPackage(t, generation, "generated.local/gen/specs") + key := testNameKey("request") + declaration := NewPreferredName(NameType, "Request", ExportedName, testNameOrder{value: "request"}) + + require.NoError(t, pkg.DeclareName(declaration, key)) + require.NoError(t, pkg.DeclareName(declaration, key)) + require.NoError(t, generation.Freeze()) + require.Equal(t, declaration.Name(), pkg.Scope().HashedUnique(key, "Ignored")) +} + +// TestDeclareNameRejectsAnotherDeclarationForOneKey checks that one lookup +// key cannot select two package-level names. +func TestDeclareNameRejectsAnotherDeclarationForOneKey(t *testing.T) { + generation := mustTestGeneration(t, "generated.local/gen", nil) + pkg := mustClaimTestPackage(t, generation, "generated.local/gen/specs") + key := testNameKey("request") + first := NewPreferredName(NameType, "Request", ExportedName, testNameOrder{value: "first"}) + second := NewPreferredName(NameType, "Request", ExportedName, testNameOrder{value: "second"}) + + require.NoError(t, pkg.DeclareName(first, key)) + err := pkg.DeclareName(second, key) + require.ErrorContains(t, err, "lookup key") +} + +// TestNameDeclarationOwnsOnePackageNamespace verifies that exact and preferred +// package symbols of every kind share one collision domain and one frozen name. +func TestNameDeclarationOwnsOnePackageNamespace(t *testing.T) { + generation := mustTestGeneration(t, "generated.local/gen", nil) + types := mustClaimTestPackage(t, generation, "generated.local/gen/types") + exact := NewExactName(NameType, "Build") + preferred := NewPreferredName(NameFunction, "Build", ExportedName, testNameOrder{value: "build"}) + + require.NoError(t, types.DeclareName(exact)) + require.NoError(t, types.DeclareName(exact)) + require.NoError(t, types.DeclareName(preferred)) + require.Equal(t, NameType, exact.Kind()) + require.Panics(t, func() { exact.Name() }) + require.Panics(t, func() { preferred.Name() }) + + require.NoError(t, generation.Freeze()) + require.Equal(t, "Build", exact.Name()) + require.Equal(t, "Build2", preferred.Name()) + require.Equal(t, "Build", exact.Name()) + + for _, kind := range []PackageNameKind{NameType, NameFunction, NameConstant, NameVariable} { + collisionGeneration := mustTestGeneration(t, "generated.local/gen", nil) + pkg := mustClaimTestPackage(t, collisionGeneration, "generated.local/gen/types") + require.NoError(t, pkg.DeclareName(NewExactName(NameType, "Shared"))) + err := pkg.DeclareName(NewExactName(kind, "Shared")) + require.ErrorContains(t, err, "Shared") + } +} + +// TestDeclareGeneratedTypeUsesStablePackageNames verifies that plugins can +// declare generated types without claiming that they are authored Goa types. +func TestDeclareGeneratedTypeUsesStablePackageNames(t *testing.T) { + declare := func(reverse bool) (string, string) { + generation := mustTestGeneration(t, "generated.local/gen", nil) + pkg := mustClaimTestPackage(t, generation, "generated.local/gen/types") + firstOrder := testNameOrder{value: "first"} + secondOrder := testNameOrder{value: "second"} + var first, second *TypeDeclaration + var err error + if reverse { + second, err = pkg.DeclareGeneratedType("Value", secondOrder) + require.NoError(t, err) + first, err = pkg.DeclareGeneratedType("Value", firstOrder) + } else { + first, err = pkg.DeclareGeneratedType("Value", firstOrder) + require.NoError(t, err) + second, err = pkg.DeclareGeneratedType("Value", secondOrder) + } + require.NoError(t, err) + require.NoError(t, generation.Freeze()) + _, err = pkg.DeclareGeneratedType("Other", testNameOrder{value: "other"}) + require.ErrorContains(t, err, "frozen") + return first.Name(), second.Name() + } + + first, second := declare(false) + reversedFirst, reversedSecond := declare(true) + require.Equal(t, "Value", first) + require.Equal(t, "Value2", second) + require.Equal(t, first, reversedFirst) + require.Equal(t, second, reversedSecond) +} + +// TestBindGeneratedTypeUsesOriginalTypeIdentity verifies that equal generated +// types can use different declarations while copies use the declaration chosen +// for their original type. +func TestBindGeneratedTypeUsesOriginalTypeIdentity(t *testing.T) { + generation := mustTestGeneration(t, "generated.local/gen", nil) + types := mustClaimTestPackage(t, generation, "generated.local/gen/types") + firstType := generatedUserType("Value", "shared") + secondType := generatedUserType("Value", "shared") + firstCopy := firstType.Dup(expr.DupAtt(firstType.Attribute())) + require.NotSame(t, firstType.Origin(), secondType.Origin()) + require.Equal(t, firstType.Hash(), secondType.Hash()) + + first, err := types.DeclareGeneratedType("Value", testNameOrder{value: "first"}) + require.NoError(t, err) + second, err := types.DeclareGeneratedType("Value", testNameOrder{value: "second"}) + require.NoError(t, err) + require.NoError(t, types.BindGeneratedType(firstType, first)) + require.NoError(t, types.BindGeneratedType(firstCopy, first)) + require.NoError(t, types.BindGeneratedType(secondType, second)) + require.NoError(t, generation.Freeze()) + + require.Equal(t, "Value", first.Name()) + require.Equal(t, "Value2", second.Name()) + boundCopy, err := types.Type(firstCopy) + require.NoError(t, err) + require.Same(t, first, boundCopy) + + scope := types.Scope() + require.Equal(t, "Value", scope.GoTypeName(&expr.AttributeExpr{Type: firstType})) + require.Equal(t, "Value", scope.GoTypeName(&expr.AttributeExpr{Type: firstCopy})) + require.Equal(t, "Value2", scope.GoTypeName(&expr.AttributeExpr{Type: secondType})) + require.Equal(t, "types.Value", scope.GoFullTypeRef(&expr.AttributeExpr{Type: firstCopy}, "types")) + require.Equal(t, "types.Value2", scope.GoFullTypeRef(&expr.AttributeExpr{Type: secondType}, "types")) + require.Equal(t, "Value", scope.GoTypeDef(&expr.AttributeExpr{Type: firstType}, false, false)) + require.Equal(t, "Value2", scope.GoTypeDef(&expr.AttributeExpr{Type: secondType}, false, false)) + require.Equal(t, "types.Value2", scope.goTypeDefWithPkgOverride( + &expr.AttributeExpr{Type: secondType}, + false, + false, + "", + "types", + )) +} + +// TestBindGeneratedTypeRejectsInvalidBindings verifies that a package only +// accepts complete, package-owned bindings before its names are final. +func TestBindGeneratedTypeRejectsInvalidBindings(t *testing.T) { + generation := mustTestGeneration(t, "generated.local/gen", nil) + types := mustClaimTestPackage(t, generation, "generated.local/gen/types") + other := mustClaimTestPackage(t, generation, "generated.local/gen/other") + userType := generatedUserType("Value", "value") + first, err := types.DeclareGeneratedType("Value", testNameOrder{value: "first"}) + require.NoError(t, err) + second, err := types.DeclareGeneratedType("Value", testNameOrder{value: "second"}) + require.NoError(t, err) + foreign, err := other.DeclareGeneratedType("Value", testNameOrder{value: "foreign"}) + require.NoError(t, err) + + require.ErrorContains(t, types.BindGeneratedType(nil, first), "nil user type") + var nilUserType *expr.UserTypeExpr + require.ErrorContains(t, types.BindGeneratedType(nilUserType, first), "nil user type") + require.ErrorContains(t, types.BindGeneratedType(userType, nil), "nil type declaration") + require.ErrorContains(t, types.BindGeneratedType( + userType, + &TypeDeclaration{declaration: NewExactName(NameType, "Value")}, + ), "declaration is not owned") + require.ErrorContains(t, types.BindGeneratedType(userType, foreign), "generated.local/gen/other") + require.NoError(t, types.BindGeneratedType(userType, first)) + require.ErrorContains(t, types.BindGeneratedType(userType, second), "already bound") + + require.NoError(t, generation.Freeze()) + require.ErrorContains(t, types.BindGeneratedType(userType, first), "frozen") +} + +// TestGeneratedPackagePreservesExactGoNames checks that names produced by +// another Go generator are stored without changing their spelling. +func TestGeneratedPackagePreservesExactGoNames(t *testing.T) { + generation := mustTestGeneration(t, "generated.local/gen", nil) + types := mustClaimTestPackage(t, generation, "generated.local/gen/types") + private := NewExactName(NameType, "api2HttpClient") + handler := NewExactName(NameFunction, "_API2_HTTPHandler") + require.NoError(t, types.DeclareName(private)) + require.NoError(t, types.DeclareName(handler)) + require.NoError(t, generation.Freeze()) + require.Equal(t, "api2HttpClient", private.Name()) + require.Equal(t, "_API2_HTTPHandler", handler.Name()) +} + +// TestGeneratedPackageRejectsInvalidExactGoName checks that an exact name +// must already be a valid Go identifier. +func TestGeneratedPackageRejectsInvalidExactGoName(t *testing.T) { + generation := mustTestGeneration(t, "generated.local/gen", nil) + types := mustClaimTestPackage(t, generation, "generated.local/gen/types") + err := types.DeclareName(NewExactName(NameType, "not a name")) + require.EqualError(t, err, `package name "not a name" is not a valid Go identifier`) +} + +// TestDependentNameUsesFrozenBase verifies that companion declarations derive +// their spelling from the exact final name selected for their base declaration. +func TestDependentNameUsesFrozenBase(t *testing.T) { + generation := mustTestGeneration(t, "generated.local/gen", nil) + pkg := mustClaimTestPackage(t, generation, "generated.local/gen/types") + require.NoError(t, pkg.DeclareName(NewExactName(NameType, "Result"))) + base := NewPreferredName(NameType, "Result", ExportedName, testNameOrder{value: "base"}) + require.NoError(t, pkg.DeclareName(base)) + validator, err := pkg.DeclareDependentName( + NameFunction, + base, + "Validate", + "", + testNameOrder{value: "validator"}, + ) + require.NoError(t, err) + + require.NoError(t, generation.Freeze()) + require.Equal(t, "Result2", base.Name()) + require.Equal(t, "ValidateResult2", validator.Name()) +} + +// TestDependentNameUsesFrozenBaseAcrossPackages verifies that one generated +// package can name a declaration from the exact type chosen in another package +// in the same generation run. +func TestDependentNameUsesFrozenBaseAcrossPackages(t *testing.T) { + freeze := func(reverse bool) (string, string) { + generation := mustTestGeneration(t, "generated.local/gen", nil) + paths := []string{ + "generated.local/gen/service/views", + "generated.local/gen/http/service/client", + } + if reverse { + paths[0], paths[1] = paths[1], paths[0] + } + packages := make(map[string]*GeneratedPackage, len(paths)) + for _, packagePath := range paths { + packages[packagePath] = mustClaimTestPackage(t, generation, packagePath) + } + views := packages["generated.local/gen/service/views"] + client := packages["generated.local/gen/http/service/client"] + require.NoError(t, views.DeclareName(NewExactName(NameType, "ResultView"))) + base := NewPreferredName(NameType, "ResultView", ExportedName, testNameOrder{value: "base"}) + require.NoError(t, views.DeclareName(base)) + constructor, err := client.DeclareDependentName( + NameFunction, + base, + "New", + "OK", + testNameOrder{value: "constructor"}, + ) + require.NoError(t, err) + require.NoError(t, generation.Freeze()) + return base.Name(), constructor.Name() + } + + base, constructor := freeze(false) + reversedBase, reversedConstructor := freeze(true) + require.Equal(t, "ResultView2", base) + require.Equal(t, "NewResultView2OK", constructor) + require.Equal(t, base, reversedBase) + require.Equal(t, constructor, reversedConstructor) +} + +// TestNameDeclarationRejectsUnownedPackageAccess verifies that only a package +// catalog can make internal declaration ownership available to typed records. +func TestNameDeclarationRejectsUnownedPackageAccess(t *testing.T) { + declaration := NewExactName(NameType, "Value") + require.Panics(t, func() { declaration.packagePath() }) +} + +// TestNameDeclarationRejectsEmptyPreferredName verifies that exact and +// suffixable declarations cannot mutate a package catalog without a Go name. +func TestNameDeclarationRejectsEmptyPreferredName(t *testing.T) { + tests := []struct { + name string + declaration *NameDeclaration + }{ + {"exact", NewExactName(NameType, "")}, + {"preferred", NewPreferredName(NameFunction, "", ExportedName, testNameOrder{value: "empty"})}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + generation := mustTestGeneration(t, "generated.local/gen", nil) + pkg := mustClaimTestPackage(t, generation, "generated.local/gen/types") + + err := pkg.DeclareName(test.declaration) + require.ErrorContains(t, err, "name must not be empty") + require.Nil(t, test.declaration.owner) + require.Empty(t, pkg.names) + require.Empty(t, pkg.exactNames) + }) + } +} + +// TestNameDeclarationRejectsInvalidKind verifies that direct and dependent +// declarations cannot mutate a package catalog with an unknown category. +func TestNameDeclarationRejectsInvalidKind(t *testing.T) { + tests := []struct { + name string + declaration func(*testing.T, *GeneratedPackage) *NameDeclaration + wantNames int + }{ + { + "exact", + func(*testing.T, *GeneratedPackage) *NameDeclaration { + return NewExactName(0, "Value") + }, + 0, + }, + { + "preferred", + func(*testing.T, *GeneratedPackage) *NameDeclaration { + return NewPreferredName(NameVariable+1, "Value", ExportedName, testNameOrder{value: "invalid"}) + }, + 0, + }, + { + "dependent", + func(t *testing.T, pkg *GeneratedPackage) *NameDeclaration { + base := NewPreferredName(NameType, "Value", ExportedName, testNameOrder{value: "base"}) + require.NoError(t, pkg.DeclareName(base)) + return newDependentName(0, base, "New", "", testNameOrder{value: "dependent"}) + }, + 1, + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + generation := mustTestGeneration(t, "generated.local/gen", nil) + pkg := mustClaimTestPackage(t, generation, "generated.local/gen/types") + declaration := test.declaration(t, pkg) + + err := pkg.DeclareName(declaration) + require.ErrorContains(t, err, "invalid package name kind") + require.Nil(t, declaration.owner) + require.Len(t, pkg.names, test.wantNames) + }) + } +} + +// TestNameDeclarationPreferredOrder verifies that typed stable identity, not +// discovery order, decides suffix ownership and rejects an indistinguishable tie. +func TestNameDeclarationPreferredOrder(t *testing.T) { + declare := func(reverse bool) (string, string) { + generation := mustTestGeneration(t, "generated.local/gen", nil) + pkg := mustClaimTestPackage(t, generation, "generated.local/gen/types") + first := NewPreferredName(NameFunction, "Build", ExportedName, testNameOrder{value: "a"}) + second := NewPreferredName(NameConstant, "Build", ExportedName, testNameOrder{value: "b"}) + declarations := []*NameDeclaration{first, second} + if reverse { + declarations[0], declarations[1] = declarations[1], declarations[0] + } + for _, declaration := range declarations { + require.NoError(t, pkg.DeclareName(declaration)) + } + require.NoError(t, generation.Freeze()) + return first.Name(), second.Name() + } + + first, second := declare(false) + reversedFirst, reversedSecond := declare(true) + require.Equal(t, "Build", first) + require.Equal(t, "Build2", second) + require.Equal(t, first, reversedFirst) + require.Equal(t, second, reversedSecond) + + generation := mustTestGeneration(t, "generated.local/gen", nil) + pkg := mustClaimTestPackage(t, generation, "generated.local/gen/types") + require.NoError(t, pkg.DeclareName(NewPreferredName( + NameFunction, + "Build", + ExportedName, + testNameOrder{value: "same"}, + ))) + err := pkg.DeclareName(NewPreferredName( + NameVariable, + "Build", + ExportedName, + testNameOrder{value: "same"}, + )) + require.ErrorContains(t, err, "cannot deterministically order") +} + +// TestPreferredNameVisibility verifies preferred declarations preserve their +// requested package visibility while sharing deterministic collision handling. +func TestPreferredNameVisibility(t *testing.T) { + generation := mustTestGeneration(t, "generated.local/gen", nil) + pkg := mustClaimTestPackage(t, generation, "generated.local/gen/types") + exported := NewPreferredName(NameFunction, "build value", ExportedName, testNameOrder{value: "exported"}) + privateFirst := NewPreferredName(NameFunction, "Build Value", UnexportedName, testNameOrder{value: "private-a"}) + privateSecond := NewPreferredName(NameFunction, "build value", UnexportedName, testNameOrder{value: "private-b"}) + require.NoError(t, pkg.DeclareName(exported)) + require.NoError(t, pkg.DeclareName(privateSecond)) + require.NoError(t, pkg.DeclareName(privateFirst)) + require.NoError(t, generation.Freeze()) + require.Equal(t, "BuildValue", exported.Name()) + require.Equal(t, "buildValue", privateFirst.Name()) + require.Equal(t, "buildValue2", privateSecond.Name()) +} + +// TestNameDeclarationOrdersConcreteFamilies verifies that unrelated named +// order types never receive each other's values and remain discovery-order independent. +func TestNameDeclarationOrdersConcreteFamilies(t *testing.T) { + declare := func(reverse bool) (string, string) { + generation := mustTestGeneration(t, "generated.local/gen", nil) + pkg := mustClaimTestPackage(t, generation, "generated.local/gen/types") + alpha := NewPreferredName(NameFunction, "Build", ExportedName, alphaTestNameOrder("same")) + omega := NewPreferredName(NameConstant, "Build", ExportedName, omegaTestNameOrder("same")) + declarations := []*NameDeclaration{alpha, omega} + if reverse { + declarations[0], declarations[1] = declarations[1], declarations[0] + } + for _, declaration := range declarations { + require.NoError(t, pkg.DeclareName(declaration)) + } + require.NoError(t, generation.Freeze()) + return alpha.Name(), omega.Name() + } + + alpha, omega := declare(false) + reversedAlpha, reversedOmega := declare(true) + require.Equal(t, "Build", alpha) + require.Equal(t, "Build2", omega) + require.Equal(t, alpha, reversedAlpha) + require.Equal(t, omega, reversedOmega) +} + +// TestNameDeclarationRejectsUnstableOrderTypes verifies that collection +// returns deterministic errors instead of accepting mutable or ambiguous order values. +func TestNameDeclarationRejectsUnstableOrderTypes(t *testing.T) { + stable := testNameOrder{value: "stable"} + tests := []struct { + name string + order PackageNameOrder + }{ + {"nil", nil}, + {"pointer", &stable}, + {"unnamed", struct{ PackageNameOrder }{PackageNameOrder: stable}}, + {"slice", unstableTestNameOrder{"mutable"}}, + {"pointer field", indirectTestNameOrder{value: new(string)}}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + generation := mustTestGeneration(t, "generated.local/gen", nil) + pkg := mustClaimTestPackage(t, generation, "generated.local/gen/types") + declaration := NewPreferredName(NameFunction, "Build", ExportedName, test.order) + err := pkg.DeclareName(declaration) + require.ErrorContains(t, err, "stable concrete named value type") + }) + } +} + +// TestNameDeclarationRejectsDependentOrderTie verifies that dependency phase +// ordering cannot hide indistinguishable facts within one concrete family. +func TestNameDeclarationRejectsDependentOrderTie(t *testing.T) { + generation := mustTestGeneration(t, "generated.local/gen", nil) + pkg := mustClaimTestPackage(t, generation, "generated.local/gen/types") + base := NewPreferredName(NameType, "Value", ExportedName, testNameOrder{value: "base"}) + first := newDependentName(NameFunction, base, "New", "First", testNameOrder{value: "same"}) + second := newDependentName(NameFunction, base, "New", "Second", testNameOrder{value: "same"}) + require.NoError(t, pkg.DeclareName(base)) + require.NoError(t, pkg.DeclareName(first)) + err := pkg.DeclareName(second) + require.ErrorContains(t, err, "cannot deterministically order") +} + +// TestNameDeclarationRejectsInvalidDependentOwners verifies that a dependent +// declaration uses a base owned by the same generation run. +func TestNameDeclarationRejectsInvalidDependentOwners(t *testing.T) { + generation := mustTestGeneration(t, "generated.local/gen", nil) + first := mustClaimTestPackage(t, generation, "generated.local/gen/first") + unowned := NewPreferredName(NameType, "Value", ExportedName, testNameOrder{value: "unowned"}) + dependent := newDependentName(NameFunction, unowned, "New", "", testNameOrder{value: "dependent"}) + err := first.DeclareName(dependent) + require.ErrorContains(t, err, "base declaration is not owned") + + require.NoError(t, first.DeclareName(unowned)) + otherGeneration := mustTestGeneration(t, "other.local/gen", nil) + otherPackage := mustClaimTestPackage(t, otherGeneration, "other.local/gen/types") + crossGeneration := newDependentName(NameFunction, unowned, "New", "", testNameOrder{value: "cross-generation"}) + err = otherPackage.DeclareName(crossGeneration) + require.ErrorContains(t, err, "base declaration belongs to another generation") +} + +// TestNameDeclarationRejectsMultipleOwners verifies that one canonical name +// record cannot be rebound to another generated package. +func TestNameDeclarationRejectsMultipleOwners(t *testing.T) { + generation := mustTestGeneration(t, "generated.local/gen", nil) + declaration := NewExactName(NameType, "Value") + require.NoError(t, mustClaimTestPackage(t, generation, "generated.local/gen/first").DeclareName(declaration)) + err := mustClaimTestPackage(t, generation, "generated.local/gen/second").DeclareName(declaration) + require.ErrorContains(t, err, "already belongs") +} + +// TestNameDeclarationRejectsSameImportAcrossGenerations verifies that +// canonical import spelling does not substitute for exact package ownership. +func TestNameDeclarationRejectsSameImportAcrossGenerations(t *testing.T) { + declaration := NewExactName(NameType, "Value") + first := mustClaimTestPackage(t, mustTestGeneration(t, "generated.local/gen", nil), "generated.local/gen/types") + second := mustClaimTestPackage(t, mustTestGeneration(t, "generated.local/gen", nil), "generated.local/gen/types") + require.NoError(t, first.DeclareName(declaration)) + err := second.DeclareName(declaration) + require.ErrorContains(t, err, "already belongs") +} + +// TestGenerationOwnsName checks declaration ownership before and after names +// are frozen without treating a matching package path as ownership. +func TestGenerationOwnsName(t *testing.T) { + generation := mustTestGeneration(t, "generated.local/gen", nil) + declaration := NewExactName(NameType, "Value") + pkg := mustClaimTestPackage(t, generation, "generated.local/gen/types") + require.NoError(t, pkg.DeclareName(declaration)) + + foreignGeneration := mustTestGeneration(t, "generated.local/gen", nil) + foreign := NewExactName(NameType, "Foreign") + require.NoError(t, mustClaimTestPackage(t, foreignGeneration, "generated.local/gen/types").DeclareName(foreign)) + + require.True(t, generation.OwnsName(declaration)) + require.False(t, generation.OwnsName(foreign)) + require.False(t, generation.OwnsName(NewExactName(NameType, "Unregistered"))) + require.False(t, generation.OwnsName(nil)) + require.True(t, pkg.OwnsName(declaration)) + require.False(t, pkg.OwnsName(foreign)) + require.False(t, pkg.OwnsName(nil)) + filePackage, ok := generation.PackageForFile("gen/types/value.go") + require.True(t, ok) + require.Same(t, pkg, filePackage) + _, ok = generation.PackageForFile("gen/other/value.go") + require.False(t, ok) + + require.NoError(t, generation.Freeze()) + require.True(t, generation.OwnsName(declaration)) +} + +// TestGeneratedOutputPathRejectsNormalizedCollisions verifies that equivalent +// import spellings cannot make two requested package identities share output. +func TestGeneratedOutputPathRejectsNormalizedCollisions(t *testing.T) { + generation := mustTestGeneration(t, "generated.local/root/../gen", nil) + first := mustClaimTestPackage(t, generation, "generated.local/gen/types") + require.Equal(t, "generated.local/gen", generation.GenPkg()) + require.Equal(t, "generated.local/gen/types", first.ImportPath()) + require.Equal(t, "gen/types", first.OutputDirectory()) + _, err := generation.ClaimPackage("generated.local/gen/values/../types") + require.EqualError(t, err, + `generated package paths "generated.local/gen/types" and "generated.local/gen/values/../types" normalize to import path "generated.local/gen/types"`) + require.Same(t, first, mustClaimTestPackage(t, generation, "generated.local/gen/types")) + require.NoError(t, generation.Freeze()) +} + +// TestGeneratedOutputPathEmitsCanonicalImport verifies that a package keeps +// its exact planner claim for reuse while generated source sees a clean path. +func TestGeneratedOutputPathEmitsCanonicalImport(t *testing.T) { + generation := mustTestGeneration(t, "generated.local/gen", nil) + pkg := mustClaimTestPackage(t, generation, "generated.local/gen/values/../types") + require.Same(t, pkg, mustClaimTestPackage(t, generation, "generated.local/gen/values/../types")) + require.Equal(t, "generated.local/gen/types", pkg.ImportPath()) + require.Equal(t, "gen/types", pkg.OutputDirectory()) + require.NoError(t, generation.Freeze()) +} + +// TestGeneratedOutputPathRejectsLateNormalizedIdentity verifies that freeze +// does not let a new raw package identity reach an existing canonical package. +func TestGeneratedOutputPathRejectsLateNormalizedIdentity(t *testing.T) { + generation := mustTestGeneration(t, "generated.local/gen", nil) + first := mustClaimTestPackage(t, generation, "generated.local/gen/types") + require.NoError(t, generation.Freeze()) + require.Same(t, first, generation.Package("generated.local/gen/types")) + _, err := generation.ClaimPackage("generated.local/gen/types") + require.ErrorContains(t, err, "cannot be claimed after generation freeze") + _, err = generation.ClaimPackage("generated.local/gen/values/../types") + require.ErrorContains(t, err, "cannot be claimed after generation freeze") +} + +// TestGeneratedOutputPathRejectsPortableDirectoryCollision verifies that two +// import identities cannot claim the same directory on a case-insensitive host. +func TestGeneratedOutputPathRejectsPortableDirectoryCollision(t *testing.T) { + generation := mustTestGeneration(t, "generated.local/gen", nil) + first := mustClaimTestPackage(t, generation, "generated.local/gen/Foo") + _, err := generation.ClaimPackage("generated.local/gen/foo") + require.EqualError(t, err, + `generated package paths "generated.local/gen/Foo" and "generated.local/gen/foo" resolve to output directory "gen/foo" on a case-insensitive filesystem`) + require.Same(t, first, mustClaimTestPackage(t, generation, "generated.local/gen/Foo")) +} + +// TestGeneratedOutputPathRejectsBackslashes verifies that invalid Go import +// separators are rejected instead of translated into another package identity. +func TestGeneratedOutputPathRejectsBackslashes(t *testing.T) { + _, err := NewGeneration(`generated.local\gen`, nil) + require.ErrorContains(t, err, "backslash") + + generation := mustTestGeneration(t, "generated.local/gen", nil) + _, err = generation.ClaimPackage(`generated.local\gen\types`) + require.ErrorContains(t, err, "contains a backslash") +} + +// TestExplicitOutputPackageClaims verifies that packages outside GenPkg use +// the same canonical import and portable output ownership as ordinary claims. +func TestExplicitOutputPackageClaims(t *testing.T) { + generation := mustTestGeneration(t, "generated.local/gen", nil) + starter, err := generation.ClaimOutputPackage("generated.local", ".") + require.NoError(t, err) + reused, err := generation.ClaimOutputPackage("generated.local", "work/..") + require.NoError(t, err) + require.Same(t, starter, reused) + require.Equal(t, "generated.local", starter.ImportPath()) + require.Equal(t, ".", starter.OutputDirectory()) + require.Same(t, starter, generation.Package("generated.local")) + + _, err = generation.ClaimOutputPackage("generated.local", "starter") + require.ErrorContains(t, err, "already mapped") + _, err = generation.ClaimOutputPackage("generated.local/../generated.local", ".") + require.ErrorContains(t, err, "normalize to import path") + require.NoError(t, generation.Freeze()) + _, err = generation.ClaimOutputPackage("generated.local/late", "late") + require.ErrorContains(t, err, "after generation freeze") +} + +// TestExplicitOutputPackageRejectsInvalidDirectories verifies that explicit +// output packages cannot escape the generation working directory or rely on +// host-specific path separators. +func TestExplicitOutputPackageRejectsInvalidDirectories(t *testing.T) { + generation := mustTestGeneration(t, "generated.local/gen", nil) + for _, directory := range []string{"../starter", "/starter", "C:/starter", `starter\service`} { + _, err := generation.ClaimOutputPackage("generated.local/starter", directory) + require.Error(t, err, directory) + } +} + +// TestExplicitOutputPackageSharesOrdinaryOwnership verifies that ordinary and +// explicit claims cannot assign one import path or portable directory twice. +func TestExplicitOutputPackageSharesOrdinaryOwnership(t *testing.T) { + generation := mustTestGeneration(t, "generated.local/gen", nil) + ordinary := mustClaimTestPackage(t, generation, "generated.local/gen/service") + reused, err := generation.ClaimOutputPackage("generated.local/gen/service", ordinary.OutputDirectory()) + require.NoError(t, err) + require.Same(t, ordinary, reused) + + _, err = generation.ClaimOutputPackage("generated.local/other", "gen/SERVICE") + require.ErrorContains(t, err, "case-insensitive filesystem") +} + +// TestGenerationRejectsImplicitLocalRoots verifies that only the exact local +// output sentinels are accepted as non-module generation roots. +func TestGenerationRejectsImplicitLocalRoots(t *testing.T) { + for _, genpkg := range []string{"", "./", "//"} { + _, err := NewGeneration(genpkg, nil) + require.Error(t, err) + } + for _, genpkg := range []string{".", "/"} { + _, err := NewGeneration(genpkg, nil) + require.NoError(t, err) + } +} + +// TestGeneratedTypeFamiliesContainCanonicalNames verifies that existing type +// and union records expose the package-owned records used for rendering. +func TestGeneratedTypeFamiliesContainCanonicalNames(t *testing.T) { + generation := mustTestGeneration(t, "generated.local/gen", nil) + pkg := mustClaimTestPackage(t, generation, "generated.local/gen/types") + user, err := pkg.DeclareUserType(generatedUserType("Widget", "widget")) + require.NoError(t, err) + union, alias := generatedUnionWithBranch("text") + unionDeclaration, err := pkg.DeclareUnion(union) + require.NoError(t, err) + branchType, err := pkg.DeclareUnionBranchType(union, "text", alias) + require.NoError(t, err) + branch, err := pkg.UnionBranch(union, "text") + require.NoError(t, err) + + require.Same(t, user.Declaration(), user.Declaration()) + require.Same(t, unionDeclaration.Declaration(), unionDeclaration.Declaration()) + require.Same(t, unionDeclaration.KindDeclaration(), unionDeclaration.KindDeclaration()) + require.Same(t, branchType.Declaration(), branchType.Declaration()) + require.Same(t, branch.KindDeclaration(), branch.KindDeclaration()) + require.Same(t, branch.ConstructorDeclaration(), branch.ConstructorDeclaration()) + require.Panics(t, func() { user.Declaration().Name() }) + require.Panics(t, func() { unionDeclaration.Declaration().Name() }) + + require.NoError(t, generation.Freeze()) + require.Equal(t, user.Name(), user.Declaration().Name()) + require.Equal(t, unionDeclaration.Name(), unionDeclaration.Declaration().Name()) + require.Equal(t, unionDeclaration.KindName(), unionDeclaration.KindDeclaration().Name()) + require.Equal(t, branch.KindConst(), branch.KindDeclaration().Name()) + require.Equal(t, branch.Constructor(), branch.ConstructorDeclaration().Name()) +} + +// TestGeneratedTypesRejectRelocatedNameCollision verifies that one generated +// package rejects distinct DSL names that produce the same exported Go name. +func TestGeneratedTypesRejectRelocatedNameCollision(t *testing.T) { + var first, second expr.UserType + root := RunDSL(t, func() { + first = dsl.Type("foo-bar", func() { + dsl.Meta("struct:pkg:path", "types") + dsl.Attribute("first", dsl.String) + }) + second = dsl.Type("foo_bar", func() { + dsl.Meta("struct:pkg:path", "types") + dsl.Attribute("second", dsl.String) + }) + }) + + generation := mustTestGeneration(t, "generated.local/gen", []eval.Root{root}) + types := mustClaimTestPackage(t, generation, "generated.local/gen/types") + _, err := types.DeclareUserType(first) + require.NoError(t, err) + _, err = types.DeclareUserType(second) + require.ErrorContains(t, err, "foo_bar") + require.ErrorContains(t, err, "FooBar") + require.ErrorContains(t, err, "already declared by exact type") +} + +// TestGenerationOwnsPackageRecords verifies that one generation returns one +// stable package record and scope for each output path. +func TestGenerationOwnsPackageRecords(t *testing.T) { + generation := mustTestGeneration(t, "generated.local/gen", nil) + + first := mustClaimTestPackage(t, generation, "generated.local/gen/types") + second := mustClaimTestPackage(t, generation, "generated.local/gen/types") + other := mustClaimTestPackage(t, generation, "generated.local/gen/other") + require.Same(t, first, second) + require.Panics(t, func() { + first.Scope() + }) + require.NoError(t, generation.Freeze()) + require.Same(t, first.Scope(), second.Scope()) + require.NotSame(t, first, other) + require.NotSame(t, first.Scope(), other.Scope()) +} + +// TestGenerationCopiesConstructionState verifies that callers cannot change +// root membership or the generated package path through constructor inputs or +// accessor results before or after freeze. +func TestGenerationCopiesConstructionState(t *testing.T) { + first := RunDSL(t, func() {}) + second := RunDSL(t, func() {}) + roots := []eval.Root{first} + generation := mustTestGeneration(t, "generated.local/gen", roots) + + roots[0] = second + returnedRoots := generation.Roots() + returnedRoots[0] = second + require.Equal(t, "generated.local/gen", generation.GenPkg()) + require.True(t, generation.HasRoot(first)) + require.False(t, generation.HasRoot(second)) + + require.NoError(t, generation.Freeze()) + roots[0] = nil + returnedRoots = generation.Roots() + returnedRoots[0] = second + require.Equal(t, "generated.local/gen", generation.GenPkg()) + require.True(t, generation.HasRoot(first)) + require.False(t, generation.HasRoot(second)) +} + +// TestGeneratedPackageUserTypes verifies that a generated package records one +// declaration per user type and that lookups do not reserve names. +func TestGeneratedPackageUserTypes(t *testing.T) { + generation := mustTestGeneration(t, "generated.local/gen", nil) + types := mustClaimTestPackage(t, generation, "generated.local/gen/types") + widget := generatedUserType("Widget", "widget") + missing := generatedUserType("Missing", "missing") + + _, err := types.UserType(missing) + require.ErrorContains(t, err, "not declared") + + first, err := types.DeclareUserType(widget) + require.NoError(t, err) + require.Panics(t, func() { first.Name() }) + require.Equal(t, "generated.local/gen/types", first.PackagePath()) + second, err := types.DeclareUserType(widget) + require.NoError(t, err) + require.Same(t, first, second) + + lookedUp, err := types.UserType(widget) + require.NoError(t, err) + require.Same(t, first, lookedUp) + declaredMissing, err := types.DeclareUserType(missing) + require.NoError(t, err) + require.Panics(t, func() { declaredMissing.Name() }) + require.NoError(t, generation.Freeze()) + require.Equal(t, "Widget", first.Name()) + require.Equal(t, "Missing", declaredMissing.Name()) + require.Equal(t, "Widget", types.Scope().GoTypeName(&expr.AttributeExpr{Type: widget})) +} + +// TestGeneratedPackageExactUserTypesDoNotMerge verifies that structural +// equality does not weaken the exact-name contract for DSL declarations. +func TestGeneratedPackageExactUserTypesDoNotMerge(t *testing.T) { + generation := mustTestGeneration(t, "generated.local/gen", nil) + types := mustClaimTestPackage(t, generation, "generated.local/gen/types") + first := generatedUserType("ValueText", "first") + equivalent := generatedUserType("ValueText", "second") + + _, err := types.DeclareUserType(first) + require.NoError(t, err) + _, err = types.DeclareUserType(equivalent) + require.ErrorContains(t, err, "ValueText") + require.ErrorContains(t, err, "already declared") +} + +// TestGeneratedPackageUserTypeCopiesShareDeclaration verifies that exact +// compiler copies use their declaration origin instead of one transient copy +// pointer as package identity. +func TestGeneratedPackageUserTypeCopiesShareDeclaration(t *testing.T) { + generation := mustTestGeneration(t, "generated.local/gen", nil) + types := mustClaimTestPackage(t, generation, "generated.local/gen/types") + original := generatedUserType("ValueText", "value-text") + copy := original.Dup(expr.DupAtt(original.Attribute())) + + first, err := types.DeclareUserType(original) + require.NoError(t, err) + second, err := types.DeclareUserType(copy) + require.NoError(t, err) + require.Same(t, first, second) + require.NoError(t, generation.Freeze()) + + lookedUp, err := types.UserType(copy) + require.NoError(t, err) + require.Same(t, first, lookedUp) +} + +// TestGeneratedPackageRepeatedUserTypesCompareCanonicalNames verifies that +// one origin may repeat an equivalent Go spelling but not change declarations. +func TestGeneratedPackageRepeatedUserTypesCompareCanonicalNames(t *testing.T) { + generation := mustTestGeneration(t, "generated.local/gen", nil) + types := mustClaimTestPackage(t, generation, "generated.local/gen/types") + userType := generatedUserType("value-text", "value-text") + expression := userType.(*expr.UserTypeExpr) + first, err := types.DeclareUserType(userType) + require.NoError(t, err) + + expression.TypeName = "value_text" + second, err := types.DeclareUserType(userType) + require.NoError(t, err) + require.Same(t, first, second) + + expression.TypeName = "different" + _, err = types.DeclareUserType(userType) + require.ErrorContains(t, err, "cannot declare both") +} + +// TestGeneratedPackageDerivedTypesUseTypedSourceIdentity verifies that view +// declarations rebuilt in the render phase select the records planned from +// the same exact source declaration. +func TestGeneratedPackageDerivedTypesUseTypedSourceIdentity(t *testing.T) { + generation := mustTestGeneration(t, "generated.local/gen", nil) + views := mustClaimTestPackage(t, generation, "generated.local/gen/service/views") + source := generatedUserType("Value", "value") + copy := source.Dup(expr.DupAtt(source.Attribute())) + projectedID := NewProjectedTypeID(source) + viewedID := NewViewedResultTypeID(source) + + projected, err := views.DeclareDerivedType(projectedID, "ValueView") + require.NoError(t, err) + viewed, err := views.DeclareDerivedType(viewedID, "Value") + require.NoError(t, err) + require.NotSame(t, projected, viewed) + require.NoError(t, generation.Freeze()) + + projectedCopy, err := views.DerivedType(NewProjectedTypeID(copy)) + require.NoError(t, err) + require.Same(t, projected, projectedCopy) + viewedCopy, err := views.DerivedType(NewViewedResultTypeID(copy)) + require.NoError(t, err) + require.Same(t, viewed, viewedCopy) + require.Equal(t, "ValueView", projected.Name()) + require.Equal(t, "Value", viewed.Name()) +} + +// TestGeneratedPackageRepeatedDerivedTypesCompareCanonicalNames verifies that +// one typed identity accepts equivalent Go spellings but not another name. +func TestGeneratedPackageRepeatedDerivedTypesCompareCanonicalNames(t *testing.T) { + generation := mustTestGeneration(t, "generated.local/gen", nil) + views := mustClaimTestPackage(t, generation, "generated.local/gen/service/views") + identity := NewProjectedTypeID(generatedUserType("Value", "value")) + first, err := views.DeclareDerivedType(identity, "value-view") + require.NoError(t, err) + + second, err := views.DeclareDerivedType(identity, "value_view") + require.NoError(t, err) + require.Same(t, first, second) + + _, err = views.DeclareDerivedType(identity, "different") + require.ErrorContains(t, err, "cannot declare both") +} + +// TestGeneratedPackageDerivedNamesIgnoreDeclarationOrder verifies that stable +// semantic source identifiers, not traversal order, decide suffix ownership. +func TestGeneratedPackageDerivedNamesIgnoreDeclarationOrder(t *testing.T) { + declare := func(reverse bool) (string, string) { + generation := mustTestGeneration(t, "generated.local/gen", nil) + views := mustClaimTestPackage(t, generation, "generated.local/gen/service/views") + first := generatedUserType("Value", "first") + second := generatedUserType("Value", "second") + ids := []DerivedTypeID{NewProjectedTypeID(first), NewProjectedTypeID(second)} + if reverse { + ids[0], ids[1] = ids[1], ids[0] + } + for _, identity := range ids { + _, err := views.DeclareDerivedType(identity, "ValueView") + require.NoError(t, err) + } + require.NoError(t, generation.Freeze()) + firstDeclaration, err := views.DerivedType(NewProjectedTypeID(first)) + require.NoError(t, err) + secondDeclaration, err := views.DerivedType(NewProjectedTypeID(second)) + require.NoError(t, err) + return firstDeclaration.Name(), secondDeclaration.Name() + } + + first, second := declare(false) + reversedFirst, reversedSecond := declare(true) + require.Equal(t, first, reversedFirst) + require.Equal(t, second, reversedSecond) +} + +// TestGeneratedPackageRejectsAmbiguousDerivedOrder verifies that two distinct +// origins cannot rely on unstable expression shape to break an otherwise +// identical semantic ordering tuple. +func TestGeneratedPackageRejectsAmbiguousDerivedOrder(t *testing.T) { + generation := mustTestGeneration(t, "generated.local/gen", nil) + views := mustClaimTestPackage(t, generation, "generated.local/gen/service/views") + first := generatedUserTypeOf("Value", "same", expr.String) + second := generatedUserTypeOf("Value", "same", expr.Int) + + _, err := views.DeclareDerivedType(NewProjectedTypeID(first), "ValueView") + require.NoError(t, err) + _, err = views.DeclareDerivedType(NewProjectedTypeID(second), "ValueView") + require.ErrorContains(t, err, "cannot deterministically order") +} + +// TestGeneratedPackageUnionBranchesShareDeclaration verifies that copies of +// one authored OneOf reuse their generated branch alias. +func TestGeneratedPackageUnionBranchesShareDeclaration(t *testing.T) { + generation := mustTestGeneration(t, "generated.local/gen", nil) + types := mustClaimTestPackage(t, generation, "generated.local/gen/types") + firstUnion, firstAlias := generatedUnionWithBranch("first") + secondUnion := expr.DupAtt(firstUnion) + secondAlias := secondUnion.Type.(*expr.Union).Values[0].Attribute.Type.(expr.UserType) + + _, err := types.DeclareUnion(firstUnion) + require.NoError(t, err) + firstDeclaration, err := types.DeclareUnionBranchType(firstUnion, "text", firstAlias) + require.NoError(t, err) + _, err = types.DeclareUnion(secondUnion) + require.NoError(t, err) + secondDeclaration, err := types.DeclareUnionBranchType(secondUnion, "text", secondAlias) + require.NoError(t, err) + require.Same(t, firstDeclaration, secondDeclaration) + require.Panics(t, func() { firstDeclaration.Name() }) + + require.NoError(t, generation.Freeze()) + require.Equal(t, "ValueBranchText", firstDeclaration.Name()) + lookedUp, err := types.UnionBranchType(secondUnion, "text") + require.NoError(t, err) + require.Same(t, firstDeclaration, lookedUp) +} + +// TestGeneratedPackageUnionBranchesAreIsolatedByUnion verifies that branch +// aliases from separately named OneOf declarations never collapse together. +func TestGeneratedPackageUnionBranchesAreIsolatedByUnion(t *testing.T) { + generation := mustTestGeneration(t, "generated.local/gen", nil) + types := mustClaimTestPackage(t, generation, "generated.local/gen/types") + firstUnion, firstAlias := generatedUnionWithBranch("first") + secondUnion, secondAlias := generatedUnionWithBranch("second") + secondUnion.Type.(*expr.Union).TypeName = "Other" + secondAlias.(*expr.UserTypeExpr).TypeName = "OtherText" + + _, err := types.DeclareUnion(firstUnion) + require.NoError(t, err) + firstDeclaration, err := types.DeclareUnionBranchType(firstUnion, "text", firstAlias) + require.NoError(t, err) + _, err = types.DeclareUnion(secondUnion) + require.NoError(t, err) + secondDeclaration, err := types.DeclareUnionBranchType(secondUnion, "text", secondAlias) + require.NoError(t, err) + require.NotSame(t, firstDeclaration, secondDeclaration) + + require.NoError(t, generation.Freeze()) + require.ElementsMatch(t, []string{"ValueBranchText", "OtherBranchText"}, []string{ + firstDeclaration.Name(), + secondDeclaration.Name(), + }) +} + +// TestGeneratedPackageUnionFamilyRejectsExactTypeNames verifies that no public +// OneOf companion silently receives a numeric suffix. +func TestGeneratedPackageUnionFamilyRejectsExactTypeNames(t *testing.T) { + generation := mustTestGeneration(t, "generated.local/gen", nil) + types := mustClaimTestPackage(t, generation, "generated.local/gen/types") + for _, name := range []string{"ValueBranchText", "ValueKindText", "NewValueText"} { + _, err := types.DeclareUserType(generatedUserType(name, name)) + require.NoError(t, err) + } + union, alias := generatedUnionWithBranch("text") + _, err := types.DeclareUnion(union) + require.ErrorContains(t, err, "set TypeName") + require.ErrorContains(t, err, "ValueKindText") + _, err = types.DeclareUnionBranchType(union, "text", alias) + require.ErrorContains(t, err, "not declared") +} + +// TestGeneratedPackageUnions verifies that authored identity makes copies +// idempotent and rejects an unrelated OneOf that asks for the same public name. +func TestGeneratedPackageUnions(t *testing.T) { + generation := mustTestGeneration(t, "generated.local/gen", nil) + types := mustClaimTestPackage(t, generation, "generated.local/gen/types") + first := generatedUnion("type", "value") + equivalent := expr.DupAtt(first) + unrelated := generatedUnion("type", "value") + different := generatedUnion("kind", "data") + + firstDeclaration, err := types.DeclareUnion(first) + require.NoError(t, err) + require.Panics(t, func() { firstDeclaration.Name() }) + require.Equal(t, "generated.local/gen/types", firstDeclaration.PackagePath()) + equivalentDeclaration, err := types.DeclareUnion(equivalent) + require.NoError(t, err) + require.Same(t, firstDeclaration, equivalentDeclaration) + + unrelatedDeclaration, err := types.DeclareUnion(unrelated) + require.ErrorContains(t, err, "set TypeName") + require.Nil(t, unrelatedDeclaration) + + differentDeclaration, err := types.DeclareUnion(different) + require.ErrorContains(t, err, "set TypeName") + require.Nil(t, differentDeclaration) + + lookedUp, err := types.Union(equivalent) + require.NoError(t, err) + require.Same(t, firstDeclaration, lookedUp) + require.NoError(t, generation.Freeze()) + require.Equal(t, "Value", firstDeclaration.Name()) + require.Equal(t, "ValueKind", firstDeclaration.KindName()) +} + +// TestGeneratedPackageUnionAndUserTypeCollisionFailsRegardlessOfOrder verifies +// that traversal order never chooses a winner or creates a numeric suffix. +func TestGeneratedPackageUnionAndUserTypeCollisionFailsRegardlessOfOrder(t *testing.T) { + for _, unionFirst := range []bool{true, false} { + t.Run(fmt.Sprintf("union first %t", unionFirst), func(t *testing.T) { + generation := mustTestGeneration(t, "generated.local/gen", nil) + types := mustClaimTestPackage(t, generation, "generated.local/gen/types") + userType := generatedUserType("Value", "value") + kindUserType := generatedUserType("ValueKind", "value-kind") + union := generatedUnion("type", "value") + var ( + userDeclaration *TypeDeclaration + kindDeclaration *TypeDeclaration + unionDeclaration *UnionDeclaration + err error + ) + if unionFirst { + unionDeclaration, err = types.DeclareUnion(union) + require.NoError(t, err) + userDeclaration, err = types.DeclareUserType(userType) + require.ErrorContains(t, err, "already declared") + } else { + userDeclaration, err = types.DeclareUserType(userType) + require.NoError(t, err) + kindDeclaration, err = types.DeclareUserType(kindUserType) + require.NoError(t, err) + require.Panics(t, func() { + types.Scope().GoTypeName(&expr.AttributeExpr{Type: userType}) + }) + unionDeclaration, err = types.DeclareUnion(union) + require.ErrorContains(t, err, "set TypeName") + } + if unionFirst { + require.Nil(t, userDeclaration) + require.Nil(t, kindDeclaration) + require.NotNil(t, unionDeclaration) + } else { + require.NotNil(t, userDeclaration) + require.NotNil(t, kindDeclaration) + require.Nil(t, unionDeclaration) + } + }) + } +} + +// TestGeneratedPackageLookupAcrossFreeze verifies that freeze keeps existing +// declarations readable and rejects every later declaration attempt. +func TestGeneratedPackageLookupAcrossFreeze(t *testing.T) { + generation := mustTestGeneration(t, "generated.local/gen", nil) + types := mustClaimTestPackage(t, generation, "generated.local/gen/types") + widget := generatedUserType("Widget", "widget") + union := generatedUnion("type", "value") + userDeclaration, err := types.DeclareUserType(widget) + require.NoError(t, err) + unionDeclaration, err := types.DeclareUnion(union) + require.NoError(t, err) + require.Panics(t, func() { unionDeclaration.Name() }) + + require.NoError(t, generation.Freeze()) + lookedUpUser, err := types.UserType(widget) + require.NoError(t, err) + require.Same(t, userDeclaration, lookedUpUser) + lookedUpUnion, err := types.Union(union) + require.NoError(t, err) + require.Same(t, unionDeclaration, lookedUpUnion) + require.Equal(t, "Value", lookedUpUnion.Name()) + require.Equal(t, "Widget", types.Scope().GoTypeName(&expr.AttributeExpr{Type: widget})) + require.Equal(t, "Value", types.Scope().GoTypeName(union)) + require.Panics(t, func() { + types.Scope().Unique("Late") + }) + require.Panics(t, func() { + types.Scope().GoTypeName(&expr.AttributeExpr{Type: generatedUserType("Late", "late")}) + }) + + _, err = types.DeclareUserType(widget) + require.ErrorContains(t, err, "frozen") + _, err = types.DeclareUnion(union) + require.ErrorContains(t, err, "frozen") +} + +// TestGeneratedPackageRejectsConflictingOriginBindings verifies that exact +// and compiler-derived declarations cannot claim the same expression origin +// in either declaration order. +func TestGeneratedPackageRejectsConflictingOriginBindings(t *testing.T) { + for _, derivedFirst := range []bool{false, true} { + t.Run(fmt.Sprintf("derived first %t", derivedFirst), func(t *testing.T) { + root := RunDSL(t, func() { + dsl.Service("Values", func() { + dsl.Method("Read", func() { + dsl.Payload(func() { + dsl.Attribute("value", dsl.String) + }) + }) + }) + }) + generation := mustTestGeneration(t, "generated.local/gen", []eval.Root{root}) + types := mustClaimTestPackage(t, generation, "generated.local/gen/values") + wrapper := root.Service("Values").Method("Read").Payload.Type.(expr.UserType) + identity, ok := generation.NormalizedMethodType(wrapper) + require.True(t, ok) + + if derivedFirst { + _, _, err := types.DeclareMethodType(identity, wrapper) + require.NoError(t, err) + _, err = types.DeclareUserType(wrapper) + require.ErrorContains(t, err, "already bound") + return + } + + _, err := types.DeclareUserType(wrapper) + require.NoError(t, err) + _, _, err = types.DeclareMethodType(identity, wrapper) + require.ErrorContains(t, err, "already bound") + }) + } +} + +// TestGenerationOwnsNormalizedWrapper verifies that normalization +// and declaration planning share the same closed method-role identity. +func TestGenerationOwnsNormalizedWrapper(t *testing.T) { + root := RunDSL(t, func() { + dsl.Service("Values", func() { + dsl.Method("Read", func() { + dsl.Payload(func() { + dsl.Attribute("value", dsl.String) + }) + }) + }) + }) + generation := mustTestGeneration(t, "generated.local/gen", []eval.Root{root}) + wrapper := root.Service("Values").Method("Read").Payload.Type.(expr.UserType) + identity, ok := generation.NormalizedMethodType(wrapper) + + require.True(t, ok) + require.Equal(t, "ReadPayload", identity.Name()) + require.Equal(t, wrapper.ID(), identity.UID()) +} + +// TestGenerationAssignsExactMethodOwners verifies that every raw object role +// becomes a generated wrapper whose declaration and example identities agree. +func TestGenerationAssignsExactMethodOwners(t *testing.T) { + service := &expr.ServiceExpr{Name: "Values"} + method := &expr.MethodExpr{ + Name: "Stream", + Service: service, + Payload: &expr.AttributeExpr{Type: &expr.Object{}}, + StreamingPayload: &expr.AttributeExpr{Type: &expr.Object{}}, + Result: &expr.AttributeExpr{Type: &expr.Object{}}, + StreamingResult: &expr.AttributeExpr{Type: &expr.Object{}}, + } + service.Methods = []*expr.MethodExpr{method} + root := &expr.RootExpr{Services: []*expr.ServiceExpr{service}} + generation := mustTestGeneration(t, "generated.local/gen", []eval.Root{root}) + + cases := []struct { + name string + attribute *expr.AttributeExpr + expected expr.ExampleIdentity + }{ + {"payload", method.Payload, expr.MethodPayloadExampleIdentity(method)}, + {"streaming payload", method.StreamingPayload, expr.MethodStreamingPayloadExampleIdentity(method)}, + {"result", method.Result, expr.MethodResultExampleIdentity(method)}, + {"streaming result", method.StreamingResult, expr.MethodStreamingResultExampleIdentity(method)}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + wrapper := tc.attribute.Type.(expr.UserType) + exampleIdentity, generated := expr.GeneratedUserTypeExampleIdentity(wrapper) + require.True(t, generated) + require.Equal(t, tc.expected, exampleIdentity) + declarationIdentity, normalized := generation.NormalizedMethodType(wrapper) + require.True(t, normalized) + require.Equal(t, wrapper.ID(), declarationIdentity.UID()) + }) + } +} + +// TestMethodTypeIdentityPreservesRawOwner proves semantic wrapper identity does +// not collapse distinct DSL names that share one preferred Go spelling. +func TestMethodTypeIdentityPreservesRawOwner(t *testing.T) { + firstMethod := &expr.MethodExpr{Name: "foo-bar", Service: &expr.ServiceExpr{Name: "Values"}} + secondMethod := &expr.MethodExpr{Name: "foo_bar", Service: &expr.ServiceExpr{Name: "Values"}} + cases := []struct { + name string + kind derivedTypeKind + first expr.ExampleIdentity + second expr.ExampleIdentity + }{ + {"payload", methodPayloadTypeKind, expr.MethodPayloadExampleIdentity(firstMethod), expr.MethodPayloadExampleIdentity(secondMethod)}, + {"streaming payload", methodStreamingPayloadTypeKind, expr.MethodStreamingPayloadExampleIdentity(firstMethod), expr.MethodStreamingPayloadExampleIdentity(secondMethod)}, + {"result", methodResultTypeKind, expr.MethodResultExampleIdentity(firstMethod), expr.MethodResultExampleIdentity(secondMethod)}, + {"streaming result", methodStreamingResultTypeKind, expr.MethodStreamingResultExampleIdentity(firstMethod), expr.MethodStreamingResultExampleIdentity(secondMethod)}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + first := newMethodTypeIdentity("test api", firstMethod.Name, tc.kind, tc.first) + second := newMethodTypeIdentity("test api", secondMethod.Name, tc.kind, tc.second) + + require.Equal(t, first.Name(), second.Name()) + require.NotEqual(t, first.UID(), second.UID()) + require.Equal(t, first.UID(), newMethodTypeIdentity("test api", firstMethod.Name, tc.kind, tc.first).UID()) + }) + } +} + +// TestMethodTypeNamesUseAPIToBreakTies verifies two APIs can write equal +// service and method wrapper names to one package without using input order. +func TestMethodTypeNamesUseAPIToBreakTies(t *testing.T) { + forwardFirst, forwardSecond := methodTypeNamesByAPI(t, false) + reverseFirst, reverseSecond := methodTypeNamesByAPI(t, true) + + require.Equal(t, "ReadPayload", forwardFirst) + require.Equal(t, "ReadPayload2", forwardSecond) + require.Equal(t, forwardFirst, reverseFirst) + require.Equal(t, forwardSecond, reverseSecond) +} + +// TestGenerationPreservesRawMethodOwner proves synthesized wrappers retain +// the raw method identity even when their preferred generated names coincide. +func TestGenerationPreservesRawMethodOwner(t *testing.T) { + root := RunDSL(t, func() { + dsl.Service("Values", func() { + dsl.Method("foo-bar", func() { + dsl.Payload(func() { + dsl.Attribute("first", dsl.String) + }) + }) + dsl.Method("foo_bar", func() { + dsl.Payload(func() { + dsl.Attribute("second", dsl.String) + }) + }) + }) + }) + generation := mustTestGeneration(t, "generated.local/gen", []eval.Root{root}) + first := root.Service("Values").Method("foo-bar").Payload.Type.(expr.UserType) + second := root.Service("Values").Method("foo_bar").Payload.Type.(expr.UserType) + firstIdentity, firstGenerated := generation.NormalizedMethodType(first) + secondIdentity, secondGenerated := generation.NormalizedMethodType(second) + + require.True(t, firstGenerated) + require.True(t, secondGenerated) + require.Equal(t, first.Name(), second.Name()) + require.NotEqual(t, first.ID(), second.ID()) + require.Equal(t, first.ID(), firstIdentity.UID()) + require.Equal(t, second.ID(), secondIdentity.UID()) +} + +// TestGenerationOwnsNormalizedMethodProvenance proves only wrappers created by +// this generation are classified as compiler-owned method types. Authored text +// that equals a synthesized UID is still an authored declaration. +func TestGenerationOwnsNormalizedMethodProvenance(t *testing.T) { + authoredMethod := &expr.MethodExpr{Name: "Authored", Service: &expr.ServiceExpr{Name: "Values"}} + authoredUID := "generated:" + expr.MethodPayloadExampleIdentity(authoredMethod).Seed() + root := RunDSL(t, func() { + authored := dsl.Type(authoredUID, func() { + dsl.Attribute("authored", dsl.String) + }) + dsl.Service("Values", func() { + dsl.Method("Authored", func() { + dsl.Payload(authored) + }) + dsl.Method("Raw", func() { + dsl.Payload(func() { + dsl.Attribute("raw", dsl.String) + }) + }) + }) + }) + generation, err := NewGeneration("generated.local/gen", []eval.Root{root}) + require.NoError(t, err) + + authored := root.Service("Values").Method("Authored").Payload.Type.(expr.UserType) + _, ok := generation.NormalizedMethodType(authored) + require.False(t, ok) + raw := root.Service("Values").Method("Raw").Payload.Type.(expr.UserType) + rawIdentity, ok := generation.NormalizedMethodType(raw) + require.True(t, ok) + require.Equal(t, raw.ID(), rawIdentity.UID()) +} + +// TestGenerationRecoversNormalizedMethodProvenance verifies that constructing +// another generation over the same evaluated root recognizes the exact typed +// wrapper instead of parsing its generated name or ID. +func TestGenerationRecoversNormalizedMethodProvenance(t *testing.T) { + root := RunDSL(t, func() { + dsl.Service("Values", func() { + dsl.Method("Read", func() { + dsl.Payload(func() { + dsl.Attribute("value", dsl.String) + }) + }) + }) + }) + first := mustTestGeneration(t, "generated.local/gen", []eval.Root{root}) + wrapper := root.Service("Values").Method("Read").Payload.Type.(expr.UserType) + firstIdentity, ok := first.NormalizedMethodType(wrapper) + require.True(t, ok) + + second := mustTestGeneration(t, "generated.local/gen", []eval.Root{root}) + secondIdentity, ok := second.NormalizedMethodType(wrapper) + require.True(t, ok) + require.Equal(t, firstIdentity.UID(), secondIdentity.UID()) +} + +// TestGenerationCatalogsAreIsolated verifies that standalone generation runs +// do not share declaration records or name reservations. +func TestGenerationCatalogsAreIsolated(t *testing.T) { + firstGeneration := mustTestGeneration(t, "generated.local/gen", nil) + first := mustClaimTestPackage(t, firstGeneration, "generated.local/gen/types") + secondGeneration := mustTestGeneration(t, "generated.local/gen", nil) + second := mustClaimTestPackage(t, secondGeneration, "generated.local/gen/types") + firstUnion := generatedUnion("type", "value") + secondUnion := generatedUnion("type", "value") + + firstDeclaration, err := first.DeclareUnion(firstUnion) + require.NoError(t, err) + secondDeclaration, err := second.DeclareUnion(secondUnion) + require.NoError(t, err) + require.NoError(t, firstGeneration.Freeze()) + require.NoError(t, secondGeneration.Freeze()) + require.Equal(t, "Value", firstDeclaration.Name()) + require.Equal(t, "Value", secondDeclaration.Name()) + require.NotSame(t, firstDeclaration, secondDeclaration) + require.NotSame(t, first.Scope(), second.Scope()) +} + +// methodTypeNamesByAPI declares equal method wrappers in the requested order +// and returns the name assigned to each API. +func methodTypeNamesByAPI(t *testing.T, reverse bool) (string, string) { + t.Helper() + generation := mustTestGeneration(t, "generated.local/gen", nil) + generatedPackage := mustClaimTestPackage(t, generation, "generated.local/gen/shared") + method := &expr.MethodExpr{Name: "Read", Service: &expr.ServiceExpr{Name: "Shared"}} + example := expr.MethodPayloadExampleIdentity(method) + firstIdentity, firstWrapper := testMethodTypeWrapper("first api", method.Name, example) + secondIdentity, secondWrapper := testMethodTypeWrapper("second api", method.Name, example) + var first, second *TypeDeclaration + if reverse { + second = declareTestMethodType(t, generatedPackage, secondIdentity, secondWrapper) + first = declareTestMethodType(t, generatedPackage, firstIdentity, firstWrapper) + } else { + first = declareTestMethodType(t, generatedPackage, firstIdentity, firstWrapper) + second = declareTestMethodType(t, generatedPackage, secondIdentity, secondWrapper) + } + require.NoError(t, generation.Freeze()) + return first.Name(), second.Name() +} + +// testMethodTypeWrapper creates one generated method wrapper with an API name +// that is used only to order equal wrapper names. +func testMethodTypeWrapper(api, method string, example expr.ExampleIdentity) (MethodTypeIdentity, expr.UserType) { + identity := newMethodTypeIdentity(api, method, methodPayloadTypeKind, example) + wrapper := expr.NewGeneratedUserType( + identity.Name(), + &expr.AttributeExpr{Type: &expr.Object{ + {Name: "value", Attribute: &expr.AttributeExpr{Type: expr.String}}, + }}, + example, + ) + return identity.bind(wrapper), wrapper +} + +// declareTestMethodType submits one generated wrapper and returns its stored +// type declaration. +func declareTestMethodType(t *testing.T, generatedPackage *GeneratedPackage, identity MethodTypeIdentity, wrapper expr.UserType) *TypeDeclaration { + t.Helper() + declaration, _, err := generatedPackage.DeclareMethodType(identity, wrapper) + require.NoError(t, err) + return declaration +} + +// generatedUserType builds a distinct user type for catalog tests. +func generatedUserType(name, id string) expr.UserType { + return generatedUserTypeOf(name, id, expr.String) +} + +// generatedUserTypeOf builds a distinct user type with the supplied shape. +func generatedUserTypeOf(name, id string, dataType expr.DataType) expr.UserType { + return &expr.UserTypeExpr{ + AttributeExpr: &expr.AttributeExpr{Type: dataType}, + TypeName: name, + UID: id, + } +} + +// generatedUnion builds a union whose emitted identity includes the supplied +// JSON envelope keys. +func generatedUnion(typeKey, valueKey string) *expr.AttributeExpr { + return &expr.AttributeExpr{Type: &expr.Union{ + TypeName: "Value", + TypeKey: typeKey, + ValueKey: valueKey, + }} +} + +// generatedUnionWithBranch builds a union with one generated branch alias. +func generatedUnionWithBranch(aliasID string) (*expr.AttributeExpr, expr.UserType) { + alias := generatedUserTypeOf("ValueText", aliasID, expr.String) + return &expr.AttributeExpr{Type: &expr.Union{ + TypeName: "Value", + Values: []*expr.NamedAttributeExpr{{ + Name: "text", + Attribute: &expr.AttributeExpr{Type: alias}, + }}, + }}, alias +} + +// mustTestGeneration creates a generation for tests whose package root is +// known to be valid. +func mustTestGeneration(t *testing.T, genpkg string, roots []eval.Root) *Generation { + t.Helper() + generation, err := NewGeneration(genpkg, roots) + require.NoError(t, err) + return generation +} + +// mustClaimTestPackage claims a package for tests whose planner path is known +// to be valid and unique. +func mustClaimTestPackage(t *testing.T, generation *Generation, path string) *GeneratedPackage { + t.Helper() + generatedPackage, err := generation.ClaimPackage(path) + require.NoError(t, err) + return generatedPackage +} diff --git a/codegen/generation.go b/codegen/generation.go new file mode 100644 index 0000000000..da9f872828 --- /dev/null +++ b/codegen/generation.go @@ -0,0 +1,352 @@ +// This file stores one evaluated design and every Go package and name produced +// from it. Goa chooses all generated and imported package names before writing +// source files. +package codegen + +import ( + "fmt" + "path" + "path/filepath" + "strings" + + "golang.org/x/mod/module" + + "goa.design/goa/v3/eval" + "goa.design/goa/v3/expr" +) + +type ( + // generationOwner identifies one run without retaining its evaluated design. + generationOwner byte + + // Generation stores the evaluated design roots and output packages used by + // one code generation run. + Generation struct { + owner *generationOwner + genpkg string + roots []eval.Root + packages map[string]*GeneratedPackage + importOwners map[string]*GeneratedPackage + outputOwners map[string]*GeneratedPackage + methodTypes map[expr.UserType]MethodTypeIdentity + frozen bool + } +) + +// NewGeneration checks the generated package path and gives unnamed method +// payloads and results stable generated types. It updates the supplied designs, +// so callers must not prepare the same designs concurrently. +func NewGeneration(genpkg string, roots []eval.Root) (*Generation, error) { + canonicalGenPkg, err := canonicalGenerationRoot(genpkg) + if err != nil { + return nil, err + } + ownedRoots := append([]eval.Root(nil), roots...) + return &Generation{ + owner: new(generationOwner), + genpkg: canonicalGenPkg, + roots: ownedRoots, + packages: make(map[string]*GeneratedPackage), + importOwners: make(map[string]*GeneratedPackage), + outputOwners: make(map[string]*GeneratedPackage), + methodTypes: normalizeRoots(ownedRoots), + }, nil +} + +// NormalizedMethodType returns the generated name and payload-or-result role +// recorded for an unnamed method type. It returns false for a type declared +// directly in the design. +func (g *Generation) NormalizedMethodType(source expr.UserType) (MethodTypeIdentity, bool) { + identity, ok := g.methodTypes[source.Origin()] + return identity, ok +} + +// GenPkg returns the import path of the generated module root. +func (g *Generation) GenPkg() string { + return g.genpkg +} + +// Roots returns a copy of the evaluated design root slice used by this run. The +// returned roots still point to the prepared design values. +func (g *Generation) Roots() []eval.Root { + return append([]eval.Root(nil), g.roots...) +} + +// ClaimPackage records path as a generated package and returns the package's +// name records. Repeating the same path returns the same package. A different +// path that resolves to the same import path or output directory returns an +// error. +func (g *Generation) ClaimPackage(path string) (*GeneratedPackage, error) { + if g.frozen { + return nil, fmt.Errorf("generated package %q cannot be claimed after generation freeze", path) + } + canonicalPath, err := canonicalGeneratedPackagePath(g.genpkg, path) + if err != nil { + return nil, err + } + outputDir, err := generatedOutputDirectory(g.genpkg, canonicalPath) + if err != nil { + return nil, err + } + return g.claimOutputPackage(path, canonicalPath, outputDir) +} + +// ClaimOutputPackage records a Go package written to outputDirectory, relative +// to the working directory. It supports generated files, such as starter +// implementations, that are written outside GenPkg but still need their names +// finalized with the other generated packages. +func (g *Generation) ClaimOutputPackage(importPath, outputDirectory string) (*GeneratedPackage, error) { + if g.frozen { + return nil, fmt.Errorf("output package %q cannot be claimed after generation freeze", importPath) + } + canonicalPath, err := canonicalOutputPackagePath(importPath) + if err != nil { + return nil, err + } + canonicalDirectory, err := canonicalOutputDirectory(outputDirectory) + if err != nil { + return nil, err + } + return g.claimOutputPackage(importPath, canonicalPath, canonicalDirectory) +} + +// claimOutputPackage records a package after its caller has checked the import +// path and output directory. +func (g *Generation) claimOutputPackage(claim, canonicalPath, outputDir string) (*GeneratedPackage, error) { + if generatedPackage, ok := g.packages[claim]; ok { + if generatedPackage.outputDir != outputDir { + return nil, fmt.Errorf( + "generated package %q is already mapped to output directory %q, not %q", + claim, + generatedPackage.outputDir, + outputDir, + ) + } + return generatedPackage, nil + } + if owner, ok := g.importOwners[canonicalPath]; ok { + return nil, fmt.Errorf( + "generated package paths %q and %q normalize to import path %q", + owner.claim, + claim, + canonicalPath, + ) + } + for existingDir, owner := range g.outputOwners { + if strings.EqualFold(existingDir, outputDir) { + return nil, fmt.Errorf( + "generated package paths %q and %q resolve to output directory %q on a case-insensitive filesystem", + owner.claim, + claim, + outputDir, + ) + } + } + generatedPackage := newGeneratedPackage(claim, canonicalPath, outputDir) + generatedPackage.generation = g.owner + generatedPackage.genpkg = g.genpkg + g.packages[claim] = generatedPackage + g.importOwners[canonicalPath] = generatedPackage + g.outputOwners[outputDir] = generatedPackage + return generatedPackage, nil +} + +// Package returns the package previously recorded for canonicalPath, which +// must be a cleaned import path. It panics when the path is not clean or was +// not recorded before Freeze. +func (g *Generation) Package(canonicalPath string) *GeneratedPackage { + cleaned, err := canonicalGeneratedPackagePath(g.genpkg, canonicalPath) + if err != nil || cleaned != canonicalPath { + panic(fmt.Sprintf("generated package lookup path %q is not canonical", canonicalPath)) + } + generatedPackage, ok := g.importOwners[canonicalPath] + if !ok { + panic(fmt.Sprintf("generated package %q was not claimed during planning", canonicalPath)) + } + return generatedPackage +} + +// Freeze assigns every generated declaration and imported package its final Go +// name. It then rejects new packages, declarations, and name requests while +// keeping the completed records available for source generation. +func (g *Generation) Freeze() error { + if g.frozen { + return nil + } + packages := make([]*GeneratedPackage, 0, len(g.packages)) + for _, generatedPackage := range g.packages { + packages = append(packages, generatedPackage) + } + if err := freezeGeneratedPackages(packages); err != nil { + return err + } + g.frozen = true + return nil +} + +// Frozen reports whether all generated and imported package names are final. +func (g *Generation) Frozen() bool { + return g.frozen +} + +// OwnsName reports whether DeclareName added declaration to a package in this +// generation. It can return true before Freeze chooses the declaration's final +// Go name. +func (g *Generation) OwnsName(declaration *NameDeclaration) bool { + if declaration == nil || declaration.owner == nil { + return false + } + return g.importOwners[declaration.owner.path] == declaration.owner +} + +// PackageForFile returns the generated package that writes outputPath. The +// second result is false when no package claimed the file's directory during +// planning or when outputPath is not a valid relative output path. +func (g *Generation) PackageForFile(outputPath string) (*GeneratedPackage, bool) { + directory, err := canonicalOutputDirectory(path.Dir(filepath.ToSlash(outputPath))) + if err != nil { + return nil, false + } + pkg, ok := g.outputOwners[directory] + return pkg, ok +} + +// ImportPath returns the cleaned Go import path for the package. +func (p *GeneratedPackage) ImportPath() string { + return p.path +} + +// OutputDirectory returns the cleaned directory, relative to the working +// directory, where this package's files are written. +func (p *GeneratedPackage) OutputDirectory() string { + return p.outputDir +} + +// OwnsName reports whether declaration was added to this exact generated +// package. A package with the same import path in another generation does not +// own the declaration. +func (p *GeneratedPackage) OwnsName(declaration *NameDeclaration) bool { + return declaration != nil && declaration.owner == p +} + +// The generated module import prefix is checked and cleaned here. Tests may +// pass dot or slash to request local output paths. +func canonicalGenerationRoot(genpkg string) (string, error) { + if genpkg == "." || genpkg == "/" { + return genpkg, nil + } + canonical, err := cleanImportPath("generated package root", genpkg) + if err != nil { + return "", err + } + if canonical == "." || canonical == "/" { + return "", fmt.Errorf("generated package root %q is invalid", genpkg) + } + if err := module.CheckImportPath(canonical); err != nil { + return "", fmt.Errorf("generated package root %q is invalid: %w", genpkg, err) + } + return canonical, nil +} + +// A generated package import path is checked and cleaned here before it is +// written in generated source. +func canonicalGeneratedPackagePath(genpkg, importPath string) (string, error) { + canonical, err := cleanImportPath("generated package path", importPath) + if err != nil { + return "", err + } + validated := canonical + if genpkg == "/" { + validated = strings.TrimPrefix(canonical, "/") + if validated == "" { + return canonical, nil + } + } else if genpkg == "." && canonical == "." { + return canonical, nil + } + if err := module.CheckImportPath(validated); err != nil { + return "", fmt.Errorf("generated package path %q is invalid: %w", importPath, err) + } + return canonical, nil +} + +// A package written outside GenPkg has its import path checked and cleaned +// here. +func canonicalOutputPackagePath(importPath string) (string, error) { + canonical, err := cleanImportPath("output package path", importPath) + if err != nil { + return "", err + } + if err := module.CheckImportPath(canonical); err != nil { + return "", fmt.Errorf("output package path %q is invalid: %w", importPath, err) + } + return canonical, nil +} + +// A relative output directory is cleaned here. Paths that escape the working +// directory or use platform-dependent separators are rejected. +func canonicalOutputDirectory(outputDirectory string) (string, error) { + if strings.Contains(outputDirectory, "\\") { + return "", fmt.Errorf("output directory %q contains a backslash", outputDirectory) + } + if path.IsAbs(outputDirectory) { + return "", fmt.Errorf("output directory %q must be relative", outputDirectory) + } + if strings.Contains(outputDirectory, ":") { + return "", fmt.Errorf("output directory %q is not portable", outputDirectory) + } + canonical := path.Clean(outputDirectory) + if canonical == ".." || strings.HasPrefix(canonical, "../") { + return "", fmt.Errorf("output directory %q escapes the generation working directory", outputDirectory) + } + return canonical, nil +} + +// cleanImportPath rejects backslashes and removes dot segments from a Go import +// path. Errors include the original path supplied by the caller. +func cleanImportPath(label, importPath string) (string, error) { + if strings.Contains(importPath, "\\") { + return "", fmt.Errorf("%s %q contains a backslash", label, importPath) + } + return path.Clean(importPath), nil +} + +// generatedOutputDirectory returns the directory under gen for importPath. It +// returns an error when importPath is outside genpkg. +func generatedOutputDirectory(genpkg, importPath string) (string, error) { + relative, ok := relativeGeneratedPackagePath(genpkg, importPath) + if !ok { + return "", fmt.Errorf( + "generated package %q is outside generated import root %q", + importPath, + genpkg, + ) + } + return canonicalOutputDirectory(path.Join(Gendir, relative)) +} + +// relativeGeneratedPackagePath removes the generated import root from a +// package path. The second result is false when the package is outside that +// root. +func relativeGeneratedPackagePath(genpkg, importPath string) (string, bool) { + switch genpkg { + case "/": + if !path.IsAbs(importPath) { + return "", false + } + return strings.TrimPrefix(importPath, "/"), true + case ".": + if path.IsAbs(importPath) || importPath == ".." || strings.HasPrefix(importPath, "../") { + return "", false + } + return importPath, true + default: + if importPath == genpkg { + return "", true + } + if !strings.HasPrefix(importPath, genpkg+"/") { + return "", false + } + return strings.TrimPrefix(importPath, genpkg+"/"), true + } +} diff --git a/codegen/generator/attached_jsonrpc_sse_integration_test.go b/codegen/generator/attached_jsonrpc_sse_integration_test.go new file mode 100644 index 0000000000..3af92ada7b --- /dev/null +++ b/codegen/generator/attached_jsonrpc_sse_integration_test.go @@ -0,0 +1,112 @@ +// This file checks that services added by plugins receive every declaration +// needed by their generated JSON-RPC server-sent-event code. +package generator + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/require" + + "goa.design/goa/v3/codegen" + "goa.design/goa/v3/dsl" + "goa.design/goa/v3/eval" + "goa.design/goa/v3/expr" +) + +// TestAttachedJSONRPCSSEServiceCompiles runs every generation step after a +// plugin adds one method that returns a value and another that streams values. +func TestAttachedJSONRPCSSEServiceCompiles(t *testing.T) { + root := codegen.RunDSL(t, func() { + dsl.API("attached-stream", func() {}) + }) + registry := newDefaultRegistry() + registry.registerPlugin("attached-stream", "gen", pluginNormal, func() Plugin { + return Plugin{Prepare: func(_ string, roots []eval.Root) error { + return attachJSONRPCSSEService(roots[0].(*expr.RootExpr)) + }} + }) + run, err := newGenerationRun("gen", registry) + require.NoError(t, err) + result, err := run.execute("generated.local/gen", []eval.Root{root}) + require.NoError(t, err) + files, err := mergeFilesByPath(result.files) + require.NoError(t, err) + + dir := t.TempDir() + writeGeneratedModule(t, dir, "generated.local") + for _, file := range files { + _, err := file.Render(dir) + require.NoError(t, err) + } + serviceCode, err := os.ReadFile(filepath.Join(dir, "gen", "attached_stream", "service.go")) + require.NoError(t, err) + generated := string(serviceCode) + require.Contains(t, generated, "Watch(context.Context, WatchServerStream) (err error)") + require.Contains(t, generated, "Send(string) error") + require.Contains(t, generated, "SendWithContext(context.Context, string) error") + require.Contains(t, generated, "Close() error") + require.NotContains(t, generated, "SendAndClose") + require.NotContains(t, generated, "SendError") + require.NotContains(t, generated, "RequestID") + require.NotContains(t, generated, "isWatchEvent") + require.NotContains(t, generated, "Send(ctx context.Context") + runGeneratedTests(t, dir) +} + +// attachJSONRPCSSEService adds one evaluated service and its JSON-RPC route to +// a design that completed DSL evaluation before the plugin ran. +func attachJSONRPCSSEService(root *expr.RootExpr) error { + read := &expr.MethodExpr{ + Name: "Read", + Payload: &expr.AttributeExpr{Type: expr.Empty}, + Result: &expr.AttributeExpr{Type: expr.String}, + Meta: expr.MetaExpr{"jsonrpc": []string{}}, + } + watch := &expr.MethodExpr{ + Name: "Watch", + Payload: &expr.AttributeExpr{Type: expr.Empty}, + StreamingResult: &expr.AttributeExpr{Type: expr.String}, + Stream: expr.ServerStreamKind, + Meta: expr.MetaExpr{"jsonrpc": []string{}}, + } + service := &expr.ServiceExpr{ + Name: "AttachedStream", + Methods: []*expr.MethodExpr{read, watch}, + Meta: expr.MetaExpr{"jsonrpc:service": []string{}}, + } + read.Service = service + watch.Service = service + + transport := &expr.HTTPServiceExpr{ + ServiceExpr: service, + JSONRPCRoute: &expr.RouteExpr{ + Method: "POST", + Path: "/rpc", + }, + SSE: &expr.HTTPSSEExpr{}, + } + transport.Root = &root.API.JSONRPC.HTTPExpr + transport.JSONRPCRoute.Endpoint = &expr.HTTPEndpointExpr{Service: transport} + for _, method := range service.Methods { + endpoint := &expr.HTTPEndpointExpr{ + MethodExpr: method, + Service: transport, + Body: method.Payload, + Params: expr.NewEmptyMappedAttributeExpr(), + Headers: expr.NewEmptyMappedAttributeExpr(), + Cookies: expr.NewEmptyMappedAttributeExpr(), + Meta: expr.MetaExpr{"jsonrpc": []string{}}, + } + if method.IsResultStreaming() { + endpoint.SSE = &expr.HTTPSSEExpr{} + } + endpoint.Routes = []*expr.RouteExpr{{Method: "POST", Path: "/rpc", Endpoint: endpoint}} + transport.HTTPEndpoints = append(transport.HTTPEndpoints, endpoint) + } + + root.Services = append(root.Services, service) + root.API.JSONRPC.Services = append(root.API.JSONRPC.Services, transport) + return root.EvaluateAttachedServices([]*expr.ServiceExpr{service}) +} diff --git a/codegen/generator/command_isolation_test.go b/codegen/generator/command_isolation_test.go new file mode 100644 index 0000000000..4fc1278201 --- /dev/null +++ b/codegen/generator/command_isolation_test.go @@ -0,0 +1,233 @@ +// This file checks that each command builds files from its own Plan and that +// simultaneous commands cannot change each other's output. +package generator + +import ( + "os" + "path/filepath" + "reflect" + "sync" + "testing" + + "github.com/stretchr/testify/require" + + "goa.design/goa/v3/codegen" + "goa.design/goa/v3/dsl" + "goa.design/goa/v3/eval" + "goa.design/goa/v3/expr" +) + +type ( + // generatorCall records the Plan passed to one command's two functions. + generatorCall struct { + planCalls int + generateCalls int + planned *Plan + generated *Plan + } + + // commandResult contains every file written by one command, indexed by its + // path beneath the output directory. + commandResult struct { + files map[string][]byte + err error + } +) + +// TestCommandsUseEachSelectedGeneratorOnce checks that each command calls only +// its listed generators and passes the same Plan to both functions. +func TestCommandsUseEachSelectedGeneratorOnce(t *testing.T) { + root := codegen.RunDSL(t, commandIsolationDSL("first")) + cases := []struct { + name string + factories []generatorFactory + selected []string + }{ + {"gen", genGeneratorFactories(), []string{"service", "transport", "openapi"}}, + {"example", exampleGeneratorFactories(), []string{"example"}}, + } + for _, test := range cases { + t.Run(test.name, func(t *testing.T) { + calls := make(map[string]*generatorCall, len(test.selected)) + registry := newRegistry() + registry.addCommand(test.name, observedGenerators(test.factories, calls)...) + + run, err := newGenerationRun(test.name, registry) + require.NoError(t, err) + result, err := run.execute("generated.local/gen", []eval.Root{root}) + require.NoError(t, err) + + require.ElementsMatch(t, test.selected, mapKeys(calls)) + for _, name := range test.selected { + call := calls[name] + require.Equal(t, 1, call.planCalls, "%s plan calls", name) + require.Equal(t, 1, call.generateCalls, "%s file calls", name) + require.Same(t, result.plan, call.planned, "%s planned Plan", name) + require.Same(t, call.planned, call.generated, "%s generated Plan", name) + } + }) + } +} + +// TestFocusedCommandDoesNotBuildExamplesOrOpenAPI checks that a command with no +// example or OpenAPI generator creates neither result. +func TestFocusedCommandDoesNotBuildExamplesOrOpenAPI(t *testing.T) { + root := codegen.RunDSL(t, commandIsolationDSL("focused")) + registry := testRegistry("focused", testGenerator(nil, nil)) + run, err := newGenerationRun("focused", registry) + require.NoError(t, err) + result, err := run.execute("generated.local/gen", []eval.Root{root}) + require.NoError(t, err) + require.Nil(t, result.plan.example) + require.Nil(t, result.plan.openapi) +} + +// TestCommandsProduceTheSameFilesWhenRunTogether checks that gen and example +// produce the same bytes alone, repeatedly, and beside another command run. +func TestCommandsProduceTheSameFilesWhenRunTogether(t *testing.T) { + for _, command := range []string{"gen", "example"} { + t.Run(command, func(t *testing.T) { + first := codegen.RunDSL(t, commandIsolationDSL("first")) + second := codegen.RunDSL(t, commandIsolationDSL("second")) + firstExpected, err := renderCommand(command, first, t.TempDir()) + require.NoError(t, err) + firstAgain, err := renderCommand(command, first, t.TempDir()) + require.NoError(t, err) + require.Equal(t, firstExpected, firstAgain) + secondExpected, err := renderCommand(command, second, t.TempDir()) + require.NoError(t, err) + + start := make(chan struct{}) + results := make(chan commandResult, 2) + var ready sync.WaitGroup + ready.Add(2) + for _, input := range []struct { + root *expr.RootExpr + dir string + }{{first, t.TempDir()}, {second, t.TempDir()}} { + go runCommandTogether(input.root, input.dir, command, start, &ready, results) + } + ready.Wait() + close(start) + firstResult := <-results + secondResult := <-results + require.NoError(t, firstResult.err) + require.NoError(t, secondResult.err) + if !reflect.DeepEqual(firstResult.files, firstExpected) { + firstResult, secondResult = secondResult, firstResult + } + require.Equal(t, firstExpected, firstResult.files) + require.Equal(t, secondExpected, secondResult.files) + }) + } +} + +// observedGenerators wraps each selected generator and records the Plan passed +// to the function that chooses names and the function that builds files. +func observedGenerators(factories []generatorFactory, calls map[string]*generatorCall) []generatorFactory { + observed := make([]generatorFactory, len(factories)) + for index, factory := range factories { + generator := factory() + call := &generatorCall{} + calls[generator.name] = call + observed[index] = observedGenerator(generator, call) + } + return observed +} + +// observedGenerator returns a factory that records calls before running the +// selected generator's original functions. +func observedGenerator(generator coreGenerator, call *generatorCall) generatorFactory { + return func() coreGenerator { + return coreGenerator{ + name: generator.name, + Plan: func(plan *Plan) error { + call.planCalls++ + call.planned = plan + return generator.Plan(plan) + }, + Generate: func(plan *Plan) ([]*codegen.File, error) { + call.generateCalls++ + call.generated = plan + return generator.Generate(plan) + }, + } + } +} + +// mapKeys returns the generator names recorded by one command. +func mapKeys(calls map[string]*generatorCall) []string { + keys := make([]string, 0, len(calls)) + for key := range calls { + keys = append(keys, key) + } + return keys +} + +// renderCommand builds and writes every file for one command. +func renderCommand(command string, root *expr.RootExpr, dir string) (map[string][]byte, error) { + run, err := newGenerationRun(command, newDefaultRegistry()) + if err != nil { + return nil, err + } + result, err := run.execute("generated.local/gen", []eval.Root{root}) + if err != nil { + return nil, err + } + files, err := mergeFilesByPath(result.files) + if err != nil { + return nil, err + } + written := make(map[string][]byte, len(files)) + for _, file := range files { + filename, err := file.Render(dir) + if err != nil { + return nil, err + } + content, err := os.ReadFile(filename) + if err != nil { + return nil, err + } + relative, err := filepath.Rel(dir, filename) + if err != nil { + return nil, err + } + written[filepath.ToSlash(relative)] = content + } + return written, nil +} + +// runCommandTogether waits until both command runs are ready, then builds and +// writes one command's files. +func runCommandTogether(root *expr.RootExpr, dir, command string, start <-chan struct{}, ready *sync.WaitGroup, results chan<- commandResult) { + ready.Done() + <-start + files, err := renderCommand(command, root, dir) + results <- commandResult{files: files, err: err} +} + +// commandIsolationDSL defines one HTTP service whose names identify its output. +func commandIsolationDSL(name string) func() { + return func() { + serviceName := name + " service" + dsl.API(name, func() { + dsl.Server(name, func() { + dsl.Services(serviceName) + dsl.Host(name, func() { + dsl.URI("http://localhost") + }) + }) + }) + dsl.Service(serviceName, func() { + dsl.Method("show", func() { + dsl.Payload(func() { + dsl.Attribute("message", dsl.String) + }) + dsl.Result(dsl.String) + dsl.HTTP(func() { + dsl.POST("/show") + }) + }) + }) + } +} diff --git a/codegen/generator/design_snapshot.go b/codegen/generator/design_snapshot.go new file mode 100644 index 0000000000..0f24512d0d --- /dev/null +++ b/codegen/generator/design_snapshot.go @@ -0,0 +1,511 @@ +// This file records the prepared design so Goa can report if a generator +// changes it. Map entries are sorted so repeated runs report the same first +// changed field. +package generator + +import ( + "fmt" + "math" + "reflect" + "runtime" + "slices" + "strconv" + "strings" + + "goa.design/goa/v3/eval" + "goa.design/goa/v3/expr" +) + +type ( + // designSnapshot stores the prepared design values for one run. + designSnapshot struct { + states []designState + references []reflect.Value + } + + // designState records one scalar, container, or reference reached at path. + designState struct { + path string + typ reflect.Type + value string + } + + // designSnapshotter records each design value once, even when pointers form + // a cycle or several fields point to the same value. + designSnapshotter struct { + states []designState + references []reflect.Value + visited map[designVisit]struct{} + } + + // designVisit identifies one pointer, map, or slice. Slice capacity separates + // slices that can reach different parts of the same underlying array. + designVisit struct { + typ reflect.Type + kind reflect.Kind + ptr uintptr + extent int + } + + // mapSnapshotEntry stores one map pair after the entries are sorted. + mapSnapshotEntry struct { + key reflect.Value + value reflect.Value + keyOrder mapOrderValue + order mapOrderValue + } + + // mapOrderValue stores enough of a map key or value to sort entries. Pointers + // are compared by address and other values by their exact contents. + mapOrderValue struct { + typ reflect.Type + kind reflect.Kind + isNil bool + boolean bool + integer int64 + unsigned uint64 + text string + reference uintptr + length int + capacity int + children []mapOrderValue + } +) + +var typeMapType = reflect.TypeFor[expr.TypeMap]() + +// snapshotPreparedDesign records every value reachable from roots after the +// designs have been prepared. +func snapshotPreparedDesign(roots []eval.Root) (*designSnapshot, error) { + snapshotter := &designSnapshotter{visited: make(map[designVisit]struct{})} + for i, root := range roots { + if err := snapshotter.appendValue(fmt.Sprintf("roots[%d]", i), reflect.ValueOf(root)); err != nil { + return nil, err + } + } + return &designSnapshot{ + states: snapshotter.states, + references: snapshotter.references, + }, nil +} + +// orderedMapEntries returns map entries in an order that does not depend on +// how Go stores the map. +func orderedMapEntries(value reflect.Value) ([]mapSnapshotEntry, error) { + entries := make([]mapSnapshotEntry, 0, value.Len()) + iterator := value.MapRange() + for iterator.Next() { + key := iterator.Key() + mapValue := iterator.Value() + keyOrder, err := mapValueOrder(key) + if err != nil { + return nil, err + } + valueOrder, err := mapValueOrder(mapValue) + if err != nil { + return nil, err + } + entries = append(entries, mapSnapshotEntry{ + key: key, + value: mapValue, + keyOrder: keyOrder, + order: valueOrder, + }) + } + if err := validateMapOrderTypes(entries); err != nil { + return nil, err + } + slices.SortFunc(entries, func(left, right mapSnapshotEntry) int { + if compared := compareMapOrderValue(left.keyOrder, right.keyOrder); compared != 0 { + return compared + } + return compareMapOrderValue(left.order, right.order) + }) + return entries, nil +} + +// validateMapOrderTypes rejects two runtime types that print the same name but +// cannot be compared. Treating them as equal would make map order vary by run. +func validateMapOrderTypes(entries []mapSnapshotEntry) error { + for i := range entries { + for j := i + 1; j < len(entries); j++ { + if err := validateMapOrderType(entries[i].keyOrder, entries[j].keyOrder); err != nil { + return err + } + if err := validateMapOrderType(entries[i].order, entries[j].order); err != nil { + return err + } + } + } + return nil +} + +// validateMapOrderType checks a runtime type and the concrete values stored in +// interface map keys and values. +func validateMapOrderType(left, right mapOrderValue) error { + if left.typ != right.typ && stableTypeName(left.typ) == stableTypeName(right.typ) { + return fmt.Errorf("cannot deterministically order distinct reflected map types %q", stableTypeName(left.typ)) + } + common := min(len(left.children), len(right.children)) + for i := range common { + if err := validateMapOrderType(left.children[i], right.children[i]); err != nil { + return err + } + } + return nil +} + +// mapValueOrder records enough of a map key or value to sort entries without +// reading through pointers. +func mapValueOrder(value reflect.Value) (mapOrderValue, error) { + if !value.IsValid() { + return mapOrderValue{}, nil + } + order := mapOrderValue{typ: value.Type(), kind: value.Kind()} + switch value.Kind() { + case reflect.Bool: + order.boolean = value.Bool() + case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: + order.integer = value.Int() + case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64, reflect.Uintptr: + order.unsigned = value.Uint() + case reflect.Float32: + order.unsigned = uint64(math.Float32bits(float32(value.Float()))) + case reflect.Float64: + order.unsigned = math.Float64bits(value.Float()) + case reflect.Complex64: + complexValue := complex64(value.Complex()) + order.children = []mapOrderValue{ + {unsigned: uint64(math.Float32bits(real(complexValue)))}, + {unsigned: uint64(math.Float32bits(imag(complexValue)))}, + } + case reflect.Complex128: + complexValue := value.Complex() + order.children = []mapOrderValue{ + {unsigned: math.Float64bits(real(complexValue))}, + {unsigned: math.Float64bits(imag(complexValue))}, + } + case reflect.String: + order.text = value.String() + case reflect.Interface: + if value.IsNil() { + order.isNil = true + break + } + inner, err := mapValueOrder(value.Elem()) + if err != nil { + return mapOrderValue{}, err + } + order.children = []mapOrderValue{inner} + case reflect.Pointer, reflect.Chan: + if value.IsNil() { + order.isNil = true + break + } + order.reference = value.Pointer() + case reflect.UnsafePointer: + order.reference = value.Pointer() + case reflect.Slice: + if value.IsNil() { + order.isNil = true + break + } + order.reference = value.Pointer() + order.length = value.Len() + order.capacity = value.Cap() + case reflect.Map: + if value.IsNil() { + order.isNil = true + break + } + order.reference = value.Pointer() + order.length = value.Len() + case reflect.Func: + if value.IsNil() { + order.isNil = true + break + } + order.reference = value.Pointer() + case reflect.Struct: + order.children = make([]mapOrderValue, value.NumField()) + for i := range value.NumField() { + field, err := mapValueOrder(value.Field(i)) + if err != nil { + return mapOrderValue{}, err + } + order.children[i] = field + } + case reflect.Array: + order.children = make([]mapOrderValue, value.Len()) + for i := range value.Len() { + element, err := mapValueOrder(value.Index(i)) + if err != nil { + return mapOrderValue{}, err + } + order.children[i] = element + } + default: + return mapOrderValue{}, fmt.Errorf("cannot order map %s value", value.Kind()) + } + return order, nil +} + +// compareMapOrderValue sorts the recorded runtime values. +func compareMapOrderValue(left, right mapOrderValue) int { + if left.typ != right.typ { + return strings.Compare(stableTypeName(left.typ), stableTypeName(right.typ)) + } + if left.kind != right.kind { + return int(left.kind) - int(right.kind) + } + if left.isNil != right.isNil { + if left.isNil { + return -1 + } + return 1 + } + if left.boolean != right.boolean { + if !left.boolean { + return -1 + } + return 1 + } + if left.integer != right.integer { + if left.integer < right.integer { + return -1 + } + return 1 + } + if left.unsigned != right.unsigned { + if left.unsigned < right.unsigned { + return -1 + } + return 1 + } + if compared := strings.Compare(left.text, right.text); compared != 0 { + return compared + } + if left.reference != right.reference { + if left.reference < right.reference { + return -1 + } + return 1 + } + if left.length != right.length { + return left.length - right.length + } + if left.capacity != right.capacity { + return left.capacity - right.capacity + } + common := min(len(left.children), len(right.children)) + for i := range common { + if compared := compareMapOrderValue(left.children[i], right.children[i]); compared != 0 { + return compared + } + } + return len(left.children) - len(right.children) +} + +// stableTypeName returns the package path and name used to sort runtime types. +func stableTypeName(typ reflect.Type) string { + if typ == nil { + return "" + } + return typ.PkgPath() + ":" + typ.String() +} + +// mapEntryPath returns the field path shown when a map entry changes. +func mapEntryPath(path string, index int, key reflect.Value) string { + if key.Kind() == reflect.String { + return path + "[" + strconv.Quote(key.String()) + "]" + } + return fmt.Sprintf("%s{%d}", path, index) +} + +// formatPointer returns a pointer address as text for a change report. +func formatPointer(pointer uintptr) string { + return "0x" + strconv.FormatUint(uint64(pointer), 16) +} + +// changedPath returns the first design field that differs from the saved copy. +func (s *designSnapshot) changedPath(roots []eval.Root) (string, error) { + defer runtime.KeepAlive(s.references) + + current, err := snapshotPreparedDesign(roots) + if err != nil { + return "", err + } + common := min(len(s.states), len(current.states)) + for i := range common { + if s.states[i] != current.states[i] { + return current.states[i].path, nil + } + } + if len(s.states) > common { + return s.states[common].path, nil + } + if len(current.states) > common { + return current.states[common].path, nil + } + return "", nil +} + +// appendValue records value and recursively records all state it can reach. +func (s *designSnapshotter) appendValue(path string, value reflect.Value) error { + if !value.IsValid() { + s.append(path, nil, "invalid") + return nil + } + typ := value.Type() + switch value.Kind() { + case reflect.Bool: + s.append(path, typ, strconv.FormatBool(value.Bool())) + case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: + s.append(path, typ, strconv.FormatInt(value.Int(), 10)) + case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64, reflect.Uintptr: + s.append(path, typ, strconv.FormatUint(value.Uint(), 10)) + case reflect.Float32: + s.append(path, typ, strconv.FormatUint(uint64(math.Float32bits(float32(value.Float()))), 16)) + case reflect.Float64: + s.append(path, typ, strconv.FormatUint(math.Float64bits(value.Float()), 16)) + case reflect.Complex64: + complexValue := complex64(value.Complex()) + s.append(path, typ, fmt.Sprintf("%x:%x", math.Float32bits(real(complexValue)), math.Float32bits(imag(complexValue)))) + case reflect.Complex128: + complexValue := value.Complex() + s.append(path, typ, fmt.Sprintf("%x:%x", math.Float64bits(real(complexValue)), math.Float64bits(imag(complexValue)))) + case reflect.String: + s.append(path, typ, value.String()) + case reflect.Interface: + if value.IsNil() { + s.append(path, typ, "nil") + return nil + } + s.append(path, typ, value.Elem().Type().String()) + return s.appendValue(path, value.Elem()) + case reflect.Pointer: + if value.IsNil() { + s.append(path, typ, "nil") + return nil + } + pointer := value.Pointer() + s.references = append(s.references, value) + s.append(path, typ, formatPointer(pointer)) + if s.seen(designVisit{typ: typ, kind: value.Kind(), ptr: pointer}) { + return nil + } + return s.appendValue(path, value.Elem()) + case reflect.Struct: + s.append(path, typ, "struct") + if typ == typeMapType { + if err := s.appendValue(path+".User", value.FieldByName("User")); err != nil { + return err + } + s.appendExternalType(path+".External", value.FieldByName("External")) + return nil + } + for i := range value.NumField() { + if err := s.appendValue(path+"."+typ.Field(i).Name, value.Field(i)); err != nil { + return err + } + } + case reflect.Array: + s.append(path, typ, strconv.Itoa(value.Len())) + for i := range value.Len() { + if err := s.appendValue(fmt.Sprintf("%s[%d]", path, i), value.Index(i)); err != nil { + return err + } + } + case reflect.Slice: + if value.IsNil() { + s.append(path, typ, "nil") + return nil + } + pointer := value.Pointer() + s.references = append(s.references, value) + s.append(path, typ, fmt.Sprintf("%s:%d:%d", formatPointer(pointer), value.Len(), value.Cap())) + visit := designVisit{typ: typ, kind: value.Kind(), ptr: pointer, extent: value.Cap()} + if s.seen(visit) { + return nil + } + reachable := value.Slice(0, value.Cap()) + for i := range reachable.Len() { + if err := s.appendValue(fmt.Sprintf("%s[%d]", path, i), reachable.Index(i)); err != nil { + return err + } + } + case reflect.Map: + if value.IsNil() { + s.append(path, typ, "nil") + return nil + } + pointer := value.Pointer() + s.references = append(s.references, value) + s.append(path, typ, fmt.Sprintf("%s:%d", formatPointer(pointer), value.Len())) + if s.seen(designVisit{typ: typ, kind: value.Kind(), ptr: pointer}) { + return nil + } + entries, err := orderedMapEntries(value) + if err != nil { + return fmt.Errorf("snapshot prepared design at %s: %w", path, err) + } + for i, entry := range entries { + entryPath := mapEntryPath(path, i, entry.key) + if err := s.appendValue(entryPath+".key", entry.key); err != nil { + return err + } + if err := s.appendValue(entryPath, entry.value); err != nil { + return err + } + } + case reflect.Func: + if value.IsNil() { + s.append(path, typ, "nil") + return nil + } + // Design evaluation has finished, so functions stored by Goa or a plugin + // will not run here. Record their type and pointer address without invoking + // them. + s.append(path, typ, formatPointer(value.Pointer())) + case reflect.Chan: + if !value.IsNil() { + return fmt.Errorf("snapshot prepared design at %s: unsupported non-nil channel %s", path, typ) + } + s.append(path, typ, "nil") + case reflect.UnsafePointer: + if value.Pointer() != 0 { + return fmt.Errorf("snapshot prepared design at %s: unsupported non-nil unsafe pointer %s", path, typ) + } + s.append(path, typ, "nil") + default: + return fmt.Errorf("snapshot prepared design at %s: unsupported %s value", path, value.Kind()) + } + return nil +} + +// appendExternalType records the concrete Go type of a conversion example. +// Generators do not read fields or other runtime state from that value. +func (s *designSnapshotter) appendExternalType(path string, value reflect.Value) { + if value.IsNil() { + s.append(path, value.Type(), "nil") + return + } + s.append(path, value.Elem().Type(), "external exemplar type") +} + +// append adds one comparable state entry to the traversal. +func (s *designSnapshotter) append(path string, typ reflect.Type, value string) { + s.states = append(s.states, designState{path: path, typ: typ, value: value}) +} + +// seen records a reference and reports whether this exact node was already traversed. +func (s *designSnapshotter) seen(visit designVisit) bool { + if _, ok := s.visited[visit]; ok { + return true + } + s.visited[visit] = struct{}{} + return false +} + +// orderedMapEntries returns map pairs in the same order on every run without +// reading through pointers stored in the map. diff --git a/codegen/generator/design_snapshot_test.go b/codegen/generator/design_snapshot_test.go new file mode 100644 index 0000000000..0881ddbd12 --- /dev/null +++ b/codegen/generator/design_snapshot_test.go @@ -0,0 +1,185 @@ +// This file verifies that the prepared-design snapshot reports persistent +// semantic mutations made after the lifecycle's explicit preparation phase. +package generator + +import ( + "testing" + "unsafe" + + "github.com/stretchr/testify/require" + + "goa.design/goa/v3/eval" + "goa.design/goa/v3/expr" +) + +// TestPreparedDesignSnapshotRejectsUnsupportedState proves live behavior not +// owned by the evaluated design cannot enter the persistent mutation audit. +func TestPreparedDesignSnapshotRejectsUnsupportedState(t *testing.T) { + value := 1 + tests := []struct { + name string + value any + want string + }{ + {"channel", make(chan int), "unsupported non-nil channel"}, + {"unsafe pointer", unsafe.Pointer(&value), "unsupported non-nil unsafe pointer"}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + root := &expr.RootExpr{Types: []expr.UserType{&expr.UserTypeExpr{ + TypeName: "Value", + AttributeExpr: &expr.AttributeExpr{ + Type: expr.String, + DefaultValue: test.value, + }, + }}} + _, err := snapshotPreparedDesign([]eval.Root{root}) + require.ErrorContains(t, err, "roots[0].Types[0].AttributeExpr.DefaultValue") + require.ErrorContains(t, err, test.want) + }) + } +} + +// TestPreparedDesignSnapshotTracksFunctions proves unchanged functions remain +// valid while replacement and nilness changes are reported as mutations. +func TestPreparedDesignSnapshotTracksFunctions(t *testing.T) { + first := func(string) string { return "first" } + second := func(string) string { return "second" } + root := &expr.RootExpr{Types: []expr.UserType{&expr.UserTypeExpr{ + TypeName: "Value", + AttributeExpr: &expr.AttributeExpr{ + Type: expr.String, + DefaultValue: first, + }, + }}} + + snapshot, err := snapshotPreparedDesign([]eval.Root{root}) + require.NoError(t, err) + changed, err := snapshot.changedPath([]eval.Root{root}) + require.NoError(t, err) + require.Empty(t, changed) + + root.Types[0].Attribute().DefaultValue = second + changed, err = snapshot.changedPath([]eval.Root{root}) + require.NoError(t, err) + require.Equal(t, "roots[0].Types[0].AttributeExpr.DefaultValue", changed) + + root.Types[0].Attribute().DefaultValue = (func(string) string)(nil) + changed, err = snapshot.changedPath([]eval.Root{root}) + require.NoError(t, err) + require.Equal(t, "roots[0].Types[0].AttributeExpr.DefaultValue", changed) +} + +// TestPreparedDesignSnapshotDetectsNilFunctionReplacement proves a function +// added where the prepared design stored nil is reported as a mutation. +func TestPreparedDesignSnapshotDetectsNilFunctionReplacement(t *testing.T) { + root := &expr.RootExpr{Types: []expr.UserType{&expr.UserTypeExpr{ + TypeName: "Value", + AttributeExpr: &expr.AttributeExpr{ + Type: expr.String, + DefaultValue: (func(string) string)(nil), + }, + }}} + snapshot, err := snapshotPreparedDesign([]eval.Root{root}) + require.NoError(t, err) + + root.Types[0].Attribute().DefaultValue = func(string) string { return "added" } + changed, err := snapshot.changedPath([]eval.Root{root}) + require.NoError(t, err) + require.Equal(t, "roots[0].Types[0].AttributeExpr.DefaultValue", changed) +} + +// TestPreparedDesignSnapshotTreatsConversionExternalAsTypeToken proves that +// conversion exemplars contribute their exact Go type but not instance state. +func TestPreparedDesignSnapshotTreatsConversionExternalAsTypeToken(t *testing.T) { + type firstExternal struct { + channel chan int + values []string + } + type secondExternal struct{} + external := &firstExternal{channel: make(chan int), values: []string{"before"}} + typeMap := &expr.TypeMap{External: external} + root := &expr.RootExpr{Conversions: []*expr.TypeMap{typeMap}} + snapshot, err := snapshotPreparedDesign([]eval.Root{root}) + require.NoError(t, err) + + external.values[0] = "after" + changed, err := snapshot.changedPath([]eval.Root{root}) + require.NoError(t, err) + require.Empty(t, changed) + + typeMap.External = &secondExternal{} + changed, err = snapshot.changedPath([]eval.Root{root}) + require.NoError(t, err) + require.Equal(t, "roots[0].Conversions[0].External", changed) +} + +// TestPreparedDesignSnapshotMapOrderIsStable proves randomized Go map +// iteration does not produce false mutation reports. +func TestPreparedDesignSnapshotMapOrderIsStable(t *testing.T) { + root := &expr.RootExpr{Types: []expr.UserType{&expr.UserTypeExpr{ + TypeName: "Value", + AttributeExpr: &expr.AttributeExpr{ + Type: expr.String, + DefaultValue: map[any]any{ + "zeta": []string{"last"}, + "alpha": []string{"first"}, + 42: "number", + }, + }, + }}} + + snapshot, err := snapshotPreparedDesign([]eval.Root{root}) + require.NoError(t, err) + for range 100 { + changed, err := snapshot.changedPath([]eval.Root{root}) + require.NoError(t, err) + require.Empty(t, changed) + } +} + +// TestPreparedDesignSnapshotDetectsAliasReplacement proves replacing one of +// two aliases with an equal-value allocation changes the recorded topology. +func TestPreparedDesignSnapshotDetectsAliasReplacement(t *testing.T) { + service := &expr.ServiceExpr{Name: "service"} + root := &expr.RootExpr{Services: []*expr.ServiceExpr{service, service}} + snapshot, err := snapshotPreparedDesign([]eval.Root{root}) + require.NoError(t, err) + + replacement := *service + root.Services[1] = &replacement + changed, err := snapshot.changedPath([]eval.Root{root}) + require.NoError(t, err) + require.Equal(t, "roots[0].Services[1]", changed) +} + +// TestPreparedDesignSnapshotDetectsInPlaceContainerMutation proves changes to +// existing map and slice storage are visible without replacing a container. +func TestPreparedDesignSnapshotDetectsInPlaceContainerMutation(t *testing.T) { + tests := []struct { + name string + mutate func(map[string][]string) + }{ + {"map", func(values map[string][]string) { values["second"] = []string{"new"} }}, + {"slice", func(values map[string][]string) { values["first"][0] = "changed" }}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + values := map[string][]string{"first": {"original"}} + root := &expr.RootExpr{Types: []expr.UserType{&expr.UserTypeExpr{ + TypeName: "Value", + AttributeExpr: &expr.AttributeExpr{ + Type: expr.String, + DefaultValue: values, + }, + }}} + snapshot, err := snapshotPreparedDesign([]eval.Root{root}) + require.NoError(t, err) + + test.mutate(values) + changed, err := snapshot.changedPath([]eval.Root{root}) + require.NoError(t, err) + require.NotEmpty(t, changed) + }) + } +} diff --git a/codegen/generator/example.go b/codegen/generator/example.go index 556911b6b1..bad67ff0a8 100644 --- a/codegen/generator/example.go +++ b/codegen/generator/example.go @@ -1,87 +1,135 @@ +// This file collects example service, server, and client files from the copied +// server data and the package names already chosen for this generation. package generator import ( + "fmt" + "goa.design/goa/v3/codegen" "goa.design/goa/v3/codegen/example" "goa.design/goa/v3/codegen/service" "goa.design/goa/v3/eval" - "goa.design/goa/v3/expr" grpccodegen "goa.design/goa/v3/grpc/codegen" httpcodegen "goa.design/goa/v3/http/codegen" jsonrpccodegen "goa.design/goa/v3/jsonrpc/codegen" ) -// Example iterates through the roots and returns files that implement an -// example service, server, and client. +// Example returns example service, server, and client files for roots. func Example(genpkg string, roots []eval.Root) ([]*codegen.File, error) { - var files []*codegen.File - for _, root := range roots { - r, ok := root.(*expr.RootExpr) - if !ok { - continue // could be a plugin root expression - } - - // Create service data - services := service.NewServicesData(r) - for _, s := range r.Services { - service.SetUserTypeImports(genpkg, services.Get(s.Name)) - } + return runStandaloneGenerator(genpkg, roots, exampleGeneratorFactory) +} +// exampleFiles returns the service, server, and client examples selected for +// this generation. +func exampleFiles(plan *Plan) ([]*codegen.File, error) { + var files []*codegen.File + for _, entry := range plan.example { + services := entry.service.Services() // example service implementation - if fs := service.ExampleServiceFiles(genpkg, r, services); len(fs) != 0 { + if fs := service.ExampleServiceFiles(entry.service); len(fs) != 0 { files = append(files, fs...) } // example interceptors implementation - if fs := service.ExampleInterceptorsFiles(genpkg, r, services); len(fs) != 0 { + if fs := service.ExampleInterceptorsFiles(entry.service); len(fs) != 0 { files = append(files, fs...) } // server main - if fs := example.ServerFiles(genpkg, r, services); len(fs) != 0 { + if fs := example.ServerFiles(entry.root, services); len(fs) != 0 { files = append(files, fs...) } // CLI main - if fs := example.CLIFiles(genpkg, r); len(fs) != 0 { + if fs := example.CLIFiles(entry.root); len(fs) != 0 { files = append(files, fs...) } // HTTP - if len(r.API.HTTP.Services) > 0 { - httpServices := httpcodegen.NewServicesData(services, r.API.HTTP) - if fs := httpcodegen.ExampleServerFiles(genpkg, httpServices); len(fs) != 0 { - files = append(files, fs...) + if entry.http != nil { + if entry.jsonrpc == nil { + if fs := entry.http.ServerFiles(); len(fs) != 0 { + files = append(files, fs...) + } } - if fs := httpcodegen.ExampleCLIFiles(genpkg, httpServices); len(fs) != 0 { + if fs := entry.http.CLIFiles(); len(fs) != 0 { files = append(files, fs...) } } // JSON-RPC - if len(r.API.JSONRPC.Services) > 0 { - jsonrpcServices := httpcodegen.NewJSONRPCServicesData(services, &r.API.JSONRPC.HTTPExpr) - if fs := jsonrpccodegen.ExampleServerFiles(genpkg, jsonrpcServices, files); len(fs) > 0 { + if entry.jsonrpc != nil { + if fs := entry.jsonrpc.ServerFiles(); len(fs) > 0 { files = append(files, fs...) } - if fs := httpcodegen.ExampleCLIFiles(genpkg, jsonrpcServices); len(fs) > 0 { + if fs := entry.jsonrpc.CLIFiles(); len(fs) > 0 { files = append(files, fs...) } } // GRPC - if len(r.API.GRPC.Services) > 0 { - grpcServices := grpccodegen.NewServicesData(services) - if fs := grpccodegen.ExampleServerFiles(genpkg, grpcServices); len(fs) > 0 { + if entry.grpc != nil { + if fs := entry.grpc.ServerFiles(); len(fs) > 0 { files = append(files, fs...) } - if fs := grpccodegen.ExampleCLIFiles(genpkg, grpcServices); len(fs) > 0 { + if fs := entry.grpc.CLIFiles(); len(fs) > 0 { files = append(files, fs...) } } - - // Add imports defined via struct:field:type - addServicesMetaTypeImports(files, services, r.Services) } return files, nil } + +// planExampleData copies the server information used by example programs and +// prepares the selected transports. +func planExampleData(plan *Plan) error { + if err := planTransportData(plan); err != nil { + return err + } + roots := serviceRoots(plan.preparedRoots) + services := make([]*service.Plan, len(roots)) + for index, root := range roots { + services[index] = plan.Service(root) + } + examplePlan, err := example.NewPlan(plan.Generation(), services...) + if err != nil { + return err + } + plan.example = make([]*examplePlanEntry, len(roots)) + for index, root := range roots { + var httpExamples *httpcodegen.ExamplePlan + if transport := plan.http[root]; transport != nil { + httpExamples, err = httpcodegen.NewExamplePlan(transport, examplePlan) + if err != nil { + return err + } + } + var jsonrpcExamples *jsonrpccodegen.ExamplePlan + if transport := plan.jsonrpc[root]; transport != nil { + jsonrpcExamples, err = jsonrpccodegen.NewExamplePlan(transport, examplePlan) + if err != nil { + return err + } + } + var grpcExamples *grpccodegen.ExamplePlan + if transport := plan.grpc[root]; transport != nil { + grpcExamples, err = grpccodegen.NewExamplePlan(transport, examplePlan) + if err != nil { + return err + } + } + rootData, ok := examplePlan.Root(services[index]) + if !ok { + return fmt.Errorf("example plan does not contain server data for API %q", root.API.Name) + } + plan.example[index] = &examplePlanEntry{ + source: root, + root: rootData, + service: services[index], + http: httpExamples, + jsonrpc: jsonrpcExamples, + grpc: grpcExamples, + } + } + return nil +} diff --git a/codegen/generator/example_cli_input_stream_compile_test.go b/codegen/generator/example_cli_input_stream_compile_test.go new file mode 100644 index 0000000000..980fdd4cbf --- /dev/null +++ b/codegen/generator/example_cli_input_stream_compile_test.go @@ -0,0 +1,73 @@ +// This file compiles generated HTTP and gRPC example clients whose methods +// require streamed input. The example client must reject those commands +// without leaving unused endpoint values in the generated program. +package generator + +import ( + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/require" + + "goa.design/goa/v3/codegen" + "goa.design/goa/v3/dsl" + "goa.design/goa/v3/eval" +) + +// TestExampleInputStreamClientsCompile generates client-streaming and +// bidirectional commands for HTTP and gRPC, then compiles the generated client. +func TestExampleInputStreamClientsCompile(t *testing.T) { + root := codegen.RunDSL(t, func() { + dsl.API("gRPC input streams", func() { + dsl.Server("stream", func() { + dsl.Services("events") + dsl.Host("local", func() { + dsl.URI("http://localhost:8080") + dsl.URI("grpc://localhost:8080") + }) + }) + }) + dsl.Service("events", func() { + dsl.Method("upload", func() { + dsl.StreamingPayload(dsl.String) + dsl.Result(dsl.String) + dsl.HTTP(func() { + dsl.POST("/upload") + }) + dsl.GRPC(func() {}) + }) + dsl.Method("exchange", func() { + dsl.StreamingPayload(dsl.String) + dsl.StreamingResult(dsl.String) + dsl.HTTP(func() { + dsl.POST("/exchange") + }) + dsl.GRPC(func() {}) + }) + }) + }) + plan := mustTestPlan(t, "generated.local/gen", []eval.Root{root}, planExampleData) + files, err := testServiceFiles(plan) + require.NoError(t, err) + transportFiles, err := testTransportFiles(plan) + require.NoError(t, err) + files = append(files, transportFiles...) + exampleFiles, err := assembleExampleFilesForTest(plan) + require.NoError(t, err) + for _, file := range exampleFiles { + if strings.HasPrefix(file.Path, filepath.Join("cmd", "stream-cli")) { + files = append(files, file) + } + } + files, err = mergeFilesByPath(files) + require.NoError(t, err) + + directory := t.TempDir() + writeGeneratedModule(t, directory, "generated.local") + for _, file := range files { + _, err := file.Render(directory) + require.NoError(t, err) + } + runGeneratedTests(t, directory) +} diff --git a/codegen/generator/example_cli_result_runtime_test.go b/codegen/generator/example_cli_result_runtime_test.go new file mode 100644 index 0000000000..84ebb4663d --- /dev/null +++ b/codegen/generator/example_cli_result_runtime_test.go @@ -0,0 +1,238 @@ +// This file runs the result writers emitted into generated example clients. +// The test covers values received from a server stream and the errors returned +// when receiving or writing fails. +package generator + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/require" + + "goa.design/goa/v3/codegen" + "goa.design/goa/v3/dsl" + "goa.design/goa/v3/eval" +) + +// TestExampleCLIResultWritersRun verifies the generated helpers against real +// endpoint functions, stream receive functions, and writers. +func TestExampleCLIResultWritersRun(t *testing.T) { + root := codegen.RunDSL(t, func() { + dsl.API("example stream", func() { + dsl.Server("stream", func() { + dsl.Services("events") + dsl.Host("local", func() { + dsl.URI("http://localhost:8080") + }) + }) + }) + dsl.Service("events", func() { + dsl.Method("watch", func() { + dsl.StreamingResult(dsl.String) + dsl.HTTP(func() { + dsl.GET("/events") + }) + }) + dsl.Method("upload", func() { + dsl.StreamingPayload(dsl.String) + dsl.Result(dsl.String) + dsl.HTTP(func() { + dsl.POST("/events") + }) + }) + dsl.Method("create", func() { + dsl.Result(dsl.String) + dsl.StreamingResult(dsl.Int) + dsl.HTTP(func() { + dsl.POST("/create") + dsl.ServerSentEvents() + }) + }) + }) + }) + plan := mustTestPlan(t, "generated.local/gen", []eval.Root{root}, planExampleData) + files, err := testServiceFiles(plan) + require.NoError(t, err) + transportFiles, err := testTransportFiles(plan) + require.NoError(t, err) + files = append(files, transportFiles...) + exampleFiles, err := assembleExampleFilesForTest(plan) + require.NoError(t, err) + for _, file := range exampleFiles { + if strings.HasPrefix(file.Path, filepath.Join("cmd", "stream-cli")) { + files = append(files, file) + } + } + files, err = mergeFilesByPath(files) + require.NoError(t, err) + + directory := t.TempDir() + writeGeneratedModule(t, directory, "generated.local") + for _, file := range files { + _, err := file.Render(directory) + require.NoError(t, err) + } + testPath := filepath.Join(directory, "cmd", "stream-cli", "result_writer_test.go") + require.NoError(t, os.WriteFile(testPath, []byte(exampleCLIResultWriterTest), 0o600)) + runGeneratedTests(t, directory) +} + +const exampleCLIResultWriterTest = `package main + +import ( + "bytes" + "context" + "errors" + "flag" + "io" + "net/http" + "net/http/httptest" + "os" + "strings" + "testing" + + goa "goa.design/goa/v3/pkg" +) + +type failingWriter struct { + err error +} + +func (w failingWriter) Write([]byte) (int, error) { + return 0, w.err +} + +func TestWriteEndpointResult(t *testing.T) { + endpoint := goa.Endpoint(func(context.Context, any) (any, error) { + return map[string]string{"message": "hello"}, nil + }) + var output bytes.Buffer + if err := writeEndpointResult(context.Background(), &output, endpoint, nil); err != nil { + t.Fatal(err) + } + if got, want := output.String(), "{\n \"message\": \"hello\"\n}\n"; got != want { + t.Fatalf("unexpected output:\n%s", got) + } +} + +func TestWriteEndpointResultReturnsEncodingError(t *testing.T) { + endpoint := goa.Endpoint(func(context.Context, any) (any, error) { + return func() {}, nil + }) + err := writeEndpointResult(context.Background(), io.Discard, endpoint, nil) + if err == nil { + t.Fatal("expected JSON encoding error") + } +} + +func TestWriteStreamResults(t *testing.T) { + values := []string{"first", "second"} + next := 0 + recv := func(context.Context) (string, error) { + if next == len(values) { + return "", io.EOF + } + value := values[next] + next++ + return value, nil + } + var output bytes.Buffer + if err := writeStreamResults(context.Background(), &output, recv); err != nil { + t.Fatal(err) + } + if got, want := output.String(), "\"first\"\n\"second\"\n"; got != want { + t.Fatalf("unexpected output: %q", got) + } +} + +func TestWriteStreamResultsReturnsReceiveError(t *testing.T) { + want := errors.New("receive failed") + recv := func(context.Context) (string, error) { + return "", want + } + err := writeStreamResults(context.Background(), io.Discard, recv) + if !errors.Is(err, want) { + t.Fatalf("got %v, want wrapped receive error", err) + } +} + +func TestWriteStreamResultsDoesNotHideFailureJoinedWithEOF(t *testing.T) { + want := errors.New("close failed") + recv := func(context.Context) (string, error) { + return "", errors.Join(io.EOF, want) + } + err := writeStreamResults(context.Background(), io.Discard, recv) + if !errors.Is(err, want) { + t.Fatalf("got %v, want wrapped close error", err) + } +} + +func TestWriteStreamResultsReturnsOutputError(t *testing.T) { + want := errors.New("write failed") + called := false + recv := func(context.Context) (string, error) { + if called { + return "", io.EOF + } + called = true + return "value", nil + } + err := writeStreamResults(context.Background(), failingWriter{err: want}, recv) + if !errors.Is(err, want) { + t.Fatalf("got %v, want wrapped output error", err) + } +} + +func TestInputStreamIsRejectedBeforeCallingEndpoint(t *testing.T) { + args := os.Args + commandLine := flag.CommandLine + defer func() { + os.Args = args + flag.CommandLine = commandLine + }() + os.Args = []string{"stream-cli", "events", "upload"} + flag.CommandLine = flag.NewFlagSet("stream-cli", flag.ContinueOnError) + if err := flag.CommandLine.Parse(os.Args[1:]); err != nil { + t.Fatal(err) + } + + err := doHTTP(context.Background(), "http", "127.0.0.1:1", 1, false, io.Discard) + want := "example client does not support streamed input for service \"events\" method \"upload\"" + if err == nil || err.Error() != want { + t.Fatalf("got %v, want %q", err, want) + } +} + +func TestMixedHTTPResultUsesNormalResponse(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + if _, err := io.WriteString(w, "\"created\""); err != nil { + t.Error(err) + } + })) + defer server.Close() + + args := os.Args + commandLine := flag.CommandLine + defer func() { + os.Args = args + flag.CommandLine = commandLine + }() + os.Args = []string{"stream-cli", "events", "create"} + flag.CommandLine = flag.NewFlagSet("stream-cli", flag.ContinueOnError) + if err := flag.CommandLine.Parse(os.Args[1:]); err != nil { + t.Fatal(err) + } + + var output bytes.Buffer + err := doHTTP(context.Background(), "http", strings.TrimPrefix(server.URL, "http://"), 1, false, &output) + if err != nil { + t.Fatal(err) + } + if got, want := output.String(), "\"created\"\n"; got != want { + t.Fatalf("got %q, want %q", got, want) + } +} +` diff --git a/codegen/generator/example_handler_args_integration_test.go b/codegen/generator/example_handler_args_integration_test.go new file mode 100644 index 0000000000..32d986e3a9 --- /dev/null +++ b/codegen/generator/example_handler_args_integration_test.go @@ -0,0 +1,48 @@ +// This file checks that generated starter servers pass transport arguments in +// the same order accepted by their generated helper functions. +package generator + +import ( + "testing" + + "github.com/stretchr/testify/require" + + "goa.design/goa/v3/codegen" + "goa.design/goa/v3/dsl" + "goa.design/goa/v3/eval" +) + +// TestJSONRPCOnlyExampleServerCompiles checks a server whose two services are +// both exposed only through JSON-RPC. +func TestJSONRPCOnlyExampleServerCompiles(t *testing.T) { + root := codegen.RunDSL(t, func() { + for _, name := range []string{"First", "Second"} { + dsl.Service(name, func() { + dsl.Method("Read", func() { + dsl.Payload(dsl.String) + dsl.Result(dsl.String) + dsl.JSONRPC(func() {}) + }) + }) + } + }) + plan := mustTestPlan(t, "generated.local/gen", []eval.Root{root}, planExampleData) + files, err := testServiceFiles(plan) + require.NoError(t, err) + transportFiles, err := testTransportFiles(plan) + require.NoError(t, err) + files = append(files, transportFiles...) + exampleFiles, err := assembleExampleFilesForTest(plan) + require.NoError(t, err) + files = append(files, exampleFiles...) + files, err = mergeFilesByPath(files) + require.NoError(t, err) + + directory := t.TempDir() + writeGeneratedModule(t, directory, "generated.local") + for _, file := range files { + _, err := file.Render(directory) + require.NoError(t, err) + } + runGeneratedTests(t, directory) +} diff --git a/codegen/generator/example_immutability_test.go b/codegen/generator/example_immutability_test.go new file mode 100644 index 0000000000..92d8e1cff8 --- /dev/null +++ b/codegen/generator/example_immutability_test.go @@ -0,0 +1,52 @@ +// This file checks that example generation uses only values copied before Go +// names are finalized. +package generator + +import ( + "bytes" + "testing" + + "github.com/stretchr/testify/require" + + "goa.design/goa/v3/codegen" + "goa.design/goa/v3/codegen/example/testdata" + "goa.design/goa/v3/eval" +) + +// TestExampleFilesDoNotReadChangedServerDesign checks that changing the API or +// server after planning cannot change any example file. +func TestExampleFilesDoNotReadChangedServerDesign(t *testing.T) { + root := codegen.RunDSL(t, testdata.ServiceForOnlyHTTPDSL) + plan := mustTestPlan(t, "generated.local/gen", []eval.Root{root}, planExampleData) + + beforeFiles, err := exampleFiles(plan) + require.NoError(t, err) + before := renderExampleFiles(t, beforeFiles) + + server := root.API.Servers[0] + root.API.Name = "changed api" + server.Name = "changed server" + server.Description = "changed description" + server.Services = nil + server.Hosts = nil + root.API.Servers = nil + + afterFiles, err := exampleFiles(plan) + require.NoError(t, err) + require.Equal(t, before, renderExampleFiles(t, afterFiles)) +} + +// renderExampleFiles writes each section and indexes the complete text by file +// path. +func renderExampleFiles(t *testing.T, files []*codegen.File) map[string]string { + t.Helper() + rendered := make(map[string]string, len(files)) + for _, file := range files { + var output bytes.Buffer + for _, section := range file.SectionTemplates { + require.NoError(t, section.Write(&output)) + } + rendered[file.Path] = output.String() + } + return rendered +} diff --git a/codegen/generator/example_output_preservation_integration_test.go b/codegen/generator/example_output_preservation_integration_test.go new file mode 100644 index 0000000000..4ed3d98a39 --- /dev/null +++ b/codegen/generator/example_output_preservation_integration_test.go @@ -0,0 +1,109 @@ +// This file checks that starter files are preserved relative to the requested +// output directory, even when generation starts in a different directory. +package generator + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/require" + + "goa.design/goa/v3/codegen" + d "goa.design/goa/v3/dsl" +) + +func TestExampleGenerationUsesOutputDirectoryForPreservation(t *testing.T) { + paths := []string{ + "status.go", + filepath.Join("interceptors", "status_server.go"), + filepath.Join("interceptors", "status_client.go"), + "multipart.go", + filepath.Join("cmd", "preserve", "main.go"), + filepath.Join("cmd", "preserve", "http.go"), + filepath.Join("cmd", "preserve", "grpc.go"), + filepath.Join("cmd", "preserve-cli", "main.go"), + filepath.Join("cmd", "preserve-cli", "http.go"), + filepath.Join("cmd", "preserve-cli", "grpc.go"), + } + preserved := map[string][]byte{ + "status.go": []byte("existing service\n"), + filepath.Join("interceptors", "status_server.go"): []byte("existing interceptor\n"), + "multipart.go": []byte("existing multipart helpers\n"), + filepath.Join("cmd", "preserve", "grpc.go"): []byte("existing server\n"), + filepath.Join("cmd", "preserve-cli", "http.go"): []byte("existing client\n"), + } + tests := []struct { + name string + existing map[string][]byte + }{ + {name: "output is empty"}, + {name: "output has starter files", existing: preserved}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + codegen.RunDSL(t, exampleOutputPreservationDSL) + launchDir := t.TempDir() + outputDir := t.TempDir() + writeGeneratedModule(t, filepath.Join(outputDir, codegen.Gendir), "generated.local/gen") + writeExampleFiles(t, launchDir, paths, []byte("file from launch directory\n")) + for path, content := range test.existing { + writeExampleFile(t, outputDir, path, content) + } + + t.Chdir(launchDir) + _, err := generate(outputDir, "example", false, newDefaultRegistry()) + require.NoError(t, err) + + for _, path := range paths { + content, err := os.ReadFile(filepath.Join(outputDir, path)) + require.NoError(t, err, path) + if existing, ok := test.existing[path]; ok { + require.Equal(t, existing, content, path) + } else { + require.NotEqual(t, []byte("file from launch directory\n"), content, path) + } + } + }) + } +} + +// exampleOutputPreservationDSL exercises every starter file producer that +// preserves user-written files. +func exampleOutputPreservationDSL() { + trace := d.Interceptor("trace") + d.API("preserve", func() {}) + d.Service("status", func() { + d.ServerInterceptor(trace) + d.ClientInterceptor(trace) + d.Method("upload", func() { + d.Payload(func() { + d.Field(1, "message", d.String) + }) + d.Result(d.String) + d.HTTP(func() { + d.POST("/upload") + d.MultipartRequest() + }) + d.GRPC(func() {}) + }) + }) +} + +// writeExampleFiles writes the same misleading content at every relative path +// in the directory where generation starts. +func writeExampleFiles(t *testing.T, dir string, paths []string, content []byte) { + t.Helper() + for _, path := range paths { + writeExampleFile(t, dir, path, content) + } +} + +// writeExampleFile writes one fixture file and creates its parent directory. +func writeExampleFile(t *testing.T, dir, path string, content []byte) { + t.Helper() + fullPath := filepath.Join(dir, path) + require.NoError(t, os.MkdirAll(filepath.Dir(fullPath), 0o750)) + require.NoError(t, os.WriteFile(fullPath, content, 0o600)) +} diff --git a/codegen/generator/example_plan_test.go b/codegen/generator/example_plan_test.go new file mode 100644 index 0000000000..61e066db76 --- /dev/null +++ b/codegen/generator/example_plan_test.go @@ -0,0 +1,79 @@ +// This file checks that plugins receive a separate copy of the example server +// description retained for the exact prepared design root. +package generator + +import ( + "testing" + + "github.com/stretchr/testify/require" + + "goa.design/goa/v3/codegen/example" + "goa.design/goa/v3/expr" +) + +func TestExampleReturnsSeparateCopyForExactRoot(t *testing.T) { + root := &expr.RootExpr{} + transport := &example.TransportData{ + Type: example.TransportHTTP, + Name: "HTTP", + Services: []string{"calc"}, + } + variable := &example.VariableData{ + Name: "version", + Description: "API version", + DefaultValue: "v1", + Values: []string{"v1", "v2"}, + } + planned := &example.Root{ + APIName: "calc", + Services: []string{"calc"}, + Servers: []*example.Data{{ + Name: "edge", + Description: "public server", + Services: []string{"calc"}, + Schemes: []string{"http"}, + Variables: []*example.VariableData{variable}, + Transports: []*example.TransportData{transport}, + Hosts: []*example.HostData{{ + Name: "development", + Schemes: []string{"http"}, + Variables: []*example.VariableData{variable}, + URIs: []*example.URIData{{ + URL: "http://localhost/{version}", + Scheme: "http", + Port: "80", + Transport: transport, + HandlerArgs: []example.HandlerArg{{ + Service: "calc", + Endpoint: true, + }}, + }}, + }}, + }}, + } + plan := &Plan{example: []*examplePlanEntry{{source: root, root: planned}}} + + got, ok := plan.Example(root) + require.True(t, ok) + require.Equal(t, planned, got) + require.NotSame(t, planned, got) + require.NotSame(t, planned.Servers[0], got.Servers[0]) + require.NotSame(t, planned.Servers[0].Hosts[0], got.Servers[0].Hosts[0]) + require.NotSame(t, variable, got.Servers[0].Variables[0]) + require.Same(t, got.Servers[0].Variables[0], got.Servers[0].Hosts[0].Variables[0]) + require.NotSame(t, transport, got.Servers[0].Transports[0]) + require.Same(t, got.Servers[0].Transports[0], got.Servers[0].Hosts[0].URIs[0].Transport) + + got.Services[0] = "changed" + got.Servers[0].Services[0] = "changed" + got.Servers[0].Variables[0].Values[0] = "changed" + got.Servers[0].Hosts[0].URIs[0].HandlerArgs[0].Service = "changed" + require.Equal(t, "calc", planned.Services[0]) + require.Equal(t, "calc", planned.Servers[0].Services[0]) + require.Equal(t, "v1", variable.Values[0]) + require.Equal(t, "calc", planned.Servers[0].Hosts[0].URIs[0].HandlerArgs[0].Service) + + got, ok = plan.Example(&expr.RootExpr{}) + require.False(t, ok) + require.Nil(t, got) +} diff --git a/codegen/generator/example_snapshot.go b/codegen/generator/example_snapshot.go new file mode 100644 index 0000000000..39a7678227 --- /dev/null +++ b/codegen/generator/example_snapshot.go @@ -0,0 +1,121 @@ +// This file copies the example server description exposed to plugins so a +// plugin cannot change the values retained by Goa for the current run. +package generator + +import "goa.design/goa/v3/codegen/example" + +// copyExampleRoot copies every exported slice and nested value that a plugin +// can read or change through the example plan API. +func copyExampleRoot(source *example.Root) *example.Root { + if source == nil { + return nil + } + copy := &example.Root{ + APIName: source.APIName, + Services: append([]string(nil), source.Services...), + Servers: make([]*example.Data, len(source.Servers)), + } + for index, server := range source.Servers { + copy.Servers[index] = copyExampleServer(server) + } + return copy +} + +// copyExampleServer preserves shared variable and transport pointers within +// one server while separating them from the retained plan. +func copyExampleServer(source *example.Data) *example.Data { + if source == nil { + return nil + } + copy := *source + copy.Services = append([]string(nil), source.Services...) + copy.Schemes = append([]string(nil), source.Schemes...) + variables := make(map[*example.VariableData]*example.VariableData, len(source.Variables)) + copy.Variables = copyExampleVariables(source.Variables, variables) + transports := make(map[*example.TransportData]*example.TransportData, len(source.Transports)) + copy.Transports = copyExampleTransports(source.Transports, transports) + copy.Hosts = make([]*example.HostData, len(source.Hosts)) + for index, host := range source.Hosts { + copy.Hosts[index] = copyExampleHost(host, variables, transports) + } + return © +} + +// copyExampleHost copies one host and reuses the copied server values referred +// to by its variables and URLs. +func copyExampleHost( + source *example.HostData, + variables map[*example.VariableData]*example.VariableData, + transports map[*example.TransportData]*example.TransportData, +) *example.HostData { + if source == nil { + return nil + } + copy := *source + copy.Schemes = append([]string(nil), source.Schemes...) + copy.Variables = copyExampleVariables(source.Variables, variables) + copy.URIs = make([]*example.URIData, len(source.URIs)) + for index, uri := range source.URIs { + if uri == nil { + continue + } + uriCopy := *uri + uriCopy.HandlerArgs = append([]example.HandlerArg(nil), uri.HandlerArgs...) + uriCopy.Transport = copyExampleTransport(uri.Transport, transports) + copy.URIs[index] = &uriCopy + } + return © +} + +// copyExampleVariables copies variables once so server and host lists still +// refer to the same copied value. +func copyExampleVariables( + sources []*example.VariableData, + copies map[*example.VariableData]*example.VariableData, +) []*example.VariableData { + result := make([]*example.VariableData, len(sources)) + for index, source := range sources { + if source == nil { + continue + } + copy := copies[source] + if copy == nil { + value := *source + value.Values = append([]string(nil), source.Values...) + copy = &value + copies[source] = copy + } + result[index] = copy + } + return result +} + +// copyExampleTransports copies transports once so server and URL descriptions +// still refer to the same copied value. +func copyExampleTransports( + sources []*example.TransportData, + copies map[*example.TransportData]*example.TransportData, +) []*example.TransportData { + result := make([]*example.TransportData, len(sources)) + for index, source := range sources { + result[index] = copyExampleTransport(source, copies) + } + return result +} + +// copyExampleTransport returns the copied form of one transport description. +func copyExampleTransport( + source *example.TransportData, + copies map[*example.TransportData]*example.TransportData, +) *example.TransportData { + if source == nil { + return nil + } + if copy := copies[source]; copy != nil { + return copy + } + copy := *source + copy.Services = append([]string(nil), source.Services...) + copies[source] = © + return © +} diff --git a/codegen/generator/example_state_test.go b/codegen/generator/example_state_test.go new file mode 100644 index 0000000000..a6e48e0c81 --- /dev/null +++ b/codegen/generator/example_state_test.go @@ -0,0 +1,139 @@ +// This file verifies that each generator execution owns independent mutable +// example state while sharing only immutable API factory configuration. +package generator + +import ( + "sync" + "testing" + + "github.com/stretchr/testify/require" + + "goa.design/goa/v3/eval" + "goa.design/goa/v3/expr" +) + +type ( + // mutatingRandomizerFactory violates the immutable API factory contract so + // the lifecycle can prove it attributes the mutation at construction. + mutatingRandomizerFactory struct { + calls int + } +) + +// NewRandomizer records a call before returning a fresh deterministic stream. +func (f *mutatingRandomizerFactory) NewRandomizer(identity expr.ExampleIdentity) expr.Randomizer { + f.calls++ + return expr.NewDeterministicRandomizerFactory().NewRandomizer(identity) +} + +func TestGenerationRejectsFactoryMutationWhenStreamIsCreated(t *testing.T) { + root := expr.RunDSL(t, func() {}) + root.API.RandomizerFactory = &mutatingRandomizerFactory{} + registry := newRegistry() + registry.addCommand("test", func() coreGenerator { + return coreGenerator{name: "examples", Plan: func(plan *Plan) error { + plan.exampleGenerator(root).At(generatorTestIdentity()) + return nil + }} + }) + + err := executeGeneration("generated.local/gen", []eval.Root{root}, "test", registry) + + require.ErrorContains(t, err, `core "examples" plan mutated prepared design`) +} + +func TestGenerationRunsOwnIndependentExampleGenerators(t *testing.T) { + root := expr.RunDSL(t, func() {}) + factory := root.API.RandomizerFactory + registry := newRegistry() + var ( + mu sync.Mutex + generators []*expr.ExampleGenerator + examples []string + ) + registry.addCommand("test", func() coreGenerator { + return coreGenerator{name: "examples", Plan: func(plan *Plan) error { + generator := plan.exampleGenerator(root) + stream := generator.At(generatorTestIdentity()) + mu.Lock() + defer mu.Unlock() + generators = append(generators, generator) + examples = append(examples, stream.String()) + return nil + }} + }) + + for range 2 { + err := executeGeneration("generated.local/gen", []eval.Root{root}, "test", registry) + require.NoError(t, err) + } + + require.Len(t, generators, 2) + require.NotSame(t, generators[0], generators[1]) + require.Equal(t, examples[0], examples[1]) + require.Equal(t, factory, root.API.RandomizerFactory) +} + +// generatorTestIdentity returns a typed owner for values drawn directly by +// lifecycle tests rather than by a code-generation subsystem. +func generatorTestIdentity() expr.ExampleIdentity { + method := &expr.MethodExpr{ + Name: "lifecycle", + Service: &expr.ServiceExpr{Name: "generator-test"}, + } + return expr.MethodPayloadExampleIdentity(method) +} + +func TestConcurrentGenerationRunsOwnIndependentExampleGenerators(t *testing.T) { + registry := newRegistry() + roots := []*expr.RootExpr{expr.RunDSL(t, func() {}), expr.RunDSL(t, func() {})} + recursive := make(map[*expr.RootExpr]*expr.UserTypeExpr, len(roots)) + for _, root := range roots { + node := &expr.UserTypeExpr{TypeName: "Node", UID: "test-node"} + node.AttributeExpr = &expr.AttributeExpr{Type: &expr.Object{ + {Name: "value", Attribute: &expr.AttributeExpr{Type: expr.String}}, + {Name: "children", Attribute: &expr.AttributeExpr{Type: &expr.Array{ + ElemType: &expr.AttributeExpr{Type: node}, + }}}, + }} + recursive[root] = node + } + var ( + mu sync.Mutex + generators []*expr.ExampleGenerator + examples []any + ) + registry.addCommand("test", func() coreGenerator { + return coreGenerator{name: "examples", Plan: func(plan *Plan) error { + root := plan.preparedRoots[0].(*expr.RootExpr) + generator := plan.exampleGenerator(root) + node := recursive[root] + example := node.Example(generator.At(expr.UserTypeExampleIdentity(node))) + mu.Lock() + defer mu.Unlock() + generators = append(generators, generator) + examples = append(examples, example) + return nil + }} + }) + + var runs sync.WaitGroup + errs := make(chan error, 2) + for _, root := range roots { + runs.Add(1) + go func(root *expr.RootExpr) { + defer runs.Done() + err := executeGeneration("generated.local/gen", []eval.Root{root}, "test", registry) + errs <- err + }(root) + } + runs.Wait() + close(errs) + for err := range errs { + require.NoError(t, err) + } + + require.Len(t, generators, 2) + require.NotSame(t, generators[0], generators[1]) + require.Equal(t, examples[0], examples[1]) +} diff --git a/codegen/generator/files_client_integration_test.go b/codegen/generator/files_client_integration_test.go new file mode 100644 index 0000000000..48289aec61 --- /dev/null +++ b/codegen/generator/files_client_integration_test.go @@ -0,0 +1,151 @@ +// This file assembles complete Files examples and verifies that the generator +// writes servers for static files without inventing clients that have no +// method to call. +package generator + +import ( + "os" + "path/filepath" + "sort" + "strings" + "testing" + + "github.com/stretchr/testify/require" + + "goa.design/goa/v3/codegen" + "goa.design/goa/v3/codegen/testutil" + "goa.design/goa/v3/dsl" + "goa.design/goa/v3/eval" +) + +func TestFilesExamplesGenerateOnlyCallableClients(t *testing.T) { + tests := []struct { + name string + design func() + present []string + absent []string + absentFragments []string + }{ + { + name: "files-only", + design: filesOnlyExampleDSL, + present: []string{ + filepath.Join("cmd", "public", "http.go"), + filepath.Join("gen", "http", "assets", "server", "server.go"), + }, + absentFragments: []string{ + "/cli/", + "-cli/", + }, + }, + { + name: "files-with-grpc", + design: filesWithGRPCExampleDSL, + present: []string{ + filepath.Join("cmd", "public-cli", "main.go"), + filepath.Join("cmd", "public-cli", "grpc.go"), + filepath.Join("gen", "grpc", "cli", "public", "cli.go"), + }, + absent: []string{ + "cmd/public-cli/http.go", + "gen/http/cli/public/cli.go", + }, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + directory, paths := renderCompleteExample(t, test.design) + testutil.CompareOrUpdateGolden( + t, + strings.Join(paths, "\n"), + filepath.Join("testdata", "golden", "files_client", test.name, "paths.golden"), + ) + + for _, path := range test.present { + require.Contains(t, paths, filepath.ToSlash(path)) + content, err := os.ReadFile(filepath.Join(directory, path)) + require.NoError(t, err) + testutil.AssertGo( + t, + filepath.Join("testdata", "golden", "files_client", test.name, path+".golden"), + string(content), + ) + } + for _, path := range test.absent { + require.NotContains(t, paths, path) + } + for _, path := range paths { + for _, fragment := range test.absentFragments { + require.NotContains(t, path, fragment) + } + } + runGeneratedTests(t, directory) + }) + } +} + +// renderCompleteExample assembles service, transport, and example files, +// writes them into one module, and returns every generated path. +func renderCompleteExample(t *testing.T, design func()) (string, []string) { + t.Helper() + root := codegen.RunDSL(t, design) + plan := mustTestPlan(t, "generated.local/gen", []eval.Root{root}, planExampleData) + files, err := testServiceFiles(plan) + require.NoError(t, err) + transportFiles, err := testTransportFiles(plan) + require.NoError(t, err) + files = append(files, transportFiles...) + exampleFiles, err := assembleExampleFilesForTest(plan) + require.NoError(t, err) + files = append(files, exampleFiles...) + files, err = mergeFilesByPath(files) + require.NoError(t, err) + + directory := t.TempDir() + writeGeneratedModule(t, directory, "generated.local") + paths := make([]string, len(files)) + for index, file := range files { + _, err := file.Render(directory) + require.NoError(t, err) + paths[index] = filepath.ToSlash(file.Path) + } + sort.Strings(paths) + return directory, paths +} + +// filesOnlyExampleDSL defines an HTTP server whose only routes serve files. +func filesOnlyExampleDSL() { + dsl.API("files", func() { + dsl.Server("public", func() { + dsl.Services("assets") + dsl.Host("local", func() { + dsl.URI("http://localhost:8080") + }) + }) + }) + dsl.Service("assets", func() { + dsl.Files("/index.html", "index.html") + }) +} + +// filesWithGRPCExampleDSL adds one callable gRPC method to the same service +// that serves static files over HTTP. +func filesWithGRPCExampleDSL() { + dsl.API("files", func() { + dsl.Server("public", func() { + dsl.Services("assets") + dsl.Host("local", func() { + dsl.URI("http://localhost:8080") + dsl.URI("grpc://localhost:8080") + }) + }) + }) + dsl.Service("assets", func() { + dsl.Files("/index.html", "index.html") + dsl.Method("read_status", func() { + dsl.Result(dsl.String) + dsl.GRPC(func() {}) + }) + }) +} diff --git a/codegen/generator/generate.go b/codegen/generator/generate.go index 1d34d30a5b..a2df02b250 100644 --- a/codegen/generator/generate.go +++ b/codegen/generator/generate.go @@ -1,26 +1,46 @@ +// The goa command calls this file with an output directory, command, and debug +// flag; it reads the evaluated design roots and returns the files it wrote. +// Every core generator and plugin plans against one Generation. Before any +// callback renders files, Goa chooses every package and declaration name and +// rejects attempts to add another declaration. package generator import ( + "errors" "fmt" + "io/fs" "os" + "os/exec" + "path" "path/filepath" "runtime" "sort" + "strings" "sync" "time" "goa.design/goa/v3/codegen" "goa.design/goa/v3/eval" - "goa.design/goa/v3/expr" + "golang.org/x/mod/module" "golang.org/x/tools/go/packages" ) // Generate runs the code generation algorithms. func Generate(dir, cmd string, debug bool) (outputs []string, err1 error) { + return generate(dir, cmd, debug, defaultRegistry) +} + +// generate runs code generation with an explicit registry so package tests can +// use isolated factories without replacing production globals. +func generate(dir, cmd string, debug bool, registry *registry) (outputs []string, err1 error) { startGenerate := time.Now() if debug { fmt.Fprintf(os.Stderr, "[TIMING] [generate] Starting generator.Generate()\n") } + run, err := newGenerationRun(cmd, registry) + if err != nil { + return nil, err + } // 1. Compute design roots. var roots []eval.Root @@ -68,144 +88,83 @@ func Generate(dir, cmd string, debug bool) (outputs []string, err1 error) { } startPkgLoad := time.Now() - pkgs, err := packages.Load(&packages.Config{Mode: packages.NeedName}, path) + genpkg, err = generatedPackageImportPath(path) if err != nil { return nil, err } - // In temporary workspaces (e.g., tests) and on Windows, PkgPath may resolve - // to an absolute filesystem path which is not a valid Go import path and - // would produce invalid imports (e.g., backslashes). Fall back to the - // relative generated package import path in that case. - if filepath.IsAbs(pkgs[0].PkgPath) { - genpkg = codegen.Gendir - } else { - genpkg = pkgs[0].PkgPath - } if debug { fmt.Fprintf(os.Stderr, "[TIMING] [generate] packages.Load took %v\n", time.Since(startPkgLoad)) fmt.Fprintf(os.Stderr, "[TIMING] [generate] Stage 2: Compute gen package import path took %v\n", time.Since(start)) } } - // 3. Retrieve goa generators for given command. - var genfuncs []Genfunc - { - start := time.Now() - gs, err := Generators(cmd) - if err != nil { - return nil, err - } - genfuncs = gs - if debug { - fmt.Fprintf(os.Stderr, "[TIMING] [generate] Stage 3: Retrieve goa generators took %v (%d generators)\n", time.Since(start), len(genfuncs)) - } + // 3. Prepare roots and build one plan. Choose every package and declaration + // name, then render core and plugin files through the fresh run objects + // created before root evaluation. + startLifecycle := time.Now() + result, err := run.execute(genpkg, roots) + if err != nil { + return nil, err } - - // 4. Run the code pre generation plugins then normalize the design - // roots. NormalizeRoot is the only sanctioned design mutation past eval - // finalization; it runs after the prepare plugins so plugin contributed - // endpoints are normalized too and before the generators so they all - // observe the same read-only design tree. - { - start := time.Now() - err := codegen.RunPluginsPrepare(cmd, genpkg, roots) - if err != nil { - return nil, err - } - for _, root := range roots { - if r, ok := root.(*expr.RootExpr); ok { - codegen.NormalizeRoot(r) - } - } - if debug { - fmt.Fprintf(os.Stderr, "[TIMING] [generate] Stage 4: Run pre-generation plugins took %v\n", time.Since(start)) - } - } - - // 5. Generate initial set of files produced by goa code generators. - // NOTE: Parallelization causes infinite recursion in AsObject() for circular type references - var genfiles []*codegen.File - { - start := time.Now() - for i, gen := range genfuncs { - genStart := time.Now() - fs, err := gen(genpkg, roots) - if err != nil { - return nil, err - } - genfiles = append(genfiles, fs...) - if debug { - fmt.Fprintf(os.Stderr, "[TIMING] [generate] Generator %d produced %d files in %v\n", i, len(fs), time.Since(genStart)) - } - } - if debug { - fmt.Fprintf(os.Stderr, "[TIMING] [generate] Stage 5: Generate initial files took %v (total %d files)\n", time.Since(start), len(genfiles)) - } + genfiles := result.files + if debug { + fmt.Fprintf(os.Stderr, "[TIMING] [generate] Stage 3: Lifecycle produced %d files in %v\n", len(genfiles), time.Since(startLifecycle)) } - // 6. Run the code generation plugins. + // 8. Merge files that target the same path to avoid overwriting content when + // multiple generators (or services) emit sections for the same file. { start := time.Now() - var err error - genfiles, err = codegen.RunPlugins(cmd, genpkg, roots, genfiles) + genfiles, err = mergeFilesByPath(genfiles) if err != nil { return nil, err } if debug { - fmt.Fprintf(os.Stderr, "[TIMING] [generate] Stage 6: Run post-generation plugins took %v (now %d files)\n", time.Since(start), len(genfiles)) - } - } - - // 7. Merge files that target the same path to avoid overwriting content when - // multiple generators (or services) emit sections for the same file. - { - start := time.Now() - genfiles = mergeFilesByPath(genfiles) - if debug { - fmt.Fprintf(os.Stderr, "[TIMING] [generate] Stage 7: Merging files by path took %v (now %d files)\n", time.Since(start), len(genfiles)) + fmt.Fprintf(os.Stderr, "[TIMING] [generate] Stage 8: Merging files by path took %v (now %d files)\n", time.Since(start), len(genfiles)) } } - // 8. Emit goa.json version file (gen command only). + // 9. Emit goa.json version file (gen command only). if cmd == "gen" { genfiles = append(genfiles, codegen.VersionFile()) } - // 9. Write the files (in parallel). + // 10. Write the files in parallel, then audit the prepared design after all + // templates and file finalizers have completed. written := make(map[string]struct{}) { start := time.Now() numWorkers := runtime.NumCPU() if debug { - fmt.Fprintf(os.Stderr, "[TIMING] [generate] Stage 9: Starting parallel file writing with %d workers\n", numWorkers) + fmt.Fprintf(os.Stderr, "[TIMING] [generate] Stage 10: Starting parallel file writing with %d workers\n", numWorkers) } - // Channel for work items type workItem struct { index int file *codegen.File } workChan := make(chan workItem, len(genfiles)) - // Channel for results - type result struct { + type renderResult struct { index int filename string duration time.Duration err error } - resultChan := make(chan result, len(genfiles)) + resultChan := make(chan renderResult, len(genfiles)) - // Start worker pool - var wg sync.WaitGroup + var workers sync.WaitGroup for range numWorkers { - wg.Add(1) + workers.Add(1) go func() { - defer wg.Done() + defer workers.Done() for work := range workChan { renderStart := time.Now() filename, err := work.file.Render(dir) - resultChan <- result{ + if err != nil { + err = fmt.Errorf("render %s: %w", work.file.Path, err) + } + resultChan <- renderResult{ index: work.index, filename: filename, duration: time.Since(renderStart), @@ -215,45 +174,45 @@ func Generate(dir, cmd string, debug bool) (outputs []string, err1 error) { }() } - // Send all files to work channel - for i, f := range genfiles { - workChan <- workItem{index: i, file: f} + for i, file := range genfiles { + workChan <- workItem{index: i, file: file} } close(workChan) - // Wait for all workers to finish in a separate goroutine go func() { - wg.Wait() + workers.Wait() close(resultChan) }() - // Collect results + firstErrorIndex := len(genfiles) var firstErr error slowRenders := 0 - for res := range resultChan { - if res.err != nil && firstErr == nil { - firstErr = res.err + for render := range resultChan { + if render.err != nil && render.index < firstErrorIndex { + firstErrorIndex = render.index + firstErr = render.err } - if res.filename != "" { - written[res.filename] = struct{}{} + if render.filename != "" { + written[render.filename] = struct{}{} } - // Only log slow renders (>100ms) to avoid spam - if debug && res.duration > 100*time.Millisecond { - fmt.Fprintf(os.Stderr, "[TIMING] [generate] File %d (%s) render took %v\n", res.index, res.filename, res.duration) + if debug && render.duration > 100*time.Millisecond { + fmt.Fprintf(os.Stderr, "[TIMING] [generate] File %d (%s) render took %v\n", render.index, render.filename, render.duration) slowRenders++ } } - + if err := result.plan.verifyPreparedDesign("generated file renders"); err != nil { + return nil, err + } if firstErr != nil { return nil, firstErr } if debug { - fmt.Fprintf(os.Stderr, "[TIMING] [generate] Stage 9: Write files took %v (%d files written, %d slow renders)\n", time.Since(start), len(written), slowRenders) + fmt.Fprintf(os.Stderr, "[TIMING] [generate] Stage 10: Write files took %v (%d files written, %d slow renders)\n", time.Since(start), len(written), slowRenders) } } - // 10. Compute all output filenames. + // 11. Compute all output filenames. { start := time.Now() outputs = make([]string, len(written)) @@ -271,7 +230,7 @@ func Generate(dir, cmd string, debug bool) (outputs []string, err1 error) { i++ } if debug { - fmt.Fprintf(os.Stderr, "[TIMING] [generate] Stage 10: Compute output filenames took %v\n", time.Since(start)) + fmt.Fprintf(os.Stderr, "[TIMING] [generate] Stage 11: Compute output filenames took %v\n", time.Since(start)) } } sort.Strings(outputs) @@ -282,63 +241,154 @@ func Generate(dir, cmd string, debug bool) (outputs []string, err1 error) { return outputs, nil } +// generatedPackageImportPath asks Go to identify the package in dir and +// returns its cleaned import path for generated files. +func generatedPackageImportPath(dir string) (string, error) { + pkgs, err := packages.Load(&packages.Config{ + Mode: packages.NeedName | packages.NeedModule | packages.NeedFiles, + Dir: dir, + }, ".") + if err != nil { + return "", fmt.Errorf("load generated Go package in %q: %w", dir, err) + } + if len(pkgs) != 1 { + return "", fmt.Errorf("load generated Go package in %q: expected exactly one package, got %d", dir, len(pkgs)) + } + pkg := pkgs[0] + if len(pkg.Errors) != 0 { + packageErrors := make([]error, len(pkg.Errors)) + for i, packageError := range pkg.Errors { + packageErrors[i] = packageError + } + return "", fmt.Errorf("load generated Go package in %q: %w", dir, errors.Join(packageErrors...)) + } + importPath := pkg.PkgPath + if pkg.Module == nil && strings.HasPrefix(importPath, "_/") { + owned, err := gopathOwnsImportPath(pkg.Dir, importPath) + if err != nil { + return "", err + } + if !owned { + return "", fmt.Errorf("generated Go package in %q has synthetic import path %q", dir, importPath) + } + } + if path.Clean(importPath) != importPath { + return "", fmt.Errorf("generated Go package in %q has noncanonical import path %q", dir, importPath) + } + if err := module.CheckImportPath(importPath); err != nil { + return "", fmt.Errorf("generated Go package in %q has invalid import path %q: %w", dir, importPath, err) + } + return importPath, nil +} + +// gopathOwnsImportPath asks the Go command for its effective GOPATH and reports +// whether dir appears at importPath beneath one of GOPATH's source directories. +func gopathOwnsImportPath(dir, importPath string) (bool, error) { + output, err := exec.Command("go", "env", "GOPATH").Output() + if err != nil { + return false, fmt.Errorf("read effective GOPATH: %w", err) + } + gopath := string(output) + if strings.HasSuffix(gopath, "\r\n") { + gopath = strings.TrimSuffix(gopath, "\r\n") + } else { + gopath = strings.TrimSuffix(gopath, "\n") + } + roots := filepath.SplitList(gopath) + for _, root := range roots { + if gopathSourceOwnsImportPath(filepath.Join(root, "src"), dir, importPath) { + return true, nil + } + } + + resolvedDir, err := filepath.EvalSymlinks(dir) + if err != nil { + return false, fmt.Errorf("resolve generated Go package directory %q: %w", dir, err) + } + var resolutionErrors []error + for _, root := range roots { + packagePath := filepath.Join(root, "src", filepath.FromSlash(importPath)) + resolvedPackagePath, err := filepath.EvalSymlinks(packagePath) + if err != nil { + if errors.Is(err, fs.ErrNotExist) { + continue + } + resolutionErrors = append(resolutionErrors, fmt.Errorf("resolve %q: %w", packagePath, err)) + continue + } + if resolvedPackagePath == resolvedDir { + return true, nil + } + } + if len(resolutionErrors) != 0 { + return false, fmt.Errorf("resolve GOPATH package path for %q: %w", importPath, errors.Join(resolutionErrors...)) + } + return false, nil +} + +// gopathSourceOwnsImportPath reports whether dir is lexically beneath source +// with the exact slash-separated relative import path. +func gopathSourceOwnsImportPath(source, dir, importPath string) bool { + relative, err := filepath.Rel(source, dir) + if err != nil || relative == ".." || strings.HasPrefix(relative, ".."+string(filepath.Separator)) { + return false + } + return filepath.ToSlash(relative) == importPath +} + // mergeFilesByPath coalesces files that share the same output path by // concatenating their non-header sections and merging header imports. This // prevents later renders from truncating earlier content when multiple // services contribute sections to the same file (e.g., shared user types with // union value methods). -func mergeFilesByPath(files []*codegen.File) []*codegen.File { - if len(files) <= 1 { - return files +func mergeFilesByPath(files []*codegen.File) ([]*codegen.File, error) { + if len(files) == 0 { + return files, nil } byPath := make(map[string]*codegen.File) - namesByPath := make(map[string]map[string]struct{}) + portablePaths := make(map[string]string) - // First pass: build merged file per path + // First pass: build one complete file per path. for _, f := range files { if f == nil { continue } - path := f.Path - if existing, ok := byPath[path]; ok { - // Merge headers (index 0) imports - if len(existing.SectionTemplates) > 0 && len(f.SectionTemplates) > 0 { - mergeHeaderImports(existing.SectionTemplates[0], f.SectionTemplates[0]) + canonicalPath, portablePath, err := canonicalOutputFilePath(f.Path) + if err != nil { + return nil, err + } + if claimedPath, ok := portablePaths[portablePath]; ok && claimedPath != canonicalPath { + return nil, fmt.Errorf( + "generated file paths %q and %q collide on a case-insensitive filesystem", + claimedPath, + canonicalPath, + ) + } + portablePaths[portablePath] = canonicalPath + f.Path = canonicalPath + if existing, ok := byPath[canonicalPath]; ok { + if existing.SkipExist != f.SkipExist { + return nil, fmt.Errorf("generated file %q has conflicting SkipExist settings", canonicalPath) } - // Initialize seen section names for this path - if namesByPath[path] == nil { - namesByPath[path] = make(map[string]struct{}) - for _, st := range existing.SectionTemplates { - namesByPath[path][st.Name] = struct{}{} - } + existingHeader, existingHasHeader := firstHeader(existing) + contributorHeader, contributorHasHeader := firstHeader(f) + if existingHasHeader != contributorHasHeader { + return nil, fmt.Errorf("generated file %q mixes header and headerless contributions", canonicalPath) } - // Append unique sections (skip header at index 0) - for i, st := range f.SectionTemplates { - if i == 0 { - continue + sectionStart := 0 + if existingHasHeader { + if err := mergeHeaderImports(existingHeader, contributorHeader); err != nil { + return nil, fmt.Errorf("merge generated file %q: %w", canonicalPath, err) } - if _, seen := namesByPath[path][st.Name]; seen { - continue - } - existing.SectionTemplates = append(existing.SectionTemplates, st) - namesByPath[path][st.Name] = struct{}{} - } - // Preserve a finalize function if destination does not have one - if existing.FinalizeFunc == nil && f.FinalizeFunc != nil { - existing.FinalizeFunc = f.FinalizeFunc + sectionStart = 1 } - // Skip adding a duplicate File entry + existing.SectionTemplates = append(existing.SectionTemplates, f.SectionTemplates[sectionStart:]...) + existing.FinalizeFunc = composeFinalizers(existing.FinalizeFunc, f.FinalizeFunc) continue } - // New path: record and initialize seen names - byPath[path] = f - m := make(map[string]struct{}) - for _, st := range f.SectionTemplates { - m[st.Name] = struct{}{} - } - namesByPath[path] = m + byPath[canonicalPath] = f } // Second pass: preserve original order by first occurrence of each path @@ -356,43 +406,112 @@ func mergeFilesByPath(files []*codegen.File) []*codegen.File { seenPaths[f.Path] = struct{}{} } } - return merged + return merged, nil } // mergeHeaderImports merges the import specs from src header into dst header, -// deduplicating by (Name, Path). If either section is not a header produced by -// codegen.Header, this function is a no-op. -func mergeHeaderImports(dst, src *codegen.SectionTemplate) { - if dst == nil || src == nil { - return - } - dmap, dok := dst.Data.(map[string]any) - smap, sok := src.Data.(map[string]any) - if !dok || !sok { - return +// rejecting package and alias conflicts rather than producing invalid Go. +func mergeHeaderImports(dst, src *codegen.SectionTemplate) error { + dmap, _ := dst.Data.(map[string]any) + smap, _ := src.Data.(map[string]any) + dpkg, _ := dmap["Pkg"].(string) + spkg, _ := smap["Pkg"].(string) + if dpkg != spkg { + return fmt.Errorf("header packages %q and %q conflict", dpkg, spkg) } dlist, _ := dmap["Imports"].([]*codegen.ImportSpec) slist, _ := smap["Imports"].([]*codegen.ImportSpec) - if len(slist) == 0 { - return - } - seen := make(map[string]struct{}, len(dlist)) + paths := make(map[string]string, len(dlist)+len(slist)) + aliases := make(map[string]string, len(dlist)+len(slist)) for _, imp := range dlist { - if imp == nil { - continue + if _, err := recordImportSpec(paths, aliases, imp); err != nil { + return err } - seen[imp.Name+"|"+imp.Path] = struct{}{} } for _, imp := range slist { - if imp == nil { - continue + duplicate, err := recordImportSpec(paths, aliases, imp) + if err != nil { + return err } - key := imp.Name + "|" + imp.Path - if _, ok := seen[key]; ok { - continue + if !duplicate { + dlist = append(dlist, imp) } - dlist = append(dlist, imp) - seen[key] = struct{}{} } dmap["Imports"] = dlist + return nil +} + +// recordImportSpec validates one import against the complete merged header and +// reports whether the exact path and alias were already present. +func recordImportSpec(paths, names map[string]string, spec *codegen.ImportSpec) (bool, error) { + if spec == nil { + return true, nil + } + if alias, ok := paths[spec.Path]; ok { + if alias != spec.Name { + return false, fmt.Errorf("import path %q uses aliases %q and %q", spec.Path, alias, spec.Name) + } + return true, nil + } + localName := spec.Name + if localName != "" && localName != "_" && localName != "." { + if importPath, ok := names[localName]; ok { + return false, fmt.Errorf("import name %q refers to paths %q and %q", localName, importPath, spec.Path) + } + names[localName] = spec.Path + } + paths[spec.Path] = spec.Name + return false, nil +} + +// canonicalOutputFilePath cleans rawPath into the portable relative path used +// to group and render a generated file. The second result is case-folded so +// two paths cannot overwrite one another on a case-insensitive filesystem. +func canonicalOutputFilePath(rawPath string) (string, string, error) { + portable := filepath.ToSlash(rawPath) + portable = strings.ReplaceAll(portable, `\`, "/") + canonical := path.Clean(portable) + if canonical == "." || + canonical == ".." || + strings.HasPrefix(canonical, "../") || + strings.HasPrefix(canonical, "/") { + return "", "", fmt.Errorf("generated file path %q must stay within the output directory", rawPath) + } + if strings.Contains(canonical, ":") { + return "", "", fmt.Errorf("generated file path %q is not portable", rawPath) + } + return filepath.FromSlash(canonical), strings.ToLower(canonical), nil +} + +// firstHeader reports the header produced by codegen.Header when it is the +// first section of file. +func firstHeader(file *codegen.File) (*codegen.SectionTemplate, bool) { + if len(file.SectionTemplates) == 0 { + return nil, false + } + header := file.SectionTemplates[0] + data, ok := header.Data.(map[string]any) + if !ok { + return nil, false + } + _, hasPackage := data["Pkg"].(string) + _, hasImports := data["Imports"].([]*codegen.ImportSpec) + return header, hasPackage && hasImports +} + +// composeFinalizers preserves every same-path contributor's post-render work +// in contributor order and stops at the first error. +func composeFinalizers(first, second func(string) error) func(string) error { + if first == nil { + return second + } + if second == nil { + return first + } + return func(path string) error { + if err := first(path); err != nil { + return err + } + return second(path) + } } diff --git a/codegen/generator/generate_grpc_cli_collision_integration_test.go b/codegen/generator/generate_grpc_cli_collision_integration_test.go new file mode 100644 index 0000000000..ba672dadf3 --- /dev/null +++ b/codegen/generator/generate_grpc_cli_collision_integration_test.go @@ -0,0 +1,68 @@ +// This file verifies that a generated gRPC command parser calls the exact +// client constructor selected for its generated client package. +package generator + +import ( + "path" + "strings" + "testing" + + "github.com/stretchr/testify/require" + + "goa.design/goa/v3/codegen" + "goa.design/goa/v3/dsl" + "goa.design/goa/v3/eval" +) + +type grpcClientCollisionOrder string + +// ComparePackageName orders the declaration added by this collision test. +func (o grpcClientCollisionOrder) ComparePackageName(other codegen.PackageNameOrder) int { + return strings.Compare(string(o), string(other.(grpcClientCollisionOrder))) +} + +func TestGeneratedGRPCCLIUsesFinalClientConstructorName(t *testing.T) { + root := codegen.RunDSL(t, func() { + dsl.Service("Records", func() { + dsl.Method("Read", func() { + dsl.Result(dsl.String) + dsl.GRPC(func() {}) + }) + }) + }) + reserve := func(plan *Plan) error { + clientPackage, err := plan.Generation().ClaimPackage(path.Join( + "generated.local/gen", "grpc", "records", "client", + )) + if err != nil { + return err + } + return clientPackage.DeclareName(codegen.NewPreferredName( + codegen.NameFunction, + "NewClient", + codegen.ExportedName, + grpcClientCollisionOrder("plugin-client-constructor"), + )) + } + plan := mustTestPlan( + t, + "generated.local/gen", + []eval.Root{root}, + planServiceData, + reserve, + planTransportData, + ) + files, err := testServiceFiles(plan) + require.NoError(t, err) + transportFiles, err := testTransportFiles(plan) + require.NoError(t, err) + files = append(files, transportFiles...) + + directory := t.TempDir() + writeGeneratedModule(t, directory, "generated.local") + for _, file := range files { + _, err := file.Render(directory) + require.NoError(t, err) + } + runGeneratedTests(t, directory) +} diff --git a/codegen/generator/generate_grpc_collection_wrapper_integration_test.go b/codegen/generator/generate_grpc_collection_wrapper_integration_test.go new file mode 100644 index 0000000000..7ea22b5806 --- /dev/null +++ b/codegen/generator/generate_grpc_collection_wrapper_integration_test.go @@ -0,0 +1,169 @@ +// This file checks that generated gRPC conversions preserve protobuf's +// collection rules when arrays appear as map values. +package generator + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/require" + + "goa.design/goa/v3/codegen" + d "goa.design/goa/v3/dsl" + "goa.design/goa/v3/eval" +) + +// TestGenerateGRPCCollectionMapValues runs generated request and response +// conversions with nil and empty protobuf wrappers around repeated fields. +func TestGenerateGRPCCollectionMapValues(t *testing.T) { + root := codegen.RunDSL(t, grpcCollectionMapValuesDSL) + plan := mustTestPlan(t, "generated.local/gen", []eval.Root{root}, planTransportData) + files, err := testServiceFiles(plan) + require.NoError(t, err) + transportFiles, err := testTransportFiles(plan) + require.NoError(t, err) + files = append(files, transportFiles...) + + dir := t.TempDir() + writeGeneratedModule(t, dir, "generated.local") + for _, file := range files { + _, err := file.Render(dir) + require.NoError(t, err) + } + writeGRPCCollectionMapValuesTest(t, dir) + runGeneratedTests(t, dir) +} + +// grpcCollectionMapValuesDSL defines an optional map whose values are arrays. +// It has no length rule, so protobuf nil and empty arrays are both valid. +func grpcCollectionMapValuesDSL() { + d.API("collection-map-values", func() {}) + collection := d.Type("Collection", func() { + d.Field(1, "values", d.MapOf(d.String, d.ArrayOf(d.String))) + }) + constrained := d.Type("ConstrainedCollection", func() { + d.Field(1, "values", d.MapOf(d.String, d.ArrayOf(d.String, func() { + d.Pattern("^ok$") + })), func() { + d.Elem(func() { + d.MinLength(1) + }) + }) + }) + d.Service("collections", func() { + d.Method("Exchange", func() { + d.Payload(collection) + d.Result(collection) + d.GRPC(func() {}) + }) + d.Method("Check", func() { + d.Payload(constrained) + d.GRPC(func() {}) + }) + }) +} + +// writeGRPCCollectionMapValuesTest writes tests that use the generated +// protobuf types and conversion functions together. +func writeGRPCCollectionMapValuesTest(t *testing.T, moduleDir string) { + t.Helper() + dir := filepath.Join(moduleDir, "collectionmaptest") + if err := os.MkdirAll(dir, 0o750); err != nil { + t.Fatalf("create collection map test package: %v", err) + } + const source = `package collectionmaptest_test + +import ( + "errors" + "testing" + + goa "goa.design/goa/v3/pkg" + genclient "generated.local/gen/grpc/collections/client" + genpb "generated.local/gen/grpc/collections/pb" + genserver "generated.local/gen/grpc/collections/server" + gencollections "generated.local/gen/collections" +) + +func TestRequestMapValuesAcceptNilAndEmptyWrappers(t *testing.T) { + message := &genpb.ExchangeRequest{Values: map[string]*genpb.ArrayOfString{ + "nil": nil, + "empty": {}, + }} + payload := genserver.NewExchangePayload(message) + assertEmptyMapValues(t, payload.Values) +} + +func TestResponseMapValuesRoundTripAsEmptyCollections(t *testing.T) { + result := &gencollections.Collection{Values: map[string][]string{ + "nil": nil, + "empty": {}, + }} + message := genserver.NewProtoExchangeResponse(result) + converted := genclient.NewExchangeResult(message) + assertEmptyMapValues(t, converted.Values) +} + +func TestNilAndEmptyWrappersKeepAuthoredLengthRules(t *testing.T) { + tests := map[string]*genpb.ArrayOfString{ + "nil": nil, + "empty": {}, + } + var messages []string + for name, wrapper := range tests { + t.Run(name, func(t *testing.T) { + message := &genpb.CheckRequest{Values: map[string]*genpb.ArrayOfString{"value": wrapper}} + err := genserver.ValidateCheckRequest(message) + if err == nil { + t.Error("empty map value passed its minimum length rule") + return + } + assertServiceErrorName(t, err, goa.InvalidLength) + messages = append(messages, err.Error()) + }) + } + if len(messages) == 2 && messages[0] != messages[1] { + t.Errorf("nil and empty wrappers returned different errors: %q and %q", messages[0], messages[1]) + } +} + +func TestPresentWrapperKeepsAuthoredItemRules(t *testing.T) { + message := &genpb.CheckRequest{Values: map[string]*genpb.ArrayOfString{ + "value": {Field: []string{"not-ok"}}, + }} + if err := genserver.ValidateCheckRequest(message); err == nil { + t.Error("invalid repeated item passed its pattern rule") + } else { + assertServiceErrorName(t, err, goa.InvalidPattern) + } +} + +func assertServiceErrorName(t *testing.T, err error, name string) { + t.Helper() + var serviceError *goa.ServiceError + if !errors.As(err, &serviceError) { + t.Fatalf("expected service error, got %T: %v", err, err) + } + if serviceError.Name != name { + t.Errorf("expected %q, got %q: %v", name, serviceError.Name, err) + } +} + +func assertEmptyMapValues(t *testing.T, values map[string][]string) { + t.Helper() + for _, key := range []string{"nil", "empty"} { + value, ok := values[key] + if !ok { + t.Errorf("converted map is missing %q", key) + continue + } + if len(value) != 0 { + t.Errorf("converted map value %q has length %d", key, len(value)) + } + } +} +` + if err := os.WriteFile(filepath.Join(dir, "collection_map_test.go"), []byte(source), 0o600); err != nil { + t.Fatalf("write collection map test: %v", err) + } +} diff --git a/codegen/generator/generate_grpc_metadata_integration_test.go b/codegen/generator/generate_grpc_metadata_integration_test.go new file mode 100644 index 0000000000..a6ca6b3e96 --- /dev/null +++ b/codegen/generator/generate_grpc_metadata_integration_test.go @@ -0,0 +1,159 @@ +// This file verifies that generated gRPC metadata codecs convert between +// native header values and relocated service aliases in both directions. +package generator + +import ( + "os" + "path/filepath" + "testing" + + "goa.design/goa/v3/codegen" + d "goa.design/goa/v3/dsl" +) + +func TestGenerateGRPCMetadataAliasesCompile(t *testing.T) { + registry := testRegistry( + "gen", + testGenerator(planServiceData, testServiceFiles), + testGenerator(planTransportData, testTransportFiles), + ) + + _ = codegen.RunDSL(t, func() { + d.API("metadata", func() {}) + value := d.Type("Value", d.Int, func() { + d.Enum(1, 2) + d.Meta("struct:pkg:path", "shared/types") + }) + values := d.Type("Values", d.ArrayOf(value), func() { + d.Meta("struct:pkg:path", "shared/types") + }) + payload := d.Type("Payload", func() { + d.Field(1, "required_values", values) + d.Field(2, "optional_value", value) + d.Field(3, "anonymous_values", d.ArrayOf(value)) + d.Field(4, "optional_values", values) + d.Required("required_values", "anonymous_values") + }) + result := d.Type("Result", func() { + d.Field(1, "header_values", values) + d.Field(2, "trailer_value", value) + d.Field(3, "optional_header_values", values) + d.Required("header_values") + }) + d.Service("Metadata", func() { + d.Method("Exchange", func() { + d.Payload(payload) + d.Result(result) + d.GRPC(func() { + d.Metadata(func() { + d.Attribute("required_values") + d.Attribute("optional_value") + d.Attribute("anonymous_values") + d.Attribute("optional_values") + }) + d.Response(func() { + d.Headers(func() { + d.Attribute("header_values") + d.Attribute("optional_header_values") + }) + d.Trailers(func() { d.Attribute("trailer_value") }) + }) + }) + }) + }) + }) + + dir := t.TempDir() + genDir := filepath.Join(dir, codegen.Gendir) + writeGeneratedModule(t, genDir, "generated.local/gen") + if _, err := generate(dir, "gen", false, registry); err != nil { + t.Fatalf("generate gRPC metadata module: %v", err) + } + writeGRPCMetadataRoundTripTest(t, genDir) + runGeneratedTests(t, genDir) +} + +// writeGRPCMetadataRoundTripTest adds consumer code outside the generated +// packages that exercises both metadata directions through their public API. +func writeGRPCMetadataRoundTripTest(t *testing.T, moduleDir string) { + t.Helper() + dir := filepath.Join(moduleDir, "roundtrip") + if err := os.MkdirAll(dir, 0o750); err != nil { + t.Fatalf("create metadata round-trip package: %v", err) + } + const source = `package roundtrip_test + +import ( + "context" + "testing" + + genclient "generated.local/gen/grpc/metadata/client" + genserver "generated.local/gen/grpc/metadata/server" + genmetadata "generated.local/gen/metadata" + gentypes "generated.local/gen/shared/types" + "google.golang.org/grpc/metadata" +) + +func TestMetadataRoundTrip(t *testing.T) { + ctx := context.Background() + optional := gentypes.Value(2) + payload := &genmetadata.Payload{ + RequiredValues: gentypes.Values{1, 2}, + OptionalValue: &optional, + AnonymousValues: []gentypes.Value{2, 1}, + OptionalValues: gentypes.Values{1}, + } + requestMetadata := metadata.MD{} + message, err := genclient.EncodeExchangeRequest(ctx, payload, &requestMetadata) + if err != nil { + t.Fatal(err) + } + decoded, err := genserver.DecodeExchangeRequest(ctx, message, requestMetadata) + if err != nil { + t.Fatal(err) + } + gotPayload := decoded.(*genmetadata.Payload) + if len(gotPayload.RequiredValues) != 2 || gotPayload.RequiredValues[1] != 2 || *gotPayload.OptionalValue != 2 || len(gotPayload.AnonymousValues) != 2 || gotPayload.AnonymousValues[1] != 1 || len(gotPayload.OptionalValues) != 1 || gotPayload.OptionalValues[0] != 1 { + t.Fatalf("unexpected payload: %#v", gotPayload) + } + for _, optionalValues := range []gentypes.Values{nil, {}} { + payload.OptionalValues = optionalValues + requestMetadata = metadata.MD{} + message, err = genclient.EncodeExchangeRequest(ctx, payload, &requestMetadata) + if err != nil { + t.Fatal(err) + } + decoded, err = genserver.DecodeExchangeRequest(ctx, message, requestMetadata) + if err != nil { + t.Fatal(err) + } + if got := decoded.(*genmetadata.Payload); len(got.OptionalValues) != 0 { + t.Fatalf("unexpected absent optional values: %#v", got.OptionalValues) + } + } + + trailer := gentypes.Value(1) + result := &genmetadata.Result{ + HeaderValues: gentypes.Values{2, 1}, + TrailerValue: &trailer, + OptionalHeaderValues: gentypes.Values{1}, + } + headers, trailers := metadata.MD{}, metadata.MD{} + response, err := genserver.EncodeExchangeResponse(ctx, result, &headers, &trailers) + if err != nil { + t.Fatal(err) + } + decoded, err = genclient.DecodeExchangeResponse(ctx, response, headers, trailers) + if err != nil { + t.Fatal(err) + } + gotResult := decoded.(*genmetadata.Result) + if len(gotResult.HeaderValues) != 2 || gotResult.HeaderValues[0] != 2 || *gotResult.TrailerValue != 1 || len(gotResult.OptionalHeaderValues) != 1 || gotResult.OptionalHeaderValues[0] != 1 { + t.Fatalf("unexpected result: %#v", gotResult) + } +} +` + if err := os.WriteFile(filepath.Join(dir, "roundtrip_test.go"), []byte(source), 0o600); err != nil { + t.Fatalf("write metadata round-trip test: %v", err) + } +} diff --git a/codegen/generator/generate_grpc_required_array_alias_integration_test.go b/codegen/generator/generate_grpc_required_array_alias_integration_test.go new file mode 100644 index 0000000000..42f155d33d --- /dev/null +++ b/codegen/generator/generate_grpc_required_array_alias_integration_test.go @@ -0,0 +1,42 @@ +// This file verifies that generated gRPC codecs compile for required arrays +// whose service elements are primitive aliases. +package generator + +import ( + "path/filepath" + "testing" + + "goa.design/goa/v3/codegen" + d "goa.design/goa/v3/dsl" +) + +// TestGenerateGRPCRequiredPrimitiveAliasArray proves the service-to-protobuf +// and protobuf-to-service conversions preserve required string alias elements. +func TestGenerateGRPCRequiredPrimitiveAliasArray(t *testing.T) { + registry := testRegistry( + "gen", + testGenerator(planServiceData, testServiceFiles), + testGenerator(planTransportData, testTransportFiles), + ) + + _ = codegen.RunDSL(t, func() { + alias := d.Type("Alias", d.String) + payload := d.Type("Payload", func() { + d.Field(1, "values", d.ArrayOfRequired(alias)) + d.Required("values") + }) + d.Service("Aliases", func() { + d.Method("Store", func() { + d.Payload(payload) + d.GRPC(func() {}) + }) + }) + }) + + directory := filepath.Join(t.TempDir(), codegen.Gendir) + writeGeneratedModule(t, directory, "generated.local/gen") + if _, err := generate(filepath.Dir(directory), "gen", false, registry); err != nil { + t.Fatalf("generate required primitive alias array: %v", err) + } + runGeneratedTests(t, directory) +} diff --git a/codegen/generator/generate_grpc_required_primitive_presence_integration_test.go b/codegen/generator/generate_grpc_required_primitive_presence_integration_test.go new file mode 100644 index 0000000000..b11563fd26 --- /dev/null +++ b/codegen/generator/generate_grpc_required_primitive_presence_integration_test.go @@ -0,0 +1,345 @@ +// This file checks that generated gRPC messages preserve required primitive +// presence across protobuf encoding and command-line JSON. +package generator + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/require" + + "goa.design/goa/v3/codegen" + d "goa.design/goa/v3/dsl" + "goa.design/goa/v3/eval" +) + +// TestGenerateGRPCRequiredPrimitivePresence proves that generated client, +// server, and command-line validators accept explicit zero values and reject +// omitted values. +func TestGenerateGRPCRequiredPrimitivePresence(t *testing.T) { + root := codegen.RunDSL(t, requiredGRPCPrimitivePresenceDSL) + plan := mustTestPlan(t, "generated.local/gen", []eval.Root{root}, planTransportData) + files, err := testServiceFiles(plan) + require.NoError(t, err) + transportFiles, err := testTransportFiles(plan) + require.NoError(t, err) + files = append(files, transportFiles...) + + dir := t.TempDir() + writeGeneratedModule(t, dir, "generated.local") + for _, file := range files { + _, err := file.Render(dir) + require.NoError(t, err) + } + writeGRPCRequiredPrimitivePresenceTest(t, dir) + runGeneratedTests(t, dir) +} + +// requiredGRPCPrimitivePresenceDSL defines object and top-level alias values +// used on both sides of one generated gRPC service. +func requiredGRPCPrimitivePresenceDSL() { + d.API("required-primitive-presence", func() {}) + stringAlias := d.Type("StringAlias", d.String) + bytesAlias := d.Type("BytesAlias", d.Bytes) + anyAlias := d.Type("AnyAlias", d.Any) + defaults := d.Type("PresenceDefaults", func() { + d.Field(1, "bytes", d.Bytes, func() { + d.Default([]byte("fallback")) + }) + d.Field(2, "value", d.Any, func() { + d.Default("fallback") + }) + }) + message := d.Type("PresenceMessage", func() { + d.Field(1, "boolean", d.Boolean) + d.Field(2, "integer", d.Int) + d.Field(3, "text", d.String) + d.Field(4, "bytes", d.Bytes) + d.Field(5, "text_alias", stringAlias) + d.Field(6, "bytes_alias", bytesAlias) + d.Field(7, "any_alias", anyAlias) + d.Required("boolean", "integer", "text", "bytes", "text_alias", "bytes_alias", "any_alias") + }) + d.Service("presence", func() { + d.Method("Exchange", func() { + d.Payload(message) + d.Result(message) + d.GRPC(func() {}) + }) + d.Method("Echo", func() { + d.Payload(stringAlias) + d.Result(stringAlias) + d.GRPC(func() {}) + }) + d.Method("Plain", func() { + d.Payload(d.String) + d.Result(d.String) + d.GRPC(func() {}) + }) + d.Method("Defaults", func() { + d.Payload(defaults) + d.GRPC(func() {}) + }) + }) +} + +// writeGRPCRequiredPrimitivePresenceTest adds tests that use the generated +// protobuf types, codecs, command-line builders, and validation functions +// together. +func writeGRPCRequiredPrimitivePresenceTest(t *testing.T, moduleDir string) { + t.Helper() + dir := filepath.Join(moduleDir, "presencetest") + if err := os.MkdirAll(dir, 0o750); err != nil { + t.Fatalf("create required primitive presence package: %v", err) + } + const source = `package presencetest_test + +import ( + "bytes" + "errors" + "testing" + + "google.golang.org/protobuf/proto" + "google.golang.org/protobuf/types/known/structpb" + + goa "goa.design/goa/v3/pkg" + genclient "generated.local/gen/grpc/presence/client" + genpb "generated.local/gen/grpc/presence/pb" + genserver "generated.local/gen/grpc/presence/server" + genpresence "generated.local/gen/presence" +) + +func TestServicePrimitiveFieldsRemainValues(t *testing.T) { + message := genpresence.PresenceMessage{ + Boolean: false, + Integer: 0, + Text: "", + Bytes: []byte{}, + TextAlias: "", + BytesAlias: []byte{}, + AnyAlias: nil, + } + if message.Boolean || message.Integer != 0 || message.Text != "" { + t.Errorf("unexpected service primitive values: %#v", message) + } +} + +func TestRequiredRequestPrimitivePresence(t *testing.T) { + message := roundTrip(t, validRequest()) + if err := genserver.ValidateExchangeRequest(message); err != nil { + t.Errorf("explicit request zero values failed validation: %v", err) + } + + missing := validRequest() + missing.Boolean = nil + assertMissingField(t, genserver.ValidateExchangeRequest(missing), "boolean") + missing = validRequest() + missing.Integer = nil + assertMissingField(t, genserver.ValidateExchangeRequest(missing), "integer") + missing = validRequest() + missing.Text = nil + assertMissingField(t, genserver.ValidateExchangeRequest(missing), "text") + missing = validRequest() + missing.Bytes_ = nil + assertMissingField(t, genserver.ValidateExchangeRequest(missing), "bytes") + missing = validRequest() + missing.TextAlias = nil + assertMissingField(t, genserver.ValidateExchangeRequest(missing), "text_alias") + missing = validRequest() + missing.BytesAlias = nil + assertMissingField(t, genserver.ValidateExchangeRequest(missing), "bytes_alias") + missing = validRequest() + missing.AnyAlias = nil + assertMissingField(t, genserver.ValidateExchangeRequest(missing), "any_alias") +} + +func TestRequiredResponsePrimitivePresence(t *testing.T) { + message := roundTrip(t, validResponse()) + if err := genclient.ValidateExchangeResponse(message); err != nil { + t.Errorf("explicit response zero values failed validation: %v", err) + } + + missing := validResponse() + missing.Boolean = nil + assertMissingField(t, genclient.ValidateExchangeResponse(missing), "boolean") + missing = validResponse() + missing.Integer = nil + assertMissingField(t, genclient.ValidateExchangeResponse(missing), "integer") + missing = validResponse() + missing.Text = nil + assertMissingField(t, genclient.ValidateExchangeResponse(missing), "text") + missing = validResponse() + missing.Bytes_ = nil + assertMissingField(t, genclient.ValidateExchangeResponse(missing), "bytes") + missing = validResponse() + missing.TextAlias = nil + assertMissingField(t, genclient.ValidateExchangeResponse(missing), "text_alias") + missing = validResponse() + missing.BytesAlias = nil + assertMissingField(t, genclient.ValidateExchangeResponse(missing), "bytes_alias") + missing = validResponse() + missing.AnyAlias = nil + assertMissingField(t, genclient.ValidateExchangeResponse(missing), "any_alias") +} + +func TestRequiredTopLevelPrimitiveAliasPresence(t *testing.T) { + empty := "" + request := roundTrip(t, &genpb.StringAlias{Field: &empty}) + if err := genserver.ValidateStringAlias(request); err != nil { + t.Errorf("explicit empty request alias failed validation: %v", err) + } + assertMissingField(t, genserver.ValidateStringAlias(&genpb.StringAlias{}), "field") + + response := roundTrip(t, &genpb.StringAlias{Field: &empty}) + if err := genclient.ValidateStringAlias(response); err != nil { + t.Errorf("explicit empty response alias failed validation: %v", err) + } + assertMissingField(t, genclient.ValidateStringAlias(&genpb.StringAlias{}), "field") +} + +func TestRequiredTopLevelPrimitivePresence(t *testing.T) { + empty := "" + request := roundTrip(t, &genpb.PlainRequest{Field: &empty}) + if err := genserver.ValidatePlainRequest(request); err != nil { + t.Errorf("explicit empty request failed validation: %v", err) + } + assertMissingField(t, genserver.ValidatePlainRequest(&genpb.PlainRequest{}), "field") + + response := roundTrip(t, &genpb.PlainResponse{Field: &empty}) + if err := genclient.ValidatePlainResponse(response); err != nil { + t.Errorf("explicit empty response failed validation: %v", err) + } + assertMissingField(t, genclient.ValidatePlainResponse(&genpb.PlainResponse{}), "field") + + explicitEmpty := "{\"field\":\"\"}" + value, err := genclient.BuildPlainPayload(&explicitEmpty) + if err != nil { + t.Errorf("explicit empty CLI value failed validation: %v", err) + } + if value != "" { + t.Errorf("expected empty CLI value, got %q", value) + } + missing := "{}" + _, err = genclient.BuildPlainPayload(&missing) + assertMissingField(t, err, "field") +} + +func TestRequiredPrimitivePresenceFromCLIJSON(t *testing.T) { + valid := "{\"boolean\":false,\"integer\":0,\"text\":\"\",\"bytes\":\"\"," + + "\"textAlias\":\"\",\"bytesAlias\":\"\",\"anyAlias\":null}" + payload, err := genclient.BuildExchangePayload(&valid) + if err != nil { + t.Errorf("explicit CLI zero values failed validation: %v", err) + } + if payload == nil { + t.Error("explicit CLI zero values returned a nil payload") + } + + missingBoolean := "{\"integer\":0,\"text\":\"\",\"bytes\":\"\"," + + "\"textAlias\":\"\",\"bytesAlias\":\"\",\"anyAlias\":null}" + _, err = genclient.BuildExchangePayload(&missingBoolean) + assertMissingField(t, err, "boolean") + + explicitEmpty := "{\"field\":\"\"}" + alias, err := genclient.BuildEchoPayload(&explicitEmpty) + if err != nil { + t.Errorf("explicit empty CLI alias failed validation: %v", err) + } + if alias != "" { + t.Errorf("expected empty CLI alias, got %q", alias) + } + missing := "{}" + _, err = genclient.BuildEchoPayload(&missing) + assertMissingField(t, err, "field") +} + +func TestOptionalNilableDefaultsUseWirePresence(t *testing.T) { + omitted := genserver.NewDefaultsPayload(&genpb.DefaultsRequest{}) + if !bytes.Equal(omitted.Bytes, []byte("fallback")) { + t.Errorf("omitted bytes got %#v", omitted.Bytes) + } + if omitted.Value != "fallback" { + t.Errorf("omitted Any got %#v", omitted.Value) + } + + present := genserver.NewDefaultsPayload(&genpb.DefaultsRequest{ + Bytes_: []byte{}, + Value: structpb.NewNullValue(), + }) + if present.Bytes == nil || len(present.Bytes) != 0 { + t.Errorf("explicit empty bytes got %#v", present.Bytes) + } + if present.Value != nil { + t.Errorf("explicit protobuf null got %#v", present.Value) + } +} + +func validRequest() *genpb.ExchangeRequest { + boolean := false + integer := int32(0) + text := "" + textAlias := "" + return &genpb.ExchangeRequest{ + Boolean: &boolean, + Integer: &integer, + Text: &text, + Bytes_: []byte{}, + TextAlias: &textAlias, + BytesAlias: []byte{}, + AnyAlias: structpb.NewNullValue(), + } +} + +func validResponse() *genpb.ExchangeResponse { + boolean := false + integer := int32(0) + text := "" + textAlias := "" + return &genpb.ExchangeResponse{ + Boolean: &boolean, + Integer: &integer, + Text: &text, + Bytes_: []byte{}, + TextAlias: &textAlias, + BytesAlias: []byte{}, + AnyAlias: structpb.NewNullValue(), + } +} + +func roundTrip[T proto.Message](t *testing.T, source T) T { + t.Helper() + encoded, err := proto.Marshal(source) + if err != nil { + t.Fatalf("marshal protobuf message: %v", err) + } + result := source.ProtoReflect().Type().New().Interface().(T) + if err := proto.Unmarshal(encoded, result); err != nil { + t.Fatalf("unmarshal protobuf message: %v", err) + } + return result +} + +func assertMissingField(t *testing.T, err error, field string) { + t.Helper() + if err == nil { + t.Errorf("expected missing field %q", field) + return + } + var serviceError *goa.ServiceError + if !errors.As(err, &serviceError) { + t.Errorf("expected Goa service error, got %T: %v", err, err) + return + } + if serviceError.Name != goa.MissingField { + t.Errorf("expected %q, got %q", goa.MissingField, serviceError.Name) + } + if serviceError.Field == nil || *serviceError.Field != field { + t.Errorf("expected field %q, got %#v", field, serviceError.Field) + } +} +` + if err := os.WriteFile(filepath.Join(dir, "required_primitive_presence_test.go"), []byte(source), 0o600); err != nil { + t.Fatalf("write required primitive presence test: %v", err) + } +} diff --git a/codegen/generator/generate_grpc_required_union_validation_integration_test.go b/codegen/generator/generate_grpc_required_union_validation_integration_test.go new file mode 100644 index 0000000000..9e9097c2a9 --- /dev/null +++ b/codegen/generator/generate_grpc_required_union_validation_integration_test.go @@ -0,0 +1,188 @@ +// This file checks that generated gRPC clients and servers reject an empty +// required OneOf and reject a selected branch whose value is nil. +package generator + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/require" + + "goa.design/goa/v3/codegen" + d "goa.design/goa/v3/dsl" + "goa.design/goa/v3/eval" +) + +func TestGenerateGRPCRequiredUnionValidators(t *testing.T) { + root := codegen.RunDSL(t, requiredGRPCUnionValidationDSL) + plan := mustTestPlan(t, "generated.local/gen", []eval.Root{root}, planTransportData) + files, err := testServiceFiles(plan) + require.NoError(t, err) + transportFiles, err := testTransportFiles(plan) + require.NoError(t, err) + files = append(files, transportFiles...) + + dir := t.TempDir() + writeGeneratedModule(t, dir, "generated.local") + for _, file := range files { + _, err := file.Render(dir) + require.NoError(t, err) + } + writeGRPCRequiredUnionValidationTest(t, dir) + runGeneratedTests(t, dir) +} + +// requiredGRPCUnionValidationDSL creates request and response unions with the +// same branches so both generated checks must enforce the same rules. +func requiredGRPCUnionValidationDSL() { + d.API("required-union", func() {}) + token := d.Type("Token", d.String) + detail := d.Type("Detail", func() { + d.Field(1, "label", d.String) + d.Required("label") + }) + inactive := d.Type("Inactive", func() {}) + request := d.Type("RequestChoice", func() { + d.OneOf("choice", func() { + d.TypeName("RequestChoiceValue") + d.Field(1, "number", d.Int, func() { d.Minimum(1) }) + d.Field(2, "detail", detail) + d.Field(3, "inactive", inactive) + d.Field(4, "blob", d.Bytes) + d.Field(5, "token", token) + }) + d.Required("choice") + }) + response := d.Type("ResponseChoice", func() { + d.OneOf("choice", func() { + d.TypeName("ResponseChoiceValue") + d.Field(1, "number", d.Int, func() { d.Minimum(1) }) + d.Field(2, "detail", detail) + d.Field(3, "inactive", inactive) + d.Field(4, "blob", d.Bytes) + d.Field(5, "token", token) + }) + d.Required("choice") + }) + d.Service("validation", func() { + d.Method("Exchange", func() { + d.Payload(request) + d.Result(response) + d.GRPC(func() {}) + }) + }) +} + +// writeGRPCRequiredUnionValidationTest adds a test which calls the generated +// server and client validation functions. +func writeGRPCRequiredUnionValidationTest(t *testing.T, moduleDir string) { + t.Helper() + dir := filepath.Join(moduleDir, "uniontest") + if err := os.MkdirAll(dir, 0o750); err != nil { + t.Fatalf("create required union validation package: %v", err) + } + const source = `package uniontest_test + +import ( + "errors" + "testing" + + goa "goa.design/goa/v3/pkg" + genclient "generated.local/gen/grpc/validation/client" + genpb "generated.local/gen/grpc/validation/pb" + genserver "generated.local/gen/grpc/validation/server" +) + +func TestServerRequestValidator(t *testing.T) { + label := "ready" + valid := []*genpb.ExchangeRequest{ + {Choice: &genpb.ExchangeRequest_Number{Number: 1}}, + {Choice: &genpb.ExchangeRequest_Detail{Detail: &genpb.Detail{Label: &label}}}, + {Choice: &genpb.ExchangeRequest_Inactive{Inactive: &genpb.Inactive{}}}, + {Choice: &genpb.ExchangeRequest_Blob{Blob: []byte{}}}, + {Choice: &genpb.ExchangeRequest_Token{Token: "ready"}}, + } + for _, message := range valid { + if err := genserver.ValidateExchangeRequest(message); err != nil { + t.Errorf("valid request branch failed: %v", err) + } + } + + var nilNumber *genpb.ExchangeRequest_Number + assertErrorName(t, genserver.ValidateExchangeRequest(&genpb.ExchangeRequest{Choice: &genpb.ExchangeRequest_Number{Number: 0}}), goa.InvalidRange) + assertMissingField(t, genserver.ValidateExchangeRequest(&genpb.ExchangeRequest{}), "choice", "\"choice\" is missing from message") + assertMissingField(t, genserver.ValidateExchangeRequest(&genpb.ExchangeRequest{Choice: nilNumber}), "number", "\"number\" is missing from message.choice") + assertMissingField(t, genserver.ValidateExchangeRequest(&genpb.ExchangeRequest{Choice: &genpb.ExchangeRequest_Detail{}}), "detail", "\"detail\" is missing from message.choice") + assertMissingField(t, genserver.ValidateExchangeRequest(&genpb.ExchangeRequest{Choice: &genpb.ExchangeRequest_Inactive{}}), "inactive", "\"inactive\" is missing from message.choice") + assertMissingField(t, genserver.ValidateExchangeRequest(&genpb.ExchangeRequest{Choice: &genpb.ExchangeRequest_Blob{}}), "blob", "\"blob\" is missing from message.choice") +} + +func TestClientResponseValidator(t *testing.T) { + label := "ready" + valid := []*genpb.ExchangeResponse{ + {Choice: &genpb.ExchangeResponse_Number{Number: 1}}, + {Choice: &genpb.ExchangeResponse_Detail{Detail: &genpb.Detail{Label: &label}}}, + {Choice: &genpb.ExchangeResponse_Inactive{Inactive: &genpb.Inactive{}}}, + {Choice: &genpb.ExchangeResponse_Blob{Blob: []byte{}}}, + {Choice: &genpb.ExchangeResponse_Token{Token: "ready"}}, + } + for _, message := range valid { + if err := genclient.ValidateExchangeResponse(message); err != nil { + t.Errorf("valid response branch failed: %v", err) + } + } + + var nilDetail *genpb.ExchangeResponse_Detail + assertErrorName(t, genclient.ValidateExchangeResponse(&genpb.ExchangeResponse{Choice: &genpb.ExchangeResponse_Number{Number: 0}}), goa.InvalidRange) + assertMissingField(t, genclient.ValidateExchangeResponse(&genpb.ExchangeResponse{}), "choice", "\"choice\" is missing from message") + assertMissingField(t, genclient.ValidateExchangeResponse(&genpb.ExchangeResponse{Choice: nilDetail}), "detail", "\"detail\" is missing from message.choice") + assertMissingField(t, genclient.ValidateExchangeResponse(&genpb.ExchangeResponse{Choice: &genpb.ExchangeResponse_Detail{}}), "detail", "\"detail\" is missing from message.choice") + assertMissingField(t, genclient.ValidateExchangeResponse(&genpb.ExchangeResponse{Choice: &genpb.ExchangeResponse_Inactive{}}), "inactive", "\"inactive\" is missing from message.choice") + assertMissingField(t, genclient.ValidateExchangeResponse(&genpb.ExchangeResponse{Choice: &genpb.ExchangeResponse_Blob{}}), "blob", "\"blob\" is missing from message.choice") +} + +// assertErrorName checks that generated validation returned the expected Goa error name. +func assertErrorName(t *testing.T, err error, name string) { + t.Helper() + if err == nil { + t.Errorf("expected %q error", name) + return + } + var serviceError *goa.ServiceError + if !errors.As(err, &serviceError) { + t.Errorf("expected Goa service error, got %T: %v", err, err) + return + } + if serviceError.Name != name { + t.Errorf("expected %q, got %q", name, serviceError.Name) + } +} + +// assertMissingField checks the error name, field, and message returned for a missing protobuf value. +func assertMissingField(t *testing.T, err error, field, message string) { + t.Helper() + if err == nil { + t.Errorf("expected missing field %q", field) + return + } + var serviceError *goa.ServiceError + if !errors.As(err, &serviceError) { + t.Errorf("expected Goa service error, got %T: %v", err, err) + return + } + if serviceError.Name != goa.MissingField { + t.Errorf("expected %q, got %q", goa.MissingField, serviceError.Name) + } + if serviceError.Field == nil || *serviceError.Field != field { + t.Errorf("expected field %q, got %#v", field, serviceError.Field) + } + if serviceError.Message != message { + t.Errorf("expected message %q, got %q", message, serviceError.Message) + } +} +` + if err := os.WriteFile(filepath.Join(dir, "required_union_validation_test.go"), []byte(source), 0o600); err != nil { + t.Fatalf("write required union validation test: %v", err) + } +} diff --git a/codegen/generator/generate_grpc_view_validation_integration_test.go b/codegen/generator/generate_grpc_view_validation_integration_test.go new file mode 100644 index 0000000000..c0e16765f3 --- /dev/null +++ b/codegen/generator/generate_grpc_view_validation_integration_test.go @@ -0,0 +1,238 @@ +// This file checks that generated gRPC clients validate the fields selected by +// each result view before converting protobuf responses. +package generator + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/require" + + "goa.design/goa/v3/codegen" + d "goa.design/goa/v3/dsl" + "goa.design/goa/v3/eval" +) + +// TestGenerateGRPCViewedResponseValidation runs unary and streaming client +// decoders against complete and malformed protobuf responses for two views. +func TestGenerateGRPCViewedResponseValidation(t *testing.T) { + root := codegen.RunDSL(t, grpcViewedResponseValidationDSL) + plan := mustTestPlan(t, "generated.local/gen", []eval.Root{root}, planTransportData) + files, err := testServiceFiles(plan) + require.NoError(t, err) + transportFiles, err := testTransportFiles(plan) + require.NoError(t, err) + files = append(files, transportFiles...) + + dir := t.TempDir() + writeGeneratedModule(t, dir, "generated.local") + for _, file := range files { + _, err := file.Render(dir) + require.NoError(t, err) + } + writeGRPCViewedResponseValidationTest(t, dir) + runGeneratedTests(t, dir) +} + +// grpcViewedResponseValidationDSL defines required fields for two views. Two +// sibling fields share one nested type, and the tiny view selects only one. +func grpcViewedResponseValidationDSL() { + d.API("view-validation", func() {}) + nested := d.Type("Nested", func() { + d.Field(1, "value", d.String) + d.Required("value") + }) + item := d.ResultType("application/vnd.view-validation.item", func() { + d.TypeName("Item") + d.Attributes(func() { + d.Field(1, "number", d.Int) + d.Field(2, "text", d.String) + d.Field(3, "selected", nested) + d.Field(4, "omitted", nested) + d.Required("number", "text", "selected", "omitted") + }) + d.View("tiny", func() { + d.Attribute("number") + d.Attribute("selected") + }) + }) + d.Service("views", func() { + d.Method("Show", func() { + d.Result(item) + d.GRPC(func() {}) + }) + d.Method("Watch", func() { + d.StreamingResult(item) + d.GRPC(func() {}) + }) + }) +} + +// writeGRPCViewedResponseValidationTest writes tests that call the generated +// unary decoder and streaming Recv method through their public interfaces. +func writeGRPCViewedResponseValidationTest(t *testing.T, moduleDir string) { + t.Helper() + dir := filepath.Join(moduleDir, "viewvalidationtest") + if err := os.MkdirAll(dir, 0o750); err != nil { + t.Fatalf("create viewed response validation package: %v", err) + } + const source = `package viewvalidationtest_test + +import ( + "context" + "errors" + "io" + "testing" + + "google.golang.org/grpc" + "google.golang.org/grpc/metadata" + + goa "goa.design/goa/v3/pkg" + genclient "generated.local/gen/grpc/views/client" + genpb "generated.local/gen/grpc/views/pb" + genviews "generated.local/gen/views" +) + +func TestUnaryViewedResponseValidation(t *testing.T) { + zero := int32(0) + empty := "" + tests := []struct { + name string + view string + message *genpb.ShowResponse + missing string + errName string + }{ + {name: "valid tiny", view: "tiny", message: &genpb.ShowResponse{Number: &zero, Selected: nested("selected")}}, + {name: "malformed tiny", view: "tiny", message: &genpb.ShowResponse{Selected: nested("selected")}, missing: "number"}, + {name: "malformed shared tiny", view: "tiny", message: &genpb.ShowResponse{Number: &zero, Selected: &genpb.Nested{}}, missing: "value"}, + {name: "valid full", view: "default", message: &genpb.ShowResponse{Number: &zero, Text: &empty, Selected: nested("selected"), Omitted: nested("omitted")}}, + {name: "malformed full", view: "default", message: &genpb.ShowResponse{Number: &zero, Selected: nested("selected"), Omitted: nested("omitted")}, missing: "text"}, + {name: "unknown view", view: "unknown", message: &genpb.ShowResponse{Number: &zero, Text: &empty, Selected: nested("selected"), Omitted: nested("omitted")}, errName: "invalid_enum_value"}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + result, err := genclient.DecodeShowResponse(context.Background(), test.message, metadata.Pairs("goa-view", test.view), nil) + if test.missing != "" { + assertMissingField(t, err, test.missing) + return + } + if test.errName != "" { + assertServiceErrorName(t, err, test.errName) + return + } + if err != nil { + t.Fatalf("decode %s response: %v", test.view, err) + } + if _, ok := result.(*genviews.Item); !ok { + t.Errorf("unexpected decoded result %T", result) + } + }) + } +} + +func TestStreamingViewedResponseValidation(t *testing.T) { + zero := int32(0) + empty := "" + tests := []struct { + name string + view string + message *genpb.WatchResponse + missing string + errName string + }{ + {name: "valid tiny", view: "tiny", message: &genpb.WatchResponse{Number: &zero, Selected: nested("selected")}}, + {name: "malformed tiny", view: "tiny", message: &genpb.WatchResponse{Selected: nested("selected")}, missing: "number"}, + {name: "malformed shared tiny", view: "tiny", message: &genpb.WatchResponse{Number: &zero, Selected: &genpb.Nested{}}, missing: "value"}, + {name: "valid full", view: "default", message: &genpb.WatchResponse{Number: &zero, Text: &empty, Selected: nested("selected"), Omitted: nested("omitted")}}, + {name: "malformed full", view: "default", message: &genpb.WatchResponse{Number: &zero, Selected: nested("selected"), Omitted: nested("omitted")}, missing: "text"}, + {name: "unknown view", view: "unknown", message: &genpb.WatchResponse{Number: &zero, Text: &empty, Selected: nested("selected"), Omitted: nested("omitted")}, errName: "invalid_enum_value"}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + transport := &watchClient{ + header: metadata.Pairs("goa-view", test.view), + message: test.message, + } + decoded, err := genclient.DecodeWatchResponse(context.Background(), transport, nil, nil) + if err != nil { + t.Fatalf("build generated stream: %v", err) + } + stream := decoded.(interface { + Recv() (*genviews.Item, error) + }) + result, err := stream.Recv() + if test.missing != "" { + assertMissingField(t, err, test.missing) + return + } + if test.errName != "" { + assertServiceErrorName(t, err, test.errName) + return + } + if err != nil { + t.Fatalf("receive %s response: %v", test.view, err) + } + if result == nil { + t.Error("generated stream returned a nil result") + } + }) + } +} + +func nested(value string) *genpb.Nested { + return &genpb.Nested{Value: &value} +} + +type watchClient struct { + header metadata.MD + message *genpb.WatchResponse + sent bool +} + +func (c *watchClient) Recv() (*genpb.WatchResponse, error) { + if c.sent { + return nil, io.EOF + } + c.sent = true + return c.message, nil +} + +func (c *watchClient) Header() (metadata.MD, error) { return c.header, nil } +func (c *watchClient) Trailer() metadata.MD { return nil } +func (c *watchClient) CloseSend() error { return nil } +func (c *watchClient) Context() context.Context { return context.Background() } +func (c *watchClient) SendMsg(any) error { return nil } +func (c *watchClient) RecvMsg(any) error { return io.EOF } + +var _ genpb.Views_WatchClient = (*watchClient)(nil) +var _ grpc.ClientStream = (*watchClient)(nil) + +func assertMissingField(t *testing.T, err error, field string) { + t.Helper() + serviceError := assertServiceErrorName(t, err, "missing_field") + if serviceError.Field == nil || *serviceError.Field != field { + t.Errorf("expected missing field %q, got %#v", field, serviceError.Field) + } +} + +func assertServiceErrorName(t *testing.T, err error, name string) *goa.ServiceError { + t.Helper() + if err == nil { + t.Fatalf("expected %q error", name) + } + var serviceError *goa.ServiceError + if !errors.As(err, &serviceError) { + t.Fatalf("expected service error, got %T: %v", err, err) + } + if serviceError.Name != name { + t.Errorf("expected %q, got %q: %v", name, serviceError.Name, err) + } + return serviceError +} +` + if err := os.WriteFile(filepath.Join(dir, "view_validation_test.go"), []byte(source), 0o600); err != nil { + t.Fatalf("write viewed response validation test: %v", err) + } +} diff --git a/codegen/generator/generate_http_error_result_integration_test.go b/codegen/generator/generate_http_error_result_integration_test.go new file mode 100644 index 0000000000..3ad194c2a8 --- /dev/null +++ b/codegen/generator/generate_http_error_result_integration_test.go @@ -0,0 +1,45 @@ +// This file verifies that HTTP clients keep Goa's built-in service error type +// after the transport generator copies method error expressions. +package generator + +import ( + "path/filepath" + "testing" + + "goa.design/goa/v3/codegen" + "goa.design/goa/v3/dsl" +) + +func TestGenerateHTTPErrorResultAndCustomError(t *testing.T) { + registry := testRegistry( + "gen", + testGenerator(planServiceData, testServiceFiles), + testGenerator(planTransportData, testTransportFiles), + ) + codegen.RunDSL(t, func() { + custom := dsl.Type("CustomError", func() { + dsl.ErrorName("name", dsl.String) + dsl.Attribute("message", dsl.String) + dsl.Required("name", "message") + }) + dsl.Service("Records", func() { + dsl.Method("Read", func() { + dsl.Error("not_found") + dsl.Error("rejected", custom) + dsl.HTTP(func() { + dsl.GET("/records") + dsl.Response("not_found", dsl.StatusNotFound) + dsl.Response("rejected", dsl.StatusBadRequest) + }) + }) + }) + }) + + directory := filepath.Join(t.TempDir(), codegen.Gendir) + writeGeneratedModule(t, directory, "generated.local/gen") + _, err := generate(filepath.Dir(directory), "gen", false, registry) + if err != nil { + t.Fatalf("generate HTTP errors: %v", err) + } + runGeneratedTests(t, directory) +} diff --git a/codegen/generator/generate_http_multipart_validation_integration_test.go b/codegen/generator/generate_http_multipart_validation_integration_test.go new file mode 100644 index 0000000000..77868f32d4 --- /dev/null +++ b/codegen/generator/generate_http_multipart_validation_integration_test.go @@ -0,0 +1,205 @@ +// This file checks complete multipart generation, including the starter +// decoder signatures and the validation that runs before payload construction. +package generator + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/require" + + "goa.design/goa/v3/codegen" + d "goa.design/goa/v3/dsl" + "goa.design/goa/v3/eval" +) + +// TestGenerateHTTPMultipartValidationCompilesAndRuns verifies generated +// object, array, and map bodies together with the generated starter decoder. +func TestGenerateHTTPMultipartValidationCompilesAndRuns(t *testing.T) { + root := codegen.RunDSL(t, multipartValidationDSL) + plan := mustTestPlan(t, "generated.local/gen", []eval.Root{root}, planExampleData) + files, err := testServiceFiles(plan) + require.NoError(t, err) + transportFiles, err := testTransportFiles(plan) + require.NoError(t, err) + files = append(files, transportFiles...) + exampleFiles, err := assembleExampleFilesForTest(plan) + require.NoError(t, err) + files = append(files, exampleFiles...) + files, err = mergeFilesByPath(files) + require.NoError(t, err) + + directory := t.TempDir() + writeGeneratedModule(t, directory, "generated.local") + for _, file := range files { + _, err := file.Render(directory) + require.NoError(t, err) + } + writeMultipartValidationRuntimeTest(t, directory) + runGeneratedTests(t, directory) +} + +// multipartValidationDSL defines one object body with mapped request values +// and two composite bodies that exercise generated callback signatures. +func multipartValidationDSL() { + part := d.Type("Part", func() { + d.Attribute("code", d.String) + d.Required("code") + }) + objectPayload := d.Type("ObjectPayload", func() { + d.Attribute("name", d.String) + d.Attribute("part", part) + d.Attribute("site", d.String) + d.Attribute("count", d.Int) + d.Attribute("token", d.String) + d.Required("name", "part", "site", "count") + }) + d.Service("upload", func() { + d.Method("Object", func() { + d.Payload(objectPayload) + d.HTTP(func() { + d.POST("/objects/{site}") + d.Param("count") + d.Header("token:X-Token") + d.MultipartRequest() + }) + }) + d.Method("Array", func() { + d.Payload(d.ArrayOf(part)) + d.HTTP(func() { + d.POST("/array") + d.MultipartRequest() + }) + }) + d.Method("Map", func() { + d.Payload(d.MapOf(d.String, d.Int)) + d.HTTP(func() { + d.POST("/map") + d.MultipartRequest() + }) + }) + }) +} + +// writeMultipartValidationRuntimeTest adds assertions against the generated +// request decoder so validation order and payload construction are exercised. +func writeMultipartValidationRuntimeTest(t *testing.T, directory string) { + t.Helper() + const source = `package multiparttest_test + +import ( + "errors" + "net/http" + "testing" + + goahttp "goa.design/goa/v3/http" + goa "goa.design/goa/v3/pkg" + genserver "generated.local/gen/http/upload/server" +) + +type mux struct{} + +func (mux) Handle(string, string, http.HandlerFunc) {} +func (mux) ServeHTTP(http.ResponseWriter, *http.Request) {} +func (mux) Vars(*http.Request) map[string]string { return map[string]string{"site": "west"} } + +func TestObjectValidationRunsBeforeConstruction(t *testing.T) { + code := "ready" + request, err := http.NewRequest(http.MethodPost, "/objects/west?count=2", nil) + if err != nil { + t.Fatal(err) + } + request.Header.Set("X-Token", "secret") + + missingName := genserver.DecodeObjectRequest(mux{}, bodyDecoder(func(body *genserver.ObjectRequestBody) { + body.Part = &genserver.PartRequestBody{Code: &code} + })) + _, err = missingName(request) + assertMissingField(t, err, "name", "body") + + name := "report" + missingCode := genserver.DecodeObjectRequest(mux{}, bodyDecoder(func(body *genserver.ObjectRequestBody) { + body.Name = &name + body.Part = &genserver.PartRequestBody{} + })) + _, err = missingCode(request) + assertMissingField(t, err, "code", "body.part") + + valid := genserver.DecodeObjectRequest(mux{}, bodyDecoder(func(body *genserver.ObjectRequestBody) { + body.Name = &name + body.Part = &genserver.PartRequestBody{Code: &code} + })) + payload, err := valid(request) + if err != nil { + t.Fatalf("valid multipart body failed: %v", err) + } + if payload.Name != name || payload.Part.Code != code || payload.Site != "west" || payload.Count != 2 { + t.Fatalf("unexpected payload: %#v", payload) + } + if payload.Token == nil || *payload.Token != "secret" { + t.Fatalf("mapped header was not preserved: %#v", payload.Token) + } +} + +func TestArrayAndMapBodiesConstructPayloads(t *testing.T) { + code := "ready" + request, err := http.NewRequest(http.MethodPost, "/", nil) + if err != nil { + t.Fatal(err) + } + decodeArray := genserver.DecodeArrayRequest(mux{}, func(*http.Request) goahttp.Decoder { + return goahttp.EncodingFunc(func(value any) error { + body := value.(*[]*genserver.PartRequestBody) + *body = []*genserver.PartRequestBody{{Code: &code}} + return nil + }) + }) + array, err := decodeArray(request) + if err != nil || len(array) != 1 || array[0].Code != code { + t.Fatalf("unexpected array payload: %#v, %v", array, err) + } + + decodeMap := genserver.DecodeMapRequest(mux{}, func(*http.Request) goahttp.Decoder { + return goahttp.EncodingFunc(func(value any) error { + body := value.(*map[string]int) + *body = map[string]int{"count": 2} + return nil + }) + }) + values, err := decodeMap(request) + if err != nil || values["count"] != 2 { + t.Fatalf("unexpected map payload: %#v, %v", values, err) + } +} + +func bodyDecoder(fill func(*genserver.ObjectRequestBody)) func(*http.Request) goahttp.Decoder { + return func(*http.Request) goahttp.Decoder { + return goahttp.EncodingFunc(func(value any) error { + fill(value.(*genserver.ObjectRequestBody)) + return nil + }) + } +} + +func assertMissingField(t *testing.T, err error, field, location string) { + t.Helper() + if err == nil { + t.Fatalf("expected missing field %q", field) + } + var serviceError *goa.ServiceError + if !errors.As(err, &serviceError) { + t.Fatalf("expected Goa service error, got %T: %v", err, err) + } + if serviceError.Name != goa.MissingField || serviceError.Field == nil || *serviceError.Field != field { + t.Fatalf("unexpected missing field error: %#v", serviceError) + } + if serviceError.Message != "\""+field+"\" is missing from "+location { + t.Fatalf("unexpected message: %q", serviceError.Message) + } +} +` + dir := filepath.Join(directory, "multiparttest") + require.NoError(t, os.MkdirAll(dir, 0o750)) + require.NoError(t, os.WriteFile(filepath.Join(dir, "multipart_validation_test.go"), []byte(source), 0o600)) +} diff --git a/codegen/generator/generate_http_required_array_alias_integration_test.go b/codegen/generator/generate_http_required_array_alias_integration_test.go new file mode 100644 index 0000000000..0b7c904a1f --- /dev/null +++ b/codegen/generator/generate_http_required_array_alias_integration_test.go @@ -0,0 +1,199 @@ +// This file verifies that HTTP validation keeps null array elements visible +// without changing primitive alias elements in service values. +package generator + +import ( + "path/filepath" + "testing" + + "goa.design/goa/v3/codegen" + "goa.design/goa/v3/dsl" +) + +// TestGenerateHTTPRequiredPrimitiveAliasArray checks the generated service and +// HTTP packages for an array whose string alias elements cannot be null. +func TestGenerateHTTPRequiredPrimitiveAliasArray(t *testing.T) { + registry := testRegistry( + "gen", + testGenerator(planServiceData, testServiceFiles), + testGenerator(planTransportData, testTransportFiles), + ) + codegen.RunDSL(t, func() { + alias := dsl.Type("Alias", dsl.String, func() { + dsl.Pattern("^[a-z]*$") + }) + plainAlias := dsl.Type("PlainAlias", dsl.String) + nested := dsl.Type("Nested", func() { + dsl.Field(1, "values", dsl.ArrayOfRequired(alias)) + dsl.Required("values") + }) + payload := dsl.Type("StorePayload", func() { + dsl.Field(1, "names", dsl.ArrayOfRequired(dsl.String)) + dsl.Field(2, "values", dsl.ArrayOfRequired(alias)) + dsl.Field(3, "nested", nested) + dsl.Required("names", "values", "nested") + }) + searchPayload := dsl.Type("SearchPayload", func() { + dsl.Attribute("values", dsl.ArrayOfRequired(alias)) + dsl.Required("values") + }) + dsl.Service("Aliases", func() { + dsl.Method("Store", func() { + dsl.Payload(payload) + dsl.HTTP(func() { + dsl.POST("/aliases") + }) + dsl.GRPC(func() {}) + }) + dsl.Method("Search", func() { + dsl.Payload(searchPayload) + dsl.HTTP(func() { + dsl.GET("/aliases") + dsl.Param("values") + }) + }) + dsl.Method("ListNames", func() { + dsl.Result(dsl.ArrayOfRequired(dsl.String)) + dsl.HTTP(func() { + dsl.GET("/names") + }) + }) + dsl.Method("ListAliases", func() { + dsl.Result(dsl.ArrayOfRequired(plainAlias)) + dsl.HTTP(func() { + dsl.GET("/alias-values") + }) + }) + }) + }) + + directory := filepath.Join(t.TempDir(), codegen.Gendir) + writeGeneratedModule(t, directory, "generated.local/gen") + _, err := generate(filepath.Dir(directory), "gen", false, registry) + if err != nil { + t.Fatalf("generate required primitive alias array: %v", err) + } + writeGeneratedContractTest( + t, + directory, + filepath.Join("http", "aliases", "server"), + requiredPrimitiveAliasArrayRuntimeTest, + ) + writeGeneratedContractTest( + t, + directory, + filepath.Join("http", "aliases", "client"), + requiredPrimitiveAliasArrayResponseRuntimeTest, + ) + runGeneratedTests(t, directory) +} + +const requiredPrimitiveAliasArrayRuntimeTest = `package server + +import ( + "encoding/json" + "testing" + + aliases "generated.local/gen/aliases" + goa "goa.design/goa/v3/pkg" +) + +func TestRequiredPrimitiveArrayElements(t *testing.T) { + var valid StoreRequestBody + if err := json.Unmarshal([]byte("{\"names\":[\"\"],\"values\":[\"\"],\"nested\":{\"values\":[\"\"]}}"), &valid); err != nil { + t.Fatalf("decode valid body: %v", err) + } + if err := ValidateStoreRequestBody(&valid); err != nil { + t.Fatalf("validate empty strings: %v", err) + } + payload := NewStorePayload(&valid) + if len(payload.Names) != 1 || payload.Names[0] != "" { + t.Fatalf("converted names = %#v", payload.Names) + } + if len(payload.Values) != 1 || payload.Values[0] != aliases.Alias("") { + t.Fatalf("converted aliases = %#v", payload.Values) + } + if payload.Nested == nil || len(payload.Nested.Values) != 1 || payload.Nested.Values[0] != aliases.Alias("") { + t.Fatalf("converted nested aliases = %#v", payload.Nested) + } + + assertNullElement := func(body string, context string) { + t.Helper() + var decoded StoreRequestBody + if err := json.Unmarshal([]byte(body), &decoded); err != nil { + t.Fatalf("decode null element: %v", err) + } + err := ValidateStoreRequestBody(&decoded) + if err == nil { + t.Fatal("null element passed validation") + } + want := goa.MissingFieldError(context, "[*]").Error() + if err.Error() != want { + t.Fatalf("validation error = %q, want %q", err, want) + } + } + assertNullElement("{\"names\":[null],\"values\":[\"ok\"],\"nested\":{\"values\":[\"ok\"]}}", "body.names") + assertNullElement("{\"names\":[\"ok\"],\"values\":[null],\"nested\":{\"values\":[\"ok\"]}}", "body.values") + assertNullElement("{\"names\":[\"ok\"],\"values\":[\"ok\"],\"nested\":{\"values\":[null]}}", "body.nested.values") +} +` + +const requiredPrimitiveAliasArrayResponseRuntimeTest = `package client + +import ( + "context" + "io" + "net/http" + "strings" + "testing" + + aliases "generated.local/gen/aliases" + goahttp "goa.design/goa/v3/http" + goa "goa.design/goa/v3/pkg" +) + +func TestRequiredPrimitiveArrayResponseElements(t *testing.T) { + decodeNames := DecodeListNamesResponse(goahttp.ResponseDecoder, false) + decoded, err := decodeNames(response("[\"\"]")) + if err != nil { + t.Fatalf("decode empty string: %v", err) + } + names := decoded.([]string) + if len(names) != 1 || names[0] != "" { + t.Fatalf("decoded names = %#v", names) + } + assertNullResponse(t, decodeNames, "body") + + decodeAliases := DecodeListAliasesResponse(goahttp.ResponseDecoder, false) + decoded, err = decodeAliases(response("[\"\"]")) + if err != nil { + t.Fatalf("decode empty alias: %v", err) + } + aliasValues := decoded.([]aliases.PlainAlias) + if len(aliasValues) != 1 || aliasValues[0] != aliases.PlainAlias("") { + t.Fatalf("decoded aliases = %#v", aliasValues) + } + assertNullResponse(t, decodeAliases, "body") +} + +func assertNullResponse(t *testing.T, decode func(*http.Response) (any, error), context string) { + t.Helper() + _, err := decode(response("[null]")) + if err == nil { + t.Fatal("null element passed validation") + } + want := goa.MissingFieldError(context, "[*]").Error() + if !strings.Contains(err.Error(), want) { + t.Fatalf("validation error = %q, want it to contain %q", err, want) + } +} + +func response(body string) *http.Response { + return &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(body)), + Header: make(http.Header), + Request: (&http.Request{}).WithContext(context.Background()), + } +} +` diff --git a/codegen/generator/generate_http_union_shape_integration_test.go b/codegen/generator/generate_http_union_shape_integration_test.go new file mode 100644 index 0000000000..5936fc185a --- /dev/null +++ b/codegen/generator/generate_http_union_shape_integration_test.go @@ -0,0 +1,152 @@ +// This file verifies that HTTP generation keeps request- and response-shaped +// unions separate when their nested branch types have different Go names. +package generator + +import ( + "context" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + "time" + + "goa.design/goa/v3/codegen" + d "goa.design/goa/v3/dsl" +) + +func TestGenerateHTTPUnionUsedByRequestAndResponseCompiles(t *testing.T) { + registry := testRegistry( + "gen", + testGenerator(planServiceData, testServiceFiles), + testGenerator(planTransportData, testTransportFiles), + ) + + dsl := func() { + d.API("test", func() {}) + + siteSet := d.Type("SiteSet", func() { + d.Attribute("site_ids", d.ArrayOf(d.String)) + d.Required("site_ids") + }) + allSites := d.Type("AllSites", func() { + d.Attribute("include_current", d.Boolean) + d.Required("include_current") + }) + setup := d.Type("Setup", func() { + d.OneOf("scope", func() { + d.Attribute("site_set", siteSet) + d.Attribute("all_sites", allSites) + }) + d.Required("scope") + }) + + d.Service("front", func() { + d.Method("configure", func() { + d.Payload(setup) + d.Result(setup) + d.HTTP(func() { + d.POST("/configure") + d.Response(200) + }) + }) + d.Method("reconfigure", func() { + d.Payload(setup) + d.Result(d.String) + d.HTTP(func() { + d.POST("/reconfigure") + d.Response(200) + }) + }) + }) + } + + _ = codegen.RunDSL(t, dsl) + dir := t.TempDir() + genDir := filepath.Join(dir, codegen.Gendir) + writeGeneratedModule(t, genDir, "generated.local/gen") + if _, err := generate(dir, "gen", false, registry); err != nil { + t.Fatalf("Generate failed: %v", err) + } + assertGeneratedUnionDeclarations(t, genDir) + runGeneratedTests(t, genDir) +} + +// assertGeneratedUnionDeclarations proves identical request derivations reuse +// one union while the differently shaped response receives another. +func assertGeneratedUnionDeclarations(t *testing.T, genDir string) { + t.Helper() + path := filepath.Join(genDir, "http", "front", "server", "types.go") + content, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read generated server types: %v", err) + } + code := string(content) + if strings.Count(code, "type ScopeRequestBody struct {") != 1 || + strings.Count(code, "type ScopeResponseBody struct {") != 1 { + t.Fatalf("expected one request union and one response union:\n%s", code) + } + if strings.Contains(code, "type Scope2 struct {") { + t.Fatalf("union declarations received a numeric suffix:\n%s", code) + } + if strings.Count(code, "type SiteSetRequestBody struct {") != 1 || + strings.Count(code, "type SiteSetResponseBody struct {") != 1 { + t.Fatalf("expected one request branch and one response branch declaration:\n%s", code) + } + if !strings.Contains(code, "Scope *ScopeRequestBody `") || + !strings.Contains(code, "Scope ScopeResponseBody `") { + t.Fatalf("request and response bodies do not use their role names:\n%s", code) + } + if strings.Count(code, "\tsiteSet *SiteSetRequestBody\n") != 1 || + strings.Count(code, "\tsiteSet *SiteSetResponseBody\n") != 1 { + t.Fatalf("unions do not reference their request and response branches:\n%s", code) + } +} + +// writeGeneratedModule creates a temporary module that resolves this Goa +// checkout explicitly instead of downloading a released generator runtime. +func writeGeneratedModule(t *testing.T, dir, modulePath string) { + t.Helper() + goaRoot := moduleDirectory(t, "goa.design/goa/v3") + if err := os.MkdirAll(dir, 0o750); err != nil { + t.Fatalf("create generated module directory: %v", err) + } + // Generated starters import Clue. Pin its Go 1.25-compatible release so a + // new Clue release cannot raise the toolchain required by these tests. + module := "module " + modulePath + "\n\ngo 1.25.0\n\n" + + "require (\n\tgoa.design/goa/v3 v3.0.0\n\tgoa.design/clue v1.2.6\n)\n\n" + + "replace goa.design/goa/v3 => " + filepath.ToSlash(goaRoot) + "\n" + if err := os.WriteFile(filepath.Join(dir, "go.mod"), []byte(module), 0o600); err != nil { + t.Fatalf("write generated go.mod: %v", err) + } +} + +// moduleDirectory returns the checked-out directory for module from the outer +// test environment. +func moduleDirectory(t *testing.T, module string) string { + t.Helper() + cmd := exec.Command("go", "list", "-m", "-f", "{{.Dir}}", module) + output, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("resolve module %s: %v\n%s", module, err, output) + } + dir := strings.TrimSpace(string(output)) + if dir == "" { + t.Fatalf("resolve module %s: empty directory", module) + } + return dir +} + +// runGeneratedTests compiles every rendered package in the isolated module. +func runGeneratedTests(t *testing.T, dir string) { + t.Helper() + ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) + defer cancel() + cmd := exec.CommandContext(ctx, "go", "test", "-mod=mod", "./...") + cmd.Dir = dir + cmd.Env = append(os.Environ(), "GOWORK=off") + output, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("compile generated packages: %v\n%s", err, output) + } +} diff --git a/codegen/generator/generate_merge_test.go b/codegen/generator/generate_merge_test.go index e4659adf66..d8967a7fbe 100644 --- a/codegen/generator/generate_merge_test.go +++ b/codegen/generator/generate_merge_test.go @@ -1,3 +1,5 @@ +// This file verifies file aggregation across core generators and plugins, +// including the separately assigned same-label section regression. package generator import ( @@ -7,51 +9,243 @@ import ( "strings" "testing" + "github.com/stretchr/testify/require" + "goa.design/goa/v3/codegen" "goa.design/goa/v3/eval" + "goa.design/goa/v3/expr" goa "goa.design/goa/v3/pkg" ) +// TestMergeFilesPreservesSameLabelSections verifies that diagnostic section +// labels do not cause the merger to discard different generated bodies. +func TestMergeFilesPreservesSameLabelSections(t *testing.T) { + finalizeMergeTestRoots(t) + registry := testRegistryFromGenfuncs([]testGenfunc{ + testRenderOnly(func(_ string, _ []eval.Root) ([]*codegen.File, error) { + return []*codegen.File{{ + Path: filepath.Join(codegen.Gendir, "types", "same_label.go"), + SectionTemplates: []*codegen.SectionTemplate{ + codegen.Header("Types", "types", nil), + {Name: "type-def", Source: "type First struct{}\n"}, + }, + }}, nil + }), + testRenderOnly(func(_ string, _ []eval.Root) ([]*codegen.File, error) { + return []*codegen.File{{ + Path: filepath.Join(codegen.Gendir, "types", "same_label.go"), + SectionTemplates: []*codegen.SectionTemplate{ + codegen.Header("Types", "types", nil), + {Name: "type-def", Source: "type Second struct{}\n"}, + }, + }}, nil + }), + }) + + dir := t.TempDir() + writeGeneratedModule(t, filepath.Join(dir, codegen.Gendir), "generated.local/gen") + _, err := generate(dir, "gen", false, registry) + require.NoError(t, err) + content, err := os.ReadFile(filepath.Join(dir, codegen.Gendir, "types", "same_label.go")) + require.NoError(t, err) + require.Contains(t, string(content), "type First struct{}") + require.Contains(t, string(content), "type Second struct{}") +} + +// TestMergeFilesRunsEveryFinalizer verifies that same-path contributors keep +// their post-render work in the same order as their generated sections. +func TestMergeFilesRunsEveryFinalizer(t *testing.T) { + var calls []string + files, err := mergeFilesByPath([]*codegen.File{ + { + Path: "gen/types.go", + SectionTemplates: []*codegen.SectionTemplate{codegen.Header("Types", "gen", nil)}, + FinalizeFunc: func(string) error { + calls = append(calls, "first") + return nil + }, + }, + { + Path: "gen/types.go", + SectionTemplates: []*codegen.SectionTemplate{codegen.Header("Types", "gen", nil)}, + FinalizeFunc: func(string) error { + calls = append(calls, "second") + return nil + }, + }, + }) + require.NoError(t, err) + require.Len(t, files, 1) + require.NoError(t, files[0].FinalizeFunc("gen/types.go")) + require.Equal(t, []string{"first", "second"}, calls) +} + +// TestMergeFilesAllowsUnaliasedImports verifies that the merger does not guess +// Go package identifiers from import path spellings it does not own. +func TestMergeFilesAllowsUnaliasedImports(t *testing.T) { + first := mergeTestFile("types", false, []*codegen.ImportSpec{ + {Path: "first.example/v2"}, + }) + second := mergeTestFile("types", false, []*codegen.ImportSpec{ + {Path: "second.example/v2"}, + }) + + files, err := mergeFilesByPath([]*codegen.File{first, second}) + + require.NoError(t, err) + require.Len(t, files, 1) + header := files[0].SectionTemplates[0].Data.(map[string]any) + require.Len(t, header["Imports"], 2) +} + +// TestMergeFilesRejectsConflictingFileContracts verifies that the merger +// reports incompatible contributors instead of silently keeping one value. +func TestMergeFilesRejectsConflictingFileContracts(t *testing.T) { + tests := []struct { + name string + first *codegen.File + second *codegen.File + err string + }{ + { + name: "skip existing", + first: mergeTestFile("types", false, nil), + second: mergeTestFile("types", true, nil), + err: "conflicting SkipExist", + }, + { + name: "package", + first: mergeTestFile("first", false, nil), + second: mergeTestFile("second", false, nil), + err: "header packages", + }, + { + name: "alias", + first: mergeTestFile("types", false, []*codegen.ImportSpec{ + {Name: "shared", Path: "example.com/first"}, + }), + second: mergeTestFile("types", false, []*codegen.ImportSpec{ + {Name: "shared", Path: "example.com/second"}, + }), + err: "import name", + }, + { + name: "path", + first: mergeTestFile("types", false, []*codegen.ImportSpec{ + {Name: "first", Path: "example.com/shared"}, + }), + second: mergeTestFile("types", false, []*codegen.ImportSpec{ + {Name: "second", Path: "example.com/shared"}, + }), + err: "import path", + }, + { + name: "conflict within first header", + first: mergeTestFile("types", false, []*codegen.ImportSpec{ + {Name: "shared", Path: "first.example/value"}, + {Name: "shared", Path: "second.example/value"}, + }), + second: mergeTestFile("types", false, nil), + err: "import name", + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + _, err := mergeFilesByPath([]*codegen.File{test.first, test.second}) + require.ErrorContains(t, err, test.err) + }) + } +} + +// TestMergeFilesCanonicalizesOutputPaths verifies that contributors targeting +// one cleaned relative file cannot bypass compatibility checks or race writes. +func TestMergeFilesCanonicalizesOutputPaths(t *testing.T) { + first := mergeTestFile("types", false, nil) + first.Path = "gen/types.go" + first.SectionTemplates = append(first.SectionTemplates, &codegen.SectionTemplate{ + Name: "first", + Source: "type First struct{}", + }) + second := mergeTestFile("types", false, nil) + second.Path = "gen/x/../types.go" + second.SectionTemplates = append(second.SectionTemplates, &codegen.SectionTemplate{ + Name: "second", + Source: "type Second struct{}", + }) + + files, err := mergeFilesByPath([]*codegen.File{first, second}) + + require.NoError(t, err) + require.Len(t, files, 1) + require.Equal(t, filepath.Join("gen", "types.go"), files[0].Path) + require.Len(t, files[0].SectionTemplates, 3) +} + +// TestMergeFilesRejectsUnsafeOutputPaths verifies that no generated file can +// escape the output directory or collide only on a portable filesystem. +func TestMergeFilesRejectsUnsafeOutputPaths(t *testing.T) { + tests := []struct { + name string + paths []string + err string + }{ + {"parent", []string{"../outside.go"}, "must stay within"}, + {"absolute", []string{filepath.Join(string(filepath.Separator), "outside.go")}, "must stay within"}, + {"volume", []string{`C:\outside.go`}, "not portable"}, + {"case fold", []string{"gen/Types.go", "gen/types.go"}, "case-insensitive"}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + files := make([]*codegen.File, len(test.paths)) + for index, outputPath := range test.paths { + files[index] = mergeTestFile("types", false, nil) + files[index].Path = outputPath + } + _, err := mergeFilesByPath(files) + require.ErrorContains(t, err, test.err) + }) + } +} + // TestGenerateMergesSamePathFiles verifies that when two generators emit content // targeting the same output path, Generate merges the sections into a single // file rather than overwriting earlier content. This is a regression test for // an issue where only a later section (e.g., a union value method) remained and // the earlier struct definition was lost. func TestGenerateMergesSamePathFiles(t *testing.T) { - t.Cleanup(func() { Generators = generators }) + finalizeMergeTestRoots(t) // Fake generators emit two files with identical Path, one containing a // type definition and the other containing a method. Without merging, the // second write would overwrite the first. - Generators = func(cmd string) ([]Genfunc, error) { - return []Genfunc{ - func(genpkg string, roots []eval.Root) ([]*codegen.File, error) { - f := &codegen.File{Path: filepath.Join(codegen.Gendir, "types", "merge_test.go")} - f.SectionTemplates = []*codegen.SectionTemplate{ - codegen.Header("User types", "types", nil), - { // struct definition - Name: "struct-type", - Source: "type MergeTest struct{}\n", - }, - } - return []*codegen.File{f}, nil - }, - func(genpkg string, roots []eval.Root) ([]*codegen.File, error) { - f := &codegen.File{Path: filepath.Join(codegen.Gendir, "types", "merge_test.go")} - f.SectionTemplates = []*codegen.SectionTemplate{ - codegen.Header("User types", "types", nil), - { // method on MergeTest - Name: "method", - Source: "func (*MergeTest) Marker() {}\n", - }, - } - return []*codegen.File{f}, nil - }, - }, nil - } + registry := testRegistryFromGenfuncs([]testGenfunc{ + testRenderOnly(func(genpkg string, roots []eval.Root) ([]*codegen.File, error) { + f := &codegen.File{Path: filepath.Join(codegen.Gendir, "types", "merge_test.go")} + f.SectionTemplates = []*codegen.SectionTemplate{ + codegen.Header("User types", "types", nil), + { // struct definition + Name: "struct-type", + Source: "type MergeTest struct{}\n", + }, + } + return []*codegen.File{f}, nil + }), + testRenderOnly(func(genpkg string, roots []eval.Root) ([]*codegen.File, error) { + f := &codegen.File{Path: filepath.Join(codegen.Gendir, "types", "merge_test.go")} + f.SectionTemplates = []*codegen.SectionTemplate{ + codegen.Header("User types", "types", nil), + { // method on MergeTest + Name: "method", + Source: "func (*MergeTest) Marker() {}\n", + }, + } + return []*codegen.File{f}, nil + }), + }) dir := t.TempDir() - _, err := Generate(dir, "gen", false) + writeGeneratedModule(t, filepath.Join(dir, codegen.Gendir), "generated.local/gen") + _, err := generate(dir, "gen", false, registry) if err != nil { t.Fatalf("Generate failed: %v", err) } @@ -76,35 +270,34 @@ func TestGenerateMergesSamePathFiles(t *testing.T) { // pool distribution. This ensures all workers process files and all files are // written correctly. func TestGenerateParallelManyFiles(t *testing.T) { - t.Cleanup(func() { Generators = generators }) + finalizeMergeTestRoots(t) // Generate 20 files to ensure we exceed typical CPU counts and exercise // the worker pool's work distribution. const numFiles = 20 - Generators = func(cmd string) ([]Genfunc, error) { - return []Genfunc{ - func(genpkg string, roots []eval.Root) ([]*codegen.File, error) { - files := make([]*codegen.File, numFiles) - for i := 0; i < numFiles; i++ { - f := &codegen.File{ - Path: filepath.Join(codegen.Gendir, "types", filepath.Join("parallel", filepath.Join("file"+string(rune('a'+i%26)), "test"+string(rune('0'+i/26))+".go"))), - } - f.SectionTemplates = []*codegen.SectionTemplate{ - codegen.Header("Types", "types", nil), - { - Name: "type-def", - Source: "type Test" + string(rune('A'+i)) + " struct{ ID int }\n", - }, - } - files[i] = f + registry := testRegistryFromGenfuncs([]testGenfunc{ + testRenderOnly(func(genpkg string, roots []eval.Root) ([]*codegen.File, error) { + files := make([]*codegen.File, numFiles) + for i := 0; i < numFiles; i++ { + f := &codegen.File{ + Path: filepath.Join(codegen.Gendir, "types", filepath.Join("parallel", filepath.Join("file"+string(rune('a'+i%26)), "test"+string(rune('0'+i/26))+".go"))), } - return files, nil - }, - }, nil - } + f.SectionTemplates = []*codegen.SectionTemplate{ + codegen.Header("Types", "types", nil), + { + Name: "type-def", + Source: "type Test" + string(rune('A'+i)) + " struct{ ID int }\n", + }, + } + files[i] = f + } + return files, nil + }), + }) dir := t.TempDir() - outputs, err := Generate(dir, "gen", false) + writeGeneratedModule(t, filepath.Join(dir, codegen.Gendir), "generated.local/gen") + outputs, err := generate(dir, "gen", false, registry) if err != nil { t.Fatalf("Generate failed: %v", err) } @@ -134,41 +327,40 @@ func TestGenerateParallelManyFiles(t *testing.T) { // handles file merging when multiple generators target the same path. This // tests the interaction between mergeFilesByPath and parallel rendering. func TestGenerateParallelWithMerge(t *testing.T) { - t.Cleanup(func() { Generators = generators }) + finalizeMergeTestRoots(t) // Three generators: first two merge to same path, third is separate. // This exercises both merging and parallel writing with NumCPU workers. - Generators = func(cmd string) ([]Genfunc, error) { - return []Genfunc{ - func(genpkg string, roots []eval.Root) ([]*codegen.File, error) { - f1 := &codegen.File{Path: filepath.Join(codegen.Gendir, "types", "merged.go")} - f1.SectionTemplates = []*codegen.SectionTemplate{ - codegen.Header("Types", "types", nil), - {Name: "type1", Source: "type Type1 struct{}\n"}, - } - return []*codegen.File{f1}, nil - }, - func(genpkg string, roots []eval.Root) ([]*codegen.File, error) { - f2 := &codegen.File{Path: filepath.Join(codegen.Gendir, "types", "merged.go")} - f2.SectionTemplates = []*codegen.SectionTemplate{ - codegen.Header("Types", "types", nil), - {Name: "type2", Source: "type Type2 struct{}\n"}, - } - return []*codegen.File{f2}, nil - }, - func(genpkg string, roots []eval.Root) ([]*codegen.File, error) { - f3 := &codegen.File{Path: filepath.Join(codegen.Gendir, "types", "separate.go")} - f3.SectionTemplates = []*codegen.SectionTemplate{ - codegen.Header("Types", "types", nil), - {Name: "type3", Source: "type Type3 struct{}\n"}, - } - return []*codegen.File{f3}, nil - }, - }, nil - } + registry := testRegistryFromGenfuncs([]testGenfunc{ + testRenderOnly(func(genpkg string, roots []eval.Root) ([]*codegen.File, error) { + f1 := &codegen.File{Path: filepath.Join(codegen.Gendir, "types", "merged.go")} + f1.SectionTemplates = []*codegen.SectionTemplate{ + codegen.Header("Types", "types", nil), + {Name: "type1", Source: "type Type1 struct{}\n"}, + } + return []*codegen.File{f1}, nil + }), + testRenderOnly(func(genpkg string, roots []eval.Root) ([]*codegen.File, error) { + f2 := &codegen.File{Path: filepath.Join(codegen.Gendir, "types", "merged.go")} + f2.SectionTemplates = []*codegen.SectionTemplate{ + codegen.Header("Types", "types", nil), + {Name: "type2", Source: "type Type2 struct{}\n"}, + } + return []*codegen.File{f2}, nil + }), + testRenderOnly(func(genpkg string, roots []eval.Root) ([]*codegen.File, error) { + f3 := &codegen.File{Path: filepath.Join(codegen.Gendir, "types", "separate.go")} + f3.SectionTemplates = []*codegen.SectionTemplate{ + codegen.Header("Types", "types", nil), + {Name: "type3", Source: "type Type3 struct{}\n"}, + } + return []*codegen.File{f3}, nil + }), + }) dir := t.TempDir() - outputs, err := Generate(dir, "gen", false) + writeGeneratedModule(t, filepath.Join(dir, codegen.Gendir), "generated.local/gen") + outputs, err := generate(dir, "gen", false, registry) if err != nil { t.Fatalf("Generate failed: %v", err) } @@ -208,38 +400,37 @@ func TestGenerateParallelWithMerge(t *testing.T) { // in the parallel worker pool, the first error is captured and returned while // other workers continue processing. func TestGenerateParallelErrorHandling(t *testing.T) { - t.Cleanup(func() { Generators = generators }) + finalizeMergeTestRoots(t) // Create multiple files where some will fail to render due to invalid paths. // Worker pool should capture first error but continue processing other files. - Generators = func(cmd string) ([]Genfunc, error) { - return []Genfunc{ - func(genpkg string, roots []eval.Root) ([]*codegen.File, error) { - files := make([]*codegen.File, 5) - for i := 0; i < 5; i++ { - f := &codegen.File{ - Path: filepath.Join(codegen.Gendir, "types", "file"+string(rune('0'+i))+".go"), - } - f.SectionTemplates = []*codegen.SectionTemplate{ - codegen.Header("Types", "types", nil), - {Name: "type", Source: "type T" + string(rune('0'+i)) + " struct{}\n"}, - } - // Make file 2 fail by adding an invalid path character after writing starts - if i == 2 { - // Use a FinalizeFunc that returns an error - f.FinalizeFunc = func(fp string) error { - return os.ErrInvalid - } + registry := testRegistryFromGenfuncs([]testGenfunc{ + testRenderOnly(func(genpkg string, roots []eval.Root) ([]*codegen.File, error) { + files := make([]*codegen.File, 5) + for i := 0; i < 5; i++ { + f := &codegen.File{ + Path: filepath.Join(codegen.Gendir, "types", "file"+string(rune('0'+i))+".go"), + } + f.SectionTemplates = []*codegen.SectionTemplate{ + codegen.Header("Types", "types", nil), + {Name: "type", Source: "type T" + string(rune('0'+i)) + " struct{}\n"}, + } + // Make file 2 fail by adding an invalid path character after writing starts + if i == 2 { + // Use a FinalizeFunc that returns an error + f.FinalizeFunc = func(fp string) error { + return os.ErrInvalid } - files[i] = f } - return files, nil - }, - }, nil - } + files[i] = f + } + return files, nil + }), + }) dir := t.TempDir() - _, err := Generate(dir, "gen", false) + writeGeneratedModule(t, filepath.Join(dir, codegen.Gendir), "generated.local/gen") + _, err := generate(dir, "gen", false, registry) if err == nil { t.Fatal("expected error from parallel generation, got nil") } @@ -252,23 +443,22 @@ func TestGenerateParallelErrorHandling(t *testing.T) { // TestGenerateParallelSingleFile verifies that parallel file writing works // correctly with just a single file (minimal parallelism edge case). func TestGenerateParallelSingleFile(t *testing.T) { - t.Cleanup(func() { Generators = generators }) + finalizeMergeTestRoots(t) - Generators = func(cmd string) ([]Genfunc, error) { - return []Genfunc{ - func(genpkg string, roots []eval.Root) ([]*codegen.File, error) { - f := &codegen.File{Path: filepath.Join(codegen.Gendir, "types", "single.go")} - f.SectionTemplates = []*codegen.SectionTemplate{ - codegen.Header("Types", "types", nil), - {Name: "type", Source: "type Single struct{}\n"}, - } - return []*codegen.File{f}, nil - }, - }, nil - } + registry := testRegistryFromGenfuncs([]testGenfunc{ + testRenderOnly(func(genpkg string, roots []eval.Root) ([]*codegen.File, error) { + f := &codegen.File{Path: filepath.Join(codegen.Gendir, "types", "single.go")} + f.SectionTemplates = []*codegen.SectionTemplate{ + codegen.Header("Types", "types", nil), + {Name: "type", Source: "type Single struct{}\n"}, + } + return []*codegen.File{f}, nil + }), + }) dir := t.TempDir() - outputs, err := Generate(dir, "gen", false) + writeGeneratedModule(t, filepath.Join(dir, codegen.Gendir), "generated.local/gen") + outputs, err := generate(dir, "gen", false, registry) if err != nil { t.Fatalf("Generate failed: %v", err) } @@ -318,3 +508,25 @@ func assertVersionFile(t *testing.T, dir string, outputs []string) []string { } return rest } + +// mergeTestFile creates one complete Go file contribution for merger tests. +func mergeTestFile(packageName string, skipExist bool, imports []*codegen.ImportSpec) *codegen.File { + return &codegen.File{ + Path: "gen/types.go", + SkipExist: skipExist, + SectionTemplates: []*codegen.SectionTemplate{codegen.Header("Types", packageName, imports)}, + } +} + +// finalizeMergeTestRoots supplies the evaluated-design precondition that the +// filesystem-facing generator receives from the goa command in production. +func finalizeMergeTestRoots(t *testing.T) { + t.Helper() + roots, err := eval.Context.Roots() + require.NoError(t, err) + for _, root := range roots { + if design, ok := root.(*expr.RootExpr); ok { + design.Finalize() + } + } +} diff --git a/codegen/generator/generate_union_exclusive_runtime_test.go b/codegen/generator/generate_union_exclusive_runtime_test.go new file mode 100644 index 0000000000..6487a5ab28 --- /dev/null +++ b/codegen/generator/generate_union_exclusive_runtime_test.go @@ -0,0 +1,228 @@ +// This file verifies that generated service and HTTP unions store only the +// branch selected by their public constructors, setters, and JSON decoder. +package generator + +import ( + "os" + "path/filepath" + "testing" + + "goa.design/goa/v3/codegen" + "goa.design/goa/v3/dsl" +) + +func TestGeneratedUnionsStoreOnlyTheSelectedBranch(t *testing.T) { + registry := testRegistry( + "gen", + testGenerator(planServiceData, testServiceFiles), + testGenerator(planTransportData, testTransportFiles), + ) + + codegen.RunDSL(t, exclusiveUnionDSL) + directory := t.TempDir() + generated := filepath.Join(directory, codegen.Gendir) + writeGeneratedModule(t, generated, "generated.local/gen") + if _, err := generate(directory, "gen", false, registry); err != nil { + t.Fatalf("generate exclusive unions: %v", err) + } + + writeGeneratedTest(t, filepath.Join(generated, "exclusive_union", "union_storage_test.go"), serviceUnionStorageTest) + writeGeneratedTest(t, filepath.Join(generated, "http", "exclusive_union", "server", "union_storage_test.go"), httpUnionStorageTest) + runGeneratedTests(t, generated) +} + +// exclusiveUnionDSL uses the same OneOf in a service payload and an HTTP +// request so both generated union implementations receive identical tests. +func exclusiveUnionDSL() { + dsl.API("exclusive union", func() {}) + inactive := dsl.Type("Inactive", func() {}) + kindValue := dsl.Type("KindValue", func() {}) + selection := dsl.Type("Selection", func() { + dsl.OneOf("choice", func() { + dsl.TypeName("ExclusiveChoice") + dsl.Attribute("text", dsl.String) + dsl.Attribute("count", dsl.Int) + dsl.Attribute("kind", kindValue) + dsl.Attribute("inactive", inactive) + }) + dsl.Required("choice") + }) + dsl.Service("ExclusiveUnion", func() { + dsl.Method("Select", func() { + dsl.Payload(selection) + dsl.Result(dsl.String) + dsl.HTTP(func() { + dsl.POST("/selection") + dsl.Response(dsl.StatusOK) + }) + }) + }) +} + +// writeGeneratedTest adds a runtime contract test beside generated source. +func writeGeneratedTest(t *testing.T, path, source string) { + t.Helper() + if err := os.WriteFile(path, []byte(source), 0o600); err != nil { + t.Fatalf("write generated contract test %s: %v", path, err) + } +} + +const serviceUnionStorageTest = `package exclusiveunion + +import ( + "encoding/json" + "errors" + "testing" + + goa "goa.design/goa/v3/pkg" +) + +func TestUnionStorage(t *testing.T) { + var selected ExclusiveChoice + selected.SetText("old") + selected.SetCount(7) + if selected.text != "" { + t.Errorf("SetCount retained text %q", selected.text) + } + if selected.count != 7 { + t.Errorf("SetCount stored %d, want 7", selected.count) + } + selected.SetKind(&KindValue{}) + if selected.kind2 == nil { + t.Error("SetKind did not store its selected branch") + } + if selected.count != 0 { + t.Errorf("SetKind retained count %d", selected.count) + } + + if err := json.Unmarshal([]byte(` + "`" + `{"type":"text","value":"old"}` + "`" + `), &selected); err != nil { + t.Errorf("decode text: %v", err) + } + if err := json.Unmarshal([]byte(` + "`" + `{"type":"count","value":9}` + "`" + `), &selected); err != nil { + t.Errorf("decode count: %v", err) + } + if selected.text != "" { + t.Errorf("decoding count retained text %q", selected.text) + } + if selected.count != 9 { + t.Errorf("decoded count %d, want 9", selected.count) + } + if err := json.Unmarshal([]byte(` + "`" + `{"type":"text","value":false}` + "`" + `), &selected); err == nil { + t.Error("decoding an invalid text value succeeded") + } + if selected.count != 9 { + t.Errorf("failed decode changed count to %d", selected.count) + } + encoded, err := json.Marshal(selected) + if err != nil { + t.Errorf("encode selected count: %v", err) + } else if string(encoded) != ` + "`" + `{"type":"count","value":9}` + "`" + ` { + t.Errorf("encoded union %s", encoded) + } + + var missing ExclusiveChoice + assertServiceError(t, missing.Validate(), goa.InvalidEnumValue, "type") + missing.SetInactive(nil) + assertServiceError(t, missing.Validate(), goa.MissingField, "value") + missing.SetInactive(&Inactive{}) + if err := missing.Validate(); err != nil { + t.Errorf("Validate rejected a selected empty-message branch: %v", err) + } +} + +// assertServiceError checks the exact Goa error returned for an invalid union. +func assertServiceError(t *testing.T, err error, name, field string) { + t.Helper() + var serviceError *goa.ServiceError + if !errors.As(err, &serviceError) { + t.Errorf("expected Goa service error, got %T: %v", err, err) + return + } + if serviceError.Name != name { + t.Errorf("error name %q, want %q", serviceError.Name, name) + } + if serviceError.Field == nil || *serviceError.Field != field { + t.Errorf("error field %#v, want %q", serviceError.Field, field) + } +} +` + +const httpUnionStorageTest = `package server + +import ( + "encoding/json" + "errors" + "testing" + + goa "goa.design/goa/v3/pkg" +) + +func TestUnionStorage(t *testing.T) { + var selected ExclusiveChoiceRequestBody + selected.SetText("old") + selected.SetCount(7) + if selected.text != "" { + t.Errorf("SetCount retained text %q", selected.text) + } + if selected.count != 7 { + t.Errorf("SetCount stored %d, want 7", selected.count) + } + selected.SetKind(&KindValueRequestBody{}) + if selected.kind2 == nil { + t.Error("SetKind did not store its selected branch") + } + if selected.count != 0 { + t.Errorf("SetKind retained count %d", selected.count) + } + + if err := json.Unmarshal([]byte(` + "`" + `{"type":"text","value":"old"}` + "`" + `), &selected); err != nil { + t.Errorf("decode text: %v", err) + } + if err := json.Unmarshal([]byte(` + "`" + `{"type":"count","value":9}` + "`" + `), &selected); err != nil { + t.Errorf("decode count: %v", err) + } + if selected.text != "" { + t.Errorf("decoding count retained text %q", selected.text) + } + if selected.count != 9 { + t.Errorf("decoded count %d, want 9", selected.count) + } + if err := json.Unmarshal([]byte(` + "`" + `{"type":"text","value":false}` + "`" + `), &selected); err == nil { + t.Error("decoding an invalid text value succeeded") + } + if selected.count != 9 { + t.Errorf("failed decode changed count to %d", selected.count) + } + encoded, err := json.Marshal(selected) + if err != nil { + t.Errorf("encode selected count: %v", err) + } else if string(encoded) != ` + "`" + `{"type":"count","value":9}` + "`" + ` { + t.Errorf("encoded union %s", encoded) + } + + var missing ExclusiveChoiceRequestBody + assertServiceError(t, missing.Validate(), goa.InvalidEnumValue, "type") + missing.SetInactive(nil) + assertServiceError(t, missing.Validate(), goa.MissingField, "value") + missing.SetInactive(&InactiveRequestBody{}) + if err := missing.Validate(); err != nil { + t.Errorf("Validate rejected a selected empty-message branch: %v", err) + } +} + +// assertServiceError checks the exact Goa error returned for an invalid union. +func assertServiceError(t *testing.T, err error, name, field string) { + t.Helper() + var serviceError *goa.ServiceError + if !errors.As(err, &serviceError) { + t.Errorf("expected Goa service error, got %T: %v", err, err) + return + } + if serviceError.Name != name { + t.Errorf("error name %q, want %q", serviceError.Name, name) + } + if serviceError.Field == nil || *serviceError.Field != field { + t.Errorf("error field %#v, want %q", serviceError.Field, field) + } +} +` diff --git a/codegen/generator/generate_union_merge_integration_test.go b/codegen/generator/generate_union_merge_integration_test.go index b4bfc7dbcf..fb678bc458 100644 --- a/codegen/generator/generate_union_merge_integration_test.go +++ b/codegen/generator/generate_union_merge_integration_test.go @@ -1,3 +1,5 @@ +// This file verifies that complete generation merges shared union +// declarations without losing their package-owned names. package generator import ( @@ -16,8 +18,12 @@ import ( // the union marker method for the union branch type. This mirrors the original // failure mode where only the union method remained and the struct was lost. func TestGenerateUnionUserTypeSamePathMerged(t *testing.T) { - t.Cleanup(func() { Generators = generators }) - Generators = func(cmd string) ([]Genfunc, error) { return []Genfunc{Service, Transport, OpenAPI}, nil } + registry := testRegistry( + "gen", + testGenerator(planServiceData, testServiceFiles), + testGenerator(planTransportData, testTransportFiles), + testGenerator(planOpenAPIData, testOpenAPIFiles), + ) dsl := func() { d.API("test", func() {}) @@ -30,6 +36,7 @@ func TestGenerateUnionUserTypeSamePathMerged(t *testing.T) { var MyUnion = d.Type("MyUnion", func() { d.Meta("struct:pkg:path", "types") d.OneOf("MyUnion", func() { + d.TypeName("MyUnionChoice") d.Attribute("sum", Summary) }) }) @@ -55,7 +62,8 @@ func TestGenerateUnionUserTypeSamePathMerged(t *testing.T) { _ = cg.RunDSL(t, dsl) dir := t.TempDir() - if _, err := Generate(dir, "gen", false); err != nil { + writeGeneratedModule(t, filepath.Join(dir, cg.Gendir), "generated.local/gen") + if _, err := generate(dir, "gen", false, registry); err != nil { t.Fatalf("Generate failed: %v", err) } diff --git a/codegen/generator/generated_grpc_shared_package_integration_test.go b/codegen/generator/generated_grpc_shared_package_integration_test.go new file mode 100644 index 0000000000..c3fd778f32 --- /dev/null +++ b/codegen/generator/generated_grpc_shared_package_integration_test.go @@ -0,0 +1,128 @@ +// This file checks two gRPC designs that write service, client, and server +// files into the same directories. Every written file must use the names +// chosen for both designs before generation starts. +package generator + +import ( + "testing" + + "github.com/stretchr/testify/require" + + "goa.design/goa/v3/codegen" + "goa.design/goa/v3/dsl" + "goa.design/goa/v3/eval" + "goa.design/goa/v3/expr" +) + +// TestGeneratedGRPCPackagesCompileAcrossSharedPackageRoots checks both design +// orders because input order must not change the names in shared directories. +func TestGeneratedGRPCPackagesCompileAcrossSharedPackageRoots(t *testing.T) { + tests := []struct { + name string + reverse bool + }{ + {name: "forward"}, + {name: "reverse", reverse: true}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + first := grpcSharedPackageRoot(t, "Shared", "First") + second := grpcSharedPackageRoot(t, "Shared", "Second") + roots := []eval.Root{first, second} + if test.reverse { + roots[0], roots[1] = roots[1], roots[0] + } + + reserveValidator := func(plan *Plan) error { + pkg, err := plan.Generation().ClaimPackage("generated.local/gen/grpc/shared/server") + if err != nil { + return err + } + for _, name := range []string{"ValidateSyncRequest", "ValidateExchangeRequest", "ValidateExchangeStreamingRequest"} { + if err := pkg.DeclareName(codegen.NewExactName(codegen.NameFunction, name)); err != nil { + return err + } + } + return nil + } + plan := mustTestPlan(t, "generated.local/gen", roots, planTransportData, reserveValidator) + files, err := testServiceFiles(plan) + require.NoError(t, err) + transportFiles, err := testTransportFiles(plan) + require.NoError(t, err) + files = append(files, transportFiles...) + files = append(files, &codegen.File{ + Path: "gen/grpc/shared/server/validator_owner.go", + SectionTemplates: []*codegen.SectionTemplate{ + { + Name: "validator-owner", + Source: `package server + +func ValidateSyncRequest() {} +func ValidateExchangeRequest() {} +func ValidateExchangeStreamingRequest() {}`, + }, + }, + }) + files, err = mergeFilesByPath(files) + require.NoError(t, err) + + dir := t.TempDir() + writeGeneratedModule(t, dir, "generated.local") + for _, file := range files { + _, err := file.Render(dir) + require.NoError(t, err) + } + runGeneratedTests(t, dir) + }) + } +} + +// grpcSharedPackageRoot returns one design whose service name chooses the +// output directories. typePrefix keeps its values separate from the other +// design that writes into those directories. +func grpcSharedPackageRoot(t *testing.T, serviceName, typePrefix string) *expr.RootExpr { + t.Helper() + return expr.RunDSL(t, func() { + dsl.API(typePrefix, func() {}) + node := dsl.Type(typePrefix+"Node", func() { + dsl.Field(1, "name", dsl.String) + dsl.Field(2, "next", typePrefix+"Node") + dsl.Required("name") + }) + payload := dsl.Type(typePrefix+"Payload", func() { + dsl.Field(1, "id", dsl.String) + dsl.Field(2, "node", node) + dsl.Required("id", "node") + }) + result := dsl.Type(typePrefix+"Result", func() { + dsl.Field(1, "status", dsl.String) + dsl.Field(2, "node", node) + dsl.Required("status", "node") + }) + failure := dsl.Type(typePrefix+"Failure", func() { + dsl.Field(1, "message", dsl.String) + dsl.Required("message") + }) + + dsl.Service(serviceName, func() { + dsl.Error("failed", failure) + dsl.GRPC(func() { + dsl.Response("failed", dsl.CodeInvalidArgument) + }) + dsl.Method("Sync", func() { + dsl.Payload(payload) + dsl.Result(result) + dsl.Error("failed", failure) + dsl.GRPC(func() {}) + }) + dsl.Method("Exchange", func() { + dsl.Payload(payload) + dsl.StreamingPayload(payload) + dsl.StreamingResult(result) + dsl.Error("failed", failure) + dsl.GRPC(func() {}) + }) + }) + }) +} diff --git a/codegen/generator/generated_package_import_test.go b/codegen/generator/generated_package_import_test.go new file mode 100644 index 0000000000..408722acfc --- /dev/null +++ b/codegen/generator/generated_package_import_test.go @@ -0,0 +1,347 @@ +// This file verifies that generation accepts only importable package identities +// returned by Go's package loader and never invents an import path. +package generator + +import ( + "os" + "path/filepath" + "runtime" + "strings" + "testing" + + "github.com/stretchr/testify/require" + "golang.org/x/tools/go/packages" +) + +func TestGeneratedPackageImportPath(t *testing.T) { + t.Run("module package", func(t *testing.T) { + t.Setenv("GO111MODULE", "on") + t.Setenv("GOWORK", "off") + t.Setenv("GOENV", "off") + t.Setenv("GOPATH", t.TempDir()) + t.Setenv("GOPACKAGESDRIVER", "off") + moduleDir := t.TempDir() + writePackageFixture(t, moduleDir, "module generated.local\n\ngo 1.25\n") + + got, err := generatedPackageImportPath(filepath.Join(moduleDir, "gen")) + require.NoError(t, err) + require.Equal(t, "generated.local/gen", got) + }) + + t.Run("authored underscore module", func(t *testing.T) { + t.Setenv("GO111MODULE", "on") + t.Setenv("GOWORK", "off") + t.Setenv("GOENV", "off") + t.Setenv("GOPATH", t.TempDir()) + t.Setenv("GOPACKAGESDRIVER", "off") + moduleDir := t.TempDir() + writePackageFixture(t, moduleDir, "module _/authored\n\ngo 1.25\n") + + got, err := generatedPackageImportPath(filepath.Join(moduleDir, "gen")) + require.NoError(t, err) + require.Equal(t, "_/authored/gen", got) + }) + + t.Run("workspace module", func(t *testing.T) { + t.Setenv("GO111MODULE", "on") + t.Setenv("GOENV", "off") + t.Setenv("GOPATH", t.TempDir()) + t.Setenv("GOPACKAGESDRIVER", "off") + workspaceDir := t.TempDir() + otherDir := filepath.Join(workspaceDir, "other") + targetDir := filepath.Join(workspaceDir, "target") + require.NoError(t, os.MkdirAll(otherDir, 0o750)) + require.NoError(t, os.MkdirAll(targetDir, 0o750)) + writePackageFixture(t, otherDir, "module workspace.local/other\n\ngo 1.25\n") + writePackageFixture(t, targetDir, "module _/target\n\ngo 1.25\n") + workFile := filepath.Join(workspaceDir, "go.work") + require.NoError(t, os.WriteFile(workFile, []byte("go 1.25\n\nuse (\n\t./other\n\t./target\n)\n"), 0o600)) + t.Setenv("GOWORK", workFile) + + got, err := generatedPackageImportPath(filepath.Join(targetDir, "gen")) + require.NoError(t, err) + require.Equal(t, "_/target/gen", got) + }) + + t.Run("GOPATH package", func(t *testing.T) { + gopath := t.TempDir() + packageDir := filepath.Join(gopath, "src", "_", "foo") + require.NoError(t, os.MkdirAll(packageDir, 0o750)) + require.NoError(t, os.WriteFile(filepath.Join(packageDir, "generated.go"), []byte("package foo\n"), 0o600)) + t.Setenv("GO111MODULE", "off") + t.Setenv("GOWORK", "off") + t.Setenv("GOENV", "off") + t.Setenv("GOPATH", gopath) + t.Setenv("GOPACKAGESDRIVER", "off") + + pkgs, err := packages.Load(&packages.Config{ + Mode: packages.NeedName | packages.NeedModule | packages.NeedFiles, + Dir: packageDir, + }, ".") + require.NoError(t, err) + require.Len(t, pkgs, 1) + require.Empty(t, pkgs[0].Errors) + require.Nil(t, pkgs[0].Module) + require.Equal(t, packageDir, pkgs[0].Dir) + require.Equal(t, "_/foo", pkgs[0].PkgPath) + + got, err := generatedPackageImportPath(packageDir) + require.NoError(t, err) + require.Equal(t, "_/foo", got) + }) + + t.Run("GOPATH package reached through symlink", func(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("Windows symlinks require privileges not available on every test host") + } + gopath := t.TempDir() + realPackageDir := filepath.Join(gopath, "src", "_", "linked") + require.NoError(t, os.MkdirAll(realPackageDir, 0o750)) + require.NoError(t, os.WriteFile(filepath.Join(realPackageDir, "generated.go"), []byte("package linked\n"), 0o600)) + linkedGOPATH := filepath.Join(t.TempDir(), "linked-gopath") + require.NoError(t, os.Symlink(gopath, linkedGOPATH)) + linkedPackageDir := filepath.Join(linkedGOPATH, "src", "_", "linked") + t.Setenv("GO111MODULE", "off") + t.Setenv("GOWORK", "off") + t.Setenv("GOENV", "off") + t.Setenv("GOPATH", gopath) + t.Setenv("GOPACKAGESDRIVER", "off") + + pkgs, err := packages.Load(&packages.Config{ + Mode: packages.NeedName | packages.NeedModule | packages.NeedFiles, + Dir: linkedPackageDir, + }, ".") + require.NoError(t, err) + require.Len(t, pkgs, 1) + require.Empty(t, pkgs[0].Errors) + require.Nil(t, pkgs[0].Module) + require.Equal(t, realPackageDir, pkgs[0].Dir) + require.Equal(t, "_/linked", pkgs[0].PkgPath) + + owned, err := gopathOwnsImportPath(linkedPackageDir, "_/linked") + require.NoError(t, err) + require.True(t, owned) + + got, err := generatedPackageImportPath(linkedPackageDir) + require.NoError(t, err) + require.Equal(t, "_/linked", got) + }) + + t.Run("missing GOPATH package", func(t *testing.T) { + gopath := filepath.Join(t.TempDir(), "missing") + importPath := "_/missing" + lexicalDir := filepath.Join(gopath, "src", filepath.FromSlash(importPath)) + t.Setenv("GO111MODULE", "off") + t.Setenv("GOWORK", "off") + t.Setenv("GOENV", "off") + t.Setenv("GOPATH", gopath) + t.Setenv("GOPACKAGESDRIVER", "off") + + owned, err := gopathOwnsImportPath(lexicalDir, importPath) + require.NoError(t, err) + require.True(t, owned) + + owned, err = gopathOwnsImportPath(t.TempDir(), importPath) + require.NoError(t, err) + require.False(t, owned) + }) + + t.Run("GOPATH symlink resolution error", func(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("Windows symlinks require privileges not available on every test host") + } + gopath := t.TempDir() + importPath := "_/loop" + packagePath := filepath.Join(gopath, "src", filepath.FromSlash(importPath)) + require.NoError(t, os.MkdirAll(filepath.Dir(packagePath), 0o750)) + require.NoError(t, os.Symlink(packagePath, packagePath)) + t.Setenv("GO111MODULE", "off") + t.Setenv("GOWORK", "off") + t.Setenv("GOENV", "off") + t.Setenv("GOPATH", gopath) + t.Setenv("GOPACKAGESDRIVER", "off") + + owned, err := gopathOwnsImportPath(t.TempDir(), importPath) + require.Error(t, err) + require.False(t, owned) + require.ErrorContains(t, err, "resolve GOPATH package path") + }) + + t.Run("non-first GOPATH package", func(t *testing.T) { + first := t.TempDir() + second := t.TempDir() + packageDir := filepath.Join(second, "src", "_", "second") + require.NoError(t, os.MkdirAll(packageDir, 0o750)) + require.NoError(t, os.WriteFile(filepath.Join(packageDir, "generated.go"), []byte("package second\n"), 0o600)) + t.Setenv("GO111MODULE", "off") + t.Setenv("GOWORK", "off") + t.Setenv("GOENV", "off") + t.Setenv("GOPATH", strings.Join([]string{first, second}, string(os.PathListSeparator))) + t.Setenv("GOPACKAGESDRIVER", "off") + + got, err := generatedPackageImportPath(packageDir) + require.NoError(t, err) + require.Equal(t, "_/second", got) + }) + + t.Run("GOPATH ending in space", func(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("Windows paths cannot portably end in a space") + } + gopath := filepath.Join(t.TempDir(), "gopath ") + packageDir := filepath.Join(gopath, "src", "_", "space") + require.NoError(t, os.MkdirAll(packageDir, 0o750)) + require.NoError(t, os.WriteFile(filepath.Join(packageDir, "generated.go"), []byte("package space\n"), 0o600)) + t.Setenv("GO111MODULE", "off") + t.Setenv("GOWORK", "off") + t.Setenv("GOENV", "off") + t.Setenv("GOPATH", gopath) + t.Setenv("GOPACKAGESDRIVER", "off") + + got, err := generatedPackageImportPath(packageDir) + require.NoError(t, err) + require.Equal(t, "_/space", got) + }) + + t.Run("GOENV GOPATH package", func(t *testing.T) { + gopath := t.TempDir() + packageDir := filepath.Join(gopath, "src", "_", "goenv") + require.NoError(t, os.MkdirAll(packageDir, 0o750)) + require.NoError(t, os.WriteFile(filepath.Join(packageDir, "generated.go"), []byte("package goenv\n"), 0o600)) + goenvFile := filepath.Join(t.TempDir(), "go.env") + require.NoError(t, os.WriteFile(goenvFile, []byte("GOPATH="+gopath+"\n"), 0o600)) + t.Setenv("GO111MODULE", "off") + t.Setenv("GOWORK", "off") + t.Setenv("GOENV", goenvFile) + t.Setenv("GOPACKAGESDRIVER", "off") + unsetTestEnv(t, "GOPATH") + + got, err := generatedPackageImportPath(packageDir) + require.NoError(t, err) + require.Equal(t, "_/goenv", got) + }) + + t.Run("empty GOPATH uses default", func(t *testing.T) { + home := t.TempDir() + gopath := filepath.Join(home, "go") + packageDir := filepath.Join(gopath, "src", "_", "default") + require.NoError(t, os.MkdirAll(packageDir, 0o750)) + require.NoError(t, os.WriteFile(filepath.Join(packageDir, "generated.go"), []byte("package defaultpkg\n"), 0o600)) + t.Setenv("GO111MODULE", "off") + t.Setenv("GOWORK", "off") + t.Setenv("GOENV", "off") + t.Setenv("GOPATH", "") + t.Setenv("HOME", home) + t.Setenv("USERPROFILE", home) + t.Setenv("GOPACKAGESDRIVER", "off") + + got, err := generatedPackageImportPath(packageDir) + require.NoError(t, err) + require.Equal(t, "_/default", got) + }) + + t.Run("synthetic GOPATH package", func(t *testing.T) { + gopath := filepath.Join(t.TempDir(), "gopath") + require.NoError(t, os.MkdirAll(gopath, 0o750)) + packageDir := filepath.Join(t.TempDir(), "outside") + require.NoError(t, os.MkdirAll(packageDir, 0o750)) + require.NoError(t, os.WriteFile(filepath.Join(packageDir, "generated.go"), []byte("package gen\n"), 0o600)) + t.Setenv("GO111MODULE", "off") + t.Setenv("GOWORK", "off") + t.Setenv("GOENV", "off") + t.Setenv("GOPATH", gopath) + t.Setenv("GOPACKAGESDRIVER", "off") + + pkgs, err := packages.Load(&packages.Config{ + Mode: packages.NeedName | packages.NeedModule | packages.NeedFiles, + Dir: packageDir, + }, ".") + require.NoError(t, err) + require.Len(t, pkgs, 1) + require.Empty(t, pkgs[0].Errors) + require.Nil(t, pkgs[0].Module) + require.Equal(t, packageDir, pkgs[0].Dir) + require.True(t, strings.HasPrefix(pkgs[0].PkgPath, "_/"), pkgs[0].PkgPath) + + got, err := generatedPackageImportPath(packageDir) + require.Error(t, err) + require.Empty(t, got) + require.ErrorContains(t, err, "synthetic import path") + }) + + t.Run("synthetic package with missing GOPATH roots", func(t *testing.T) { + first := filepath.Join(t.TempDir(), "missing-first") + second := filepath.Join(t.TempDir(), "missing-second") + packageDir := filepath.Join(t.TempDir(), "outside") + require.NoError(t, os.MkdirAll(packageDir, 0o750)) + require.NoError(t, os.WriteFile(filepath.Join(packageDir, "generated.go"), []byte("package gen\n"), 0o600)) + t.Setenv("GO111MODULE", "off") + t.Setenv("GOWORK", "off") + t.Setenv("GOENV", "off") + t.Setenv("GOPATH", strings.Join([]string{first, second}, string(os.PathListSeparator))) + t.Setenv("GOPACKAGESDRIVER", "off") + + got, err := generatedPackageImportPath(packageDir) + require.Error(t, err) + require.Empty(t, got) + require.ErrorContains(t, err, "synthetic import path") + }) + + t.Run("invalid package", func(t *testing.T) { + t.Setenv("GO111MODULE", "on") + t.Setenv("GOWORK", "off") + t.Setenv("GOENV", "off") + t.Setenv("GOPATH", t.TempDir()) + t.Setenv("GOPACKAGESDRIVER", "off") + moduleDir := t.TempDir() + writePackageFixture(t, moduleDir, "module generated.local\n\ngo 1.25\n") + genDir := filepath.Join(moduleDir, "gen") + require.NoError(t, os.WriteFile(filepath.Join(genDir, "other.go"), []byte("package other\n"), 0o600)) + + got, err := generatedPackageImportPath(genDir) + require.Error(t, err) + require.Empty(t, got) + var packageError packages.Error + require.ErrorAs(t, err, &packageError) + }) + + t.Run("invalid module", func(t *testing.T) { + t.Setenv("GO111MODULE", "on") + t.Setenv("GOWORK", "off") + t.Setenv("GOENV", "off") + t.Setenv("GOPATH", t.TempDir()) + t.Setenv("GOPACKAGESDRIVER", "off") + moduleDir := t.TempDir() + writePackageFixture(t, moduleDir, "module invalid path\n\ngo 1.25\n") + + got, err := generatedPackageImportPath(filepath.Join(moduleDir, "gen")) + require.Error(t, err) + require.Empty(t, got) + require.ErrorContains(t, err, "errors parsing") + }) +} + +// unsetTestEnv removes key for one subtest and restores its exact process +// state after the loader has observed the missing variable. +func unsetTestEnv(t *testing.T, key string) { + t.Helper() + value, exists := os.LookupEnv(key) + require.NoError(t, os.Unsetenv(key)) + t.Cleanup(func() { + if exists { + require.NoError(t, os.Setenv(key, value)) + return + } + require.NoError(t, os.Unsetenv(key)) + }) +} + +// writePackageFixture creates the module and generated package consumed by the +// real package loader in each test case. +func writePackageFixture(t *testing.T, moduleDir, moduleFile string) { + t.Helper() + require.NoError(t, os.WriteFile(filepath.Join(moduleDir, "go.mod"), []byte(moduleFile), 0o600)) + genDir := filepath.Join(moduleDir, "gen") + require.NoError(t, os.MkdirAll(genDir, 0o750)) + require.NoError(t, os.WriteFile(filepath.Join(genDir, "generated.go"), []byte("package gen\n"), 0o600)) +} diff --git a/codegen/generator/generated_service_path_integration_test.go b/codegen/generator/generated_service_path_integration_test.go new file mode 100644 index 0000000000..16791f6208 --- /dev/null +++ b/codegen/generator/generated_service_path_integration_test.go @@ -0,0 +1,125 @@ +// This file checks that every generated transport and example uses the service +// directory selected by the shared service plan. +package generator + +import ( + "path/filepath" + "testing" + + "github.com/stretchr/testify/require" + + "goa.design/goa/v3/dsl" + "goa.design/goa/v3/eval" + "goa.design/goa/v3/expr" +) + +// TestGeneratedTransportsUsePlannedServicePaths checks both design orders so +// adding a suffix to one service directory cannot redirect another service's +// HTTP, JSON-RPC, gRPC, command-line, or example imports. +func TestGeneratedTransportsUsePlannedServicePaths(t *testing.T) { + tests := []struct { + name string + reverse bool + }{ + {name: "forward"}, + {name: "reverse", reverse: true}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + root := servicePathRoot(t, test.reverse) + plan := mustTestPlan(t, "generated.local/gen", []eval.Root{root}, planExampleData) + expected := map[string]string{ + "read-value": "read_value", + "read_value": "read_value3", + "read_value2": "read_value2", + } + for _, service := range root.Services { + require.Equal(t, expected[service.Name], plan.Service(root).Services().Get(service.Name).PathName) + } + + files, err := testServiceFiles(plan) + require.NoError(t, err) + transportFiles, err := testTransportFiles(plan) + require.NoError(t, err) + files = append(files, transportFiles...) + exampleFiles, err := assembleExampleFilesForTest(plan) + require.NoError(t, err) + files = append(files, exampleFiles...) + files, err = mergeFilesByPath(files) + require.NoError(t, err) + + generatedPaths := make(map[string]struct{}, len(files)) + for _, file := range files { + generatedPaths[filepath.ToSlash(file.Path)] = struct{}{} + } + for _, servicePath := range expected { + for _, filePath := range []string{ + "gen/" + servicePath + "/service.go", + "gen/http/" + servicePath + "/server/server.go", + "gen/jsonrpc/" + servicePath + "/server/server.go", + "gen/grpc/" + servicePath + "/server/server.go", + } { + _, ok := generatedPaths[filePath] + require.True(t, ok, "missing generated file %s", filePath) + } + } + + dir := t.TempDir() + writeGeneratedModule(t, dir, "generated.local") + for _, file := range files { + _, err := file.Render(dir) + require.NoError(t, err) + } + runGeneratedTests(t, dir) + }) + } +} + +// servicePathRoot returns one API whose services exercise every generated +// transport and both one-shot and streaming JSON-RPC output. +func servicePathRoot(t *testing.T, reverse bool) *expr.RootExpr { + t.Helper() + return expr.RunDSL(t, func() { + names := []string{"read-value", "read_value", "read_value2"} + if reverse { + names[0], names[2] = names[2], names[0] + } + servers := map[string]string{ + "read-value": "dash", + "read_value": "underscore", + "read_value2": "numbered", + } + dsl.API("path api", func() { + for _, serviceName := range names { + dsl.Server(servers[serviceName], func() { + dsl.Services(serviceName) + dsl.Host("local", func() { dsl.URI("http://localhost") }) + }) + } + }) + for _, serviceName := range names { + dsl.Service(serviceName, func() { + dsl.Method("HTTP call", func() { + dsl.Payload(dsl.String) + dsl.Result(dsl.String) + dsl.HTTP(func() { dsl.POST("/call") }) + }) + dsl.Method("JSON-RPC call", func() { + dsl.Payload(dsl.String) + dsl.Result(dsl.String) + dsl.JSONRPC(func() {}) + }) + dsl.Method("JSON-RPC stream", func() { + dsl.Payload(dsl.String) + dsl.StreamingResult(dsl.String) + dsl.JSONRPC(func() { dsl.ServerSentEvents() }) + }) + dsl.Method("gRPC call", func() { + dsl.Payload(dsl.String) + dsl.Result(dsl.String) + dsl.GRPC(func() {}) + }) + }) + } + }) +} diff --git a/codegen/generator/generated_transport_alias_integration_test.go b/codegen/generator/generated_transport_alias_integration_test.go new file mode 100644 index 0000000000..ae30f3d228 --- /dev/null +++ b/codegen/generator/generated_transport_alias_integration_test.go @@ -0,0 +1,283 @@ +// This file checks that generated files use the exact package names assigned +// before the files are written. +package generator + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/require" + + "goa.design/goa/v3/codegen" + "goa.design/goa/v3/dsl" + "goa.design/goa/v3/eval" + "goa.design/goa/v3/expr" +) + +type ( + httpHelperCollisionOrder string + jsonRPCSharedPackageMode uint8 +) + +const ( + jsonRPCUnary jsonRPCSharedPackageMode = iota + jsonRPCSSE +) + +// ComparePackageName orders names added by the collision test. +func (o httpHelperCollisionOrder) ComparePackageName(other codegen.PackageNameOrder) int { + return strings.Compare(string(o), string(other.(httpHelperCollisionOrder))) +} + +// TestGeneratedTransportPackagesCompileWithServiceAliasCollisions proves that +// client, server, protobuf, command-line, and service imports still name the +// service they belong to when service names produce the same Go import name. +func TestGeneratedTransportPackagesCompileWithServiceAliasCollisions(t *testing.T) { + root := codegen.RunDSL(t, func() { + interceptor := dsl.Interceptor("Trace", func() {}) + for _, name := range []string{"Foo", "Fooc", "Foosvr", "Foojssvr"} { + dsl.Service(name, func() { + if name == "Foo" { + dsl.ClientInterceptor(interceptor) + } + dsl.Method("Read", func() { + dsl.Payload(dsl.String) + dsl.Result(dsl.String) + dsl.HTTP(func() { dsl.POST("/" + strings.ToLower(name)) }) + dsl.GRPC(func() {}) + }) + dsl.Method("ReadJSON", func() { + dsl.Payload(dsl.String) + dsl.Result(dsl.String) + dsl.JSONRPC(func() {}) + }) + }) + } + }) + + plan := mustTestPlan(t, "generated.local/gen", []eval.Root{root}, planExampleData) + files, err := testServiceFiles(plan) + require.NoError(t, err) + transport, err := testTransportFiles(plan) + require.NoError(t, err) + files = append(files, transport...) + exampleFiles, err := assembleExampleFilesForTest(plan) + require.NoError(t, err) + files = append(files, exampleFiles...) + + dir := t.TempDir() + writeGeneratedModule(t, dir, "generated.local") + for _, file := range files { + _, err := file.Render(dir) + require.NoError(t, err) + } + runGeneratedTests(t, dir) +} + +// TestGeneratedCLICompilesWhenImportsMatchLocalNames verifies that endpoint +// parsers keep using package imports when a generated local prefers the same name. +func TestGeneratedCLICompilesWhenImportsMatchLocalNames(t *testing.T) { + root := codegen.RunDSL(t, func() { + trace := dsl.Interceptor("Trace", func() {}) + message := dsl.Type("Message", func() { + dsl.Field(1, "value", dsl.String) + }) + dsl.Service("C", func() { + dsl.Method("Read", func() { + dsl.Payload(message) + dsl.StreamingResult(message) + dsl.GRPC(func() {}) + }) + }) + dsl.Service("Data", func() { + dsl.ClientInterceptor(trace) + dsl.Method("Read", func() { + dsl.HTTP(func() { dsl.GET("/data") }) + }) + }) + }) + + plan := mustTestPlan(t, "generated.local/gen", []eval.Root{root}, planServiceData, planTransportData) + files, err := testServiceFiles(plan) + require.NoError(t, err) + transportFiles, err := testTransportFiles(plan) + require.NoError(t, err) + files = append(files, transportFiles...) + + directory := t.TempDir() + writeGeneratedModule(t, directory, "generated.local") + for _, file := range files { + _, err := file.Render(directory) + require.NoError(t, err) + } + runGeneratedTests(t, directory) +} + +// TestGRPCOnlyExamplesCompile checks that a server with no HTTP service does +// not refer to an HTTP command-line flag. +func TestGRPCOnlyExamplesCompile(t *testing.T) { + root := codegen.RunDSL(t, func() { + dsl.Service("Echo", func() { + dsl.Method("Read", func() { + dsl.Payload(dsl.String) + dsl.Result(dsl.String) + dsl.GRPC(func() {}) + }) + }) + }) + plan := mustTestPlan(t, "generated.local/gen", []eval.Root{root}, planExampleData) + files, err := testServiceFiles(plan) + require.NoError(t, err) + transportFiles, err := testTransportFiles(plan) + require.NoError(t, err) + files = append(files, transportFiles...) + exampleFiles, err := assembleExampleFilesForTest(plan) + require.NoError(t, err) + files = append(files, exampleFiles...) + + dir := t.TempDir() + writeGeneratedModule(t, dir, "generated.local") + for _, file := range files { + _, err := file.Render(dir) + require.NoError(t, err) + } + runGeneratedTests(t, dir) +} + +// TestGeneratedHTTPHelpersCompileWithPackageNameCollisions checks that file and +// mixed-result stream helpers use their chosen names in definitions and calls. +func TestGeneratedHTTPHelpersCompileWithPackageNameCollisions(t *testing.T) { + root := httpHelperCollisionRoot(t, "Foo Bar", "First") + reserve := func(plan *Plan) error { + pkg, err := plan.Generation().ClaimPackage("generated.local/gen/http/foo_bar/server") + if err != nil { + return err + } + declarations := []*codegen.NameDeclaration{ + codegen.NewPreferredName(codegen.NameType, "appendFS", codegen.UnexportedName, httpHelperCollisionOrder("append-fs")), + codegen.NewPreferredName(codegen.NameFunction, "appendPrefix", codegen.UnexportedName, httpHelperCollisionOrder("append-prefix")), + codegen.NewPreferredName(codegen.NameType, "discardCreateServerStream", codegen.UnexportedName, httpHelperCollisionOrder("discard-stream")), + } + for _, declaration := range declarations { + if err := pkg.DeclareName(declaration); err != nil { + return err + } + } + return nil + } + plan := mustTestPlan(t, "generated.local/gen", []eval.Root{root}, planServiceData, reserve, planTransportData) + files, err := testServiceFiles(plan) + require.NoError(t, err) + protocolFiles, err := testTransportFiles(plan) + require.NoError(t, err) + files = append(files, protocolFiles...) + dir := t.TempDir() + writeGeneratedModule(t, dir, "generated.local") + for _, file := range files { + _, err := file.Render(dir) + require.NoError(t, err) + } + runGeneratedTests(t, dir) +} + +// TestGeneratedJSONRPCPackagesCompileAcrossSharedPackageRoots checks that each +// generated client and server uses the names chosen for its own declarations +// when two designs write files into the same Go packages. +func TestGeneratedJSONRPCPackagesCompileAcrossSharedPackageRoots(t *testing.T) { + tests := []struct { + name string + mode jsonRPCSharedPackageMode + }{ + {name: "ordinary", mode: jsonRPCUnary}, + {name: "server sent events", mode: jsonRPCSSE}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + first := jsonRPCSharedPackageRoot(t, "Shared", "First", test.mode) + second := jsonRPCSharedPackageRoot(t, "Shared", "Second", test.mode) + plan := mustTestPlan(t, "generated.local/gen", []eval.Root{first, second}, planTransportData) + files, err := testServiceFiles(plan) + require.NoError(t, err) + for _, root := range []*expr.RootExpr{first, second} { + jsonPlan := plan.jsonrpc[root] + files = append(files, jsonPlan.ServerFiles()...) + files = append(files, jsonPlan.ClientFiles()...) + files = append(files, jsonPlan.ServerTypeFiles()...) + files = append(files, jsonPlan.ClientTypeFiles()...) + files = append(files, jsonPlan.PathFiles()...) + } + files, err = mergeFilesByPath(files) + require.NoError(t, err) + dir := t.TempDir() + writeGeneratedModule(t, dir, "generated.local") + for _, file := range files { + _, err := file.Render(dir) + require.NoError(t, err) + } + runGeneratedTests(t, dir) + }) + } +} + +// jsonRPCSharedPackageRoot returns one design whose service name controls the +// generated directory. typePrefix keeps its service types separate from the +// other design that writes into the same directory. Every design uses the Call +// method, so their stream types and constructors request the same Go names. +func jsonRPCSharedPackageRoot(t *testing.T, serviceName, typePrefix string, mode jsonRPCSharedPackageMode) *expr.RootExpr { + t.Helper() + return expr.RunDSL(t, func() { + dsl.API(typePrefix, func() {}) + payload := dsl.Type(typePrefix+"Payload", func() { + dsl.Attribute("value", dsl.String) + }) + result := dsl.Type(typePrefix+"Result", func() { + dsl.Attribute("value", dsl.String) + }) + dsl.Service(serviceName, func() { + dsl.Method("Call", func() { + switch mode { + case jsonRPCUnary: + dsl.Payload(payload) + dsl.Result(result) + dsl.JSONRPC(func() {}) + case jsonRPCSSE: + dsl.Payload(payload) + dsl.StreamingResult(result) + dsl.JSONRPC(func() { dsl.ServerSentEvents() }) + default: + panic("unknown JSON-RPC test mode") + } + }) + }) + }) +} + +// httpHelperCollisionRoot returns one design with mixed HTTP results and a +// mapped file. serviceName controls the output directory and typePrefix keeps +// the service types distinct when two designs share that directory. +func httpHelperCollisionRoot(t *testing.T, serviceName, typePrefix string) *expr.RootExpr { + t.Helper() + return expr.RunDSL(t, func() { + payload := dsl.Type(typePrefix+"Payload", func() { + dsl.Attribute("value", dsl.String) + }) + result := dsl.Type(typePrefix+"Result", func() { + dsl.Attribute("value", dsl.String) + }) + event := dsl.Type(typePrefix+"Event", func() { + dsl.Attribute("value", dsl.String) + }) + dsl.Service(serviceName, func() { + dsl.Method("Create", func() { + dsl.Payload(payload) + dsl.Result(result) + dsl.StreamingResult(event) + dsl.HTTP(func() { + dsl.POST("/create") + dsl.ServerSentEvents() + }) + }) + dsl.Files("/asset.json", "/embedded/file.json") + }) + }) +} diff --git a/codegen/generator/generation_test.go b/codegen/generator/generation_test.go new file mode 100644 index 0000000000..5128010846 --- /dev/null +++ b/codegen/generator/generation_test.go @@ -0,0 +1,212 @@ +// This file checks that every package name is chosen before core generators or +// plugins write files. +package generator + +import ( + "fmt" + "path/filepath" + "testing" + + "github.com/stretchr/testify/require" + + "goa.design/goa/v3/codegen" + "goa.design/goa/v3/eval" + "goa.design/goa/v3/expr" + httpdata "goa.design/goa/v3/http/codegen/testdata" +) + +func TestGeneratePhasesShareOneGeneration(t *testing.T) { + command := fmt.Sprintf("test-generation-phases-%p", t) + root := codegen.RunDSL(t, func() {}) + + var ( + events []string + planned *codegen.Generation + lateDeclare error + preparedRoots []eval.Root + ) + typesPath := "generated.local/gen/types" + union := &expr.AttributeExpr{Type: &expr.Union{TypeName: "Value", TypeKey: "type", ValueKey: "value"}} + lateUnion := &expr.AttributeExpr{Type: &expr.Union{TypeName: "Late", TypeKey: "kind", ValueKey: "data"}} + + assertGeneration := func(generation *codegen.Generation) error { + if planned != generation { + return fmt.Errorf("generation changed between plan and render") + } + if len(generation.Roots()) != len(preparedRoots) { + return fmt.Errorf("generation roots changed after plugin preparation") + } + return nil + } + registry := newRegistry() + registry.addCommand(command, + func() coreGenerator { + return coreGenerator{ + Plan: func(plan *Plan) error { + events = append(events, "core-plan-first") + planned = plan.Generation() + typesPath = planned.GenPkg() + "/types" + types, err := planned.ClaimPackage(typesPath) + if err != nil { + return err + } + _, err = types.DeclareUnion(union) + return err + }, + Generate: func(plan *Plan) ([]*codegen.File, error) { + events = append(events, "core-render-first") + generation := plan.Generation() + if err := assertGeneration(generation); err != nil { + return nil, err + } + declaration, err := generation.Package(typesPath).Union(union) + if err != nil { + return nil, err + } + if declaration.Name() == "" { + return nil, fmt.Errorf("union name is empty during render") + } + _, lateDeclare = generation.Package(typesPath).DeclareUnion(lateUnion) + if lateDeclare == nil { + return nil, fmt.Errorf("render declared a new union after freeze") + } + return nil, nil + }, + } + }, + func() coreGenerator { + return coreGenerator{ + Plan: func(plan *Plan) error { + events = append(events, "core-plan-second") + return assertGeneration(plan.Generation()) + }, + Generate: func(plan *Plan) ([]*codegen.File, error) { + events = append(events, "core-render-second") + return nil, assertGeneration(plan.Generation()) + }, + } + }, + ) + registry.registerPlugin( + "lifecycle", + command, + pluginNormal, + func() Plugin { + return Plugin{ + Prepare: func(_ string, roots []eval.Root) error { + events = append(events, "plugin-prepare") + preparedRoots = roots + return nil + }, + Plan: func(plan *Plan) error { + events = append(events, "plugin-plan") + return assertGeneration(plan.Generation()) + }, + Generate: func(plan *Plan, files []*codegen.File) ([]*codegen.File, error) { + events = append(events, "plugin-render") + return files, assertGeneration(plan.Generation()) + }, + } + }, + ) + + err := executeGeneration("generated.local/gen", []eval.Root{root}, command, registry) + require.NoError(t, err) + require.ErrorContains(t, lateDeclare, "frozen") + require.Equal(t, []string{ + "plugin-prepare", + "core-plan-first", + "core-plan-second", + "plugin-plan", + "core-render-first", + "core-render-second", + "plugin-render", + }, events) +} + +func TestCommandsPlanOnlyTheirFiles(t *testing.T) { + root := codegen.RunDSL(t, httpdata.AliasTypeDSL) + + genRun, err := newGenerationRun("gen", newDefaultRegistry()) + require.NoError(t, err) + genResult, err := genRun.execute("generated.local/gen", []eval.Root{root}) + require.NoError(t, err) + require.Nil(t, genResult.plan.example) + require.NotNil(t, genResult.plan.openapi) + + exampleRun, err := newGenerationRun("example", newDefaultRegistry()) + require.NoError(t, err) + exampleResult, err := exampleRun.execute("generated.local/gen", []eval.Root{root}) + require.NoError(t, err) + require.NotNil(t, exampleResult.plan.example) + require.Nil(t, exampleResult.plan.openapi) +} + +// TestRenderUsesRetainedPlans proves that file rendering does not look up +// services or transports from the prepared design after planning finishes. +func TestRenderUsesRetainedPlans(t *testing.T) { + root := codegen.RunDSL(t, httpdata.AliasTypeDSL) + plan := mustTestPlan( + t, + "generated.local/gen", + []eval.Root{root}, + planServiceData, + planTransportData, + ) + + plan.preparedRoots = nil + plan.services = nil + plan.http = nil + plan.jsonrpcHTTP = nil + plan.jsonrpc = nil + plan.grpc = nil + + serviceFiles, err := serviceFiles(plan) + require.NoError(t, err) + require.NotEmpty(t, serviceFiles) + transportFiles, err := transportFiles(plan) + require.NoError(t, err) + require.NotEmpty(t, transportFiles) +} + +// TestPreparedRootsRejectFileRenderMutation proves that persistent mutations +// made by templates and file finalizers are rejected after rendering completes. +func TestPreparedRootsRejectFileRenderMutation(t *testing.T) { + for _, phase := range []string{"template", "finalizer"} { + t.Run(phase, func(t *testing.T) { + root := codegen.RunDSL(t, httpdata.AliasTypeDSL) + dir := t.TempDir() + writeGeneratedModule(t, filepath.Join(dir, codegen.Gendir), "generated.local/gen") + mutate := func() { + root.API.HTTP.Services[0].HTTPEndpoints[0].Routes[0].Path = "/changed" + } + first := &codegen.File{ + Path: "first.txt", + SectionTemplates: []*codegen.SectionTemplate{{ + Name: "first", + Source: "first", + }}, + } + if phase == "template" { + first.SectionTemplates[0].Source = "{{ mutate }}" + first.SectionTemplates[0].FuncMap = map[string]any{"mutate": func() string { + mutate() + return "first" + }} + } else { + first.FinalizeFunc = func(_ string) error { + mutate() + return nil + } + } + registry := testRegistry("test", func() coreGenerator { + return coreGenerator{name: "files", Generate: func(_ *Plan) ([]*codegen.File, error) { + return []*codegen.File{first}, nil + }} + }) + + _, err := generate(dir, "test", false, registry) + require.ErrorContains(t, err, "generated file renders mutated prepared design") + }) + } +} diff --git a/codegen/generator/generators.go b/codegen/generator/generators.go index e949d35391..76fda65c73 100644 --- a/codegen/generator/generators.go +++ b/codegen/generator/generators.go @@ -1,30 +1,155 @@ +// This file lists the generators used by each command. Every command run gets +// new functions, and both functions receive the same Plan. package generator import ( "fmt" + "reflect" "goa.design/goa/v3/codegen" "goa.design/goa/v3/eval" ) -// Genfunc is the type of the functions invoked to generate code. -type Genfunc func(genpkg string, roots []eval.Root) ([]*codegen.File, error) +type ( + // Genfunc is the released signature of a standalone generator function. + // The current generator uses the run-wide Plan instead. + // + // Deprecated: Register a PluginFactory to add generated files. + Genfunc func(genpkg string, roots []eval.Root) ([]*codegen.File, error) -// Generators returns the qualified paths (including the package name) to the -// code generator functions for the given command, an error if the command is -// not supported. Generators is a public variable so that external code (e.g. -// plugins) may override the default generators. + // coreGenerator chooses names and then builds one group of generated files. + coreGenerator struct { + // name identifies the file group in error messages. + name string + // Plan chooses generated names and saves the data needed to build files. + Plan func(*Plan) error + // Generate builds files from that same Plan after all names are final. + Generate func(*Plan) ([]*codegen.File, error) + } + + // generatorFactory returns a new pair of generator functions when called. + generatorFactory func() coreGenerator +) + +// Generators returns the generator functions for command. Plugins may replace +// it to add, remove, or reorder generators. var Generators = generators -// generators returns the generator functions exposed by the generator package -// for the given command. -func generators(cmd string) ([]Genfunc, error) { - switch cmd { +// generators returns Goa's built-in generator functions for command. +func generators(command string) ([]Genfunc, error) { + switch command { case "gen": return []Genfunc{Service, Transport, OpenAPI}, nil case "example": return []Genfunc{Example}, nil default: - return nil, fmt.Errorf("unknown command %q", cmd) + return nil, fmt.Errorf("unknown command %q", command) + } +} + +// generatorFactories returns fresh shared-plan adapters for the released +// generator list. Goa's built-in functions plan together. Additional plugin +// functions receive the prepared roots after every generated name is fixed. +func generatorFactories(command string) ([]generatorFactory, error) { + generate, err := Generators(command) + if err != nil { + return nil, err + } + factories := make([]generatorFactory, len(generate)) + for index, generator := range generate { + if generator == nil { + return nil, fmt.Errorf("generator %d for command %q is nil", index, command) + } + factories[index] = generatorFactoryFor(generator) + } + return factories, nil +} + +// generatorFactoryFor keeps built-in generators in the shared planning pass +// and adapts other released generator functions to the final rendering pass. +func generatorFactoryFor(generate Genfunc) generatorFactory { + pointer := reflect.ValueOf(generate).Pointer() + switch pointer { + case reflect.ValueOf(Service).Pointer(): + return serviceGeneratorFactory + case reflect.ValueOf(Transport).Pointer(): + return transportGeneratorFactory + case reflect.ValueOf(OpenAPI).Pointer(): + return openAPIGeneratorFactory + case reflect.ValueOf(Example).Pointer(): + return exampleGeneratorFactory + default: + return func() coreGenerator { + return coreGenerator{ + name: "external generator", + Generate: func(plan *Plan) ([]*codegen.File, error) { + generation := plan.Generation() + return generate(generation.GenPkg(), generation.Roots()) + }, + } + } + } +} + +// runStandaloneGenerator runs one released built-in function with a complete +// plan. It does not run registered plugins because callers invoked the core +// generator directly. +func runStandaloneGenerator(genpkg string, roots []eval.Root, factory generatorFactory) ([]*codegen.File, error) { + run := generationRun{cores: []coreGenerator{factory()}} + result, err := run.execute(genpkg, roots) + if err != nil { + return nil, err + } + return result.files, nil +} + +// genGeneratorFactories returns the service, transport, and OpenAPI generators +// used by the gen command. +func genGeneratorFactories() []generatorFactory { + return []generatorFactory{ + serviceGeneratorFactory, + transportGeneratorFactory, + openAPIGeneratorFactory, + } +} + +// exampleGeneratorFactories returns the generator used by the example command. +func exampleGeneratorFactories() []generatorFactory { + return []generatorFactory{exampleGeneratorFactory} +} + +// serviceGeneratorFactory returns a fresh service generator for one run. +func serviceGeneratorFactory() coreGenerator { + return coreGenerator{ + name: "service", + Plan: planServiceData, + Generate: serviceFiles, + } +} + +// transportGeneratorFactory returns a fresh transport generator for one run. +func transportGeneratorFactory() coreGenerator { + return coreGenerator{ + name: "transport", + Plan: planTransportData, + Generate: transportFiles, + } +} + +// openAPIGeneratorFactory returns a fresh OpenAPI generator for one run. +func openAPIGeneratorFactory() coreGenerator { + return coreGenerator{ + name: "openapi", + Plan: planOpenAPIData, + Generate: openAPIFiles, + } +} + +// exampleGeneratorFactory returns a fresh example generator for one run. +func exampleGeneratorFactory() coreGenerator { + return coreGenerator{ + name: "example", + Plan: planExampleData, + Generate: exampleFiles, } } diff --git a/codegen/generator/http_plan_test.go b/codegen/generator/http_plan_test.go new file mode 100644 index 0000000000..bef382bf10 --- /dev/null +++ b/codegen/generator/http_plan_test.go @@ -0,0 +1,33 @@ +// This file checks that plugins can find only the ordinary HTTP plan belonging +// to the exact prepared design root they received. +package generator + +import ( + "testing" + + "github.com/stretchr/testify/require" + + "goa.design/goa/v3/expr" + httpcodegen "goa.design/goa/v3/http/codegen" +) + +func TestHTTPReturnsOnlyExactOrdinaryPlan(t *testing.T) { + root := &expr.RootExpr{} + sameContents := &expr.RootExpr{} + httpPlan := &httpcodegen.Plan{} + jsonrpcPlan := &httpcodegen.Plan{} + plan := &Plan{ + http: map[*expr.RootExpr]*httpcodegen.Plan{root: httpPlan}, + jsonrpcHTTP: map[*expr.RootExpr]*httpcodegen.Plan{sameContents: jsonrpcPlan}, + } + + got, ok := plan.HTTP(root) + require.True(t, ok) + require.Same(t, httpPlan, got) + got, ok = plan.HTTP(sameContents) + require.False(t, ok) + require.Nil(t, got) + got, ok = plan.HTTP(&expr.RootExpr{}) + require.False(t, ok) + require.Nil(t, got) +} diff --git a/codegen/generator/http_sse_retry_integration_test.go b/codegen/generator/http_sse_retry_integration_test.go new file mode 100644 index 0000000000..ce94a9134d --- /dev/null +++ b/codegen/generator/http_sse_retry_integration_test.go @@ -0,0 +1,120 @@ +// This file checks retry values in complete generated HTTP SSE transports. +// The server writes the designed integer field and the client rebuilds the +// service result and ignores retry fields that are not ASCII digits. +package generator + +import ( + "testing" + + "goa.design/goa/v3/dsl" +) + +// httpSSERetryContractTest runs against the temporary generated module. +const httpSSERetryContractTest = `package integration + +import ( + "context" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/stretchr/testify/require" + + service "generated.local/gen/sse_retry" + client "generated.local/gen/http/sse_retry/client" + server "generated.local/gen/http/sse_retry/server" + goahttp "goa.design/goa/v3/http" +) + +// retryService sends one event with both fields selected. +type retryService struct{} + +func (*retryService) Watch(_ context.Context, stream service.WatchServerStream) error { + data := "null" + retry := 2500 + return stream.Send(&service.Event{Data: &data, Retry: &retry}) +} + +func TestRetryRoundTrip(t *testing.T) { + handler := server.NewWatchHandler( + service.NewWatchEndpoint(&retryService{}), + goahttp.NewMuxer(), + goahttp.RequestDecoder, + goahttp.ResponseEncoder, + func(_ context.Context, _ http.ResponseWriter, err error) { t.Errorf("serve SSE: %v", err) }, + nil, + ) + httpServer := httptest.NewServer(handler) + defer httpServer.Close() + + stream := openWatch(t, httpServer.URL, http.DefaultClient) + event, err := stream.Recv() + require.NoError(t, err) + require.NotNil(t, event.Data) + require.Equal(t, "null", *event.Data) + require.NotNil(t, event.Retry) + require.Equal(t, 2500, *event.Retry) +} + +func TestMalformedRetryIsIgnored(t *testing.T) { + httpServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "text/event-stream") + _, err := io.WriteString(w, "retry: later\ndata: ready\n\n") + require.NoError(t, err) + })) + defer httpServer.Close() + + stream := openWatch(t, httpServer.URL, http.DefaultClient) + event, err := stream.Recv() + require.NoError(t, err) + require.NotNil(t, event.Data) + require.Equal(t, "ready", *event.Data) + require.Nil(t, event.Retry) +} + +// openWatch starts the generated client stream against url. +func openWatch(t *testing.T, url string, doer goahttp.Doer) client.WatchClientStream { + t.Helper() + transport := client.NewClient( + "http", + strings.TrimPrefix(url, "http://"), + doer, + goahttp.RequestEncoder, + goahttp.ResponseDecoder, + false, + ) + raw, err := transport.Watch()(context.Background(), nil) + require.NoError(t, err) + return raw.(client.WatchClientStream) +} +` + +// TestGeneratedHTTPSSERetryParsing checks valid and invalid retry values using +// a generated server and client rather than template fragments. +func TestGeneratedHTTPSSERetryParsing(t *testing.T) { + dir := generateViewedTransportModule(t, httpSSERetryDSL) + writeGeneratedContractTest(t, dir, ".", httpSSERetryContractTest) + runGeneratedPackageTests(t, dir, ".") +} + +// httpSSERetryDSL defines one event with optional data and retry fields. The +// generated service uses pointers so nil and selected values remain distinct. +func httpSSERetryDSL() { + event := dsl.Type("Event", func() { + dsl.Attribute("data", dsl.String) + dsl.Attribute("retry", dsl.Int) + }) + dsl.Service("SSE Retry", func() { + dsl.Method("Watch", func() { + dsl.StreamingResult(event) + dsl.HTTP(func() { + dsl.GET("/watch") + dsl.ServerSentEvents("data", func() { + dsl.SSEEventRetry("retry") + }) + }) + }) + }) +} diff --git a/codegen/generator/lifecycle.go b/codegen/generator/lifecycle.go new file mode 100644 index 0000000000..1ac8724307 --- /dev/null +++ b/codegen/generator/lifecycle.go @@ -0,0 +1,153 @@ +// This file runs plugin preparation, name selection, file planning, and file +// writing in one order shared by production generation and tests. +package generator + +import ( + "fmt" + + "goa.design/goa/v3/codegen" + "goa.design/goa/v3/eval" +) + +type ( + // generationRun stores the new core generators and plugins used by one run. + generationRun struct { + cores []coreGenerator + plugins []runPlugin + } + + // runPlugin stores one plugin's registered name and its Prepare, Plan, and + // Generate functions for this run. + runPlugin struct { + name string + Plugin + } + + // generationResult stores the generation state and files produced by one + // run. + generationResult struct { + plan *Plan + files []*codegen.File + } +) + +// executeGeneration creates new core generators and plugins, prepares the +// designs, chooses all names, and reports whether generation succeeded. +func executeGeneration(genpkg string, roots []eval.Root, command string, registry *registry) error { + run, err := newGenerationRun(command, registry) + if err != nil { + return err + } + _, err = run.execute(genpkg, roots) + return err +} + +// newGenerationRun copies the registered factories and calls each one once. +func newGenerationRun(command string, registry *registry) (*generationRun, error) { + coreFactories, pluginDescriptors, err := registry.snapshot(command) + if err != nil { + return nil, err + } + cores := make([]coreGenerator, len(coreFactories)) + for i, factory := range coreFactories { + cores[i] = factory() + } + plugins := make([]runPlugin, len(pluginDescriptors)) + for i, descriptor := range pluginDescriptors { + plugins[i] = runPlugin{name: descriptor.name, Plugin: descriptor.factory()} + } + return &generationRun{cores: cores, plugins: plugins}, nil +} + +// execute prepares the supplied designs, chooses names, and builds files. +func (r *generationRun) execute(genpkg string, roots []eval.Root) (*generationResult, error) { + for _, plugin := range r.plugins { + if plugin.Prepare != nil { + if err := plugin.Prepare(genpkg, roots); err != nil { + return nil, err + } + } + } + generation, err := codegen.NewGeneration(genpkg, roots) + if err != nil { + return nil, err + } + design, err := snapshotPreparedDesign(roots) + if err != nil { + return nil, err + } + plan := &Plan{ + generation: generation, + preparedRoots: roots, + examples: newExampleGenerators(roots), + design: design, + } + if err := plan.verifyPreparedDesign("example generator creation"); err != nil { + return nil, err + } + for _, core := range r.cores { + if core.Plan != nil { + callbackErr := core.Plan(plan) + if err := plan.verifyPreparedDesign(fmt.Sprintf("core %q plan", core.name)); err != nil { + return nil, err + } + if callbackErr != nil { + return nil, callbackErr + } + } + } + for _, plugin := range r.plugins { + if plugin.Plan != nil { + callbackErr := plugin.Plan(plan) + if err := plan.verifyPreparedDesign(fmt.Sprintf("plugin %q plan", plugin.name)); err != nil { + return nil, err + } + if callbackErr != nil { + return nil, callbackErr + } + } + } + freezeErr := plan.Generation().Freeze() + if err := plan.verifyPreparedDesign("generation freeze"); err != nil { + return nil, err + } + if freezeErr != nil { + return nil, freezeErr + } + linkErr := plan.link() + if err := plan.verifyPreparedDesign("plan linking"); err != nil { + return nil, err + } + if linkErr != nil { + return nil, linkErr + } + + var files []*codegen.File + for _, core := range r.cores { + if core.Generate == nil { + continue + } + generated, callbackErr := core.Generate(plan) + if err := plan.verifyPreparedDesign(fmt.Sprintf("core %q generate", core.name)); err != nil { + return nil, err + } + if callbackErr != nil { + return nil, callbackErr + } + files = append(files, generated...) + } + for _, plugin := range r.plugins { + if plugin.Generate == nil { + continue + } + generated, callbackErr := plugin.Generate(plan, files) + if err := plan.verifyPreparedDesign(fmt.Sprintf("plugin %q generate", plugin.name)); err != nil { + return nil, err + } + if callbackErr != nil { + return nil, callbackErr + } + files = generated + } + return &generationResult{plan: plan, files: files}, nil +} diff --git a/codegen/generator/openapi.go b/codegen/generator/openapi.go index 0381d2231a..5cb3538aad 100644 --- a/codegen/generator/openapi.go +++ b/codegen/generator/openapi.go @@ -1,20 +1,45 @@ +// This file builds each requested OpenAPI document while the evaluated design +// is available. File generation later returns the documents already built. package generator import ( "goa.design/goa/v3/codegen" "goa.design/goa/v3/eval" - "goa.design/goa/v3/expr" httpcodegen "goa.design/goa/v3/http/codegen" ) -// OpenAPI iterates through the roots and returns the files needed to render -// the service OpenAPI spec. It produces OpenAPI specifications only if the -// roots define a HTTP service. -func OpenAPI(_ string, roots []eval.Root) ([]*codegen.File, error) { - for _, root := range roots { - if r, ok := root.(*expr.RootExpr); ok { - return httpcodegen.OpenAPIFiles(r) +// OpenAPI returns the OpenAPI documents for roots. +func OpenAPI(genpkg string, roots []eval.Root) ([]*codegen.File, error) { + return runStandaloneGenerator(genpkg, roots, openAPIGeneratorFactory) +} + +// openAPIFiles returns the OpenAPI files built during planning. +func openAPIFiles(plan *Plan) ([]*codegen.File, error) { + if len(plan.openapiReplacements) > 0 { + var files []*codegen.File + for _, openapi := range plan.openapiReplacements { + files = append(files, openapi.Files()...) } + return files, nil + } + return plan.openapi.Files(), nil +} + +// planOpenAPIData builds the OpenAPI files for the application's design root. +// Later roots contain generated support services and do not describe another +// application API. +func planOpenAPIData(plan *Plan) error { + roots := serviceRoots(plan.Generation().Roots()) + if len(roots) == 0 { + plan.openapi = new(httpcodegen.OpenAPIPlan) + plan.openapiRoot = nil + return nil + } + openapi, err := httpcodegen.NewOpenAPIPlan(roots[0], plan.exampleGenerator(roots[0])) + if err != nil { + return err } - return nil, nil + plan.openapi = openapi + plan.openapiRoot = roots[0] + return nil } diff --git a/codegen/generator/openapi_replace_test.go b/codegen/generator/openapi_replace_test.go new file mode 100644 index 0000000000..812633a68e --- /dev/null +++ b/codegen/generator/openapi_replace_test.go @@ -0,0 +1,86 @@ +// This file verifies that a plugin can replace the OpenAPI documents for the +// exact application design before generation names become final. +package generator + +import ( + "testing" + + "github.com/stretchr/testify/require" + + "goa.design/goa/v3/codegen" + "goa.design/goa/v3/eval" + "goa.design/goa/v3/expr" + httpcodegen "goa.design/goa/v3/http/codegen" + "goa.design/goa/v3/http/codegen/openapi" + httpdata "goa.design/goa/v3/http/codegen/testdata" +) + +func TestReplaceOpenAPI(t *testing.T) { + root := codegen.RunDSL(t, httpdata.SimpleDSL) + generation, err := codegen.NewGeneration("generated.local/gen", []eval.Root{root}) + require.NoError(t, err) + plan := &Plan{ + generation: generation, + preparedRoots: []eval.Root{root}, + examples: newExampleGenerators([]eval.Root{root}), + } + require.NoError(t, planOpenAPIData(plan)) + replacement, err := httpcodegen.NewOpenAPIPlan(root, expr.NewExampleGenerator(root.API.RandomizerFactory)) + require.NoError(t, err) + second, err := httpcodegen.NewOpenAPIPlanFromSpecs( + root, + expr.NewExampleGenerator(root.API.RandomizerFactory), + []openapi.Spec{{Version: openapi.Version20, Path: "localized/openapi"}}, + openapi.Values{}, + ) + require.NoError(t, err) + + require.NoError(t, plan.ReplaceOpenAPI(root, replacement, second)) + files, err := openAPIFiles(plan) + require.NoError(t, err) + require.Equal(t, append(replacement.Files(), second.Files()...), files) +} + +func TestReplaceOpenAPIRejectsInvalidOwnerPhaseAndPlans(t *testing.T) { + root := codegen.RunDSL(t, httpdata.SimpleDSL) + other := codegen.RunDSL(t, httpdata.SimpleDSL) + generation, err := codegen.NewGeneration("generated.local/gen", []eval.Root{root}) + require.NoError(t, err) + plan := &Plan{ + generation: generation, + preparedRoots: []eval.Root{root}, + examples: newExampleGenerators([]eval.Root{root}), + } + require.NoError(t, planOpenAPIData(plan)) + replacement, err := httpcodegen.NewOpenAPIPlan(root, expr.NewExampleGenerator(root.API.RandomizerFactory)) + require.NoError(t, err) + original, err := openAPIFiles(plan) + require.NoError(t, err) + + require.ErrorContains(t, plan.ReplaceOpenAPI(nil, replacement), "root is nil") + require.ErrorContains(t, plan.ReplaceOpenAPI(other, replacement), "not the application design root") + require.ErrorContains(t, plan.ReplaceOpenAPI(root), "at least one") + require.ErrorContains(t, plan.ReplaceOpenAPI(root, nil), "plan 0 is nil") + require.ErrorContains(t, plan.ReplaceOpenAPI(root, replacement, replacement), "same output path") + upper, err := httpcodegen.NewOpenAPIPlanFromSpecs( + root, + expr.NewExampleGenerator(root.API.RandomizerFactory), + []openapi.Spec{{Version: openapi.Version20, Path: "docs/API"}}, + openapi.Values{}, + ) + require.NoError(t, err) + lower, err := httpcodegen.NewOpenAPIPlanFromSpecs( + root, + expr.NewExampleGenerator(root.API.RandomizerFactory), + []openapi.Spec{{Version: openapi.Version30, Path: "docs/api"}}, + openapi.Values{}, + ) + require.NoError(t, err) + require.ErrorContains(t, plan.ReplaceOpenAPI(root, upper, lower), "case-insensitive filesystem") + unchanged, err := openAPIFiles(plan) + require.NoError(t, err) + require.Equal(t, original, unchanged) + + require.NoError(t, generation.Freeze()) + require.ErrorContains(t, plan.ReplaceOpenAPI(root, replacement), "after generation freeze") +} diff --git a/codegen/generator/plan.go b/codegen/generator/plan.go new file mode 100644 index 0000000000..61844ad383 --- /dev/null +++ b/codegen/generator/plan.go @@ -0,0 +1,216 @@ +// This file stores the input designs, chosen Go names, and output files for one +// run. Built-in file writers and plugins read the same values. +package generator + +import ( + "fmt" + "strings" + + "goa.design/goa/v3/codegen" + "goa.design/goa/v3/codegen/example" + "goa.design/goa/v3/codegen/service" + "goa.design/goa/v3/eval" + "goa.design/goa/v3/expr" + grpccodegen "goa.design/goa/v3/grpc/codegen" + httpcodegen "goa.design/goa/v3/http/codegen" + jsonrpccodegen "goa.design/goa/v3/jsonrpc/codegen" +) + +type ( + // Plan holds the input designs, chosen Go names, and generated files for one + // run. Code that writes files receives it after all Go names are known. + Plan struct { + generation *codegen.Generation + preparedRoots []eval.Root + examples map[*expr.RootExpr]*expr.ExampleGenerator + example []*examplePlanEntry + openapi *httpcodegen.OpenAPIPlan + openapiRoot *expr.RootExpr + openapiReplacements []*httpcodegen.OpenAPIPlan + services map[*expr.RootExpr]*service.Plan + serviceOrder []*service.Plan + http map[*expr.RootExpr]*httpcodegen.Plan + jsonrpcHTTP map[*expr.RootExpr]*httpcodegen.Plan + jsonrpc map[*expr.RootExpr]*jsonrpccodegen.Plan + grpc map[*expr.RootExpr]*grpccodegen.Plan + transports []*transportPlanEntry + transportDone bool + design *designSnapshot + } + + // transportPlanEntry keeps the transport plans for one service design in + // the order chosen during planning. + transportPlanEntry struct { + http *httpcodegen.Plan + jsonrpcHTTP *httpcodegen.Plan + jsonrpc *jsonrpccodegen.Plan + grpc *grpccodegen.Plan + } + + // examplePlanEntry keeps one copied example root with the plans that write + // files for that same design. + examplePlanEntry struct { + source *expr.RootExpr + root *example.Root + service *service.Plan + http *httpcodegen.ExamplePlan + jsonrpc *jsonrpccodegen.ExamplePlan + grpc *grpccodegen.ExamplePlan + } +) + +// Generation returns the names chosen for Go declarations and imports in this +// run. +func (p *Plan) Generation() *codegen.Generation { + return p.generation +} + +// Service returns the generated service data for root. It panics when root was +// not included in this run. +func (p *Plan) Service(root *expr.RootExpr) *service.Plan { + plan, ok := p.services[root] + if !ok { + panic(fmt.Sprintf("service plan requested for unplanned design root %q", root.API.Name)) + } + return plan +} + +// HTTP returns the ordinary HTTP plan created for root. It returns false for a +// different root value and for designs exposed only through JSON-RPC. +func (p *Plan) HTTP(root *expr.RootExpr) (*httpcodegen.Plan, bool) { + plan, ok := p.http[root] + return plan, ok +} + +// GRPC returns the gRPC plan created for the exact design root. It returns +// false when the root was not included in gRPC planning. +func (p *Plan) GRPC(root *expr.RootExpr) (*grpccodegen.Plan, bool) { + plan, ok := p.grpc[root] + return plan, ok +} + +// JSONRPC returns the JSON-RPC plan created for the exact design root. It +// returns false when the root was not included in JSON-RPC planning. +func (p *Plan) JSONRPC(root *expr.RootExpr) (*jsonrpccodegen.Plan, bool) { + plan, ok := p.jsonrpc[root] + return plan, ok +} + +// Example returns a separate copy of the example server description created +// for the exact design root. It returns false when the root was not included +// in example planning. +func (p *Plan) Example(root *expr.RootExpr) (*example.Root, bool) { + for _, entry := range p.example { + if entry.source == root { + return copyExampleRoot(entry.root), true + } + } + return nil, false +} + +// ReplaceOpenAPI replaces the OpenAPI documents for the application root with +// files already built by plans. It must be called during plugin planning, +// before generated names become final. +func (p *Plan) ReplaceOpenAPI(root *expr.RootExpr, plans ...*httpcodegen.OpenAPIPlan) error { + if root == nil { + return fmt.Errorf("OpenAPI replacement root is nil") + } + if root != p.openapiRoot { + return fmt.Errorf("root %q is not the application design root", root.API.Name) + } + if p.generation.Frozen() { + return fmt.Errorf("OpenAPI documents cannot be replaced after generation freeze") + } + if len(plans) == 0 { + return fmt.Errorf("OpenAPI replacement requires at least one plan") + } + for index, plan := range plans { + if plan == nil { + return fmt.Errorf("OpenAPI replacement plan %d is nil", index) + } + } + if err := validateOpenAPIPlanPaths(plans); err != nil { + return err + } + p.openapiReplacements = append([]*httpcodegen.OpenAPIPlan(nil), plans...) + return nil +} + +// exampleGenerator returns the example values created for root. It panics when +// root was not included in this run. +func (p *Plan) exampleGenerator(root *expr.RootExpr) *expr.ExampleGenerator { + generator, ok := p.examples[root] + if !ok { + panic(fmt.Sprintf("example generator requested for unplanned design root %q", root.API.Name)) + } + return generator +} + +// link completes each service and then builds the protocol files that use it. +func (p *Plan) link() error { + for _, plan := range p.serviceOrder { + if err := plan.Link(); err != nil { + return err + } + } + for _, transport := range p.transports { + if plan := transport.http; plan != nil { + if err := plan.Link(); err != nil { + return err + } + } + if plan := transport.jsonrpcHTTP; plan != nil { + if err := plan.Link(); err != nil { + return err + } + } + if plan := transport.jsonrpc; plan != nil { + if err := plan.Link(); err != nil { + return err + } + } + if plan := transport.grpc; plan != nil { + if err := plan.Link(); err != nil { + return err + } + } + } + return nil +} + +// verifyPreparedDesign reports the first service design value changed after +// planning and names the code that changed it. +func (p *Plan) verifyPreparedDesign(operation string) error { + path, err := p.design.changedPath(p.preparedRoots) + if err != nil { + return fmt.Errorf("%s left prepared design unverifiable: %w", operation, err) + } + if path != "" { + return fmt.Errorf("%s mutated prepared design at %s", operation, path) + } + return nil +} + +// validateOpenAPIPlanPaths rejects two OpenAPI files that use the same path, +// including paths that differ only by letter case. +func validateOpenAPIPlanPaths(plans []*httpcodegen.OpenAPIPlan) error { + var paths []string + for _, plan := range plans { + for _, file := range plan.Files() { + for _, existing := range paths { + if existing == file.Path { + return fmt.Errorf("OpenAPI plans use the same output path %q", file.Path) + } + if strings.EqualFold(existing, file.Path) { + return fmt.Errorf( + "OpenAPI paths %q and %q collide on a case-insensitive filesystem", + existing, + file.Path, + ) + } + } + paths = append(paths, file.Path) + } + } + return nil +} diff --git a/codegen/generator/plugin.go b/codegen/generator/plugin.go new file mode 100644 index 0000000000..0eac52f595 --- /dev/null +++ b/codegen/generator/plugin.go @@ -0,0 +1,239 @@ +// This file stores the generator functions registered for each command and +// creates a separate plugin value for each run. Registration closes when +// generation first starts. +package generator + +import ( + "fmt" + "slices" + "strings" + "sync" + + "goa.design/goa/v3/codegen" + "goa.design/goa/v3/codegen/internal/pluginregistry" +) + +type ( + // Plugin contains the optional functions run for one new plugin instance. + // Plan and Generate receive the same Plan pointer. + Plugin struct { + // Prepare may change designs before Goa records their prepared values. + Prepare codegen.PrepareFunc + // Plan adds the plugin's package-level names before all names are final. + Plan func(*Plan) error + // Generate adds or changes files after all names are final. + Generate func(*Plan, []*codegen.File) ([]*codegen.File, error) + } + + // PluginFactory creates one independent plugin instance for each run. + PluginFactory func() Plugin + + // registry stores the core and plugin factories used by each command. + registry struct { + mu sync.Mutex + commands map[string][]generatorFactory + commandGenerators func(string) ([]generatorFactory, error) + plugins []pluginDescriptor + registeredPlugins func() []registeredPluginDescriptor + sealed bool + } + + // pluginDescriptor stores one plugin registration. + pluginDescriptor struct { + name string + command string + position pluginPosition + factory PluginFactory + } + + // registeredPluginDescriptor copies one plugin registered through the + // released Goa v3 API before adapting it to a per-run plugin. + registeredPluginDescriptor struct { + name string + command string + position pluginPosition + prepare codegen.PrepareFunc + generate codegen.GenerateFunc + } + + // pluginPosition defines the three plugin ordering groups. + pluginPosition uint8 +) + +const ( + pluginFirst pluginPosition = iota + pluginNormal + pluginLast +) + +var defaultRegistry = newDefaultRegistry() + +// RegisterPlugin registers a factory in the normal alphabetically ordered +// group. It panics when name is empty, command is unknown, factory is nil, the +// command already has a plugin with name, or generation has already started. +func RegisterPlugin(name, command string, factory PluginFactory) { + defaultRegistry.registerPlugin(name, command, pluginNormal, factory) +} + +// RegisterPluginFirst registers a factory before normal and Last plugins. It +// enforces the same registration contract as RegisterPlugin. +func RegisterPluginFirst(name, command string, factory PluginFactory) { + defaultRegistry.registerPlugin(name, command, pluginFirst, factory) +} + +// RegisterPluginLast registers a factory after First and normal plugins. It +// enforces the same registration contract as RegisterPlugin. +func RegisterPluginLast(name, command string, factory PluginFactory) { + defaultRegistry.registerPlugin(name, command, pluginLast, factory) +} + +// newRegistry creates an empty list of commands and plugins for setup or tests. +func newRegistry() *registry { + return ®istry{commands: make(map[string][]generatorFactory)} +} + +// newDefaultRegistry creates the production command registry before external +// package initialization registers plugins. +func newDefaultRegistry() *registry { + registry := newRegistry() + registry.commands["gen"] = genGeneratorFactories() + registry.commands["example"] = exampleGeneratorFactories() + registry.commandGenerators = generatorFactories + registry.registeredPlugins = snapshotRegisteredPlugins + return registry +} + +// addCommand adds core generators to a command used by a test. +func (r *registry) addCommand(command string, factories ...generatorFactory) { + r.mu.Lock() + defer r.mu.Unlock() + if r.sealed { + panic("generator registry is sealed") + } + r.commands[command] = slices.Clone(factories) +} + +// registerPlugin adds one named factory to a known command before generation +// starts. A command cannot contain two plugins with the same name. +func (r *registry) registerPlugin(name, command string, position pluginPosition, factory PluginFactory) { + if factory == nil { + panic("plugin factory is nil") + } + r.mu.Lock() + defer r.mu.Unlock() + if r.sealed { + panic("generator plugin registry is sealed") + } + if name == "" { + panic("plugin name is empty") + } + if _, ok := r.commands[command]; !ok { + panic(fmt.Sprintf("unknown generator command %q", command)) + } + for _, plugin := range r.plugins { + if plugin.command == command && plugin.name == name { + panic(fmt.Sprintf("plugin %q is already registered for command %q", name, command)) + } + } + r.plugins = append(r.plugins, pluginDescriptor{ + name: name, + command: command, + position: position, + factory: factory, + }) +} + +// snapshot closes registration and returns copied factories in a repeatable order. +func (r *registry) snapshot(command string) ([]generatorFactory, []pluginDescriptor, error) { + var ( + selectedFactories []generatorFactory + hasSelection bool + ) + if r.commandGenerators != nil { + var err error + selectedFactories, err = r.commandGenerators(command) + if err != nil { + return nil, nil, err + } + hasSelection = true + } + r.mu.Lock() + defer r.mu.Unlock() + factories, ok := r.commands[command] + if hasSelection { + factories = selectedFactories + ok = true + } + if !ok { + return nil, nil, fmt.Errorf("unknown command %q", command) + } + r.sealed = true + selected := make([]pluginDescriptor, 0, len(r.plugins)) + factoryNames := make(map[string]struct{}, len(r.plugins)) + for _, plugin := range r.plugins { + if plugin.command != command { + continue + } + selected = append(selected, plugin) + factoryNames[plugin.name] = struct{}{} + } + if r.registeredPlugins != nil { + for _, registered := range r.registeredPlugins() { + if registered.command != command { + continue + } + if _, ok := factoryNames[registered.name]; ok { + return nil, nil, fmt.Errorf("plugin %q is already registered for command %q", registered.name, command) + } + selected = append(selected, registered.pluginDescriptor()) + } + } + slices.SortStableFunc(selected, func(left, right pluginDescriptor) int { + if left.position != right.position { + return int(left.position) - int(right.position) + } + return strings.Compare(left.name, right.name) + }) + return slices.Clone(factories), selected, nil +} + +// snapshotRegisteredPlugins stops further callback registration and copies +// each registered callback into the list used by this generation run. +func snapshotRegisteredPlugins() []registeredPluginDescriptor { + plugins := pluginregistry.Snapshot[codegen.PrepareFunc, codegen.GenerateFunc]() + descriptors := make([]registeredPluginDescriptor, len(plugins)) + for index, plugin := range plugins { + position := pluginNormal + if plugin.Position == pluginregistry.First { + position = pluginFirst + } else if plugin.Position == pluginregistry.Last { + position = pluginLast + } + descriptors[index] = registeredPluginDescriptor{ + name: plugin.Name, + command: plugin.Command, + position: position, + prepare: plugin.Prepare, + generate: plugin.Generate, + } + } + return descriptors +} + +// pluginDescriptor adapts a released callback pair to the same factory and +// per-run Plan used by newer plugins. +func (p registeredPluginDescriptor) pluginDescriptor() pluginDescriptor { + return pluginDescriptor{ + name: p.name, + command: p.command, + position: p.position, + factory: func() Plugin { + return Plugin{ + Prepare: p.prepare, + Generate: func(plan *Plan, files []*codegen.File) ([]*codegen.File, error) { + return p.generate(plan.Generation().GenPkg(), plan.preparedRoots, files) + }, + } + }, + } +} diff --git a/codegen/generator/plugin_public_integration_test.go b/codegen/generator/plugin_public_integration_test.go new file mode 100644 index 0000000000..751d1fce3a --- /dev/null +++ b/codegen/generator/plugin_public_integration_test.go @@ -0,0 +1,380 @@ +// This file runs the public plugin registration APIs in fresh child processes. +// Each child uses the real default registry, so the tests cover rejecting late +// registrations and running released callbacks across generation commands. +package generator + +import ( + "cmp" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/require" + + "goa.design/goa/v3/codegen" + servicecodegen "goa.design/goa/v3/codegen/service" + "goa.design/goa/v3/dsl" + "goa.design/goa/v3/eval" + "goa.design/goa/v3/expr" + httpcodegen "goa.design/goa/v3/http/codegen" +) + +type ( + // publicHTTPPluginOrder gives declarations in the compile test a stable + // order within the generated server package. + publicHTTPPluginOrder string + + // publicHTTPPluginData supplies three generated names to the plugin source + // template after Goa has chosen all generated names. + publicHTTPPluginData struct { + // Wrapper is the handler wrapper name chosen with core server names. + Wrapper *codegen.NameDeclaration + // EndpointWrapper is the private wrapper chosen for the Read endpoint. + EndpointWrapper *codegen.NameDeclaration + // Mount is the extra mount function name chosen with core server names. + Mount *codegen.NameDeclaration + } +) + +const publicPluginChildMode = "GOA_PUBLIC_PLUGIN_CHILD" + +// TestPublicPluginRegistrationUsesDefaultGenerationRun verifies released and +// factory registrations using fresh package globals in separate processes. +func TestPublicPluginRegistrationUsesDefaultGenerationRun(t *testing.T) { + switch os.Getenv(publicPluginChildMode) { + case "run": + runPublicPluginChild(t) + return + case "duplicate": + runPublicPluginDuplicateChild(t) + return + case "repeat": + runPublicPluginRepeatedRunChild(t) + return + case "http-extension": + runPublicHTTPServerExtensionChild(t) + return + case "legacy-http-endpoint": + runPublicLegacyHTTPEndpointChild(t) + return + } + + for _, mode := range []string{"run", "duplicate", "repeat", "http-extension", "legacy-http-endpoint"} { + t.Run(mode, func(t *testing.T) { + command := exec.Command(os.Args[0], "-test.run=^TestPublicPluginRegistrationUsesDefaultGenerationRun$") + command.Env = append(os.Environ(), publicPluginChildMode+"="+mode) + output, err := command.CombinedOutput() + require.NoErrorf(t, err, "child process failed:\n%s", output) + }) + } +} + +// runPublicLegacyHTTPEndpointChild checks that a released plugin can add an +// endpoint using the public handler name without knowing Goa's private plan. +func runPublicLegacyHTTPEndpointChild(t *testing.T) { + root := codegen.RunDSL(t, func() { + dsl.Service("Calc", func() { + dsl.Method("Read", func() { + dsl.HTTP(func() { + dsl.GET("/items") + }) + }) + }) + }) + codegen.RegisterPlugin("legacy-http-endpoint", "gen", nil, func(_ string, _ []eval.Root, files []*codegen.File) ([]*codegen.File, error) { + for _, file := range files { + if file.Path != filepath.Join(codegen.Gendir, "http", "calc", "server", "server.go") { + continue + } + for _, section := range file.SectionTemplates { + if section.Name != "server-init" { + continue + } + data := section.Data.(*httpcodegen.ServiceData) + data.Endpoints = append(data.Endpoints, &httpcodegen.EndpointData{ + Method: &servicecodegen.MethodData{VarName: "CORS"}, + MountHandler: "MountCORSHandler", + HandlerInit: "NewCORSHandler", + }) + section.Source = strings.ReplaceAll( + section.Source, + `e.{{ .Method.VarName }}, mux, {{ if .MultipartRequestDecoder }}{{ .MultipartRequestDecoder.InitName }}(mux, {{ .MultipartRequestDecoder.VarName }}){{ else }}decoder{{ end }}, encoder, errhandler, formatter{{ if isWebSocketEndpoint . }}, upgrader, configurer.{{ .Method.VarName }}Fn{{ end }})`, + `{{ if ne .Method.VarName "CORS" }}e.{{ .Method.VarName }}, mux, {{ if .MultipartRequestDecoder }}{{ .MultipartRequestDecoder.InitName }}(mux, {{ .MultipartRequestDecoder.VarName }}){{ else }}decoder{{ end }}, encoder, errhandler, formatter{{ if isWebSocketEndpoint . }}, upgrader, configurer.{{ .Method.VarName }}Fn{{ end }}{{ end }})`, + ) + } + } + return files, nil + }) + + run, err := newGenerationRun("gen", defaultRegistry) + require.NoError(t, err) + result, err := run.execute("generated.local/gen", []eval.Root{root}) + require.NoError(t, err) + for _, file := range result.files { + if file.Path != filepath.Join(codegen.Gendir, "http", "calc", "server", "server.go") { + continue + } + code := codegen.SectionsCode(t, file.Section("server-init")) + require.Contains(t, code, "CORS: NewCORSHandler()") + mount := codegen.SectionsCode(t, file.Section("server-mount")) + require.Contains(t, mount, "MountCORSHandler(mux, h.CORS)") + return + } + t.Fatal("generated HTTP server file is missing") +} + +// runPublicPluginRepeatedRunChild registers through the released API once and +// checks that the same functions receive each later run's package and root. +func runPublicPluginRepeatedRunChild(t *testing.T) { + var prepared, generated []string + codegen.RegisterPlugin( + "repeat", + "gen", + func(genpkg string, roots []eval.Root) error { + prepared = append(prepared, genpkg+":"+roots[0].(*expr.RootExpr).API.Name) + return nil + }, + func(genpkg string, roots []eval.Root, files []*codegen.File) ([]*codegen.File, error) { + name := roots[0].(*expr.RootExpr).API.Name + generated = append(generated, genpkg+":"+name) + return append(files, &codegen.File{Path: "released-" + name}), nil + }, + ) + + packages := []string{"generated.local/first", "generated.local/second"} + for index, name := range []string{"first", "second"} { + root := expr.RunDSL(t, func() { + dsl.API(name, func() { + }) + }) + run, err := newGenerationRun("gen", defaultRegistry) + require.NoError(t, err) + result, err := run.execute(packages[index], []eval.Root{root}) + require.NoError(t, err) + require.Equal(t, "released-"+name, result.files[len(result.files)-1].Path) + } + + require.Equal(t, []string{ + "generated.local/first:first", + "generated.local/second:second", + }, prepared) + require.Equal(t, prepared, generated) +} + +// runPublicHTTPServerExtensionChild generates and compiles an HTTP service with +// a public per-run plugin that defines both extension function bodies. +func runPublicHTTPServerExtensionChild(t *testing.T) { + root := codegen.RunDSL(t, func() { + dsl.Service("Calc", func() { + dsl.Method("Read", func() { + dsl.Payload(func() { + dsl.Attribute("id", dsl.String) + }) + dsl.HTTP(func() { + dsl.GET("/items/{id}") + }) + }) + }) + }) + RegisterPlugin("http-server-extension", "gen", func() Plugin { + data := &publicHTTPPluginData{} + return Plugin{ + Plan: func(plan *Plan) error { + httpPlan, ok := plan.HTTP(root) + if !ok { + return fmt.Errorf("ordinary HTTP plan is missing") + } + service := root.API.HTTP.Services[0] + var err error + data.Wrapper, err = httpPlan.DeclareServerHandlerWrapper(service, "WrapExtension", publicHTTPPluginOrder("wrapper")) + if err != nil { + return err + } + data.EndpointWrapper, err = httpPlan.DeclareServerEndpointHandlerWrapper(service.HTTPEndpoints[0], "wrapReadExtension", publicHTTPPluginOrder("endpoint wrapper")) + if err != nil { + return err + } + data.Mount, err = httpPlan.DeclareServerMount(service, "MountExtension", publicHTTPPluginOrder("mount"), []httpcodegen.ServerMountPoint{{ + Method: "Extension preflight", + Verb: "OPTIONS", + Pattern: "/items/{id}", + }}) + return err + }, + Generate: func(_ *Plan, files []*codegen.File) ([]*codegen.File, error) { + return append(files, publicHTTPServerExtensionFile(data)), nil + }, + } + }) + + dir := t.TempDir() + genDir := filepath.Join(dir, codegen.Gendir) + writeGeneratedModule(t, genDir, "generated.local/gen") + _, err := Generate(dir, "gen", false) + require.NoError(t, err) + serverSource, err := os.ReadFile(filepath.Join(genDir, "http", "calc", "server", "server.go")) + require.NoError(t, err) + require.Contains(t, string(serverSource), "h = WrapExtension(wrapReadExtension(h))") + require.Contains(t, string(serverSource), "MountReadHandler(mux, h.Read)") + require.NotContains(t, string(serverSource), "MountReadHandler(mux, WrapExtension") + runGeneratedTests(t, genDir) +} + +// runPublicPluginChild mixes both public APIs and checks the arguments and +// files passed through the real default generation run. +func runPublicPluginChild(t *testing.T) { + root := expr.RunDSL(t, func() {}) + var events []string + registerPublicFactoryPlugin("a-first", pluginFirst, &events) + registerPublicReleasedPlugin("z-first", pluginFirst, root, &events) + registerPublicReleasedPlugin("a-normal", pluginNormal, root, &events) + registerPublicFactoryPlugin("z-normal", pluginNormal, &events) + registerPublicReleasedPlugin("a-last", pluginLast, root, &events) + registerPublicFactoryPlugin("z-last", pluginLast, &events) + + run, err := newGenerationRun("gen", defaultRegistry) + require.NoError(t, err) + result, err := run.execute("generated.local/gen", []eval.Root{root}) + require.NoError(t, err) + require.Equal(t, []string{ + "prepare:a-first", "prepare:z-first", "prepare:a-normal", "prepare:z-normal", "prepare:a-last", "prepare:z-last", + "generate:a-first", "generate:z-first:factory:a-first", "generate:a-normal:released:z-first", + "generate:z-normal:released:a-normal", "generate:a-last:factory:z-normal", "generate:z-last:released:a-last", + }, events) + require.Equal(t, "factory:z-last", result.files[len(result.files)-1].Path) + require.PanicsWithValue(t, "plugin registry is sealed", func() { + codegen.RegisterPlugin("late", "gen", nil, publicUnchangedFiles) + }) + require.PanicsWithValue(t, "generator plugin registry is sealed", func() { + RegisterPlugin("late", "gen", func() Plugin { + return Plugin{} + }) + }) +} + +// runPublicPluginDuplicateChild proves that registrations for another command +// do not block or run during this command. +func runPublicPluginDuplicateChild(t *testing.T) { + called := false + codegen.RegisterPlugin("duplicate", "example", nil, func(_ string, _ []eval.Root, files []*codegen.File) ([]*codegen.File, error) { + called = true + return files, nil + }) + RegisterPlugin("duplicate", "example", func() Plugin { + called = true + return Plugin{} + }) + + _, err := newGenerationRun("gen", defaultRegistry) + require.NoError(t, err) + require.False(t, called) +} + +// registerPublicReleasedPlugin adds one old-style callback pair through the +// exact API used by released Goa v3 plugins. +func registerPublicReleasedPlugin(name string, position pluginPosition, root eval.Root, events *[]string) { + prepare := func(genpkg string, roots []eval.Root) error { + if genpkg != "generated.local/gen" { + return fmt.Errorf("prepare received package %q", genpkg) + } + if len(roots) != 1 || roots[0] != root { + return fmt.Errorf("prepare received another run's roots") + } + *events = append(*events, "prepare:"+name) + return nil + } + generate := func(genpkg string, roots []eval.Root, files []*codegen.File) ([]*codegen.File, error) { + if genpkg != "generated.local/gen" { + return nil, fmt.Errorf("generate received package %q", genpkg) + } + if len(roots) != 1 || roots[0] != root { + return nil, fmt.Errorf("generate received another run's roots") + } + *events = append(*events, "generate:"+name+":"+files[len(files)-1].Path) + return append(files, &codegen.File{Path: "released:" + name}), nil + } + switch position { + case pluginFirst: + codegen.RegisterPluginFirst(name, "gen", prepare, generate) + case pluginNormal: + codegen.RegisterPlugin(name, "gen", prepare, generate) + case pluginLast: + codegen.RegisterPluginLast(name, "gen", prepare, generate) + } +} + +// registerPublicFactoryPlugin adds one planning-aware plugin through the new +// API and records the file left by the preceding plugin. +func registerPublicFactoryPlugin(name string, position pluginPosition, events *[]string) { + factory := func() Plugin { + return Plugin{ + Prepare: func(_ string, _ []eval.Root) error { + *events = append(*events, "prepare:"+name) + return nil + }, + Generate: func(_ *Plan, files []*codegen.File) ([]*codegen.File, error) { + event := "generate:" + name + if len(files) > 0 { + event += ":" + files[len(files)-1].Path + } + *events = append(*events, event) + return append(files, &codegen.File{Path: "factory:" + name}), nil + }, + } + } + switch position { + case pluginFirst: + RegisterPluginFirst(name, "gen", factory) + case pluginNormal: + RegisterPlugin(name, "gen", factory) + case pluginLast: + RegisterPluginLast(name, "gen", factory) + } +} + +// publicUnchangedFiles is a valid callback used to test late registration. +func publicUnchangedFiles(_ string, _ []eval.Root, files []*codegen.File) ([]*codegen.File, error) { + return files, nil +} + +// publicHTTPServerExtensionFile writes the two functions promised during +// plugin planning into the generated Calc server package. +func publicHTTPServerExtensionFile(data *publicHTTPPluginData) *codegen.File { + return &codegen.File{ + Path: filepath.Join(codegen.Gendir, "http", "calc", "server", "plugin.go"), + SectionTemplates: []*codegen.SectionTemplate{ + codegen.Header("Calc HTTP server plugin", "server", []*codegen.ImportSpec{ + codegen.SimpleImport("net/http"), + codegen.GoaNamedImport("http", "goahttp"), + }), + { + Name: "http-server-extension", + Source: `// {{ .Wrapper.Name }} wraps a handler mounted from the Calc design. +func {{ .Wrapper.Name }}(handler http.Handler) http.Handler { + return handler +} + +// {{ .EndpointWrapper.Name }} wraps only the Read endpoint handler. +func {{ .EndpointWrapper.Name }}(handler http.Handler) http.Handler { + return handler +} + +// {{ .Mount.Name }} adds the Calc preflight route. +func {{ .Mount.Name }}(mux goahttp.Muxer) { + mux.Handle("OPTIONS", "/items/{id}", http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNoContent) + })) +}`, + Data: data, + }, + }, + } +} + +// ComparePackageName gives public plugin declarations a stable order. +func (o publicHTTPPluginOrder) ComparePackageName(other codegen.PackageNameOrder) int { + return cmp.Compare(string(o), string(other.(publicHTTPPluginOrder))) +} diff --git a/codegen/generator/plugin_registry_contract_test.go b/codegen/generator/plugin_registry_contract_test.go new file mode 100644 index 0000000000..66d94f1299 --- /dev/null +++ b/codegen/generator/plugin_registry_contract_test.go @@ -0,0 +1,90 @@ +// This file verifies that plugin registration rejects ambiguous ownership +// before a generation run seals and snapshots the command registry. +package generator + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +// TestPluginRegistrationRejectsInvalidIdentity verifies that malformed plugin +// ownership is rejected before any generation run snapshots the registry. +func TestPluginRegistrationRejectsInvalidIdentity(t *testing.T) { + tests := []struct { + name string + plugin string + command string + }{ + {name: "empty plugin name", command: "test"}, + {name: "unknown command", plugin: "plugin", command: "missing"}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + registry := newRegistry() + registry.addCommand("test") + + require.Panics(t, func() { + registry.registerPlugin(test.plugin, test.command, pluginNormal, func() Plugin { + return Plugin{} + }) + }) + }) + } +} + +// TestPluginRegistrationRejectsDuplicateCommandName verifies that a plugin +// cannot acquire two ordering positions for the same command and owner name. +func TestPluginRegistrationRejectsDuplicateCommandName(t *testing.T) { + positions := []struct { + name string + position pluginPosition + }{ + {name: "first", position: pluginFirst}, + {name: "normal", position: pluginNormal}, + {name: "last", position: pluginLast}, + } + + for _, initial := range positions { + for _, duplicate := range positions { + t.Run(initial.name+" then "+duplicate.name, func(t *testing.T) { + registry := newRegistry() + registry.addCommand("test") + registry.registerPlugin("plugin", "test", initial.position, func() Plugin { + return Plugin{} + }) + + require.Panics(t, func() { + registry.registerPlugin("plugin", "test", duplicate.position, func() Plugin { + return Plugin{} + }) + }) + }) + } + } +} + +// TestPluginRegistrationScopesNamesToCommand verifies that two commands may +// use the same owner name because each command snapshots its own plugin list. +func TestPluginRegistrationScopesNamesToCommand(t *testing.T) { + registry := newRegistry() + registry.addCommand("first") + registry.addCommand("second") + registry.registerPlugin("plugin", "first", pluginNormal, func() Plugin { + return Plugin{} + }) + registry.registerPlugin("plugin", "second", pluginNormal, func() Plugin { + return Plugin{} + }) + + _, first, err := registry.snapshot("first") + require.NoError(t, err) + require.Len(t, first, 1) + require.Equal(t, "first", first[0].command) + + _, second, err := registry.snapshot("second") + require.NoError(t, err) + require.Len(t, second, 1) + require.Equal(t, "second", second[0].command) +} diff --git a/codegen/generator/plugin_test.go b/codegen/generator/plugin_test.go new file mode 100644 index 0000000000..4300e73b27 --- /dev/null +++ b/codegen/generator/plugin_test.go @@ -0,0 +1,754 @@ +// This file verifies that generator and plugin factories create isolated run +// objects and that every phase receives one retained Plan in stable order. +package generator + +import ( + "errors" + "fmt" + "path/filepath" + "sync" + "testing" + + "github.com/stretchr/testify/require" + + "goa.design/goa/v3/codegen" + "goa.design/goa/v3/eval" + "goa.design/goa/v3/expr" + httpcodegen "goa.design/goa/v3/http/codegen" + httpdata "goa.design/goa/v3/http/codegen/testdata" +) + +// TestPluginFactoryOrderAndPlan verifies First, normal, and Last ordering and +// proves that plugin planning and rendering receive the exact same Plan pointer. +func TestPluginFactoryOrderAndPlan(t *testing.T) { + registry := newRegistry() + registry.addCommand("test", func() coreGenerator { + return coreGenerator{} + }) + var ( + events []string + plans []*Plan + planMux sync.Mutex + ) + register := func(position pluginPosition, name string) { + registry.registerPlugin(name, "test", position, func() Plugin { + return Plugin{ + Prepare: func(_ string, _ []eval.Root) error { + events = append(events, "prepare:"+name) + return nil + }, + Plan: func(plan *Plan) error { + events = append(events, "plan:"+name) + planMux.Lock() + plans = append(plans, plan) + planMux.Unlock() + return nil + }, + Generate: func(plan *Plan, files []*codegen.File) ([]*codegen.File, error) { + events = append(events, "generate:"+name) + planMux.Lock() + plans = append(plans, plan) + planMux.Unlock() + return files, nil + }, + } + }) + } + register(pluginLast, "z-last") + register(pluginNormal, "z-normal") + register(pluginFirst, "b-first") + register(pluginFirst, "a-first") + register(pluginNormal, "a-normal") + register(pluginLast, "a-last") + + err := executeGeneration("generated.local/gen", nil, "test", registry) + require.NoError(t, err) + require.Equal(t, []string{ + "prepare:a-first", "prepare:b-first", "prepare:a-normal", "prepare:z-normal", "prepare:a-last", "prepare:z-last", + "plan:a-first", "plan:b-first", "plan:a-normal", "plan:z-normal", "plan:a-last", "plan:z-last", + "generate:a-first", "generate:b-first", "generate:a-normal", "generate:z-normal", "generate:a-last", "generate:z-last", + }, events) + require.Len(t, plans, 12) + for _, plan := range plans[1:] { + require.Same(t, plans[0], plan) + } + require.NotNil(t, plans[0].Generation()) +} + +// TestPluginPlannedHTTPDataIsAccepted checks that a factory plugin may declare +// a constructor during Plan and use it as direct HTTP data during Generate. +func TestPluginPlannedHTTPDataIsAccepted(t *testing.T) { + root := codegen.RunDSL(t, httpdata.ServerSimpleRoutingDSL) + registry := newDefaultRegistry() + registry.registerPlugin("planned-http-data", "gen", pluginNormal, func() Plugin { + var declaration *codegen.NameDeclaration + return Plugin{ + Plan: func(plan *Plan) error { + pkg, err := plan.Generation().ClaimPackage("generated.local/gen/http/plugin") + if err != nil { + return err + } + declaration = codegen.NewExactName(codegen.NameFunction, "BuildPluginBody") + return pkg.DeclareName(declaration) + }, + Generate: func(_ *Plan, files []*codegen.File) ([]*codegen.File, error) { + return append(files, &codegen.File{ + Path: "gen/http/plugin/plugin.go", + SectionTemplates: []*codegen.SectionTemplate{{ + Name: "plugin-init", + Data: &httpcodegen.InitData{ + Declaration: declaration, + Name: declaration.Name(), + }, + }}, + }), nil + }, + } + }) + + run, err := newGenerationRun("gen", registry) + require.NoError(t, err) + _, err = run.execute("generated.local/gen", []eval.Root{root}) + require.NoError(t, err) +} + +// TestPluginOwnedHTTPDeclarationReplacementIsAccepted checks that a later +// plugin may replace a declaration with another name planned by the same run. +func TestPluginOwnedHTTPDeclarationReplacementIsAccepted(t *testing.T) { + root := codegen.RunDSL(t, httpdata.ServerSimpleRoutingDSL) + registry := newDefaultRegistry() + var ( + init *httpcodegen.InitData + declaration *codegen.NameDeclaration + replacement *codegen.NameDeclaration + laterRan bool + ) + registry.registerPlugin("a-add-init", "gen", pluginNormal, func() Plugin { + return Plugin{ + Plan: func(plan *Plan) error { + pkg, err := plan.Generation().ClaimPackage("generated.local/gen/http/plugin") + if err != nil { + return err + } + declaration = codegen.NewExactName(codegen.NameFunction, "BuildPluginBody") + replacement = codegen.NewExactName(codegen.NameFunction, "BuildOtherBody") + if err := pkg.DeclareName(declaration); err != nil { + return err + } + return pkg.DeclareName(replacement) + }, + Generate: func(_ *Plan, files []*codegen.File) ([]*codegen.File, error) { + init = &httpcodegen.InitData{Declaration: declaration, Name: declaration.Name()} + return append(files, &codegen.File{ + Path: "gen/http/plugin/plugin.go", + SectionTemplates: []*codegen.SectionTemplate{{Name: "plugin-init", Data: init}}, + }), nil + }, + } + }) + registry.registerPlugin("b-replace-init", "gen", pluginNormal, func() Plugin { + return Plugin{Generate: func(_ *Plan, files []*codegen.File) ([]*codegen.File, error) { + init.Declaration = replacement + init.Name = replacement.Name() + return files, nil + }} + }) + registry.registerPlugin("c-later", "gen", pluginNormal, func() Plugin { + return Plugin{Generate: func(_ *Plan, files []*codegen.File) ([]*codegen.File, error) { + laterRan = true + return files, nil + }} + }) + + run, err := newGenerationRun("gen", registry) + require.NoError(t, err) + _, err = run.execute("generated.local/gen", []eval.Root{root}) + require.NoError(t, err) + require.True(t, laterRan) +} + +// TestPluginCallbackErrorIsPreserved checks that an ordinary callback failure +// is returned unchanged and stops later plugins. +func TestPluginCallbackErrorIsPreserved(t *testing.T) { + root := &expr.RootExpr{API: &expr.APIExpr{ + Name: "callback-error", + RandomizerFactory: expr.NewDeterministicRandomizerFactory(), + }} + registry := newRegistry() + registry.addCommand("test") + laterRan := false + registry.registerPlugin("fail", "test", pluginNormal, func() Plugin { + return Plugin{Generate: func(_ *Plan, files []*codegen.File) ([]*codegen.File, error) { + return files, errors.New("callback failed") + }} + }) + registry.registerPlugin("z-later", "test", pluginNormal, func() Plugin { + return Plugin{Generate: func(_ *Plan, files []*codegen.File) ([]*codegen.File, error) { + laterRan = true + return files, nil + }} + }) + + run, err := newGenerationRun("test", registry) + require.NoError(t, err) + _, err = run.execute("generated.local/gen", []eval.Root{root}) + require.EqualError(t, err, "callback failed") + require.False(t, laterRan) +} + +// TestPluginDesignMutationErrorTakesPrecedence checks that Goa reports a +// forbidden design change even when the callback also returns its own error. +// The changed root would otherwise remain visible to later generation runs. +func TestPluginDesignMutationErrorTakesPrecedence(t *testing.T) { + root := &expr.RootExpr{API: &expr.APIExpr{ + Name: "before", + RandomizerFactory: expr.NewDeterministicRandomizerFactory(), + }} + registry := newRegistry() + registry.addCommand("test") + registry.registerPlugin("mutate-and-fail", "test", pluginNormal, func() Plugin { + return Plugin{Generate: func(_ *Plan, files []*codegen.File) ([]*codegen.File, error) { + root.API.Name = "after" + return files, errors.New("callback failed") + }} + }) + + run, err := newGenerationRun("test", registry) + require.NoError(t, err) + _, err = run.execute("generated.local/gen", []eval.Root{root}) + require.ErrorContains(t, err, `plugin "mutate-and-fail" generate mutated prepared design`) + require.NotEqual(t, "callback failed", err.Error()) +} + +// TestPluginFactorySequentialIsolation verifies that every run invokes the +// factory again and no mutable callback state survives from an earlier run. +func TestPluginFactorySequentialIsolation(t *testing.T) { + registry := isolatedPluginRegistry(t) + + for i := range 2 { + root := &expr.RootExpr{API: &expr.APIExpr{ + Name: fmt.Sprintf("run-%d", i), + RandomizerFactory: expr.NewDeterministicRandomizerFactory(), + }} + err := executeGeneration( + fmt.Sprintf("generated.local/gen%d", i), + []eval.Root{root}, + "test", + registry, + ) + require.NoError(t, err) + } +} + +// TestPluginFactoryConcurrentIsolation verifies that registry snapshots are +// race-safe and concurrent runs own independent callback state. +func TestPluginFactoryConcurrentIsolation(t *testing.T) { + registry := isolatedPluginRegistry(t) + var wait sync.WaitGroup + errs := make(chan error, 2) + for i := range 2 { + wait.Add(1) + go func(index int) { + defer wait.Done() + root := &expr.RootExpr{API: &expr.APIExpr{ + Name: fmt.Sprintf("run-%d", index), + RandomizerFactory: expr.NewDeterministicRandomizerFactory(), + }} + err := executeGeneration( + fmt.Sprintf("generated.local/gen%d", index), + []eval.Root{root}, + "test", + registry, + ) + errs <- err + }(i) + } + wait.Wait() + close(errs) + for err := range errs { + require.NoError(t, err) + } +} + +// TestPreparedRootsBecomeExactGenerationSnapshot verifies that plugin +// preparation completes before Generation copies root membership and values. +func TestPreparedRootsBecomeExactGenerationSnapshot(t *testing.T) { + root := &expr.RootExpr{API: &expr.APIExpr{ + Name: "before", + RandomizerFactory: expr.NewDeterministicRandomizerFactory(), + }} + registry := newRegistry() + registry.addCommand("test", func() coreGenerator { + return coreGenerator{ + Plan: func(plan *Plan) error { + if !plan.Generation().HasRoot(root) { + return fmt.Errorf("prepared root is absent from generation") + } + if root.API.Name != "after" { + return fmt.Errorf("generation observed API name %q", root.API.Name) + } + return nil + }, + } + }) + registry.registerPlugin("prepare", "test", pluginNormal, func() Plugin { + return Plugin{Prepare: func(_ string, roots []eval.Root) error { + roots[0].(*expr.RootExpr).API.Name = "after" + return nil + }} + }) + + err := executeGeneration("generated.local/gen", []eval.Root{root}, "test", registry) + require.NoError(t, err) +} + +// TestPreparedRootsRejectNonAttributeMutations verifies that service, method, +// transport, and pointer-topology changes stop before the next callback. +func TestPreparedRootsRejectNonAttributeMutations(t *testing.T) { + tests := []struct { + name string + phase string + configure func(*registry, func()) + }{ + { + name: "service identity during plugin plan", + phase: `plugin "a-mutator" plan`, + configure: func(registry *registry, following func()) { + registry.addCommand("test") + registry.registerPlugin("a-mutator", "test", pluginNormal, func() Plugin { + return Plugin{Plan: func(plan *Plan) error { + plan.Generation().Roots()[0].(*expr.RootExpr).Services[0].Name = "changed" + return nil + }} + }) + registry.registerPlugin("z-following", "test", pluginNormal, func() Plugin { + return Plugin{Plan: func(_ *Plan) error { + following() + return nil + }} + }) + }, + }, + { + name: "method identity during core generate", + phase: `core "method-mutator" generate`, + configure: func(registry *registry, following func()) { + registry.addCommand( + "test", + func() coreGenerator { + return coreGenerator{name: "method-mutator", Generate: func(plan *Plan) ([]*codegen.File, error) { + plan.Generation().Roots()[0].(*expr.RootExpr).Services[0].Methods[0].Name = "changed" + return nil, nil + }} + }, + func() coreGenerator { + return coreGenerator{name: "following", Generate: func(_ *Plan) ([]*codegen.File, error) { + following() + return nil, nil + }} + }, + ) + }, + }, + { + name: "HTTP route during plugin generate", + phase: `plugin "a-mutator" generate`, + configure: func(registry *registry, following func()) { + registry.addCommand("test") + registry.registerPlugin("a-mutator", "test", pluginNormal, func() Plugin { + return Plugin{Generate: func(plan *Plan, files []*codegen.File) ([]*codegen.File, error) { + plan.Generation().Roots()[0].(*expr.RootExpr).API.HTTP.Services[0].HTTPEndpoints[0].Routes[0].Path = "/changed" + return files, nil + }} + }) + registry.registerPlugin("z-following", "test", pluginNormal, func() Plugin { + return Plugin{Generate: func(_ *Plan, files []*codegen.File) ([]*codegen.File, error) { + following() + return files, nil + }} + }) + }, + }, + { + name: "equal service replacement during core plan", + phase: `core "topology-mutator" plan`, + configure: func(registry *registry, following func()) { + registry.addCommand( + "test", + func() coreGenerator { + return coreGenerator{name: "topology-mutator", Plan: func(plan *Plan) error { + root := plan.Generation().Roots()[0].(*expr.RootExpr) + copy := *root.Services[0] + root.Services[0] = © + return nil + }} + }, + func() coreGenerator { + return coreGenerator{name: "following", Plan: func(_ *Plan) error { + following() + return nil + }} + }, + ) + }, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + root := expr.RunDSL(t, httpdata.AliasTypeDSL) + registry := newRegistry() + followingRan := false + test.configure(registry, func() { + followingRan = true + }) + + err := executeGeneration("generated.local/gen", []eval.Root{root}, "test", registry) + require.ErrorContains(t, err, test.phase+" mutated prepared design") + require.False(t, followingRan) + }) + } +} + +// TestPluginRegistrySealsOnFirstSnapshot verifies that a run cannot observe +// factories registered after the registry's immutable snapshot is established. +func TestPluginRegistrySealsOnFirstSnapshot(t *testing.T) { + registry := newRegistry() + registry.addCommand("test", func() coreGenerator { + return coreGenerator{} + }) + err := executeGeneration("generated.local/gen", nil, "test", registry) + require.NoError(t, err) + require.Panics(t, func() { + registry.registerPlugin("late", "test", pluginNormal, func() Plugin { + return Plugin{} + }) + }) +} + +// TestReleasedAndFactoryPluginsShareOneRun verifies that plugins registered +// through either API run in one order and receive the same prepared design and +// current file list. +func TestReleasedAndFactoryPluginsShareOneRun(t *testing.T) { + root := &expr.RootExpr{API: &expr.APIExpr{ + Name: "prepared", + RandomizerFactory: expr.NewDeterministicRandomizerFactory(), + }} + registry := newRegistry() + registry.addCommand("test", func() coreGenerator { + return coreGenerator{Generate: func(_ *Plan) ([]*codegen.File, error) { + return []*codegen.File{{Path: "core"}}, nil + }} + }) + var events []string + registry.registeredPlugins = func() []registeredPluginDescriptor { + return []registeredPluginDescriptor{ + releasedPluginForTest("z-first", "test", pluginFirst, root, &events), + releasedPluginForTest("a-normal", "test", pluginNormal, root, &events), + releasedPluginForTest("a-last", "test", pluginLast, root, &events), + } + } + registerFactoryPluginForTest(registry, "a-first", pluginFirst, &events) + registerFactoryPluginForTest(registry, "z-normal", pluginNormal, &events) + registerFactoryPluginForTest(registry, "z-last", pluginLast, &events) + + run, err := newGenerationRun("test", registry) + require.NoError(t, err) + result, err := run.execute("generated.local/gen", []eval.Root{root}) + require.NoError(t, err) + require.Equal(t, []string{ + "prepare:a-first", "prepare:z-first", "prepare:a-normal", "prepare:z-normal", "prepare:a-last", "prepare:z-last", + "generate:a-first:core", "generate:z-first:factory:a-first", "generate:a-normal:released:z-first", + "generate:z-normal:released:a-normal", "generate:a-last:factory:z-normal", "generate:z-last:released:a-last", + }, events) + require.Equal(t, "factory:z-last", result.files[len(result.files)-1].Path) +} + +// TestReleasedDuplicatePluginsKeepRegistrationOrder verifies that callbacks +// with the same released command and name still run in registration order. +func TestReleasedDuplicatePluginsKeepRegistrationOrder(t *testing.T) { + registry := newRegistry() + registry.addCommand("test", func() coreGenerator { + return coreGenerator{Generate: func(_ *Plan) ([]*codegen.File, error) { + return []*codegen.File{{Path: "core"}}, nil + }} + }) + var events []string + registry.registeredPlugins = func() []registeredPluginDescriptor { + duplicate := func(event string) registeredPluginDescriptor { + return registeredPluginDescriptor{ + name: "same", + command: "test", + position: pluginNormal, + generate: func(_ string, _ []eval.Root, files []*codegen.File) ([]*codegen.File, error) { + events = append(events, event+":"+files[len(files)-1].Path) + return append(files, &codegen.File{Path: event}), nil + }, + } + } + return []registeredPluginDescriptor{duplicate("first"), duplicate("second")} + } + + run, err := newGenerationRun("test", registry) + require.NoError(t, err) + _, err = run.execute("generated.local/gen", nil) + require.NoError(t, err) + require.Equal(t, []string{"first:core", "second:first"}, events) +} + +// TestReleasedPluginCallbackReceivesEachRun checks that the same registered +// function can run twice. Each call receives only that run's generated package, +// design roots, and files. +func TestReleasedPluginCallbackReceivesEachRun(t *testing.T) { + registry := newRegistry() + registry.addCommand("test", func() coreGenerator { + return coreGenerator{Generate: func(plan *Plan) ([]*codegen.File, error) { + root := plan.Generation().Roots()[0].(*expr.RootExpr) + return []*codegen.File{{Path: "core-" + root.API.Name}}, nil + }} + }) + + var ( + preparedPackages []string + preparedRoots [][]eval.Root + generatedPackages []string + generatedRoots [][]eval.Root + generatedFiles [][]*codegen.File + ) + //nolint:unparam // The released callback signature includes an error result. + prepare := func(genpkg string, roots []eval.Root) error { + preparedPackages = append(preparedPackages, genpkg) + preparedRoots = append(preparedRoots, append([]eval.Root(nil), roots...)) + return nil + } + //nolint:unparam // The released callback signature includes an error result. + generate := func(genpkg string, roots []eval.Root, files []*codegen.File) ([]*codegen.File, error) { + generatedPackages = append(generatedPackages, genpkg) + generatedRoots = append(generatedRoots, append([]eval.Root(nil), roots...)) + generatedFiles = append(generatedFiles, append([]*codegen.File(nil), files...)) + return append(files, &codegen.File{Path: "released-" + roots[0].(*expr.RootExpr).API.Name}), nil + } + registry.registeredPlugins = func() []registeredPluginDescriptor { + return []registeredPluginDescriptor{{ + name: "released", + command: "test", + position: pluginNormal, + prepare: prepare, + generate: generate, + }} + } + + packages := []string{"generated.local/first", "generated.local/second"} + roots := []*expr.RootExpr{ + {API: &expr.APIExpr{Name: "first", RandomizerFactory: expr.NewDeterministicRandomizerFactory()}}, + {API: &expr.APIExpr{Name: "second", RandomizerFactory: expr.NewDeterministicRandomizerFactory()}}, + } + for index, root := range roots { + run, err := newGenerationRun("test", registry) + require.NoError(t, err) + result, err := run.execute(packages[index], []eval.Root{root}) + require.NoError(t, err) + require.Equal(t, "released-"+root.API.Name, result.files[1].Path) + } + + require.Equal(t, packages, preparedPackages) + require.Equal(t, packages, generatedPackages) + for index, root := range roots { + require.Len(t, preparedRoots[index], 1) + require.Same(t, root, preparedRoots[index][0]) + require.Len(t, generatedRoots[index], 1) + require.Same(t, root, generatedRoots[index][0]) + require.Len(t, generatedFiles[index], 1) + require.Equal(t, "core-"+root.API.Name, generatedFiles[index][0].Path) + } +} + +// TestReleasedPluginNilFileRemainsVisibleUntilMerge checks that one plugin may +// return a one-item list containing nil. The next plugin receives that list +// unchanged, and Goa omits nil before writing files. Released Goa accidentally +// panicked when nil was the only file; generation now handles every list size +// consistently. +func TestReleasedPluginNilFileRemainsVisibleUntilMerge(t *testing.T) { + codegen.RunDSL(t, func() { + }) + registry := newRegistry() + registry.addCommand("test") + observedNil := false + registry.registeredPlugins = func() []registeredPluginDescriptor { + return []registeredPluginDescriptor{ + { + name: "a-return-nil", + command: "test", + position: pluginNormal, + generate: func(_ string, _ []eval.Root, _ []*codegen.File) ([]*codegen.File, error) { + return []*codegen.File{nil}, nil + }, + }, + { + name: "b-observe-nil", + command: "test", + position: pluginNormal, + generate: func(_ string, _ []eval.Root, files []*codegen.File) ([]*codegen.File, error) { + observedNil = len(files) == 1 && files[0] == nil + return files, nil + }, + }, + } + } + directory := t.TempDir() + writeGeneratedModule(t, filepath.Join(directory, codegen.Gendir), "generated.local/gen") + + outputs, err := generate(directory, "test", false, registry) + require.NoError(t, err) + require.True(t, observedNil) + require.Empty(t, outputs) +} + +// TestReleasedAndFactoryPluginDuplicatesStopBeforeCallbacks verifies that a +// command/name pair cannot be registered once through each API. +func TestReleasedAndFactoryPluginDuplicatesStopBeforeCallbacks(t *testing.T) { + registry := newRegistry() + registry.addCommand("test") + called := false + registry.registeredPlugins = func() []registeredPluginDescriptor { + return []registeredPluginDescriptor{{ + name: "duplicate", + command: "test", + position: pluginFirst, + generate: func(_ string, _ []eval.Root, files []*codegen.File) ([]*codegen.File, error) { + called = true + return files, nil + }, + }} + } + registry.registerPlugin("duplicate", "test", pluginNormal, func() Plugin { + called = true + return Plugin{} + }) + + _, err := newGenerationRun("test", registry) + require.ErrorContains(t, err, `plugin "duplicate" is already registered for command "test"`) + require.False(t, called) +} + +// isolatedPluginRegistry builds a factory whose private phase counter must +// always start at zero and advance exactly once through the three phases. +func isolatedPluginRegistry(t *testing.T) *registry { + t.Helper() + registry := newRegistry() + registry.addCommand("test", func() coreGenerator { + phase := 0 + return coreGenerator{ + name: "state", + Plan: func(_ *Plan) error { + if phase != 0 { + return fmt.Errorf("core plan started at phase %d", phase) + } + phase++ + return nil + }, + Generate: func(plan *Plan) ([]*codegen.File, error) { + if phase != 1 { + return nil, fmt.Errorf("core generate started at phase %d", phase) + } + phase++ + return []*codegen.File{{Path: plan.Generation().GenPkg()}}, nil + }, + } + }) + registry.registerPlugin("state", "test", pluginNormal, func() Plugin { + var ( + phase int + preparedRoot eval.Root + planned *Plan + ) + return Plugin{ + Prepare: func(_ string, roots []eval.Root) error { + if phase != 0 { + return fmt.Errorf("prepare started at phase %d", phase) + } + if len(roots) != 1 { + return fmt.Errorf("prepare received %d roots", len(roots)) + } + preparedRoot = roots[0] + phase++ + return nil + }, + Plan: func(plan *Plan) error { + if phase != 1 { + return fmt.Errorf("plan started at phase %d", phase) + } + roots := plan.Generation().Roots() + if len(roots) != 1 || roots[0] != preparedRoot { + return fmt.Errorf("plan received another run's roots") + } + planned = plan + phase++ + return nil + }, + Generate: func(plan *Plan, files []*codegen.File) ([]*codegen.File, error) { + if phase != 2 { + return nil, fmt.Errorf("generate started at phase %d", phase) + } + if plan != planned { + return nil, fmt.Errorf("generate received another run's plan") + } + if len(files) != 1 || files[0].Path != plan.Generation().GenPkg() { + return nil, fmt.Errorf("generate received another run's files") + } + phase++ + return files, nil + }, + } + }) + return registry +} + +// releasedPluginForTest creates an old-style callback that checks the exact +// package, root, and file list passed from the shared generation run. +func releasedPluginForTest(name, command string, position pluginPosition, root eval.Root, events *[]string) registeredPluginDescriptor { + return registeredPluginDescriptor{ + name: name, + command: command, + position: position, + prepare: func(genpkg string, roots []eval.Root) error { + if genpkg != "generated.local/gen" { + return fmt.Errorf("prepare received package %q", genpkg) + } + if len(roots) != 1 || roots[0] != root { + return fmt.Errorf("prepare received another run's roots") + } + *events = append(*events, "prepare:"+name) + return nil + }, + generate: func(genpkg string, roots []eval.Root, files []*codegen.File) ([]*codegen.File, error) { + if genpkg != "generated.local/gen" { + return nil, fmt.Errorf("generate received package %q", genpkg) + } + if len(roots) != 1 || roots[0] != root { + return nil, fmt.Errorf("generate received another run's roots") + } + last := files[len(files)-1].Path + *events = append(*events, "generate:"+name+":"+last) + return append(files, &codegen.File{Path: "released:" + name}), nil + }, + } +} + +// registerFactoryPluginForTest adds a factory plugin that records its current +// input file and appends one file for the following plugin. +func registerFactoryPluginForTest(registry *registry, name string, position pluginPosition, events *[]string) { + registry.registerPlugin(name, "test", position, func() Plugin { + return Plugin{ + Prepare: func(_ string, _ []eval.Root) error { + *events = append(*events, "prepare:"+name) + return nil + }, + Generate: func(_ *Plan, files []*codegen.File) ([]*codegen.File, error) { + last := files[len(files)-1].Path + *events = append(*events, "generate:"+name+":"+last) + return append(files, &codegen.File{Path: "factory:" + name}), nil + }, + } + }) +} diff --git a/codegen/generator/public_api_compatibility_test.go b/codegen/generator/public_api_compatibility_test.go new file mode 100644 index 0000000000..f719076502 --- /dev/null +++ b/codegen/generator/public_api_compatibility_test.go @@ -0,0 +1,62 @@ +// This file protects released generator types that do not expose or run the +// internal generation sequence. +package generator + +import ( + "fmt" + "testing" + + "github.com/stretchr/testify/require" + + "goa.design/goa/v3/codegen" + "goa.design/goa/v3/eval" +) + +// TestReleasedGeneratorFunctionType checks the released function signature. +func TestReleasedGeneratorFunctionType(t *testing.T) { + var generate Genfunc = func(string, []eval.Root) ([]*codegen.File, error) { + return []*codegen.File{{Path: "generated.go"}}, nil + } + files, err := generate("generated.local/gen", nil) + require.NoError(t, err) + require.Equal(t, "generated.go", files[0].Path) +} + +// TestReleasedGeneratorEntryPoints checks the generator functions that plugins +// can call directly or return from Generators. +func TestReleasedGeneratorEntryPoints(t *testing.T) { + var ( + service Genfunc = Service + transport Genfunc = Transport + openAPI Genfunc = OpenAPI + example Genfunc = Example + ) + require.NotNil(t, service) + require.NotNil(t, transport) + require.NotNil(t, openAPI) + require.NotNil(t, example) + + original := Generators + t.Cleanup(func() { + Generators = original + }) + Generators = func(command string) ([]Genfunc, error) { + if command != "custom" { + return nil, fmt.Errorf("unknown command %q", command) + } + return []Genfunc{func(string, []eval.Root) ([]*codegen.File, error) { + return []*codegen.File{{Path: "custom.go"}}, nil + }}, nil + } + generators, err := Generators("custom") + require.NoError(t, err) + require.Len(t, generators, 1) + require.NotNil(t, generators[0]) + + run, err := newGenerationRun("custom", newDefaultRegistry()) + require.NoError(t, err) + result, err := run.execute("generated.local/gen", nil) + require.NoError(t, err) + require.Len(t, result.files, 1) + require.Equal(t, "custom.go", result.files[0].Path) +} diff --git a/codegen/generator/purity_test.go b/codegen/generator/purity_test.go index aa99e2ed31..b9e9c9723d 100644 --- a/codegen/generator/purity_test.go +++ b/codegen/generator/purity_test.go @@ -1,13 +1,13 @@ +// This file verifies through the production lifecycle boundary that only +// preparation and normalization may change evaluated design expressions. package generator import ( - "reflect" "testing" - "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - "goa.design/goa/v3/codegen" + "goa.design/goa/v3/dsl" "goa.design/goa/v3/eval" "goa.design/goa/v3/expr" grpcdata "goa.design/goa/v3/grpc/codegen/testdata" @@ -15,50 +15,16 @@ import ( jsonrpcdata "goa.design/goa/v3/jsonrpc/codegen/testdata" ) -type ( - // attrState captures the mutable state of a design attribute expression: - // the identity of its type, the user type naming, the object shape and - // deep copies of the meta and validation expressions. Two snapshots of - // the same attribute are equal if and only if no reachable state was - // rewritten in between. - attrState struct { - Type uintptr - Primitive expr.Kind - TypeName string - UID string - Identifier string - Views []string - UTAttr uintptr - Fields []string - Description string - Meta expr.MetaExpr - Validation *expr.ValidationExpr - DefaultValue any - } - - // visitKey identifies a visited pointer during the design walk. The type - // disambiguates a struct from its first field which share the address. - visitKey struct { - ptr uintptr - typ reflect.Type - } -) - -// TestGeneratorsTreatDesignAsReadOnly is the design purity invariant: once -// eval finalization ran and codegen.NormalizeRoot applied the only sanctioned -// post-finalization rewrite, running every generator ("gen" and "example") -// must leave the design expression tree bit for bit unchanged. The fixtures -// cover alias chains, result views, websocket streaming, SSE with anonymous -// object payloads and results (the NormalizeRoot wrapping case), mixed -// HTTP+JSON-RPC transports and gRPC unions and streaming. +// TestGeneratorsTreatDesignAsReadOnly audits the persistent design state after +// generation construction applies the sanctioned normalization. Running every +// generator ("gen" and "example") must leave the prepared semantic design +// unchanged after each callback and completed render. The fixtures cover alias +// chains, result views, websocket streaming, SSE with anonymous object payloads +// and results, mixed HTTP+JSON-RPC transports, and gRPC unions and streaming. // -// Process global state is deliberately out of the snapshot: -// - expr.GeneratedResultTypes is appended to by expr.Dup when generators -// duplicate generated result types; it is a separate eval root, not part -// of the design tree (known purity hole, documented in expr/dup.go). -// - the example randomizer seen-value cache lives on the API expression -// example generator and is legitimately filled by example and OpenAPI -// generation; the walk skips it. +// The production lifecycle snapshot owns this audit. Dormant eval.DSLFunc +// closure captures and process-global state outside the prepared roots are not +// evaluated design input and remain outside this assertion. func TestGeneratorsTreatDesignAsReadOnly(t *testing.T) { cases := []struct { Name string @@ -66,6 +32,7 @@ func TestGeneratorsTreatDesignAsReadOnly(t *testing.T) { }{ {"alias-chains", httpdata.AliasTypeDSL}, {"result-views", httpdata.ResultBodyMultipleViewsDSL}, + {"result-collection-custom-view", resultCollectionCustomViewDSL}, {"websocket-bidirectional", httpdata.BidirectionalStreamingDSL}, {"sse-anonymous-object", httpdata.SSEObjectDSL}, {"jsonrpc-mixed-transport", jsonrpcdata.JSONRPCKitchenSinkDSL}, @@ -74,127 +41,67 @@ func TestGeneratorsTreatDesignAsReadOnly(t *testing.T) { } for _, c := range cases { t.Run(c.Name, func(t *testing.T) { - root := expr.RunDSL(t, c.DSL) - codegen.NormalizeRoot(root) - before := snapshotDesign(root) + expr.RunDSL(t, c.DSL) + roots, err := eval.Context.Roots() + require.NoError(t, err) for _, cmd := range []string{"gen", "example"} { - genfuncs, err := Generators(cmd) + err := executeGeneration("generated.local/gen", roots, cmd, newDefaultRegistry()) require.NoError(t, err) - for _, gen := range genfuncs { - _, err := gen("gen", []eval.Root{root}) - require.NoError(t, err) - } - } - - after := snapshotDesign(root) - assert.Len(t, after, len(before), "attributes appeared in or disappeared from the design") - for att, b := range before { - a, ok := after[att] - if !assert.True(t, ok, "attribute %q (%p) disappeared from the design", b.Description, att) { - continue - } - assert.Equal(t, b, a, "attribute %q (%p) was mutated by a generator", b.Description, att) } }) } } -// snapshotDesign walks every expression reachable from the root via exported -// fields and captures the state of each attribute expression encountered. -func snapshotDesign(root *expr.RootExpr) map[*expr.AttributeExpr]attrState { - atts := make(map[*expr.AttributeExpr]attrState) - visited := make(map[visitKey]struct{}) - exampleGenType := reflect.TypeOf((*expr.ExampleGenerator)(nil)) - var walk func(v reflect.Value) - walk = func(v reflect.Value) { - switch v.Kind() { - case reflect.Pointer: - if v.IsNil() { - return - } - key := visitKey{ptr: v.Pointer(), typ: v.Type()} - if _, ok := visited[key]; ok { - return - } - visited[key] = struct{}{} - if v.Type() == exampleGenType { - // The example generator carries the randomizer seen-value - // cache which generation legitimately fills; it is not part - // of the design. - return - } - if v.CanInterface() { - if att, ok := v.Interface().(*expr.AttributeExpr); ok { - atts[att] = snapshotAttribute(att) - } - } - walk(v.Elem()) - case reflect.Interface: - if v.IsNil() { - return - } - walk(v.Elem()) - case reflect.Struct: - for i := range v.NumField() { - if v.Type().Field(i).PkgPath != "" { - continue // unexported - } - walk(v.Field(i)) - } - case reflect.Slice, reflect.Array: - for i := range v.Len() { - walk(v.Index(i)) - } - case reflect.Map: - iter := v.MapRange() - for iter.Next() { - walk(iter.Key()) - walk(iter.Value()) - } - } - } - walk(reflect.ValueOf(root)) - return atts +// resultCollectionCustomViewDSL defines a generated collection whose method +// result selects a non-default view. Copying this result during planning must +// not add another collection to the evaluated design. +func resultCollectionCustomViewDSL() { + item := dsl.ResultType("application/vnd.item", func() { + dsl.Attribute("name", expr.String) + dsl.View("default", func() { + dsl.Attribute("name") + }) + dsl.View("tiny", func() { + dsl.Attribute("name") + }) + }) + dsl.Service("items", func() { + dsl.Method("list", func() { + dsl.Result(dsl.CollectionOf(item), func() { + dsl.View("tiny") + }) + dsl.HTTP(func() { + dsl.GET("/items") + }) + }) + }) } -// snapshotAttribute captures the mutable state of att. Meta and validation -// are deep copied so in-place writes are detected; the type is captured by -// identity together with the user type name, attribute and shape so renames, -// attribute swaps and field changes are detected too. -func snapshotAttribute(att *expr.AttributeExpr) attrState { - s := attrState{ - Description: att.Description, - DefaultValue: att.DefaultValue, - } - if att.Meta != nil { - s.Meta = att.Meta.Dup() - } - if att.Validation != nil { - s.Validation = att.Validation.Dup() - } - switch dt := att.Type.(type) { - case nil: - case expr.Primitive: - s.Primitive = dt.Kind() - case expr.UserType: - s.Type = reflect.ValueOf(att.Type).Pointer() - s.TypeName = dt.Name() - s.UID = dt.ID() - s.UTAttr = reflect.ValueOf(dt.Attribute()).Pointer() - if rt, ok := dt.(*expr.ResultTypeExpr); ok { - s.Identifier = rt.Identifier - for _, v := range rt.Views { - s.Views = append(s.Views, v.Name) - } - } - default: - s.Type = reflect.ValueOf(att.Type).Pointer() - } - if obj := expr.AsObject(att.Type); obj != nil { - for _, nat := range *obj { - s.Fields = append(s.Fields, nat.Name) - } - } - return s +// TestPreparedRootsRejectAttributeMutation proves that generation stops when +// a core planner changes an attribute after the mutable lifecycle phase. +func TestPreparedRootsRejectAttributeMutation(t *testing.T) { + root := expr.RunDSL(t, httpdata.AliasTypeDSL) + registry := newRegistry() + target := root.Types[0].Attribute() + followingRan := false + registry.addCommand( + "test", + func() coreGenerator { + return coreGenerator{name: "attribute-mutator", Plan: func(_ *Plan) error { + target.Description = "changed after preparation" + return nil + }} + }, + func() coreGenerator { + return coreGenerator{name: "following", Plan: func(_ *Plan) error { + followingRan = true + return nil + }} + }, + ) + + err := executeGeneration("generated.local/gen", []eval.Root{root}, "test", registry) + require.ErrorContains(t, err, `core "attribute-mutator" plan mutated prepared design`) + require.False(t, followingRan) } diff --git a/codegen/generator/registry_test.go b/codegen/generator/registry_test.go new file mode 100644 index 0000000000..e4d02a394b --- /dev/null +++ b/codegen/generator/registry_test.go @@ -0,0 +1,56 @@ +// This file supplies isolated command registries to generator integration +// tests. Test factories adapt the transitional Generation-based core renderers +// without restoring mutable production hooks. +package generator + +import ( + "goa.design/goa/v3/codegen" + "goa.design/goa/v3/eval" +) + +type ( + // testGenfunc describes one retained planner and renderer used by an + // isolated generator command. + testGenfunc struct { + // Plan declares package symbols and retains the analysis used by Generate. + Plan func(*Plan) error + // Generate renders fixture files from the linked plan. + Generate func(*Plan) ([]*codegen.File, error) + } +) + +// testRegistry returns an isolated registry for one command. +func testRegistry(command string, factories ...generatorFactory) *registry { + registry := newRegistry() + registry.addCommand(command, factories...) + return registry +} + +// testRegistryFromGenfuncs creates one isolated command from fixture callbacks. +func testRegistryFromGenfuncs(callbacks []testGenfunc) *registry { + factories := make([]generatorFactory, len(callbacks)) + for i, callback := range callbacks { + factories[i] = testGenerator(callback.Plan, callback.Generate) + } + return testRegistry("gen", factories...) +} + +// testRenderOnly adapts a root-based rendering fixture into a test callback. +func testRenderOnly(generate func(string, []eval.Root) ([]*codegen.File, error)) testGenfunc { + return testGenfunc{Generate: func(plan *Plan) ([]*codegen.File, error) { + generation := plan.Generation() + return generate(generation.GenPkg(), generation.Roots()) + }} +} + +// testGenerator returns a fresh core generator that receives one retained plan +// from declaration collection through rendering. +func testGenerator(plan func(*Plan) error, generate func(*Plan) ([]*codegen.File, error)) generatorFactory { + return func() coreGenerator { + return coreGenerator{ + name: "test", + Plan: plan, + Generate: generate, + } + } +} diff --git a/codegen/generator/release_regressions_integration_test.go b/codegen/generator/release_regressions_integration_test.go new file mode 100644 index 0000000000..05217b2ad9 --- /dev/null +++ b/codegen/generator/release_regressions_integration_test.go @@ -0,0 +1,46 @@ +// These tests compile complete services with custom path parameter types. +// They verify that both HTTP clients and servers import the packages used +// in their generated path function signatures. +package generator + +import ( + "testing" + + "github.com/stretchr/testify/require" + + "goa.design/goa/v3/codegen" + d "goa.design/goa/v3/dsl" + "goa.design/goa/v3/eval" +) + +func TestGenerateCustomPathType(t *testing.T) { + root := codegen.RunDSL(t, func() { + d.Service("products", func() { + d.Method("lookup", func() { + d.Payload(func() { + d.Attribute("id", d.String, func() { + d.Format(d.FormatUUID) + d.Meta("struct:field:type", "uuid.UUID", "github.com/google/uuid") + }) + d.Required("id") + }) + d.Result(d.String) + d.HTTP(func() { + d.GET("/products/{id}") + }) + }) + }) + }) + plan := mustTestPlan(t, "generated.local/gen", []eval.Root{root}, planTransportData) + files, err := testServiceFiles(plan) + require.NoError(t, err) + transport, err := testTransportFiles(plan) + require.NoError(t, err) + dir := t.TempDir() + writeGeneratedModule(t, dir, "generated.local") + for _, file := range append(files, transport...) { + _, err := file.Render(dir) + require.NoError(t, err) + } + runGeneratedTests(t, dir) +} diff --git a/codegen/generator/run_examples.go b/codegen/generator/run_examples.go new file mode 100644 index 0000000000..1665ebb5b9 --- /dev/null +++ b/codegen/generator/run_examples.go @@ -0,0 +1,21 @@ +// This file creates a separate example generator for each evaluated Goa root +// in one command. Repeated or concurrent commands therefore do not share the +// random value sequence or the record of types currently being visited. +package generator + +import ( + "goa.design/goa/v3/eval" + "goa.design/goa/v3/expr" +) + +// newExampleGenerators creates one fresh mutable generator for every Goa +// design root participating in a run. +func newExampleGenerators(roots []eval.Root) map[*expr.RootExpr]*expr.ExampleGenerator { + generators := make(map[*expr.RootExpr]*expr.ExampleGenerator) + for _, root := range roots { + if design, ok := root.(*expr.RootExpr); ok { + generators[design] = expr.NewExampleGenerator(design.API.RandomizerFactory) + } + } + return generators +} diff --git a/codegen/generator/service.go b/codegen/generator/service.go index b4c3782b8e..3e7efef654 100644 --- a/codegen/generator/service.go +++ b/codegen/generator/service.go @@ -1,3 +1,5 @@ +// This file assembles generated service files after every participating Goa +// design root has submitted its declarations and all package names are final. package generator import ( @@ -7,78 +9,49 @@ import ( "goa.design/goa/v3/expr" ) -// Service iterates through the roots and returns the files needed to render -// the service code. It returns an error if the roots slice does not include -// a goa design. +// Service returns the files that define service types, endpoints, clients, and +// result views for roots. func Service(genpkg string, roots []eval.Root) ([]*codegen.File, error) { - var files []*codegen.File - var userTypePkgs = make(map[string][]string) - for _, root := range roots { - r, ok := root.(*expr.RootExpr) - if !ok { - continue - } - // Create service data - services := service.NewServicesData(r) - - for _, s := range r.Services { - d := services.Get(s.Name) - service.SetUserTypeImports(genpkg, d) - - // Make sure service is first so name scope is - // properly initialized. - svcFiles := service.Files(genpkg, s, services, userTypePkgs) - addServiceImports(svcFiles, d) - files = append(files, svcFiles...) - - endpointFiles := []*codegen.File{ - service.EndpointFile(genpkg, s, services), - service.ClientFile(genpkg, s, services), - } - addServiceImports(endpointFiles, d) - files = append(files, endpointFiles...) - - if f := service.ViewsFile(genpkg, s, services); f != nil { - addServiceImports([]*codegen.File{f}, d) - files = append(files, f) - } - convFiles, err := service.ConvertFiles(r, s, services) - if err != nil { - return nil, err - } - files = append(files, convFiles...) - } - } - return files, nil + return runStandaloneGenerator(genpkg, roots, serviceGeneratorFactory) } -func addServiceImports(files []*codegen.File, d *service.Data) { - for _, f := range files { - if len(f.SectionTemplates) == 0 { - continue - } - service.AddServiceDataMetaTypeImports(f.SectionTemplates[0], d) - service.AddUserTypeImports(f.SectionTemplates[0], d) - } +// serviceFiles returns the service files described by plan's completed package +// declarations and the example generator created for this run. +func serviceFiles(plan *Plan) ([]*codegen.File, error) { + return service.Files(plan.serviceOrder...) } -func addServicesImports(files []*codegen.File, services *service.ServicesData, svcs []*expr.ServiceExpr) { - for _, s := range svcs { - addServiceImports(files, services.Get(s.Name)) +// planServiceData declares service-owned generated package types for every Goa +// design root in generation. +func planServiceData(plan *Plan) error { + if plan.services != nil { + return nil } -} - -func addMetaTypeImports(files []*codegen.File, d *service.Data) { - for _, f := range files { - if len(f.SectionTemplates) == 0 { - continue - } - service.AddServiceDataMetaTypeImports(f.SectionTemplates[0], d) + plan.services = make(map[*expr.RootExpr]*service.Plan) + roots := serviceRoots(plan.Generation().Roots()) + inputs := make([]service.PlanInput, len(roots)) + for index, root := range roots { + inputs[index] = service.PlanInput{Root: root, Examples: plan.exampleGenerator(root)} } + servicePlans, err := service.NewPlans(plan.Generation(), inputs...) + if err != nil { + return err + } + for index, root := range roots { + plan.services[root] = servicePlans[index] + } + plan.serviceOrder = servicePlans + return nil } -func addServicesMetaTypeImports(files []*codegen.File, services *service.ServicesData, svcs []*expr.ServiceExpr) { - for _, s := range svcs { - addMetaTypeImports(files, services.Get(s.Name)) +// serviceRoots returns every Goa design root that emits files into the same +// generated package tree. +func serviceRoots(roots []eval.Root) []*expr.RootExpr { + var designRoots []*expr.RootExpr + for _, root := range roots { + if design, ok := root.(*expr.RootExpr); ok { + designRoots = append(designRoots, design) + } } + return designRoots } diff --git a/codegen/generator/service_union_package_scope_test.go b/codegen/generator/service_union_package_scope_test.go new file mode 100644 index 0000000000..d2fb7b061b --- /dev/null +++ b/codegen/generator/service_union_package_scope_test.go @@ -0,0 +1,1206 @@ +// This file verifies that service generation allocates relocated union symbols +// once across every design root that contributes to a generated Go package. +package generator + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/require" + + "goa.design/goa/v3/codegen" + servicecodegen "goa.design/goa/v3/codegen/service" + "goa.design/goa/v3/dsl" + "goa.design/goa/v3/eval" + "goa.design/goa/v3/expr" +) + +// TestRelocatedUnionPackageNamesCompile verifies that two services and their +// HTTP and gRPC transports compile against distinct unions in one shared package. +func TestRelocatedUnionPackageNamesCompile(t *testing.T) { + registry := testRegistryFromGenfuncs([]testGenfunc{ + {Plan: planServiceData, Generate: testServiceFiles}, + {Plan: planTransportData, Generate: testTransportFiles}, + }) + + root := func() { + dsl.API("relocated union package names", func() {}) + + firstInput := dsl.Type("FirstInput", func() { + dsl.Meta("struct:pkg:path", "types") + dsl.OneOf("Value", func() { + dsl.TypeName("FirstValueChoice") + dsl.Field(1, "record", func() { + dsl.OneOf("Nested", func() { + dsl.TypeName("FirstNestedChoice") + dsl.Field(1, "text", dsl.String) + }) + dsl.Required("Nested") + }) + }) + dsl.Required("Value") + }) + secondInput := dsl.Type("SecondInput", func() { + dsl.Meta("struct:pkg:path", "types") + dsl.OneOf("Value", func() { + dsl.TypeName("SecondValueChoice") + dsl.Field(1, "record", func() { + dsl.OneOf("Nested", func() { + dsl.TypeName("SecondNestedChoice") + dsl.Field(1, "text", dsl.Int) + }) + dsl.Required("Nested") + }) + }) + dsl.Required("Value") + }) + dsl.Service("First", func() { + dsl.Method("Read", func() { + dsl.Payload(firstInput) + dsl.Result(dsl.String) + dsl.HTTP(func() { + dsl.POST("/first") + dsl.Response(200) + }) + dsl.GRPC(func() {}) + }) + dsl.Method("ReadJSON", func() { + dsl.Payload(firstInput) + dsl.Result(dsl.String) + dsl.JSONRPC(func() {}) + }) + }) + dsl.Service("Second", func() { + dsl.Method("Read", func() { + dsl.Payload(secondInput) + dsl.Result(dsl.String) + dsl.HTTP(func() { + dsl.POST("/second") + dsl.Response(200) + }) + dsl.GRPC(func() {}) + }) + dsl.Method("ReadJSON", func() { + dsl.Payload(secondInput) + dsl.Result(dsl.String) + dsl.JSONRPC(func() {}) + }) + }) + } + codegen.RunDSL(t, root) + + dir := t.TempDir() + genDir := filepath.Join(dir, codegen.Gendir) + writeGeneratedModule(t, genDir, "generated.local/gen") + _, err := generate(dir, "gen", false, registry) + require.NoError(t, err) + for _, path := range []string{ + filepath.Join("types", "first_input.go"), + filepath.Join("types", "second_input.go"), + filepath.Join("http", "first", "server", "server.go"), + filepath.Join("http", "second", "server", "server.go"), + filepath.Join("grpc", "first", "server", "server.go"), + filepath.Join("grpc", "second", "server", "server.go"), + filepath.Join("jsonrpc", "first", "server", "server.go"), + filepath.Join("jsonrpc", "second", "server", "server.go"), + } { + require.FileExists(t, filepath.Join(genDir, path)) + } + runGeneratedTests(t, genDir) +} + +// TestInheritedTransportErrorMappingsCompileWithMethodErrors verifies reusable +// HTTP and gRPC response policy binds to the equivalent error value declared by +// the endpoint method instead of retaining the API declaration object. +func TestInheritedTransportErrorMappingsCompileWithMethodErrors(t *testing.T) { + registry := testRegistryFromGenfuncs([]testGenfunc{ + {Plan: planServiceData, Generate: testServiceFiles}, + {Plan: planTransportData, Generate: testTransportFiles}, + }) + + codegen.RunDSL(t, func() { + dsl.API("error policy", func() { + dsl.Error("bad_request", dsl.String) + dsl.HTTP(func() { dsl.Response(dsl.StatusBadRequest, "bad_request") }) + dsl.GRPC(func() { dsl.Response("bad_request", dsl.CodeInvalidArgument) }) + }) + dsl.Service("Values", func() { + dsl.Method("Read", func() { + dsl.Error("bad_request", dsl.String) + dsl.HTTP(func() { dsl.GET("/values") }) + dsl.GRPC(func() {}) + }) + }) + }) + + dir := t.TempDir() + genDir := filepath.Join(dir, codegen.Gendir) + writeGeneratedModule(t, genDir, "generated.local/gen") + _, err := generate(dir, "gen", false, registry) + require.NoError(t, err) + runGeneratedTests(t, genDir) +} + +// TestNestedTransportMetadataOwnsRecursiveImports verifies conversion helpers +// import a custom field type nested inside a relocated service declaration. +func TestNestedTransportMetadataOwnsRecursiveImports(t *testing.T) { + registry := testRegistryFromGenfuncs([]testGenfunc{ + {Plan: planServiceData, Generate: testServiceFiles}, + {Plan: planTransportData, Generate: testTransportFiles}, + }) + + codegen.RunDSL(t, func() { + outer := dsl.Type("Outer", func() { + dsl.Meta("struct:pkg:path", "domain/outer") + dsl.Field(1, "value", dsl.String, func() { + dsl.Meta("struct:field:type", "custom.Value", "generated.local/gen/custom/value", "custom") + }) + }) + dsl.Service("Values", func() { + dsl.Method("HTTP", func() { + dsl.Payload(outer) + dsl.HTTP(func() { dsl.POST("/values") }) + }) + dsl.Method("GRPC", func() { + dsl.Payload(outer) + dsl.GRPC(func() {}) + }) + dsl.Method("JSONRPC", func() { + dsl.Payload(outer) + dsl.JSONRPC(func() {}) + }) + }) + }) + + dir := t.TempDir() + genDir := filepath.Join(dir, codegen.Gendir) + writeGeneratedModule(t, genDir, "generated.local/gen") + _, err := generate(dir, "gen", false, registry) + require.NoError(t, err) + writeStubPackage(t, filepath.Join(genDir, "custom", "value"), "custom") + runGeneratedTests(t, genDir) +} + +// TestTransportServiceImportsUseFrozenAliases verifies a service package whose +// natural name collides with a fixed runtime import is declared and referenced +// with the same generation-owned qualifier in every transport. +func TestTransportServiceImportsUseFrozenAliases(t *testing.T) { + registry := testRegistryFromGenfuncs([]testGenfunc{ + {Plan: planServiceData, Generate: testServiceFiles}, + {Plan: planTransportData, Generate: testTransportFiles}, + }) + + codegen.RunDSL(t, func() { + dsl.Service("Goa", func() { + for _, transport := range []string{"HTTP", "GRPC", "JSONRPC"} { + dsl.Method(transport, func() { + dsl.Payload(func() { dsl.Field(1, "value", dsl.String) }) + switch transport { + case "HTTP": + dsl.HTTP(func() { dsl.POST("/values") }) + case "GRPC": + dsl.GRPC(func() {}) + case "JSONRPC": + dsl.JSONRPC(func() {}) + } + }) + } + }) + dsl.Service("Goahttp", func() { + dsl.Method("HTTP", func() { + dsl.Payload(func() { dsl.Field(1, "value", dsl.String) }) + dsl.HTTP(func() { dsl.POST("/http-values") }) + }) + }) + dsl.Service("Goapb", func() { + dsl.Method("GRPC", func() { + dsl.Payload(func() { dsl.Field(1, "value", dsl.String) }) + dsl.GRPC(func() {}) + }) + }) + }) + + dir := t.TempDir() + genDir := filepath.Join(dir, codegen.Gendir) + writeGeneratedModule(t, genDir, "generated.local/gen") + _, err := generate(dir, "gen", false, registry) + require.NoError(t, err) + runGeneratedTests(t, genDir) +} + +// TestInheritedTransportErrorsOwnImports verifies API-level response policy +// imports the relocated effective error referenced by generated HTTP and gRPC +// encoders even though the method does not redeclare it. +func TestInheritedTransportErrorsOwnImports(t *testing.T) { + registry := testRegistryFromGenfuncs([]testGenfunc{ + {Plan: planServiceData, Generate: testServiceFiles}, + {Plan: planTransportData, Generate: testTransportFiles}, + }) + + codegen.RunDSL(t, func() { + fault := dsl.Type("Fault", func() { + dsl.Meta("struct:pkg:path", "domain/errors") + dsl.Attribute("message", dsl.String) + }) + dsl.API("error imports", func() { + dsl.Error("fault", fault) + dsl.HTTP(func() { dsl.Response(dsl.StatusBadRequest, "fault") }) + dsl.GRPC(func() { dsl.Response("fault", dsl.CodeInvalidArgument) }) + }) + dsl.Service("Values", func() { + dsl.Method("Read", func() { + dsl.HTTP(func() { dsl.GET("/values") }) + dsl.GRPC(func() {}) + }) + }) + }) + + dir := t.TempDir() + genDir := filepath.Join(dir, codegen.Gendir) + writeGeneratedModule(t, genDir, "generated.local/gen") + _, err := generate(dir, "gen", false, registry) + require.NoError(t, err) + runGeneratedTests(t, genDir) +} + +// TestServiceUnionGeneratedBranchShapesCompile verifies that generated branch +// aliases with one natural name but different primitive shapes remain distinct. +func TestServiceUnionGeneratedBranchShapesCompile(t *testing.T) { + registry := testRegistryFromGenfuncs([]testGenfunc{{Plan: planServiceData, Generate: testServiceFiles}}) + + codegen.RunDSL(t, func() { + first := dsl.Type("FirstValue", func() { + dsl.Meta("struct:pkg:path", "types") + dsl.OneOf("Value", func() { + dsl.TypeName("FirstValueChoice") + dsl.Attribute("text", dsl.String) + }) + }) + second := dsl.Type("SecondValue", func() { + dsl.Meta("struct:pkg:path", "types") + dsl.OneOf("Value", func() { + dsl.TypeName("SecondValueChoice") + dsl.Attribute("text", dsl.Int) + }) + }) + dsl.Service("Values", func() { + dsl.Method("Read", func() { + dsl.Payload(first) + dsl.Result(second) + }) + }) + }) + + dir := t.TempDir() + genDir := filepath.Join(dir, codegen.Gendir) + writeGeneratedModule(t, genDir, "generated.local/gen") + _, err := generate(dir, "gen", false, registry) + require.NoError(t, err) + unionFiles, err := filepath.Glob(filepath.Join(genDir, "types", "*.go")) + require.NoError(t, err) + var unionSource strings.Builder + for _, path := range unionFiles { + source, err := os.ReadFile(path) + require.NoError(t, err) + unionSource.Write(source) + } + require.Contains(t, unionSource.String(), "type FirstValueChoice struct") + require.Contains(t, unionSource.String(), "type FirstValueChoiceBranchText string") + require.Contains(t, unionSource.String(), "type SecondValueChoice struct") + require.Contains(t, unionSource.String(), "type SecondValueChoiceBranchText int") + runGeneratedTests(t, genDir) +} + +// TestServiceUnionFamilyNamesRejectExactDeclarationCollisions verifies that a +// union never renames its public constants and constructors to avoid another +// exact DSL declaration. +func TestServiceUnionFamilyNamesRejectExactDeclarationCollisions(t *testing.T) { + registry := testRegistryFromGenfuncs([]testGenfunc{{Plan: planServiceData, Generate: testServiceFiles}}) + + codegen.RunDSL(t, func() { + kind := dsl.Type("ValueKindText", dsl.String) + constructor := dsl.Type("NewValueText", dsl.String) + payload := dsl.Type("Payload", func() { + dsl.Attribute("kind", kind) + dsl.Attribute("constructor", constructor) + dsl.OneOf("Value", func() { + dsl.Attribute("text", dsl.String) + }) + }) + dsl.Service("Values", func() { + dsl.Method("Read", func() { + dsl.Payload(payload) + }) + }) + }) + + dir := t.TempDir() + genDir := filepath.Join(dir, codegen.Gendir) + writeGeneratedModule(t, genDir, "generated.local/gen") + _, err := generate(dir, "gen", false, registry) + require.ErrorContains(t, err, `cannot declare exact constant "ValueKindText"`) + require.ErrorContains(t, err, "set TypeName on the OneOf to a unique name") +} + +// TestServiceFilesOwnTheirImports verifies that imports used by one service do +// not leak into another service file generated from the same design root. +func TestServiceFilesOwnTheirImports(t *testing.T) { + registry := testRegistryFromGenfuncs([]testGenfunc{ + {Plan: planServiceData, Generate: testServiceFiles}, + {Plan: planTransportData, Generate: testTransportFiles}, + }) + + codegen.RunDSL(t, func() { + dsl.API("file-owned imports", func() {}) + firstInput := dsl.Type("FirstInput", func() { + dsl.Meta("struct:pkg:path", "first/shared") + dsl.Field(1, "value", dsl.String) + }) + secondInput := dsl.Type("SecondInput", func() { + dsl.Meta("struct:pkg:path", "second/shared") + dsl.Field(1, "value", dsl.String) + }) + dsl.Service("First", func() { + dsl.Method("Read", func() { + dsl.Payload(firstInput) + dsl.HTTP(func() { + dsl.POST("/first") + dsl.Response(204) + }) + dsl.GRPC(func() {}) + }) + }) + dsl.Service("Second", func() { + dsl.Method("Read", func() { + dsl.Payload(secondInput) + dsl.HTTP(func() { + dsl.POST("/second") + dsl.Response(204) + }) + dsl.GRPC(func() {}) + }) + }) + }) + + dir := t.TempDir() + genDir := filepath.Join(dir, codegen.Gendir) + writeGeneratedModule(t, genDir, "generated.local/gen") + _, err := generate(dir, "gen", false, registry) + require.NoError(t, err) + runGeneratedTests(t, genDir) +} + +// TestRawBodyStructsRemainInEndpointsPackage verifies relocated payload and +// result declarations never relocate the request/response wrappers consumed by +// the raw HTTP body path. +func TestRawBodyStructsRemainInEndpointsPackage(t *testing.T) { + registry := testRegistryFromGenfuncs([]testGenfunc{ + {Plan: planServiceData, Generate: testServiceFiles}, + {Plan: planTransportData, Generate: testTransportFiles}, + }) + + codegen.RunDSL(t, func() { + upload := dsl.Type("Upload", func() { + dsl.Meta("struct:pkg:path", "domain/types") + dsl.Attribute("length", dsl.Int) + dsl.Required("length") + }) + download := dsl.Type("Download", func() { + dsl.Meta("struct:pkg:path", "domain/types") + dsl.Attribute("length", dsl.Int) + dsl.Required("length") + }) + dsl.Service("RawBodies", func() { + dsl.Method("Upload", func() { + dsl.Payload(upload) + dsl.HTTP(func() { + dsl.POST("/upload") + dsl.Header("length:Content-Length") + dsl.SkipRequestBodyEncodeDecode() + dsl.Response(dsl.StatusNoContent) + }) + }) + dsl.Method("Download", func() { + dsl.Result(download) + dsl.HTTP(func() { + dsl.GET("/download") + dsl.SkipResponseBodyEncodeDecode() + dsl.Response(dsl.StatusOK, func() { + dsl.Header("length:Content-Length") + }) + }) + }) + }) + }) + + dir := t.TempDir() + genDir := filepath.Join(dir, codegen.Gendir) + writeGeneratedModule(t, genDir, "generated.local/gen") + _, err := generate(dir, "gen", false, registry) + require.NoError(t, err) + + clientSource, err := os.ReadFile(filepath.Join(genDir, "http", "raw_bodies", "client", "client.go")) + require.NoError(t, err) + require.Contains(t, string(clientSource), "&rawbodies.DownloadResponseData") + require.NotContains(t, string(clientSource), "types.DownloadResponseData") + codecSource, err := os.ReadFile(filepath.Join(genDir, "http", "raw_bodies", "client", "encode_decode.go")) + require.NoError(t, err) + require.Contains(t, string(codecSource), "*rawbodies.UploadRequestData") + require.NotContains(t, string(codecSource), "types.UploadRequestData") + runGeneratedTests(t, genDir) +} + +// TestServiceReferencesUseImportPathAliases verifies that one service can +// reference generated packages with the same Go package name without emitting +// duplicate import aliases or ambiguous qualified references. +func TestServiceReferencesUseImportPathAliases(t *testing.T) { + registry := testRegistryFromGenfuncs([]testGenfunc{{Plan: planServiceData, Generate: testServiceFiles}}) + + codegen.RunDSL(t, func() { + dsl.API("path-owned aliases", func() {}) + first := dsl.Type("First", func() { + dsl.Meta("struct:pkg:path", "first/shared") + dsl.Attribute("value", dsl.String) + }) + second := dsl.Type("Second", func() { + dsl.Meta("struct:pkg:path", "second/shared") + dsl.Attribute("value", dsl.String) + }) + dsl.Service("Values", func() { + dsl.Method("First", func() { + dsl.Payload(first) + }) + dsl.Method("Second", func() { + dsl.Payload(second) + }) + }) + }) + + dir := t.TempDir() + genDir := filepath.Join(dir, codegen.Gendir) + writeGeneratedModule(t, genDir, "generated.local/gen") + _, err := generate(dir, "gen", false, registry) + require.NoError(t, err) + content, err := os.ReadFile(filepath.Join(genDir, "values", "service.go")) + require.NoError(t, err) + code := string(content) + require.Contains(t, code, `shared "generated.local/gen/first/shared"`) + require.Contains(t, code, `shared2 "generated.local/gen/second/shared"`) + require.Contains(t, code, `*shared.First`) + require.Contains(t, code, `*shared2.Second`) + runGeneratedTests(t, genDir) +} + +// TestTransportReferencesUseImportPathAliases verifies HTTP, gRPC, and +// JSON-RPC files qualify two same-basename service packages with the aliases +// frozen by the shared generation. +func TestTransportReferencesUseImportPathAliases(t *testing.T) { + registry := testRegistryFromGenfuncs([]testGenfunc{ + {Plan: planServiceData, Generate: testServiceFiles}, + {Plan: planTransportData, Generate: testTransportFiles}, + }) + + codegen.RunDSL(t, func() { + first := dsl.Type("First", func() { + dsl.Meta("struct:pkg:path", "first/shared") + dsl.Field(1, "value", dsl.String) + }) + second := dsl.Type("Second", func() { + dsl.Meta("struct:pkg:path", "second/shared") + dsl.Field(1, "value", dsl.String) + }) + dsl.Service("Values", func() { + for _, method := range []struct { + name string + path string + payload expr.UserType + }{ + {"HTTPFirst", "/http/first", first}, + {"HTTPSecond", "/http/second", second}, + } { + dsl.Method(method.name, func() { + dsl.Payload(method.payload) + dsl.HTTP(func() { dsl.POST(method.path) }) + }) + } + for _, method := range []struct { + name string + payload expr.UserType + }{ + {"GRPCFirst", first}, + {"GRPCSecond", second}, + } { + dsl.Method(method.name, func() { + dsl.Payload(method.payload) + dsl.GRPC(func() {}) + }) + } + for _, method := range []struct { + name string + payload expr.UserType + }{ + {"JSONFirst", first}, + {"JSONSecond", second}, + } { + dsl.Method(method.name, func() { + dsl.Payload(method.payload) + dsl.JSONRPC(func() {}) + }) + } + }) + }) + + dir := t.TempDir() + genDir := filepath.Join(dir, codegen.Gendir) + writeGeneratedModule(t, genDir, "generated.local/gen") + _, err := generate(dir, "gen", false, registry) + require.NoError(t, err) + for _, transport := range []string{"http", "grpc", "jsonrpc"} { + source := generatedTreeSource(t, filepath.Join(genDir, transport, "values")) + require.Contains(t, source, `shared "generated.local/gen/first/shared"`) + require.Contains(t, source, `shared2 "generated.local/gen/second/shared"`) + require.Contains(t, source, "shared.First") + require.Contains(t, source, "shared2.Second") + } + runGeneratedTests(t, genDir) +} + +// generatedTreeSource returns the concatenated Go source below root in path +// order so tests can assert file-owned imports without depending on which +// transport file contains a conversion helper. +func generatedTreeSource(t *testing.T, root string) string { + t.Helper() + var source strings.Builder + err := filepath.WalkDir(root, func(path string, entry os.DirEntry, err error) error { + if err != nil { + return err + } + if entry.IsDir() || filepath.Ext(path) != ".go" { + return nil + } + data, err := os.ReadFile(path) + if err != nil { + return err + } + source.Write(data) + return nil + }) + require.NoError(t, err) + return source.String() +} + +// TestNamedUnionBranchImportsReferenceOnly verifies that unions.go does not +// expand a named branch definition and import packages used only where that +// named type itself is declared. +func TestNamedUnionBranchImportsReferenceOnly(t *testing.T) { + registry := testRegistryFromGenfuncs([]testGenfunc{{Plan: planServiceData, Generate: testServiceFiles}}) + + codegen.RunDSL(t, func() { + dsl.API("named branch imports", func() {}) + value := dsl.Type("Value", func() { + dsl.OneOf("choice", func() { + dsl.Attribute("external", dsl.String, func() { + dsl.Meta("struct:field:type", "json.Value", "generated.local/gen/custom/json", "json") + }) + }) + }) + dsl.Service("Values", func() { + dsl.Method("Read", func() { + dsl.Result(value) + }) + }) + }) + + dir := t.TempDir() + genDir := filepath.Join(dir, codegen.Gendir) + writeGeneratedModule(t, genDir, "generated.local/gen") + _, err := generate(dir, "gen", false, registry) + require.NoError(t, err) + writeStubPackage(t, filepath.Join(genDir, "custom", "json"), "json") + content, err := os.ReadFile(filepath.Join(genDir, "values", "unions.go")) + require.NoError(t, err) + code := string(content) + require.Contains(t, code, `"encoding/json"`) + require.NotContains(t, code, `"generated.local/gen/custom/json"`) + runGeneratedTests(t, genDir) +} + +// TestNormalizedMethodTypesUseServicePackageNames verifies that raw method +// object wrappers collide only with declarations emitted in the same service +// package, never with a nested declaration relocated elsewhere. +func TestNormalizedMethodTypesUseServicePackageNames(t *testing.T) { + registry := testRegistryFromGenfuncs([]testGenfunc{{Plan: planServiceData, Generate: testServiceFiles}}) + + t.Run("relocated name does not collide", func(t *testing.T) { + codegen.RunDSL(t, func() { + dsl.API("relocated wrapper names", func() {}) + relocated := dsl.Type("UsePayload", func() { + dsl.Meta("struct:pkg:path", "types") + dsl.Field(1, "value", dsl.String) + }) + dsl.Service("Values", func() { + dsl.Method("Other", func() { + dsl.Payload(func() { + dsl.Field(1, "nested", relocated) + }) + dsl.HTTP(func() { + dsl.POST("/other") + dsl.Response(204) + }) + dsl.GRPC(func() {}) + }) + dsl.Method("Use", func() { + dsl.Payload(func() { + dsl.Field(1, "value", dsl.String) + }) + dsl.HTTP(func() { + dsl.POST("/use") + dsl.Response(204) + }) + dsl.GRPC(func() {}) + }) + }) + }) + + dir := t.TempDir() + genDir := filepath.Join(dir, codegen.Gendir) + writeGeneratedModule(t, genDir, "generated.local/gen") + _, err := generate(dir, "gen", false, registry) + require.NoError(t, err) + content, err := os.ReadFile(filepath.Join(genDir, "values", "service.go")) + require.NoError(t, err) + require.Contains(t, string(content), "type UsePayload struct") + require.NotContains(t, string(content), "type UsePayload2 struct") + runGeneratedTests(t, genDir) + }) + + t.Run("local name collides", func(t *testing.T) { + registry := testRegistryFromGenfuncs([]testGenfunc{ + {Plan: planServiceData, Generate: testServiceFiles}, + {Plan: planTransportData, Generate: testTransportFiles}, + }) + codegen.RunDSL(t, func() { + dsl.API("local wrapper names", func() {}) + local := dsl.Type("UsePayload", func() { + dsl.Field(1, "existing", dsl.String) + }) + dsl.Service("Values", func() { + dsl.Method("Existing", func() { + dsl.Payload(local) + dsl.HTTP(func() { + dsl.POST("/existing") + dsl.Response(204) + }) + dsl.GRPC(func() {}) + }) + dsl.Method("Use", func() { + dsl.Payload(func() { + dsl.Field(1, "value", dsl.String) + }) + dsl.HTTP(func() { + dsl.POST("/use") + dsl.Response(204) + }) + dsl.GRPC(func() {}) + }) + }) + }) + + dir := t.TempDir() + genDir := filepath.Join(dir, codegen.Gendir) + writeGeneratedModule(t, genDir, "generated.local/gen") + _, err := generate(dir, "gen", false, registry) + require.NoError(t, err) + content, err := os.ReadFile(filepath.Join(genDir, "values", "service.go")) + require.NoError(t, err) + code := string(content) + require.Contains(t, code, "type UsePayload struct") + require.Contains(t, code, "type UsePayload2 struct") + runGeneratedTests(t, genDir) + }) +} + +// TestNestedRelocatedDeclarationsOwnTheirImports verifies that metadata imports +// used by two relocated declarations stay in their respective declaration +// files and do not leak into the service file that references their package. +func TestNestedRelocatedDeclarationsOwnTheirImports(t *testing.T) { + registry := testRegistryFromGenfuncs([]testGenfunc{{Plan: planServiceData, Generate: testServiceFiles}}) + + codegen.RunDSL(t, func() { + dsl.API("nested file-owned imports", func() {}) + outer := dsl.Type("Outer", func() { + dsl.Meta("struct:pkg:path", "models") + dsl.Attribute("value", dsl.String, func() { + dsl.Meta("struct:field:type", "shared.Value", "generated.local/gen/custom/first/shared", "shared") + }) + }) + inner := dsl.Type("Inner", func() { + dsl.Meta("struct:pkg:path", "models") + dsl.Attribute("value", dsl.String, func() { + dsl.Meta("struct:field:type", "shared.Value", "generated.local/gen/custom/second/shared", "shared") + }) + }) + dsl.Service("Nested", func() { + dsl.Method("Outer", func() { + dsl.Payload(outer) + }) + dsl.Method("Inner", func() { + dsl.Payload(inner) + }) + }) + }) + + dir := t.TempDir() + genDir := filepath.Join(dir, codegen.Gendir) + writeGeneratedModule(t, genDir, "generated.local/gen") + _, err := generate(dir, "gen", false, registry) + require.NoError(t, err) + writeStubPackage(t, filepath.Join(genDir, "custom", "first", "shared"), "shared") + writeStubPackage(t, filepath.Join(genDir, "custom", "second", "shared"), "shared") + runGeneratedTests(t, genDir) +} + +// TestTransportSectionsOwnTheirImports verifies that a streaming transport +// file imports only the declarations used by the streaming endpoints it +// renders, even when another endpoint uses the same package name elsewhere. +func TestTransportSectionsOwnTheirImports(t *testing.T) { + root := codegen.RunDSL(t, func() { + dsl.API("transport section imports", func() {}) + streamMessage := dsl.Type("StreamMessage", func() { + dsl.Meta("struct:pkg:path", "stream/shared") + dsl.Attribute("value", dsl.String) + }) + request := dsl.Type("Request", func() { + dsl.Meta("struct:pkg:path", "request/shared") + dsl.Attribute("value", dsl.String) + }) + dsl.Service("Messages", func() { + dsl.Method("Watch", func() { + dsl.StreamingResult(streamMessage) + dsl.HTTP(func() { + dsl.GET("/watch") + dsl.Response(200) + }) + }) + dsl.Method("Create", func() { + dsl.Payload(request) + dsl.HTTP(func() { + dsl.POST("/messages") + dsl.Response(204) + }) + }) + }) + }) + plan := mustTestPlan(t, "generated.local/gen", []eval.Root{root}, planTransportData) + files, err := testTransportFiles(plan) + require.NoError(t, err) + + var header strings.Builder + for _, file := range files { + if filepath.ToSlash(file.Path) != "gen/http/messages/server/websocket.go" { + continue + } + require.NoError(t, file.SectionTemplates[0].Write(&header)) + break + } + require.NotEmpty(t, header.String()) + require.Contains(t, header.String(), `"generated.local/gen/stream/shared"`) + require.NotContains(t, header.String(), `"generated.local/gen/request/shared"`) +} + +// TestRelocatedStreamingUnionReferencesCompile verifies ordinary HTTP +// WebSocket and SSE files and JSON-RPC SSE files resolve relocated streaming +// declarations through the frozen service packages. +func TestRelocatedStreamingUnionReferencesCompile(t *testing.T) { + registry := testRegistryFromGenfuncs([]testGenfunc{ + {Plan: planServiceData, Generate: testServiceFiles}, + {Plan: planTransportData, Generate: testTransportFiles}, + }) + + codegen.RunDSL(t, func() { + streamInput := relocatedStreamingType("StreamInput", "InputChoice", dsl.String) + streamOutput := relocatedStreamingType("StreamOutput", "OutputChoice", dsl.Int) + sseEvent := dsl.Type("SSEEvent", func() { + dsl.Attribute("data", streamOutput) + dsl.Attribute("id", dsl.String) + dsl.Required("data", "id") + }) + dsl.Service("HTTPStreams", func() { + dsl.Method("Socket", func() { + dsl.StreamingPayload(streamInput) + dsl.StreamingResult(streamOutput) + dsl.HTTP(func() { dsl.GET("/socket") }) + }) + dsl.Method("Events", func() { + dsl.StreamingResult(sseEvent) + dsl.HTTP(func() { + dsl.GET("/events") + dsl.ServerSentEvents("data", func() { dsl.SSEEventID("id") }) + }) + }) + }) + dsl.Service("JSONEvents", func() { + dsl.Method("Events", func() { + dsl.StreamingResult(sseEvent) + dsl.JSONRPC(func() { + dsl.ServerSentEvents("data", func() { dsl.SSEEventID("id") }) + }) + }) + }) + }) + + dir := t.TempDir() + genDir := filepath.Join(dir, codegen.Gendir) + writeGeneratedModule(t, genDir, "generated.local/gen") + _, err := generate(dir, "gen", false, registry) + require.NoError(t, err) + for _, path := range []string{ + filepath.Join("http", "http_streams", "server", "websocket.go"), + filepath.Join("http", "http_streams", "server", "sse.go"), + filepath.Join("jsonrpc", "json_events", "server", "sse.go"), + } { + require.FileExists(t, filepath.Join(genDir, path)) + } + runGeneratedTests(t, genDir) +} + +// relocatedStreamingType builds an object with a nested union that is emitted +// in the shared streaming package used by the integration test. +func relocatedStreamingType(name, unionName string, value expr.DataType) expr.UserType { + return dsl.Type(name, func() { + dsl.Meta("struct:pkg:path", "stream/types") + dsl.OneOf(unionName, func() { + dsl.Attribute("value", value) + }) + dsl.Required(unionName) + }) +} + +// writeStubPackage creates the external package referenced by struct:field:type +// metadata inside the generated module used by the integration test. +func writeStubPackage(t *testing.T, dir, packageName string) { + t.Helper() + require.NoError(t, os.MkdirAll(dir, 0o750)) + require.NoError(t, os.WriteFile( + filepath.Join(dir, "value.go"), + []byte("package "+packageName+"\n\ntype Value string\n"), + 0o600, + )) +} + +func TestServiceRelocatedUnionNamesSpanDesignRoots(t *testing.T) { + roots := []eval.Root{ + codegen.RunDSL(t, unusedRelocatedValueRoot()), + codegen.RunDSL(t, relocatedUnionRoot("ZExistingValue", "FirstService")), + codegen.RunDSL(t, relocatedUnionRoot("MExistingValue", "SecondService")), + codegen.RunDSL(t, relocatedUnionRoot("AAddedValue", "ThirdService")), + codegen.RunDSL(t, relocatedDifferentUnionRoot()), + codegen.RunDSL(t, relocatedTopLevelValueRoot()), + } + plan := mustTestPlan(t, "goa.design/goa/example", roots, planServiceData) + files, err := testServiceFiles(plan) + require.NoError(t, err) + + var generated strings.Builder + for _, file := range files { + if !strings.Contains(file.Path, filepath.Join("gen", "types")) { + continue + } + for _, section := range file.SectionTemplates { + require.NoError(t, section.Write(&generated)) + } + } + code := generated.String() + require.Equal(t, 1, strings.Count(code, "type Value struct {"), code) + require.Contains(t, code, "type Value struct {\n\tText string", code) + for _, name := range []string{ + "ZExistingValueChoice", + "MExistingValueChoice", + "AAddedValueChoice", + "DifferentValueChoice", + } { + require.Equal(t, 1, strings.Count(code, "type "+name+" struct {"), code) + require.Equal(t, 1, strings.Count(code, "type "+name+"Kind string"), code) + } + require.Equal(t, + []string{ + "ZExistingValueChoice", + "MExistingValueChoice", + "AAddedValueChoice", + "DifferentValueChoice", + }, + []string{ + unionFieldType(code, "ZExistingValue"), + unionFieldType(code, "MExistingValue"), + unionFieldType(code, "AAddedValue"), + unionFieldType(code, "DifferentValue"), + }, + ) +} + +// TestServiceRelocatedUnionOwnerCompilesAcrossGeneration verifies a complete +// root analysis emits one shared relocated union for every referencing service. +func TestServiceRelocatedUnionOwnerCompilesAcrossGeneration(t *testing.T) { + root := codegen.RunDSL(t, sharedRelocatedUnionRoot()) + plan := mustTestPlan(t, "generated.local/gen", []eval.Root{root}, planServiceData) + files, err := servicecodegen.Files(plan.Service(root)) + require.NoError(t, err) + dir := t.TempDir() + for _, file := range files { + _, err := file.Render(dir) + require.NoError(t, err) + } + writeGeneratedModule(t, dir, "generated.local") + runGeneratedTests(t, dir) +} + +// TestServiceAndExamplesCompileWithImportQualifierCollisions verifies that +// fixed packages, generated service packages, generated views packages, and +// metadata packages share one path-owned qualifier mapping. +func TestServiceAndExamplesCompileWithImportQualifierCollisions(t *testing.T) { + root := codegen.RunDSL(t, func() { + result := dsl.ResultType("application/vnd.value", func() { + dsl.TypeName("Value") + dsl.Attribute("custom", dsl.String, func() { + dsl.Meta("struct:field:type", "valuesviews.Value", "generated.local/custom/views", "valuesviews") + }) + dsl.View("default", func() { + dsl.Attribute("custom") + }) + }) + dsl.Service("Values", func() { + dsl.Method("Read", func() { + dsl.Payload(dsl.String, func() { + dsl.Meta("struct:field:type", "values.Value", "generated.local/custom/values", "values") + }) + dsl.Result(result) + }) + }) + dsl.Service("Fmt", func() { + dsl.Method("Read", func() { + dsl.Payload(dsl.String, func() { + dsl.Meta("struct:field:type", "strings.Value", "generated.local/custom/strings", "strings") + }) + }) + }) + }) + plan := mustTestPlan(t, "generated.local/gen", []eval.Root{root}, planServiceData) + servicePlan := plan.Service(root) + + dir := t.TempDir() + files, err := testServiceFiles(plan) + require.NoError(t, err) + files = append(files, servicecodegen.ExampleServiceFiles(servicePlan)...) + for _, file := range files { + _, err := file.Render(dir) + require.NoError(t, err) + } + writeGeneratedModule(t, dir, "generated.local") + writeStubPackage(t, filepath.Join(dir, "custom", "strings"), "strings") + writeStubPackage(t, filepath.Join(dir, "custom", "values"), "values") + writeStubPackage(t, filepath.Join(dir, "custom", "views"), "valuesviews") + runGeneratedTests(t, dir) +} + +// TestFixedRuntimeAliasesCompileWithGoaAndLogServices verifies that generated +// service imports are suffixed when static interceptor templates require the +// goa and log qualifiers for their runtime packages. +func TestFixedRuntimeAliasesCompileWithGoaAndLogServices(t *testing.T) { + root := codegen.RunDSL(t, func() { + interceptor := dsl.Interceptor("Trace") + for _, name := range []string{"Goa", "Log"} { + dsl.Service(name, func() { + dsl.ServerInterceptor(interceptor) + dsl.Method("Read", func() {}) + }) + } + }) + plan := mustTestPlan(t, "generated.local/gen", []eval.Root{root}, planServiceData) + servicePlan := plan.Service(root) + + dir := t.TempDir() + files, err := testServiceFiles(plan) + require.NoError(t, err) + files = append(files, servicecodegen.ExampleInterceptorsFiles(servicePlan)...) + for _, file := range files { + _, err := file.Render(dir) + require.NoError(t, err) + } + goaSource, err := os.ReadFile(filepath.Join(dir, "interceptors", "goa_server.go")) + require.NoError(t, err) + require.Contains(t, string(goaSource), `goa "goa.design/goa/v3/pkg"`) + require.Contains(t, string(goaSource), `goa2 "generated.local/gen/goa"`) + logSource, err := os.ReadFile(filepath.Join(dir, "interceptors", "log_server.go")) + require.NoError(t, err) + require.Contains(t, string(logSource), `"goa.design/clue/log"`) + require.Contains(t, string(logSource), `log2 "generated.local/gen/log"`) + writeGeneratedModule(t, dir, "generated.local") + runGeneratedTests(t, dir) +} + +// TestTransportStaticAliasesCompileWithHttpAndPathServices verifies transport +// imports retain their literal qualifiers beside conflicting service names. +func TestTransportStaticAliasesCompileWithHttpAndPathServices(t *testing.T) { + registry := testRegistryFromGenfuncs([]testGenfunc{ + {Plan: planServiceData, Generate: testServiceFiles}, + {Plan: planTransportData, Generate: testTransportFiles}, + }) + + codegen.RunDSL(t, func() { + for _, name := range []string{"Http", "Path"} { + dsl.Service(name, func() { + dsl.Method("Read", func() { + dsl.Payload(dsl.String) + dsl.Result(dsl.String) + dsl.HTTP(func() { dsl.POST("/" + strings.ToLower(name)) }) + dsl.GRPC(func() {}) + }) + dsl.Method("ReadJSON", func() { + dsl.Payload(dsl.String) + dsl.Result(dsl.String) + dsl.JSONRPC(func() {}) + }) + }) + } + }) + + dir := t.TempDir() + genDir := filepath.Join(dir, codegen.Gendir) + writeGeneratedModule(t, genDir, "generated.local/gen") + _, err := generate(dir, "gen", false, registry) + require.NoError(t, err) + httpServers, err := filepath.Glob(filepath.Join(genDir, "http", "*", "server", "server.go")) + require.NoError(t, err) + require.Len(t, httpServers, 2) + var httpSource strings.Builder + for _, server := range httpServers { + source, err := os.ReadFile(server) + require.NoError(t, err) + httpSource.Write(source) + } + require.Contains(t, httpSource.String(), `http_ "generated.local/gen/http_"`) + require.Contains(t, httpSource.String(), `path "generated.local/gen/path"`) + require.NotContains(t, httpSource.String(), `"path"`) + runGeneratedTests(t, genDir) +} + +// unusedRelocatedValueRoot declares a relocated type that no service reaches +// and does not force generation. It must not reserve a generated package name. +func unusedRelocatedValueRoot() func() { + return func() { + dsl.Type("Value", func() { + dsl.Meta("struct:pkg:path", "types") + dsl.Attribute("unused", dsl.String) + }) + } +} + +// sharedRelocatedUnionRoot uses one relocated union from two services so one +// generation-wide render emits its definition exactly once. +func sharedRelocatedUnionRoot() func() { + return func() { + value := relocatedValueType("SharedValue", "SharedValueChoice") + dsl.Service("FirstService", func() { + dsl.Method("Read", func() { + dsl.Payload(value) + }) + }) + dsl.Service("SecondService", func() { + dsl.Method("Read", func() { + dsl.Payload(value) + }) + }) + } +} + +// relocatedValueType defines one force-generated owner of an exact union in +// the generated types package. +func relocatedValueType(name, unionName string) expr.UserType { + return dsl.Type(name, func() { + dsl.Meta("struct:pkg:path", "types") + dsl.Meta("type:generate:force") + dsl.OneOf("Value", func() { + dsl.TypeName(unionName) + dsl.Attribute("Bool", dsl.Boolean) + dsl.Attribute("Enum", dsl.String) + dsl.Attribute("Number", dsl.Float64) + }) + }) +} + +// relocatedTopLevelValueRoot declares an emitted top-level Value alongside +// explicitly named union declarations. +func relocatedTopLevelValueRoot() func() { + return func() { + value := dsl.Type("Value", func() { + dsl.Meta("struct:pkg:path", "types") + dsl.Attribute("text", dsl.String) + dsl.Required("text") + }) + dsl.Service("FifthService", func() { + dsl.Method("Read", func() { + dsl.Payload(value) + }) + }) + } +} + +// relocatedDifferentUnionRoot defines a union with a different exact name and +// branch shape. +func relocatedDifferentUnionRoot() func() { + return func() { + value := dsl.Type("DifferentValue", func() { + dsl.Meta("struct:pkg:path", "types") + dsl.Meta("type:generate:force") + dsl.OneOf("Value", func() { + dsl.TypeName("DifferentValueChoice") + dsl.Attribute("Bool", dsl.Boolean) + dsl.Attribute("Text", dsl.String) + }) + }) + dsl.Service("FourthService", func() { + dsl.Method("Read", func() { + dsl.Payload(value) + }) + }) + } +} + +// relocatedUnionRoot defines one independently declared union with an exact +// name that remains stable across design-root ordering. +func relocatedUnionRoot(typeName, serviceName string) func() { + return func() { + value := dsl.Type(typeName, func() { + dsl.Meta("struct:pkg:path", "types") + dsl.Meta("type:generate:force") + dsl.OneOf("Value", func() { + dsl.TypeName(typeName + "Choice") + dsl.Attribute("Bool", dsl.Boolean) + dsl.Attribute("Enum", dsl.String) + dsl.Attribute("Number", dsl.Float64) + }) + }) + dsl.Service(serviceName, func() { + dsl.Method("Read", func() { + dsl.Payload(value) + }) + }) + } +} + +// unionFieldType returns the generated union type referenced by owner. +func unionFieldType(code, owner string) string { + prefix := "type " + owner + " struct {\n\tValue " + start := strings.Index(code, prefix) + if start == -1 { + return "" + } + start += len(prefix) + end := strings.IndexByte(code[start:], '\n') + if end == -1 { + return "" + } + return code[start : start+end] +} diff --git a/codegen/generator/test_helpers_test.go b/codegen/generator/test_helpers_test.go new file mode 100644 index 0000000000..37ab10cd66 --- /dev/null +++ b/codegen/generator/test_helpers_test.go @@ -0,0 +1,50 @@ +// This file builds complete generator plans for tests. +package generator + +import ( + "testing" + + "github.com/stretchr/testify/require" + + "goa.design/goa/v3/codegen" + "goa.design/goa/v3/eval" +) + +// mustTestPlan chooses package names, finishes each selected generator, and +// fails the calling test if any step is invalid. +func mustTestPlan(t *testing.T, genpkg string, roots []eval.Root, planners ...func(*Plan) error) *Plan { + t.Helper() + generation, err := codegen.NewGeneration(genpkg, roots) + require.NoError(t, err) + plan := &Plan{ + generation: generation, + preparedRoots: roots, + examples: newExampleGenerators(roots), + } + for _, planner := range planners { + require.NoError(t, planner(plan)) + } + require.NoError(t, generation.Freeze()) + require.NoError(t, plan.link()) + return plan +} + +// testServiceFiles returns service files from the plan under test. +func testServiceFiles(plan *Plan) ([]*codegen.File, error) { + return serviceFiles(plan) +} + +// testTransportFiles returns transport files from the plan under test. +func testTransportFiles(plan *Plan) ([]*codegen.File, error) { + return transportFiles(plan) +} + +// testOpenAPIFiles returns OpenAPI files from the plan under test. +func testOpenAPIFiles(plan *Plan) ([]*codegen.File, error) { + return openAPIFiles(plan) +} + +// assembleExampleFilesForTest returns example files from the plan under test. +func assembleExampleFilesForTest(plan *Plan) ([]*codegen.File, error) { + return exampleFiles(plan) +} diff --git a/codegen/generator/testdata/golden/files_client/files-only/cmd/public/http.go.golden b/codegen/generator/testdata/golden/files_client/files-only/cmd/public/http.go.golden new file mode 100644 index 0000000000..843672b61d --- /dev/null +++ b/codegen/generator/testdata/golden/files_client/files-only/cmd/public/http.go.golden @@ -0,0 +1,102 @@ +package main + +import ( + "context" + "net/http" + "net/url" + "sync" + "time" + + assetssvr "generated.local/gen/http/assets/server" + "goa.design/clue/debug" + "goa.design/clue/log" + goahttp "goa.design/goa/v3/http" +) + +// handleHTTPServer starts configures and starts a HTTP server on the given +// URL. It shuts down the server if any error is received in the error channel. +func handleHTTPServer(ctx context.Context, u *url.URL, wg *sync.WaitGroup, errc chan error, dbg bool) { + + // Provide the transport specific request decoder and response encoder. + // The goa http package has built-in support for JSON, XML and gob. + // Other encodings can be used by providing the corresponding functions, + // see goa.design/implement/encoding. + var ( + dec = goahttp.RequestDecoder + enc = goahttp.ResponseEncoder + ) + + // Build the service HTTP request multiplexer and mount debug and profiler + // endpoints in debug mode. + var mux goahttp.Muxer + { + mux = goahttp.NewMuxer() + if dbg { + // Mount pprof handlers for memory profiling under /debug/pprof. + debug.MountPprofHandlers(debug.Adapt(mux)) + // Mount /debug endpoint to enable or disable debug logs at runtime. + debug.MountDebugLogEnabler(debug.Adapt(mux)) + } + } + + // Wrap the endpoints with the transport specific layers. The generated + // server packages contains code generated from the design which maps + // the service input and output data structures to HTTP requests and + // responses. + var ( + assetsServer *assetssvr.Server + ) + { + eh := errorHandler(ctx) + assetsServer = assetssvr.New(nil, mux, dec, enc, eh, nil, nil) + } + + // Configure the mux. + assetssvr.Mount(mux, assetsServer) + + var handler http.Handler = mux + if dbg { + // Log query and response bodies if debug logs are enabled. + handler = debug.HTTP()(handler) + } + handler = log.HTTP(ctx)(handler) + + // Start HTTP server using default configuration, change the code to + // configure the server as required by your service. + srv := &http.Server{Addr: u.Host, Handler: handler, ReadHeaderTimeout: time.Second * 60} + for _, m := range assetsServer.Mounts { + log.Printf(ctx, "HTTP %q mounted on %s %s", m.Method, m.Verb, m.Pattern) + } + + (*wg).Add(1) + go func() { + defer (*wg).Done() + + // Start HTTP server in a separate goroutine. + go func() { + log.Printf(ctx, "HTTP server listening on %q", u.Host) + errc <- srv.ListenAndServe() + }() + + <-ctx.Done() + log.Printf(ctx, "shutting down HTTP server at %q", u.Host) + + // Shutdown gracefully with a 30s timeout. + shutdownCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 30*time.Second) + defer cancel() + + err := srv.Shutdown(shutdownCtx) + if err != nil { + log.Printf(shutdownCtx, "failed to shutdown: %v", err) + } + }() +} + +// errorHandler returns a function that writes and logs the given error. +// The function also writes and logs the error unique ID so that it's possible +// to correlate. +func errorHandler(logCtx context.Context) func(context.Context, http.ResponseWriter, error) { + return func(ctx context.Context, w http.ResponseWriter, err error) { + log.Printf(logCtx, "ERROR: %s", err.Error()) + } +} diff --git a/codegen/generator/testdata/golden/files_client/files-only/gen/http/assets/server/server.go.golden b/codegen/generator/testdata/golden/files_client/files-only/gen/http/assets/server/server.go.golden new file mode 100644 index 0000000000..ab8fd931c0 --- /dev/null +++ b/codegen/generator/testdata/golden/files_client/files-only/gen/http/assets/server/server.go.golden @@ -0,0 +1,105 @@ +// Code generated by goa, DO NOT EDIT. +// +// assets HTTP server +// +// Command: +// goa + +package server + +import ( + "context" + "net/http" + "path" + + assets "generated.local/gen/assets" + goahttp "goa.design/goa/v3/http" +) + +// Server lists the assets service endpoint HTTP handlers. +type Server struct { + Mounts []*MountPoint + IndexHTML http.Handler +} + +// MountPoint holds information about the mounted endpoints. +type MountPoint struct { + // Method is the name of the service method served by the mounted HTTP handler. + Method string + // Verb is the HTTP method used to match requests to the mounted handler. + Verb string + // Pattern is the HTTP request path pattern used to match requests to the + // mounted handler. + Pattern string +} + +// New instantiates HTTP handlers for all the assets service endpoints using +// the provided encoder and decoder. The handlers are mounted on the given mux +// using the HTTP verb and path defined in the design. errhandler is called +// whenever a response fails to be encoded. formatter is used to format errors +// returned by the service methods prior to encoding. Both errhandler and +// formatter are optional and can be nil. +func New( + e *assets.Endpoints, + mux goahttp.Muxer, + decoder func(*http.Request) goahttp.Decoder, + encoder func(context.Context, http.ResponseWriter) goahttp.Encoder, + errhandler func(context.Context, http.ResponseWriter, error), + formatter func(ctx context.Context, err error) goahttp.Statuser, + fileSystemIndexHTML http.FileSystem, +) *Server { + if fileSystemIndexHTML == nil { + fileSystemIndexHTML = http.Dir(".") + } + fileSystemIndexHTML = appendPrefix(fileSystemIndexHTML, "/") + return &Server{ + Mounts: []*MountPoint{ + {"Serve index.html", "GET", "/index.html"}, + }, + IndexHTML: http.FileServer(fileSystemIndexHTML), + } +} + +// Service returns the name of the service served. +func (s *Server) Service() string { return "assets" } + +// Use wraps the server handlers with the given middleware. +func (s *Server) Use(m func(http.Handler) http.Handler) { +} + +// MethodNames returns the methods served. +func (s *Server) MethodNames() []string { return assets.MethodNames[:] } + +// Mount configures the mux to serve the assets endpoints. +func Mount(mux goahttp.Muxer, h *Server) { + MountIndexHTML(mux, h.IndexHTML) +} + +// Mount configures the mux to serve the assets endpoints. +func (s *Server) Mount(mux goahttp.Muxer) { + Mount(mux, s) +} + +// appendFS adds a fixed directory to file paths before opening them. +type appendFS struct { + prefix string + fs http.FileSystem +} + +// Open opens the named file, appending the prefix to the file path before +// passing it to the underlying file system. +func (s appendFS) Open(name string) (http.File, error) { + switch name { + } + return s.fs.Open(path.Join(s.prefix, name)) +} + +// appendPrefix returns a file system that adds prefix before opening each path. +func appendPrefix(fsys http.FileSystem, prefix string) http.FileSystem { + return appendFS{prefix: prefix, fs: fsys} +} + +// MountIndexHTML configures the mux to serve GET request made to "/index.html". +func MountIndexHTML(mux goahttp.Muxer, h http.Handler) { + mux.Handle("GET", "/index.html", h.ServeHTTP) +} diff --git a/codegen/generator/testdata/golden/files_client/files-only/paths.golden b/codegen/generator/testdata/golden/files_client/files-only/paths.golden new file mode 100644 index 0000000000..fe1aab1cbf --- /dev/null +++ b/codegen/generator/testdata/golden/files_client/files-only/paths.golden @@ -0,0 +1,13 @@ +assets.go +cmd/public/http.go +cmd/public/main.go +gen/assets/client.go +gen/assets/endpoints.go +gen/assets/service.go +gen/http/assets/client/client.go +gen/http/assets/client/encode_decode.go +gen/http/assets/client/paths.go +gen/http/assets/client/types.go +gen/http/assets/server/paths.go +gen/http/assets/server/server.go +gen/http/assets/server/types.go diff --git a/codegen/generator/testdata/golden/files_client/files-with-grpc/cmd/public-cli/grpc.go.golden b/codegen/generator/testdata/golden/files_client/files-with-grpc/cmd/public-cli/grpc.go.golden new file mode 100644 index 0000000000..475a4c04dc --- /dev/null +++ b/codegen/generator/testdata/golden/files_client/files-with-grpc/cmd/public-cli/grpc.go.golden @@ -0,0 +1,42 @@ +package main + +import ( + "context" + "errors" + "flag" + "fmt" + "io" + + cli "generated.local/gen/grpc/cli/public" + "google.golang.org/grpc" + "google.golang.org/grpc/credentials/insecure" +) + +func doGRPC(ctx context.Context, _ string, host string, _ int, _ bool, stdout io.Writer) (err error) { + conn, err := grpc.NewClient(host, grpc.WithTransportCredentials(insecure.NewCredentials())) + if err != nil { + return fmt.Errorf("connect to gRPC server at %s: %w", host, err) + } + defer func() { + err = errors.Join(err, conn.Close()) + }() + endpoint, payload, err := cli.ParseEndpoint( + conn, + ) + if err != nil { + return fmt.Errorf("parse endpoint: %w", err) + } + + switch flag.Arg(0) { + case "assets": + switch flag.Arg(1) { + case "read-status": + return writeEndpointResult(ctx, stdout, endpoint, payload) + } + } + panic("parsed gRPC command has no generated result writer") +} + +func grpcUsageExamples() string { + return cli.UsageExamples() +} diff --git a/codegen/generator/testdata/golden/files_client/files-with-grpc/cmd/public-cli/main.go.golden b/codegen/generator/testdata/golden/files_client/files-with-grpc/cmd/public-cli/main.go.golden new file mode 100644 index 0000000000..5fec8005ec --- /dev/null +++ b/codegen/generator/testdata/golden/files_client/files-with-grpc/cmd/public-cli/main.go.golden @@ -0,0 +1,139 @@ +package main + +import ( + "context" + "encoding/json" + "errors" + "flag" + "fmt" + "io" + "net/url" + "os" + "strings" + + goa "goa.design/goa/v3/pkg" +) + +func main() { + var ( + hostF = flag.String("host", "local", "Server host (valid values: local)") + addrF = flag.String("url", "", "URL to service host") + + verboseF = flag.Bool("verbose", false, "Print request and response details") + vF = flag.Bool("v", false, "Print request and response details") + timeoutF = flag.Int("timeout", 30, "Maximum number of seconds to wait for response") + ) + flag.Usage = usage + flag.Parse() + + var ( + addr string + timeout int + debug bool + ) + { + addr = *addrF + if addr == "" { + switch *hostF { + case "local": + addr = "grpc://localhost:8080" + default: + fmt.Fprintf(os.Stderr, "invalid host argument: %q (valid hosts: local)\n", *hostF) + os.Exit(1) + } + } + timeout = *timeoutF + debug = *verboseF || *vF + } + + var ( + scheme string + host string + ) + { + u, err := url.Parse(addr) + if err != nil { + fmt.Fprintf(os.Stderr, "invalid URL %#v: %s\n", addr, err) + os.Exit(1) + } + scheme = u.Scheme + host = u.Host + } + + var ( + err error + ) + { + switch scheme { + case "grpc", "grpcs": + err = doGRPC(context.Background(), scheme, host, timeout, debug, os.Stdout) + default: + fmt.Fprintf(os.Stderr, "invalid scheme: %q (valid schemes: grpc)\n", scheme) + os.Exit(1) + } + } + if err != nil { + if errors.Is(err, flag.ErrHelp) { + os.Exit(0) + } + fmt.Fprintln(os.Stderr, err.Error()) + fmt.Fprintln(os.Stderr, "run '"+os.Args[0]+" --help' for detailed usage.") + os.Exit(1) + } + +} + +// writeEndpointResult calls one normal endpoint and writes its result as JSON. +func writeEndpointResult(ctx context.Context, stdout io.Writer, endpoint goa.Endpoint, payload any) error { + data, err := endpoint(ctx, payload) + if err != nil { + return err + } + return writeJSON(stdout, data) +} + +// writeJSON writes one indented JSON value followed by a newline. +func writeJSON(stdout io.Writer, data any) error { + if data == nil { + return nil + } + encoded, err := json.MarshalIndent(data, "", " ") + if err != nil { + return fmt.Errorf("encode result: %w", err) + } + if _, err := fmt.Fprintln(stdout, string(encoded)); err != nil { + return fmt.Errorf("write result: %w", err) + } + return nil +} + +func usage() { + usageCommands := []string{ + "assets read-status", + } + fmt.Fprintf(os.Stderr, `%s is a command line client for the files API. + +Usage: + %s [-host HOST][-url URL][-timeout SECONDS][-verbose|-v] SERVICE ENDPOINT [flags] + + -host HOST: server host (local). valid values: local + -url URL: specify service URL overriding host URL (grpc://localhost:8080) + -timeout: maximum number of seconds to wait for response (30) + -verbose|-v: print request and response details (false) + +Commands: +%s +Additional help: + %s SERVICE [ENDPOINT] --help + +Example: +%s +`, os.Args[0], os.Args[0], indent(strings.Join(usageCommands, "\n")), os.Args[0], indent(grpcUsageExamples())) +} + +func indent(s string) string { + if s == "" { + return "" + } + return " " + strings.ReplaceAll(s, "\n", "\n ") +} diff --git a/codegen/generator/testdata/golden/files_client/files-with-grpc/gen/grpc/cli/public/cli.go.golden b/codegen/generator/testdata/golden/files_client/files-with-grpc/gen/grpc/cli/public/cli.go.golden new file mode 100644 index 0000000000..fd2fcae4e4 --- /dev/null +++ b/codegen/generator/testdata/golden/files_client/files-with-grpc/gen/grpc/cli/public/cli.go.golden @@ -0,0 +1,147 @@ +// Code generated by goa, DO NOT EDIT. +// +// public gRPC client CLI support package +// +// Command: +// goa + +package cli + +import ( + "flag" + "fmt" + "os" + + assetsc "generated.local/gen/grpc/assets/client" + goa "goa.design/goa/v3/pkg" + grpc "google.golang.org/grpc" +) + +// UsageCommands returns the set of commands and sub-commands using the format +// +// command (subcommand1|subcommand2|...) +func UsageCommands() []string { + return []string{ + "assets read-status", + } +} + +// UsageExamples produces an example of a valid invocation of the CLI tool. +func UsageExamples() string { + return os.Args[0] + " " + "assets read-status" + "\n" + + "" +} + +// ParseEndpoint returns the endpoint and payload as specified on the command +// line. +func ParseEndpoint( + cc *grpc.ClientConn, + opts ...grpc.CallOption, +) (goa.Endpoint, any, error) { + var ( + assetsFlags = flag.NewFlagSet("assets", flag.ContinueOnError) + + assetsReadStatusFlags = flag.NewFlagSet("read-status", flag.ExitOnError) + ) + assetsFlags.Usage = assetsUsage + assetsReadStatusFlags.Usage = assetsReadStatusUsage + + if err := flag.CommandLine.Parse(os.Args[1:]); err != nil { + return nil, nil, err + } + + if flag.NArg() < 2 { // two non flag args are required: SERVICE and ENDPOINT (aka COMMAND) + return nil, nil, fmt.Errorf("not enough arguments") + } + + var ( + svcn string + svcf *flag.FlagSet + ) + { + svcn = flag.Arg(0) + switch svcn { + case "assets": + svcf = assetsFlags + default: + return nil, nil, fmt.Errorf("unknown service %q", svcn) + } + } + if err := svcf.Parse(flag.Args()[1:]); err != nil { + return nil, nil, err + } + + var ( + epn string + epf *flag.FlagSet + ) + { + epn = svcf.Arg(0) + switch svcn { + case "assets": + switch epn { + case "read-status": + epf = assetsReadStatusFlags + + } + + } + } + if epf == nil { + return nil, nil, fmt.Errorf("unknown %q endpoint %q", svcn, epn) + } + + // Parse endpoint flags if any + if svcf.NArg() > 1 { + if err := epf.Parse(svcf.Args()[1:]); err != nil { + return nil, nil, err + } + } + + var ( + data any + endpoint goa.Endpoint + err error + ) + { + switch svcn { + case "assets": + c := assetsc.NewClient(cc, opts...) + switch epn { + case "read-status": + endpoint = c.ReadStatus() + } + } + } + if err != nil { + return nil, nil, err + } + + return endpoint, data, nil +} + +// assetsUsage displays the usage of the assets command and its subcommands. +func assetsUsage() { + fmt.Fprintln(os.Stderr, `Service is the assets service interface.`) + fmt.Fprintf(os.Stderr, "Usage:\n %s [globalflags] assets COMMAND [flags]\n\n", os.Args[0]) + fmt.Fprintln(os.Stderr, "COMMAND:") + fmt.Fprintln(os.Stderr, ` read-status: ReadStatus implements read_status.`) + fmt.Fprintln(os.Stderr) + fmt.Fprintln(os.Stderr, "Additional help:") + fmt.Fprintf(os.Stderr, " %s assets COMMAND --help\n", os.Args[0]) +} +func assetsReadStatusUsage() { + // Header with flags + fmt.Fprintf(os.Stderr, "%s [flags] assets read-status", os.Args[0]) + fmt.Fprintln(os.Stderr) + + // Description + fmt.Fprintln(os.Stderr) + fmt.Fprintln(os.Stderr, `ReadStatus implements read_status.`) + + // Flags list + + fmt.Fprintln(os.Stderr) + fmt.Fprintln(os.Stderr, "Example:") + fmt.Fprintf(os.Stderr, " %s %s\n", os.Args[0], "assets read-status") +} diff --git a/codegen/generator/testdata/golden/files_client/files-with-grpc/paths.golden b/codegen/generator/testdata/golden/files_client/files-with-grpc/paths.golden new file mode 100644 index 0000000000..dc49d7b095 --- /dev/null +++ b/codegen/generator/testdata/golden/files_client/files-with-grpc/paths.golden @@ -0,0 +1,25 @@ +assets.go +cmd/public-cli/grpc.go +cmd/public-cli/main.go +cmd/public/grpc.go +cmd/public/http.go +cmd/public/main.go +gen/assets/client.go +gen/assets/endpoints.go +gen/assets/service.go +gen/grpc/assets/client/cli.go +gen/grpc/assets/client/client.go +gen/grpc/assets/client/encode_decode.go +gen/grpc/assets/client/types.go +gen/grpc/assets/pb/goagen_generated.local_assets.proto +gen/grpc/assets/server/encode_decode.go +gen/grpc/assets/server/server.go +gen/grpc/assets/server/types.go +gen/grpc/cli/public/cli.go +gen/http/assets/client/client.go +gen/http/assets/client/encode_decode.go +gen/http/assets/client/paths.go +gen/http/assets/client/types.go +gen/http/assets/server/paths.go +gen/http/assets/server/server.go +gen/http/assets/server/types.go diff --git a/codegen/generator/transport.go b/codegen/generator/transport.go index f69af44d50..8939b29ce6 100644 --- a/codegen/generator/transport.go +++ b/codegen/generator/transport.go @@ -1,8 +1,9 @@ +// This file builds the HTTP, gRPC, and JSON-RPC files for every service. Each +// generated file lists the Go packages that it uses. package generator import ( "goa.design/goa/v3/codegen" - "goa.design/goa/v3/codegen/service" "goa.design/goa/v3/eval" "goa.design/goa/v3/expr" grpccodegen "goa.design/goa/v3/grpc/codegen" @@ -10,51 +11,165 @@ import ( jsonrpccodegen "goa.design/goa/v3/jsonrpc/codegen" ) -// Transport iterates through the roots and returns the files needed to render -// the transport code. +// Transport returns the HTTP, gRPC, and JSON-RPC files for roots. func Transport(genpkg string, roots []eval.Root) ([]*codegen.File, error) { + return runStandaloneGenerator(genpkg, roots, transportGeneratorFactory) +} + +// transportFiles returns all HTTP, gRPC, and JSON-RPC files for one run. +func transportFiles(plan *Plan) ([]*codegen.File, error) { var files []*codegen.File - for _, root := range roots { - r, ok := root.(*expr.RootExpr) - if !ok { - continue // could be a plugin root expression + for _, transport := range plan.transports { + // HTTP + if httpPlan := transport.http; httpPlan != nil { + files = append(files, httpPlan.ServerFiles()...) + files = append(files, httpPlan.ClientFiles()...) + files = append(files, httpPlan.ServerTypeFiles()...) + files = append(files, httpPlan.ClientTypeFiles()...) + files = append(files, httpPlan.PathFiles()...) + files = append(files, httpPlan.ClientCLIFiles()...) } - // Create service data - services := service.NewServicesData(r) - for _, s := range r.Services { - service.SetUserTypeImports(genpkg, services.Get(s.Name)) + // GRPC + if grpcPlan := transport.grpc; grpcPlan != nil { + files = append(files, grpcPlan.ProtoFiles()...) + files = append(files, grpcPlan.ServerFiles()...) + files = append(files, grpcPlan.ClientFiles()...) + files = append(files, grpcPlan.ServerTypeFiles()...) + files = append(files, grpcPlan.ClientTypeFiles()...) + files = append(files, grpcPlan.ClientCLIFiles()...) } - // HTTP - httpServices := httpcodegen.NewServicesData(services, r.API.HTTP) - files = append(files, httpcodegen.ServerFiles(genpkg, httpServices)...) - files = append(files, httpcodegen.ClientFiles(genpkg, httpServices)...) - files = append(files, httpcodegen.ServerTypeFiles(genpkg, httpServices)...) - files = append(files, httpcodegen.ClientTypeFiles(genpkg, httpServices)...) - files = append(files, httpcodegen.PathFiles(httpServices)...) - files = append(files, httpcodegen.ClientCLIFiles(genpkg, httpServices)...) + // JSON-RPC + if jsonrpcPlan := transport.jsonrpc; jsonrpcPlan != nil { + files = append(files, jsonrpcPlan.ServerFiles()...) + files = append(files, jsonrpcPlan.ClientFiles()...) + files = append(files, jsonrpcPlan.ServerTypeFiles()...) + files = append(files, jsonrpcPlan.ClientTypeFiles()...) + files = append(files, jsonrpcPlan.PathFiles()...) + files = append(files, jsonrpcPlan.ClientCLIFiles()...) + } + } + return files, nil +} - // GRPC - grpcServices := grpccodegen.NewServicesData(services) - files = append(files, grpccodegen.ProtoFiles(genpkg, grpcServices)...) - files = append(files, grpccodegen.ServerFiles(genpkg, grpcServices)...) - files = append(files, grpccodegen.ClientFiles(genpkg, grpcServices)...) - files = append(files, grpccodegen.ServerTypeFiles(genpkg, grpcServices)...) - files = append(files, grpccodegen.ClientTypeFiles(genpkg, grpcServices)...) - files = append(files, grpccodegen.ClientCLIFiles(genpkg, grpcServices)...) +// planTransportData chooses all Go package, import, type, and function names +// before generated files use them. +func planTransportData(plan *Plan) error { + if err := planServiceData(plan); err != nil { + return err + } + if plan.transportDone { + return nil + } + generation := plan.Generation() + roots := serviceRoots(generation.Roots()) + if err := planHTTPTransports(plan, roots); err != nil { + return err + } + if err := planJSONRPCTransports(plan, roots); err != nil { + return err + } + var hasGRPC bool + for _, root := range roots { + hasGRPC = hasGRPC || len(root.API.GRPC.Services) > 0 + } + if hasGRPC { + var inputs []grpccodegen.PlanInput + var plannedRoots []*expr.RootExpr + for _, root := range roots { + if len(root.API.GRPC.Services) == 0 { + continue + } + inputs = append(inputs, grpccodegen.PlanInput{Root: root, Service: plan.Service(root)}) + plannedRoots = append(plannedRoots, root) + } + grpcPlans, err := grpccodegen.NewPlans(generation, inputs...) + if err != nil { + return err + } + plan.grpc = make(map[*expr.RootExpr]*grpccodegen.Plan, len(grpcPlans)) + for index, root := range plannedRoots { + plan.grpc[root] = grpcPlans[index] + } + } + plan.transports = make([]*transportPlanEntry, len(roots)) + for index, root := range roots { + plan.transports[index] = &transportPlanEntry{ + http: plan.http[root], + jsonrpcHTTP: plan.jsonrpcHTTP[root], + jsonrpc: plan.jsonrpc[root], + grpc: plan.grpc[root], + } + } + plan.transportDone = true + return nil +} - // JSON-RPC - jsonrpcServices := httpcodegen.NewJSONRPCServicesData(services, &r.API.JSONRPC.HTTPExpr) - files = append(files, jsonrpccodegen.ServerFiles(genpkg, jsonrpcServices)...) - files = append(files, jsonrpccodegen.ClientFiles(genpkg, jsonrpcServices)...) - files = append(files, httpcodegen.ServerTypeFiles(genpkg, jsonrpcServices)...) - files = append(files, httpcodegen.ClientTypeFiles(genpkg, jsonrpcServices)...) - files = append(files, httpcodegen.PathFiles(jsonrpcServices)...) - files = append(files, httpcodegen.ClientCLIFiles(genpkg, jsonrpcServices)...) +// planHTTPTransports prepares every HTTP service together. When two services +// write to the same Go package, Goa gives their types and functions different names. +func planHTTPTransports(plan *Plan, roots []*expr.RootExpr) error { + var inputs []httpcodegen.PlanInput + var plannedRoots []*expr.RootExpr + for _, root := range roots { + if len(root.API.HTTP.Services) == 0 { + continue + } + inputs = append(inputs, httpcodegen.PlanInput{Root: root, Service: plan.Service(root)}) + plannedRoots = append(plannedRoots, root) + } + if len(inputs) == 0 { + return nil + } + plans, err := httpcodegen.NewPlans(plan.Generation(), inputs...) + if err != nil { + return err + } + plan.http = make(map[*expr.RootExpr]*httpcodegen.Plan, len(plans)) + for index, root := range plannedRoots { + plan.http[root] = plans[index] + } + return nil +} - // Add service data meta type imports - addServicesImports(files, services, r.Services) +// planJSONRPCTransports prepares the HTTP request and response types used by +// JSON-RPC. It then gives those values to the JSON-RPC generator so function +// definitions and calls use the same Go names. +func planJSONRPCTransports(plan *Plan, roots []*expr.RootExpr) error { + var inputs []httpcodegen.PlanInput + var plannedRoots []*expr.RootExpr + for _, root := range roots { + if len(root.API.JSONRPC.Services) == 0 { + continue + } + inputs = append(inputs, httpcodegen.PlanInput{Root: root, Service: plan.Service(root)}) + plannedRoots = append(plannedRoots, root) } - return files, nil + if len(inputs) == 0 { + return nil + } + httpPlans, err := httpcodegen.NewJSONRPCPlans(plan.Generation(), inputs...) + if err != nil { + return err + } + jsonrpcInputs := make([]jsonrpccodegen.PlanInput, len(inputs)) + plan.jsonrpcHTTP = make(map[*expr.RootExpr]*httpcodegen.Plan, len(httpPlans)) + for index, root := range plannedRoots { + plan.jsonrpcHTTP[root] = httpPlans[index] + jsonrpcInputs[index] = jsonrpccodegen.PlanInput{ + Root: root, + Service: plan.Service(root), + HTTP: httpPlans[index], + ApplicationHTTP: plan.http[root], + } + } + jsonrpcPlans, err := jsonrpccodegen.NewPlans(plan.Generation(), jsonrpcInputs...) + if err != nil { + return err + } + plan.jsonrpc = make(map[*expr.RootExpr]*jsonrpccodegen.Plan, len(jsonrpcPlans)) + for index, root := range plannedRoots { + plan.jsonrpc[root] = jsonrpcPlans[index] + } + return nil } diff --git a/codegen/generator/transport_plan_test.go b/codegen/generator/transport_plan_test.go new file mode 100644 index 0000000000..3cd8f74dbc --- /dev/null +++ b/codegen/generator/transport_plan_test.go @@ -0,0 +1,38 @@ +// This file checks that plugins can find the gRPC and JSON-RPC plans retained +// for the exact prepared design root they received. +package generator + +import ( + "testing" + + "github.com/stretchr/testify/require" + + "goa.design/goa/v3/expr" + grpccodegen "goa.design/goa/v3/grpc/codegen" + jsonrpccodegen "goa.design/goa/v3/jsonrpc/codegen" +) + +func TestTransportPlansUseExactRoot(t *testing.T) { + grpcRoot := &expr.RootExpr{} + jsonrpcRoot := &expr.RootExpr{} + grpcPlan := &grpccodegen.Plan{} + jsonrpcPlan := &jsonrpccodegen.Plan{} + plan := &Plan{ + grpc: map[*expr.RootExpr]*grpccodegen.Plan{grpcRoot: grpcPlan}, + jsonrpc: map[*expr.RootExpr]*jsonrpccodegen.Plan{jsonrpcRoot: jsonrpcPlan}, + } + + gotGRPC, ok := plan.GRPC(grpcRoot) + require.True(t, ok) + require.Same(t, grpcPlan, gotGRPC) + gotGRPC, ok = plan.GRPC(&expr.RootExpr{}) + require.False(t, ok) + require.Nil(t, gotGRPC) + + gotJSONRPC, ok := plan.JSONRPC(jsonrpcRoot) + require.True(t, ok) + require.Same(t, jsonrpcPlan, gotJSONRPC) + gotJSONRPC, ok = plan.JSONRPC(&expr.RootExpr{}) + require.False(t, ok) + require.Nil(t, gotJSONRPC) +} diff --git a/codegen/generator/viewed_transport_import_integration_test.go b/codegen/generator/viewed_transport_import_integration_test.go new file mode 100644 index 0000000000..3c211d043e --- /dev/null +++ b/codegen/generator/viewed_transport_import_integration_test.go @@ -0,0 +1,219 @@ +// This file verifies that transport client files import generated views only +// when one of their emitted response or stream-receive sections references it. +package generator + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/require" + + "goa.design/goa/v3/codegen" + "goa.design/goa/v3/dsl" +) + +// TestViewedTransportClientImportsCompile verifies that HTTP and JSON-RPC +// response decoders and gRPC response and stream decoders receive the exact +// generated views import used by their rendered sections. The unary gRPC +// client also proves client.go does not reserve the codec-only import. +func TestViewedTransportClientImportsCompile(t *testing.T) { + registry := testRegistryFromGenfuncs([]testGenfunc{ + {Plan: planServiceData, Generate: testServiceFiles}, + {Plan: planTransportData, Generate: testTransportFiles}, + }) + + codegen.RunDSL(t, func() { + result := dsl.ResultType("application/vnd.viewed-import", func() { + dsl.Field(1, "value", dsl.String) + dsl.Required("value") + dsl.View("default", func() { + dsl.Attribute("value") + }) + }) + dsl.Service("ViewedHTTPJSON", func() { + dsl.Method("HTTP", func() { + dsl.Result(result) + dsl.HTTP(func() { + dsl.GET("/http") + }) + }) + dsl.Method("JSONRPC", func() { + dsl.Result(result) + dsl.JSONRPC(func() {}) + }) + }) + dsl.Service("ViewedUnary", func() { + dsl.Method("GRPCUnary", func() { + dsl.Result(result) + dsl.GRPC(func() {}) + }) + }) + dsl.Service("ViewedStream", func() { + dsl.Method("GRPCStream", func() { + dsl.StreamingResult(result) + dsl.GRPC(func() {}) + }) + }) + dsl.Service("ViewedHTTPSSE", func() { + dsl.Method("Events", func() { + dsl.StreamingResult(result) + dsl.HTTP(func() { + dsl.GET("/http-sse") + dsl.ServerSentEvents("value") + }) + }) + }) + dsl.Service("ViewedHTTPWebSocket", func() { + dsl.Method("Events", func() { + dsl.StreamingResult(result) + dsl.HTTP(func() { + dsl.GET("/http-websocket") + }) + }) + }) + dsl.Service("Ordinary", func() { + dsl.Method("HTTP", func() { + dsl.Result(dsl.String) + dsl.HTTP(func() { + dsl.GET("/ordinary") + }) + }) + dsl.Method("JSONRPC", func() { + dsl.Result(dsl.String) + dsl.JSONRPC(func() {}) + }) + dsl.Method("GRPCUnary", func() { + dsl.Result(dsl.String) + dsl.GRPC(func() {}) + }) + dsl.Method("GRPCStream", func() { + dsl.StreamingResult(dsl.String) + dsl.GRPC(func() {}) + }) + }) + }) + + dir := t.TempDir() + genDir := filepath.Join(dir, codegen.Gendir) + writeGeneratedModule(t, genDir, "generated.local/gen") + _, err := generate(dir, "gen", false, registry) + require.NoError(t, err) + httpJSON := codegen.SnakeCase("ViewedHTTPJSON") + unary := codegen.SnakeCase("ViewedUnary") + stream := codegen.SnakeCase("ViewedStream") + assertFilesImportPath(t, genDir, "/"+httpJSON+"/views\"", []string{ + filepath.Join("http", httpJSON, "client", "encode_decode.go"), + filepath.Join("http", httpJSON, "client", "types.go"), + filepath.Join("http", httpJSON, "server", "encode_decode.go"), + filepath.Join("http", httpJSON, "server", "types.go"), + filepath.Join("jsonrpc", httpJSON, "client", "encode_decode.go"), + filepath.Join("jsonrpc", httpJSON, "client", "types.go"), + filepath.Join("jsonrpc", httpJSON, "server", "server.go"), + filepath.Join("jsonrpc", httpJSON, "server", "types.go"), + }) + assertNoImportPath(t, filepath.Join(genDir, "grpc", unary, "client", "client.go"), "/"+unary+"/views\"") + assertFilesImportPath(t, genDir, "/"+unary+"/views\"", []string{ + filepath.Join("grpc", unary, "client", "encode_decode.go"), + filepath.Join("grpc", unary, "client", "types.go"), + filepath.Join("grpc", unary, "server", "encode_decode.go"), + filepath.Join("grpc", unary, "server", "types.go"), + }) + assertFilesImportPath(t, genDir, "/"+stream+"/views\"", []string{ + filepath.Join("grpc", stream, "client", "client.go"), + filepath.Join("grpc", stream, "client", "types.go"), + filepath.Join("grpc", stream, "server", "encode_decode.go"), + filepath.Join("grpc", stream, "server", "types.go"), + }) + assertNoImportPath(t, filepath.Join(genDir, "grpc", stream, "client", "encode_decode.go"), "/"+stream+"/views\"") + assertViewedStreamingTransportFiles(t, genDir) + ordinary := codegen.SnakeCase("Ordinary") + assertFilesOmitImportPath(t, genDir, "/"+ordinary+"/views\"", []string{ + filepath.Join("http", ordinary, "client", "encode_decode.go"), + filepath.Join("http", ordinary, "client", "types.go"), + filepath.Join("http", ordinary, "server", "encode_decode.go"), + filepath.Join("http", ordinary, "server", "types.go"), + filepath.Join("jsonrpc", ordinary, "client", "encode_decode.go"), + filepath.Join("jsonrpc", ordinary, "client", "types.go"), + filepath.Join("jsonrpc", ordinary, "server", "server.go"), + filepath.Join("jsonrpc", ordinary, "server", "types.go"), + filepath.Join("grpc", ordinary, "client", "client.go"), + filepath.Join("grpc", ordinary, "client", "encode_decode.go"), + filepath.Join("grpc", ordinary, "client", "types.go"), + filepath.Join("grpc", ordinary, "server", "encode_decode.go"), + filepath.Join("grpc", ordinary, "server", "types.go"), + }) + runGeneratedTests(t, genDir) +} + +// assertViewedStreamingTransportFiles checks the generated HTTP SSE and +// WebSocket files. Client files import the views package to validate each result +// after rebuilding it. Server files use the service constructor, which performs +// that validation without a direct views import. +func assertViewedStreamingTransportFiles(t *testing.T, genDir string) { + t.Helper() + httpSSE := codegen.SnakeCase("ViewedHTTPSSE") + httpWebSocket := codegen.SnakeCase("ViewedHTTPWebSocket") + for _, path := range []string{ + filepath.Join("http", httpSSE, "server", "sse.go"), + filepath.Join("http", httpSSE, "client", "sse.go"), + filepath.Join("http", httpWebSocket, "server", "websocket.go"), + } { + require.FileExists(t, filepath.Join(genDir, path)) + } + assertImportPath( + t, + filepath.Join(genDir, "http", httpWebSocket, "client", "websocket.go"), + "/"+httpWebSocket+"/views\"", + ) + assertImportPath( + t, + filepath.Join(genDir, "http", httpSSE, "client", "sse.go"), + "/"+httpSSE+"/views\"", + ) + assertFilesOmitImportPath(t, genDir, "/"+httpSSE+"/views\"", []string{ + filepath.Join("http", httpSSE, "server", "sse.go"), + }) + assertNoImportPath( + t, + filepath.Join(genDir, "http", httpWebSocket, "server", "websocket.go"), + "/"+httpWebSocket+"/views\"", + ) +} + +// assertFilesImportPath verifies that each generated file imports a package +// whose full path ends with suffix. +func assertFilesImportPath(t *testing.T, root, suffix string, paths []string) { + t.Helper() + for _, path := range paths { + assertImportPath(t, filepath.Join(root, path), suffix) + } +} + +// assertFilesOmitImportPath verifies that none of the generated files reserve +// the package whose full path ends with suffix. +func assertFilesOmitImportPath(t *testing.T, root, suffix string, paths []string) { + t.Helper() + for _, path := range paths { + assertNoImportPath(t, filepath.Join(root, path), suffix) + } +} + +// assertImportPath verifies that a generated file imports a package whose full +// path ends with suffix. +func assertImportPath(t *testing.T, path, suffix string) { + t.Helper() + content, err := os.ReadFile(path) + require.NoError(t, err) + require.Contains(t, string(content), suffix) +} + +// assertNoImportPath verifies that a generated file does not reserve an import +// for a package unused by its rendered sections. +func assertNoImportPath(t *testing.T, path, suffix string) { + t.Helper() + content, err := os.ReadFile(path) + require.NoError(t, err) + require.False(t, strings.Contains(string(content), suffix), string(content)) +} diff --git a/codegen/generator/viewed_transport_representation_integration_test.go b/codegen/generator/viewed_transport_representation_integration_test.go new file mode 100644 index 0000000000..c7edeef636 --- /dev/null +++ b/codegen/generator/viewed_transport_representation_integration_test.go @@ -0,0 +1,235 @@ +// This file checks that generated clients and servers send each result view +// with the JSON fields selected by that view. +package generator + +import ( + "context" + "os" + "os/exec" + "path/filepath" + "testing" + "time" + + "github.com/stretchr/testify/require" + + "goa.design/goa/v3/codegen" + "goa.design/goa/v3/dsl" + "goa.design/goa/v3/expr" +) + +// TestGeneratedHTTPViewedSSEServerUsesRequestView checks that an SSE request +// uses the view selected by the service call. A method with one fixed view does +// not choose a view while it runs. +func TestGeneratedHTTPViewedSSEServerUsesRequestView(t *testing.T) { + dir := generateViewedTransportModule(t, viewedHTTPSSEDSL) + writeGeneratedContractTest(t, dir, filepath.Join("http", "http_view_stream", "server"), httpViewedSSEServerTest) + runGeneratedPackageTests(t, dir, "./http/http_view_stream/server") +} + +// TestGeneratedHTTPViewedSSEClientRebuildsResult checks that an SSE client +// reads the selected HTTP body before rebuilding the service result, including +// JSON field names and nested fields. +func TestGeneratedHTTPViewedSSEClientRebuildsResult(t *testing.T) { + dir := generateViewedTransportModule(t, viewedHTTPSSEDSL) + writeGeneratedContractTest(t, dir, filepath.Join("http", "http_view_stream", "client"), httpViewedSSEClientTest) + runGeneratedPackageTests(t, dir, "./http/http_view_stream/client") +} + +// TestGeneratedJSONRPCUnaryViewedRepresentation checks that a one-result call +// sends both the selected view name and its JSON body. The view name must not +// come from an HTTP header. +func TestGeneratedJSONRPCUnaryViewedRepresentation(t *testing.T) { + dir := generateViewedTransportModule(t, viewedJSONRPCUnaryDSL) + writeGeneratedContractTest(t, dir, filepath.Join("jsonrpc", "jsonrpc_unary", "client"), jsonRPCViewedUnaryClientTest) + runGeneratedPackageTests(t, dir, "./jsonrpc/jsonrpc_unary/client") +} + +// TestGeneratedJSONRPCUnaryServerEmitsViewedRepresentation checks that the +// server writes the selected view and matching body in the JSON-RPC result. A +// method with one fixed view writes only the body. +func TestGeneratedJSONRPCUnaryServerEmitsViewedRepresentation(t *testing.T) { + dir := generateViewedTransportModule(t, viewedJSONRPCUnaryDSL) + writeGeneratedContractTest(t, dir, filepath.Join("jsonrpc", "jsonrpc_unary", "server"), jsonRPCViewedUnaryServerTest) + runGeneratedPackageTests(t, dir, "./jsonrpc/jsonrpc_unary/server") +} + +// TestGeneratedJSONRPCViewedServiceNameCompiles checks that the selected-view +// value does not hide the generated service package with the same Go name. +func TestGeneratedJSONRPCViewedServiceNameCompiles(t *testing.T) { + dir := generateViewedTransportModule(t, viewedJSONRPCQualifierCollisionDSL) + runGeneratedPackageTests(t, dir, "./jsonrpc/viewed/client") +} + +// TestGeneratedJSONRPCSSEViewedRepresentation checks that JSON-RPC SSE pairs +// every view name with its matching body and rebuilds service results from +// notifications before the terminal response ends the stream. +func TestGeneratedJSONRPCSSEViewedRepresentation(t *testing.T) { + dir := generateViewedTransportModule(t, viewedJSONRPCSSEDSL) + writeGeneratedContractTest(t, dir, filepath.Join("jsonrpc", "jsonrpcsse", "client"), jsonRPCViewedSSEClientTest) + runGeneratedPackageTests(t, dir, "./jsonrpc/jsonrpcsse/client") +} + +// TestGeneratedJSONRPCSSEServerEmitsViewedRepresentation checks that SSE +// notifications contain the same view name and body that clients read. +// Methods with one fixed view contain only the body. +func TestGeneratedJSONRPCSSEServerEmitsViewedRepresentation(t *testing.T) { + dir := generateViewedTransportModule(t, viewedJSONRPCSSEDSL) + writeGeneratedContractTest(t, dir, filepath.Join("jsonrpc", "jsonrpcsse", "server"), jsonRPCViewedSSEServerTest) + runGeneratedPackageTests(t, dir, "./jsonrpc/jsonrpcsse/server") +} + +// generateViewedTransportModule generates a temporary Go module for one test. +func generateViewedTransportModule(t *testing.T, design func()) string { + t.Helper() + registry := testRegistryFromGenfuncs([]testGenfunc{ + {Plan: planServiceData, Generate: testServiceFiles}, + {Plan: planTransportData, Generate: testTransportFiles}, + }) + codegen.RunDSL(t, design) + dir := filepath.Join(t.TempDir(), codegen.Gendir) + writeGeneratedModule(t, dir, "generated.local/gen") + _, err := generate(filepath.Dir(dir), "gen", false, registry) + require.NoError(t, err) + return dir +} + +// writeGeneratedContractTest adds a test that calls one generated package. +// The generated module is temporary; the source tree remains untouched. +func writeGeneratedContractTest(t *testing.T, moduleDir, packageDir, source string) { + t.Helper() + dir := filepath.Join(moduleDir, packageDir) + require.NoError(t, os.MkdirAll(dir, 0o750)) + require.NoError(t, os.WriteFile(filepath.Join(dir, "viewed_contract_test.go"), []byte(source), 0o600)) +} + +// runGeneratedPackageTests compiles and runs one generated package. Limiting +// the command to that package makes a failure point to the code under test. +func runGeneratedPackageTests(t *testing.T, dir, packagePattern string) { + t.Helper() + ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) + defer cancel() + cmd := exec.CommandContext(ctx, "go", "test", "-mod=mod", packagePattern) + cmd.Dir = dir + cmd.Env = append(os.Environ(), "GOWORK=off") + output, err := cmd.CombinedOutput() + if err != nil { + t.Errorf("test generated package %s: %v\n%s", packagePattern, err, output) + } +} + +// viewedResultType defines a result whose selected view changes both the JSON +// body fields and their JSON names. +func viewedResultType() *expr.ResultTypeExpr { + profile := dsl.Type("Profile", func() { + dsl.Attribute("display_name", dsl.String) + dsl.Required("display_name") + }) + return dsl.ResultType("application/vnd.viewed-event", func() { + dsl.TypeName("Event") + dsl.Attribute("event_id", dsl.String) + dsl.Attribute("profile", profile) + dsl.Required("event_id", "profile") + dsl.View("summary", func() { + dsl.Attribute("event_id") + }) + dsl.View("detailed", func() { + dsl.Attribute("event_id") + dsl.Attribute("profile") + }) + }) +} + +// viewedHTTPSSEDSL creates HTTP SSE methods with selectable and fixed views. +func viewedHTTPSSEDSL() { + event := viewedResultType() + immediate := dsl.Type("Immediate", func() { + dsl.Attribute("message", dsl.String) + }) + dsl.Service("HTTP View Stream", func() { + dsl.Method("watch", func() { + dsl.StreamingResult(event) + dsl.HTTP(func() { + dsl.GET("/watch") + dsl.ServerSentEvents() + }) + }) + dsl.Method("fixed", func() { + dsl.StreamingResult(event, func() { + dsl.View("detailed") + }) + dsl.HTTP(func() { + dsl.GET("/fixed") + dsl.ServerSentEvents() + }) + }) + dsl.Method("mixed", func() { + dsl.Result(immediate) + dsl.StreamingResult(event) + dsl.HTTP(func() { + dsl.GET("/mixed") + dsl.ServerSentEvents() + }) + }) + }) +} + +// viewedJSONRPCUnaryDSL creates one-result JSON-RPC methods with selectable +// and fixed views. +func viewedJSONRPCUnaryDSL() { + event := viewedResultType() + dsl.Service("JSON RPC Unary", func() { + dsl.JSONRPC(func() { + dsl.POST("/rpc") + }) + dsl.Method("fetch", func() { + dsl.Result(event) + dsl.JSONRPC(func() {}) + }) + dsl.Method("fixed", func() { + dsl.Result(event, func() { + dsl.View("detailed") + }) + dsl.JSONRPC(func() {}) + }) + }) +} + +// viewedJSONRPCQualifierCollisionDSL uses the service name that previously +// matched the local selected-view value in the generated decoder. +func viewedJSONRPCQualifierCollisionDSL() { + event := viewedResultType() + dsl.Service("viewed", func() { + dsl.JSONRPC(func() { + dsl.POST("/rpc") + }) + dsl.Method("fetch", func() { + dsl.Result(event) + dsl.JSONRPC(func() {}) + }) + }) +} + +// viewedJSONRPCSSEDSL creates JSON-RPC SSE methods with selectable and fixed +// views. +func viewedJSONRPCSSEDSL() { + event := viewedResultType() + dsl.Service("JSON RPC SSE", func() { + dsl.JSONRPC(func() { + dsl.POST("/events") + }) + dsl.Method("watch", func() { + dsl.StreamingResult(event) + dsl.JSONRPC(func() { + dsl.ServerSentEvents(func() {}) + }) + }) + dsl.Method("fixed", func() { + dsl.StreamingResult(event, func() { + dsl.View("detailed") + }) + dsl.JSONRPC(func() { + dsl.ServerSentEvents(func() {}) + }) + }) + }) +} diff --git a/codegen/generator/viewed_transport_runtime_sources_test.go b/codegen/generator/viewed_transport_runtime_sources_test.go new file mode 100644 index 0000000000..6da20e3534 --- /dev/null +++ b/codegen/generator/viewed_transport_runtime_sources_test.go @@ -0,0 +1,937 @@ +// This file contains source code that calls generated HTTP and JSON-RPC code +// with result views. Each source string runs in a temporary Go module. +package generator + +const httpViewedSSEServerTest = `package server + +import ( + "context" + "encoding/json" + "errors" + "io" + "net/http" + "net/http/httptest" + "reflect" + "strings" + "testing" + + "github.com/stretchr/testify/require" + + service "generated.local/gen/http_view_stream" + goahttp "goa.design/goa/v3/http" + goa "goa.design/goa/v3/pkg" +) + +type viewedService struct { + serviceDefaults + watchView string +} + +type unknownViewService struct { + serviceDefaults + sendError chan error +} + +type changingViewService struct { + serviceDefaults + sendError chan error +} + +type sendResultService struct { + serviceDefaults + sendError chan error +} + +type fixedErrorAfterEventService struct{ serviceDefaults } + +type mixedErrorAfterEventService struct{ serviceDefaults } + +type serviceDefaults struct{} + +type statusRecorder struct { + *httptest.ResponseRecorder + statuses []int +} + +type streamResponseWriter struct { + header http.Header + status int + body strings.Builder + writeError error +} + +var errEventWrite = errors.New("event write failed") +var errAfterEvent = errors.New("service failed after event") + +func (serviceDefaults) Watch(_ context.Context, _ service.WatchServerStream) error { + return nil +} + +func (serviceDefaults) Fixed(_ context.Context, _ service.FixedServerStream) error { + return nil +} + +func (serviceDefaults) Mixed(_ context.Context, _ service.MixedServerStream) (*service.Immediate, error) { + return nil, nil +} + +func (s *viewedService) Watch(_ context.Context, stream service.WatchServerStream) error { + stream.SetView(s.watchView) + return stream.Send(viewedEvent()) +} + +func (s *viewedService) Fixed(_ context.Context, stream service.FixedServerStream) error { + return stream.Send(viewedEvent()) +} + +func (s *unknownViewService) Watch(_ context.Context, stream service.WatchServerStream) error { + stream.SetView("unknown") + err := stream.Send(viewedEvent()) + s.sendError <- err + return err +} + +func (*unknownViewService) Fixed(_ context.Context, stream service.FixedServerStream) error { + return stream.Send(viewedEvent()) +} + +func (s *changingViewService) Watch(_ context.Context, stream service.WatchServerStream) error { + stream.SetView("summary") + if err := stream.Send(viewedEvent()); err != nil { + return err + } + stream.SetView("detailed") + err := stream.Send(viewedEvent()) + s.sendError <- err + return nil +} + +func (*changingViewService) Fixed(_ context.Context, stream service.FixedServerStream) error { + return stream.Send(viewedEvent()) +} + +func (s *sendResultService) Watch(_ context.Context, stream service.WatchServerStream) error { + err := stream.Send(viewedEvent()) + s.sendError <- err + return err +} + +func (*sendResultService) Fixed(_ context.Context, stream service.FixedServerStream) error { + return stream.Send(viewedEvent()) +} + +func (*fixedErrorAfterEventService) Watch(_ context.Context, stream service.WatchServerStream) error { + return stream.Send(viewedEvent()) +} + +func (*fixedErrorAfterEventService) Fixed(_ context.Context, stream service.FixedServerStream) error { + if err := stream.Send(viewedEvent()); err != nil { + return err + } + return errAfterEvent +} + +func (*mixedErrorAfterEventService) Mixed(_ context.Context, stream service.MixedServerStream) (*service.Immediate, error) { + if err := stream.Send(viewedEvent()); err != nil { + return nil, err + } + return nil, errAfterEvent +} + +func (w *statusRecorder) WriteHeader(status int) { + w.statuses = append(w.statuses, status) + w.ResponseRecorder.WriteHeader(status) +} + +func (w *streamResponseWriter) Header() http.Header { + return w.header +} + +func (w *streamResponseWriter) WriteHeader(status int) { + w.status = status +} + +func (w *streamResponseWriter) Write(data []byte) (int, error) { + if w.writeError != nil { + return 0, w.writeError + } + return w.body.Write(data) +} + +func TestViewedSSEServerUsesRequestView(t *testing.T) { + svc := &viewedService{watchView: "detailed"} + handler := NewWatchHandler( + service.NewWatchEndpoint(svc), + goahttp.NewMuxer(), + goahttp.RequestDecoder, + goahttp.ResponseEncoder, + func(context.Context, http.ResponseWriter, error) {}, + nil, + ) + recorder := httptest.NewRecorder() + handler.ServeHTTP(recorder, httptest.NewRequest("GET", "/watch", nil)) + require.Equal(t, "detailed", recorder.Header().Get("goa-view")) + require.JSONEq(t, + ` + "`" + `{"event_id":"event-1","profile":{"display_name":"Ada"}}` + "`" + `, + sseData(t, recorder.Body.String()), + ) +} + +func TestFixedViewedSSEServerIsSpecialized(t *testing.T) { + _, exposesSetView := reflect.TypeOf((*service.FixedServerStream)(nil)).Elem().MethodByName("SetView") + require.False(t, exposesSetView) + + svc := &viewedService{} + handler := NewFixedHandler( + service.NewFixedEndpoint(svc), + goahttp.NewMuxer(), + goahttp.RequestDecoder, + goahttp.ResponseEncoder, + func(context.Context, http.ResponseWriter, error) {}, + nil, + ) + recorder := httptest.NewRecorder() + handler.ServeHTTP(recorder, httptest.NewRequest("GET", "/fixed", nil)) + require.JSONEq(t, + ` + "`" + `{"event_id":"event-1","profile":{"display_name":"Ada"}}` + "`" + `, + sseData(t, recorder.Body.String()), + ) +} + +func TestFixedViewedSSEServerDoesNotEncodeServiceErrorAfterEvent(t *testing.T) { + var handled error + handler := NewFixedHandler( + service.NewFixedEndpoint(&fixedErrorAfterEventService{}), + goahttp.NewMuxer(), + goahttp.RequestDecoder, + goahttp.ResponseEncoder, + func(_ context.Context, _ http.ResponseWriter, err error) { handled = err }, + nil, + ) + recorder := httptest.NewRecorder() + handler.ServeHTTP(recorder, httptest.NewRequest("GET", "/fixed", nil)) + require.ErrorIs(t, handled, errAfterEvent) + require.Equal(t, http.StatusOK, recorder.Code) + require.Equal(t, 1, strings.Count(recorder.Body.String(), "data:")) + require.NotContains(t, recorder.Body.String(), ` + "`" + `"name":` + "`" + `) +} + +func TestMixedResultSSEServerDoesNotEncodeServiceErrorAfterEvent(t *testing.T) { + var handled error + handler := NewMixedHandler( + service.NewMixedEndpoint(&mixedErrorAfterEventService{}), + goahttp.NewMuxer(), + goahttp.RequestDecoder, + goahttp.ResponseEncoder, + func(_ context.Context, _ http.ResponseWriter, err error) { handled = err }, + nil, + ) + request := httptest.NewRequest("GET", "/mixed", nil) + request.Header.Set("Accept", "text/event-stream") + recorder := &statusRecorder{ResponseRecorder: httptest.NewRecorder()} + handler.ServeHTTP(recorder, request) + require.ErrorIs(t, handled, errAfterEvent) + require.Equal(t, []int{http.StatusOK}, recorder.statuses) + require.Equal(t, 1, strings.Count(recorder.Body.String(), "data:")) + require.JSONEq(t, + ` + "`" + `{"event_id":"event-1","profile":{"display_name":"Ada"}}` + "`" + `, + sseData(t, recorder.Body.String()), + ) + require.NotContains(t, recorder.Body.String(), ` + "`" + `"name":` + "`" + `) +} + +func TestUnknownViewedSSEServerSelectionIsRejectedBeforeWriting(t *testing.T) { + sendError := make(chan error, 1) + handler := NewWatchHandler( + service.NewWatchEndpoint(&unknownViewService{sendError: sendError}), + goahttp.NewMuxer(), + goahttp.RequestDecoder, + goahttp.ResponseEncoder, + func(context.Context, http.ResponseWriter, error) {}, + nil, + ) + server := httptest.NewServer(handler) + defer server.Close() + response, err := server.Client().Get(server.URL + "/watch") + require.NoError(t, err) + defer response.Body.Close() + body, err := io.ReadAll(response.Body) + require.NoError(t, err) + requireBoundaryError(t, <-sendError, goa.InvalidEnumValue, "view") + require.Equal(t, http.StatusBadRequest, response.StatusCode) + var serviceError map[string]any + require.NoError(t, json.Unmarshal(body, &serviceError)) + require.Equal(t, goa.InvalidEnumValue, serviceError["name"]) + require.Contains(t, serviceError["message"], "value of view") +} + +func TestEmptyViewedSSEServerSelectionUsesDefaultView(t *testing.T) { + handler := NewWatchHandler( + service.NewWatchEndpoint(&viewedService{}), + goahttp.NewMuxer(), + goahttp.RequestDecoder, + goahttp.ResponseEncoder, + func(context.Context, http.ResponseWriter, error) {}, + nil, + ) + server := httptest.NewServer(handler) + defer server.Close() + response, err := server.Client().Get(server.URL + "/watch") + require.NoError(t, err) + defer response.Body.Close() + body, err := io.ReadAll(response.Body) + require.NoError(t, err) + require.Equal(t, http.StatusOK, response.StatusCode) + require.Equal(t, "default", response.Header.Get("goa-view")) + require.JSONEq(t, + ` + "`" + `{"event_id":"event-1","profile":{"display_name":"Ada"}}` + "`" + `, + sseData(t, string(body)), + ) +} + +func TestViewedSSEServerRejectsViewChangesAfterFirstEvent(t *testing.T) { + sendError := make(chan error, 1) + handler := NewWatchHandler( + service.NewWatchEndpoint(&changingViewService{sendError: sendError}), + goahttp.NewMuxer(), + goahttp.RequestDecoder, + goahttp.ResponseEncoder, + func(context.Context, http.ResponseWriter, error) {}, + nil, + ) + recorder := httptest.NewRecorder() + handler.ServeHTTP(recorder, httptest.NewRequest("GET", "/watch", nil)) + requireBoundaryError(t, <-sendError, goa.InvalidEnumValue, "view") + require.Equal(t, "summary", recorder.Header().Get("goa-view")) + require.Equal(t, 1, strings.Count(recorder.Body.String(), "data:")) +} + +func TestViewedSSEServerReturnsEventWriteError(t *testing.T) { + sendError := make(chan error, 1) + handler := NewWatchHandler( + service.NewWatchEndpoint(&sendResultService{sendError: sendError}), + goahttp.NewMuxer(), + goahttp.RequestDecoder, + goahttp.ResponseEncoder, + func(context.Context, http.ResponseWriter, error) {}, + nil, + ) + writer := &streamResponseWriter{header: make(http.Header), writeError: errEventWrite} + handler.ServeHTTP(writer, httptest.NewRequest("GET", "/watch", nil)) + require.ErrorIs(t, <-sendError, errEventWrite) + require.Equal(t, http.StatusOK, writer.status) +} + +func TestViewedSSEServerReturnsFlushError(t *testing.T) { + sendError := make(chan error, 1) + handler := NewWatchHandler( + service.NewWatchEndpoint(&sendResultService{sendError: sendError}), + goahttp.NewMuxer(), + goahttp.RequestDecoder, + goahttp.ResponseEncoder, + func(context.Context, http.ResponseWriter, error) {}, + nil, + ) + writer := &streamResponseWriter{header: make(http.Header)} + handler.ServeHTTP(writer, httptest.NewRequest("GET", "/watch", nil)) + require.ErrorIs(t, <-sendError, http.ErrNotSupported) + require.Equal(t, http.StatusOK, writer.status) + require.NotEmpty(t, writer.body.String()) +} + +func viewedEvent() *service.Event { + return &service.Event{ + EventID: "event-1", + Profile: &service.Profile{DisplayName: "Ada"}, + } +} + +func sseData(t *testing.T, event string) string { + t.Helper() + for _, line := range strings.Split(event, "\n") { + if strings.HasPrefix(line, "data:") { + return strings.TrimSpace(strings.TrimPrefix(line, "data:")) + } + } + t.Errorf("SSE event has no data field: %q", event) + return "" +} + +func requireBoundaryError(t *testing.T, err error, name, field string) { + t.Helper() + var serviceError *goa.ServiceError + require.ErrorAs(t, err, &serviceError) + require.Equal(t, name, serviceError.Name) + require.NotNil(t, serviceError.Field) + require.Equal(t, field, *serviceError.Field) +} +` + +const httpViewedSSEClientTest = `package client + +import ( + "context" + "io" + "net/http" + "strings" + "testing" + + "github.com/stretchr/testify/require" + + service "generated.local/gen/http_view_stream" + goahttp "goa.design/goa/v3/http" +) + +type doerFunc func(*http.Request) (*http.Response, error) + +func (f doerFunc) Do(req *http.Request) (*http.Response, error) { + return f(req) +} + +func TestViewedSSEClientReconstructsSelectedBody(t *testing.T) { + cases := []struct { + name string + endpoint func(*Client) func(context.Context, any) (any, error) + recv func(any) (*service.Event, error) + view string + body string + wantProfile bool + }{ + { + name: "summary", + endpoint: func(c *Client) func(context.Context, any) (any, error) { return c.Watch() }, + recv: func(raw any) (*service.Event, error) { + var stream service.WatchClientStream = raw.(WatchClientStream) + return stream.Recv() + }, + view: "summary", + body: ` + "`" + `{"event_id":"summary-event"}` + "`" + `, + }, + { + name: "detailed fixed", + endpoint: func(c *Client) func(context.Context, any) (any, error) { return c.Fixed() }, + recv: func(raw any) (*service.Event, error) { + var stream service.FixedClientStream = raw.(FixedClientStream) + return stream.Recv() + }, + body: ` + "`" + `{"event_id":"detailed-event","profile":{"display_name":"Ada"}}` + "`" + `, + wantProfile: true, + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + doer := doerFunc(func(*http.Request) (*http.Response, error) { + header := http.Header{"Content-Type": []string{"text/event-stream"}} + if tc.view != "" { + header.Set("goa-view", tc.view) + } + return &http.Response{ + StatusCode: http.StatusOK, + Header: header, + Body: io.NopCloser(strings.NewReader("data: " + tc.body + "\n\n")), + }, nil + }) + client := NewClient( + "http", "example.test", doer, + goahttp.RequestEncoder, goahttp.ResponseDecoder, false, + ) + rawStream, err := tc.endpoint(client)(context.Background(), nil) + require.NoError(t, err) + event, err := tc.recv(rawStream) + require.NoError(t, err) + require.Equal(t, strings.TrimSuffix(tc.name, " fixed")+"-event", event.EventID) + if tc.wantProfile { + require.Equal(t, "Ada", event.Profile.DisplayName) + } else { + require.Nil(t, event.Profile) + } + }) + } +} +` + +const jsonRPCViewedUnaryClientTest = `package client + +import ( + "io" + "net/http" + "strings" + "testing" + + "github.com/stretchr/testify/require" + + service "generated.local/gen/jsonrpc_unary" + goahttp "goa.design/goa/v3/http" + goa "goa.design/goa/v3/pkg" +) + +func TestVariableViewedUnaryResponseUsesRepresentationBody(t *testing.T) { + response := jsonRPCResponse( + ` + "`" + `{"jsonrpc":"2.0","id":"1","result":{"view":"detailed","body":{"event_id":"event-1","profile":{"display_name":"Ada"}}}}` + "`" + `, + ) + response.Header.Set("goa-view", "summary") + var result any + var err error + require.NotPanics(t, func() { + result, err = DecodeFetchResponse(goahttp.ResponseDecoder, false)(response, "1") + }) + require.NoError(t, err) + event := result.(*service.Event) + require.Equal(t, "event-1", event.EventID) + require.Equal(t, "Ada", event.Profile.DisplayName) +} + +func TestVariableViewedUnaryResponseRejectsInvalidRepresentation(t *testing.T) { + cases := []struct { + name string + result string + errorName string + field string + }{ + {"missing view", ` + "`" + `{"body":{"event_id":"event-1"}}` + "`" + `, goa.MissingField, "view"}, + {"null view", ` + "`" + `{"view":null,"body":{"event_id":"event-1"}}` + "`" + `, goa.MissingField, "view"}, + {"missing body", ` + "`" + `{"view":"summary"}` + "`" + `, goa.MissingField, "body"}, + {"null body", ` + "`" + `{"view":"summary","body":null}` + "`" + `, goa.MissingField, "body"}, + {"unknown view", ` + "`" + `{"view":"unknown","body":{"event_id":"event-1"}}` + "`" + `, goa.InvalidEnumValue, "view"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + response := jsonRPCResponse(` + "`" + `{"jsonrpc":"2.0","id":"1","result":` + "`" + ` + tc.result + "}") + var err error + require.NotPanics(t, func() { + _, err = DecodeFetchResponse(goahttp.ResponseDecoder, false)(response, "1") + }) + requireBoundaryError(t, err, tc.errorName, tc.field) + }) + } +} + +func TestFixedViewedUnaryResponseUsesBodyOnly(t *testing.T) { + response := jsonRPCResponse( + ` + "`" + `{"jsonrpc":"2.0","id":"1","result":{"event_id":"event-1","profile":{"display_name":"Ada"}}}` + "`" + `, + ) + result, err := DecodeFixedResponse(goahttp.ResponseDecoder, false)(response, "1") + require.NoError(t, err) + event := result.(*service.Event) + require.Equal(t, "event-1", event.EventID) + require.Equal(t, "Ada", event.Profile.DisplayName) +} + +func jsonRPCResponse(body string) *http.Response { + return &http.Response{ + StatusCode: http.StatusOK, + Header: make(http.Header), + Body: io.NopCloser(strings.NewReader(body)), + } +} + +func requireBoundaryError(t *testing.T, err error, name, field string) { + t.Helper() + var serviceError *goa.ServiceError + require.ErrorAs(t, err, &serviceError) + require.Equal(t, name, serviceError.Name) + require.NotNil(t, serviceError.Field) + require.Equal(t, field, *serviceError.Field) +} +` + +const jsonRPCViewedUnaryServerTest = `package server + +import ( + "bytes" + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/require" + + service "generated.local/gen/jsonrpc_unary" + goahttp "goa.design/goa/v3/http" + goa "goa.design/goa/v3/pkg" +) + +type viewedService struct { + fetchView string +} + +func (s *viewedService) Fetch(context.Context) (*service.Event, string, error) { + return viewedEvent(), s.fetchView, nil +} + +func (*viewedService) Fixed(context.Context) (*service.Event, error) { + return viewedEvent(), nil +} + +func TestVariableViewedUnaryServerEmitsRepresentation(t *testing.T) { + recorder := serveJSONRPC(t, "fetch", "detailed") + require.Empty(t, recorder.Header().Get("goa-view")) + require.JSONEq(t, + ` + "`" + `{"view":"detailed","body":{"event_id":"event-1","profile":{"display_name":"Ada"}}}` + "`" + `, + jsonRPCResult(t, recorder), + ) +} + +func TestFixedViewedUnaryServerEmitsBodyOnly(t *testing.T) { + recorder := serveJSONRPC(t, "fixed", "") + require.JSONEq(t, + ` + "`" + `{"event_id":"event-1","profile":{"display_name":"Ada"}}` + "`" + `, + jsonRPCResult(t, recorder), + ) +} + +func TestUnknownViewedUnaryServerSelectionIsRejected(t *testing.T) { + result, err := service.NewFetchEndpoint(&viewedService{fetchView: "unknown"})(context.Background(), nil) + require.Nil(t, result) + requireBoundaryError(t, err, goa.InvalidEnumValue, "view") +} + +func serveJSONRPC(t *testing.T, method, view string) *httptest.ResponseRecorder { + t.Helper() + server := New( + service.NewEndpoints(&viewedService{fetchView: view}), + goahttp.NewMuxer(), + goahttp.RequestDecoder, + goahttp.ResponseEncoder, + func(context.Context, http.ResponseWriter, error) {}, + ) + body := []byte(` + "`" + `{"jsonrpc":"2.0","id":"1","method":"` + "`" + ` + method + ` + "`" + `"}` + "`" + `) + recorder := httptest.NewRecorder() + server.ServeHTTP(recorder, httptest.NewRequest("POST", "/rpc", bytes.NewReader(body))) + require.Equal(t, http.StatusOK, recorder.Code) + return recorder +} + +func jsonRPCResult(t *testing.T, recorder *httptest.ResponseRecorder) string { + t.Helper() + var response struct { + Result json.RawMessage ` + "`" + `json:"result"` + "`" + ` + } + require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &response)) + return string(response.Result) +} + +func viewedEvent() *service.Event { + return &service.Event{ + EventID: "event-1", + Profile: &service.Profile{DisplayName: "Ada"}, + } +} + +func requireBoundaryError(t *testing.T, err error, name, field string) { + t.Helper() + var serviceError *goa.ServiceError + require.ErrorAs(t, err, &serviceError) + require.Equal(t, name, serviceError.Name) + require.NotNil(t, serviceError.Field) + require.Equal(t, field, *serviceError.Field) +} +` + +const jsonRPCViewedSSEClientTest = `package client + +import ( + "context" + "encoding/json" + "io" + "net/http" + "strconv" + "strings" + "testing" + + "github.com/stretchr/testify/require" + + service "generated.local/gen/jsonrpcsse" + goahttp "goa.design/goa/v3/http" + goa "goa.design/goa/v3/pkg" +) + +type doerFunc func(*http.Request) (*http.Response, error) + +func (f doerFunc) Do(req *http.Request) (*http.Response, error) { + return f(req) +} + +func TestViewedSSENotificationReconstructsTransportBody(t *testing.T) { + data := ` + "`" + `{"jsonrpc":"2.0","method":"watch","params":{"view":"detailed","body":{"event_id":"event-1","profile":{"display_name":"Ada"}}}}` + "`" + ` + event, err := recvWatch("ignored", data) + require.NoError(t, err) + require.Equal(t, "event-1", event.EventID) + require.Equal(t, "Ada", event.Profile.DisplayName) +} + +func TestViewedSSEFinalResponseEndsStream(t *testing.T) { + client := sseClientResponse("not-a-private-response-label", func(request *http.Request) string { + var envelope struct { + ID string ` + "`json:\"id\"`" + ` + } + require.NoError(t, json.NewDecoder(request.Body).Decode(&envelope)) + return ` + "`" + `{"jsonrpc":"2.0","id":` + "`" + ` + strconv.Quote(envelope.ID) + ` + "`" + `,"result":null}` + "`" + ` + }) + raw, err := client.Watch()(context.Background(), nil) + require.NoError(t, err) + _, err = raw.(*WatchStreamImpl).Recv() + require.ErrorIs(t, err, io.EOF) +} + +func TestViewedSSERejectsInvalidRepresentation(t *testing.T) { + cases := []struct { + name string + params string + errorName string + field string + }{ + {"missing view", ` + "`" + `{"body":{"event_id":"event-1"}}` + "`" + `, goa.MissingField, "view"}, + {"null view", ` + "`" + `{"view":null,"body":{"event_id":"event-1"}}` + "`" + `, goa.MissingField, "view"}, + {"missing body", ` + "`" + `{"view":"summary"}` + "`" + `, goa.MissingField, "body"}, + {"null body", ` + "`" + `{"view":"summary","body":null}` + "`" + `, goa.MissingField, "body"}, + {"unknown view", ` + "`" + `{"view":"unknown","body":{"event_id":"event-1"}}` + "`" + `, goa.InvalidEnumValue, "view"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + data := ` + "`" + `{"jsonrpc":"2.0","method":"watch","params":` + "`" + ` + tc.params + "}" + _, err := recvWatch("ignored", data) + requireBoundaryError(t, err, tc.errorName, tc.field) + }) + } +} + +func TestFixedViewedSSEUsesBodyOnly(t *testing.T) { + data := ` + "`" + `{"jsonrpc":"2.0","method":"fixed","params":{"event_id":"event-1","profile":{"display_name":"Ada"}}}` + "`" + ` + event, err := recvFixed("ignored", data) + require.NoError(t, err) + require.Equal(t, "event-1", event.EventID) + require.Equal(t, "Ada", event.Profile.DisplayName) +} + +func recvWatch(eventType, data string) (*service.Event, error) { + client := sseClient(eventType, data) + raw, err := client.Watch()(context.Background(), nil) + if err != nil { + return nil, err + } + transport := raw.(*WatchStreamImpl) + var stream service.WatchClientStream = transport + return stream.Recv() +} + +func recvFixed(eventType, data string) (*service.Event, error) { + client := sseClient(eventType, data) + raw, err := client.Fixed()(context.Background(), nil) + if err != nil { + return nil, err + } + transport := raw.(*FixedStreamImpl) + var stream service.FixedClientStream = transport + return stream.Recv() +} + +func sseClient(eventType, data string) *Client { + return sseClientResponse(eventType, func(*http.Request) string { return data }) +} + +func sseClientResponse(eventType string, data func(*http.Request) string) *Client { + doer := doerFunc(func(request *http.Request) (*http.Response, error) { + body := "event: " + eventType + "\ndata: " + data(request) + "\n\n" + return &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"text/event-stream"}}, + Body: io.NopCloser(strings.NewReader(body)), + }, nil + }) + return NewClient( + "http", "example.test", doer, + goahttp.RequestEncoder, goahttp.ResponseDecoder, false, + ) +} + +func requireBoundaryError(t *testing.T, err error, name, field string) { + t.Helper() + var serviceError *goa.ServiceError + require.ErrorAs(t, err, &serviceError) + require.Equal(t, name, serviceError.Name) + require.NotNil(t, serviceError.Field) + require.Equal(t, field, *serviceError.Field) +} +` + +const jsonRPCViewedSSEServerTest = `package server + +import ( + "bytes" + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/stretchr/testify/require" + + service "generated.local/gen/jsonrpcsse" + goahttp "goa.design/goa/v3/http" + goa "goa.design/goa/v3/pkg" +) + +type viewedService struct{} + +type unknownViewService struct { + sendError chan error +} + +type changedViewService struct { + sendError chan error +} + +func (*viewedService) Watch(_ context.Context, stream service.WatchServerStream) error { + stream.SetView("summary") + if err := stream.Send(viewedEvent()); err != nil { + return err + } + return stream.Send(viewedEvent()) +} + +func (*viewedService) Fixed(_ context.Context, stream service.FixedServerStream) error { + if err := stream.Send(viewedEvent()); err != nil { + return err + } + return stream.Send(viewedEvent()) +} + +func (s *unknownViewService) Watch(_ context.Context, stream service.WatchServerStream) error { + stream.SetView("unknown") + err := stream.Send(viewedEvent()) + s.sendError <- err + return err +} + +func (*unknownViewService) Fixed(_ context.Context, stream service.FixedServerStream) error { + return stream.Send(viewedEvent()) +} + +func (s *changedViewService) Watch(_ context.Context, stream service.WatchServerStream) error { + stream.SetView("summary") + if err := stream.Send(viewedEvent()); err != nil { + return err + } + stream.SetView("detailed") + err := stream.Send(viewedEvent()) + s.sendError <- err + return err +} + +func (*changedViewService) Fixed(_ context.Context, stream service.FixedServerStream) error { + return stream.Send(viewedEvent()) +} + +func TestVariableViewedSSEServerEmitsRepresentation(t *testing.T) { + recorder := serveSSE(t, "watch") + records := jsonRPCSSERecords(t, recorder.Body.String()) + require.Len(t, records, 3) + require.JSONEq(t, + ` + "`" + `{"view":"summary","body":{"event_id":"event-1"}}` + "`" + `, + string(records[0].Params), + ) + require.JSONEq(t, + ` + "`" + `{"view":"summary","body":{"event_id":"event-1"}}` + "`" + `, + string(records[1].Params), + ) + require.JSONEq(t, ` + "`" + `null` + "`" + `, string(records[2].Result)) +} + +func TestChangedViewedSSEServerSelectionIsRejectedBeforeWriting(t *testing.T) { + sendError := make(chan error, 1) + recorder := serveSSEService(t, "watch", &changedViewService{sendError: sendError}) + requireBoundaryError(t, <-sendError, goa.InvalidEnumValue, "view") + records := jsonRPCSSERecords(t, recorder.Body.String()) + require.Len(t, records, 2) + require.JSONEq(t, + ` + "`" + `{"view":"summary","body":{"event_id":"event-1"}}` + "`" + `, + string(records[0].Params), + ) + require.NotEmpty(t, records[1].Error) +} + +func TestFixedViewedSSEServerEmitsBodyOnly(t *testing.T) { + recorder := serveSSE(t, "fixed") + records := jsonRPCSSERecords(t, recorder.Body.String()) + require.Len(t, records, 3) + require.JSONEq(t, + ` + "`" + `{"event_id":"event-1","profile":{"display_name":"Ada"}}` + "`" + `, + string(records[0].Params), + ) + require.JSONEq(t, + ` + "`" + `{"event_id":"event-1","profile":{"display_name":"Ada"}}` + "`" + `, + string(records[1].Params), + ) + require.JSONEq(t, ` + "`" + `null` + "`" + `, string(records[2].Result)) +} + +func TestUnknownViewedSSEServerSelectionIsRejectedBeforeWriting(t *testing.T) { + sendError := make(chan error, 1) + recorder := serveSSEService(t, "watch", &unknownViewService{sendError: sendError}) + requireBoundaryError(t, <-sendError, goa.InvalidEnumValue, "view") + records := jsonRPCSSERecords(t, recorder.Body.String()) + require.Len(t, records, 1) + require.Empty(t, records[0].Params) + require.NotEmpty(t, records[0].Error) +} + +func serveSSE(t *testing.T, method string) *httptest.ResponseRecorder { + t.Helper() + return serveSSEService(t, method, &viewedService{}) +} + +func serveSSEService(t *testing.T, method string, svc service.Service) *httptest.ResponseRecorder { + t.Helper() + server := New( + service.NewEndpoints(svc), + goahttp.NewMuxer(), + goahttp.RequestDecoder, + goahttp.ResponseEncoder, + func(context.Context, http.ResponseWriter, error) {}, + ) + body := []byte(` + "`" + `{"jsonrpc":"2.0","id":"1","method":"` + "`" + ` + method + ` + "`" + `"}` + "`" + `) + recorder := httptest.NewRecorder() + server.ServeHTTP(recorder, httptest.NewRequest("POST", "/events", bytes.NewReader(body))) + require.Equal(t, http.StatusOK, recorder.Code) + return recorder +} + +type sseRecord struct { + Params json.RawMessage ` + "`" + `json:"params"` + "`" + ` + Result json.RawMessage ` + "`" + `json:"result"` + "`" + ` + Error json.RawMessage ` + "`" + `json:"error"` + "`" + ` +} + +func jsonRPCSSERecords(t *testing.T, event string) []sseRecord { + t.Helper() + var records []sseRecord + for _, line := range strings.Split(event, "\n") { + if strings.HasPrefix(line, "data:") { + data := strings.TrimSpace(strings.TrimPrefix(line, "data:")) + var record sseRecord + require.NoError(t, json.Unmarshal([]byte(data), &record)) + records = append(records, record) + } + } + return records +} + +func viewedEvent() *service.Event { + return &service.Event{ + EventID: "event-1", + Profile: &service.Profile{DisplayName: "Ada"}, + } +} + +func requireBoundaryError(t *testing.T, err error, name, field string) { + t.Helper() + var serviceError *goa.ServiceError + require.ErrorAs(t, err, &serviceError) + require.Equal(t, name, serviceError.Name) + require.NotNil(t, serviceError.Field) + require.Equal(t, field, *serviceError.Field) +} +` diff --git a/codegen/go_transform.go b/codegen/go_transform.go index 911543ef3e..3d108cb9b9 100644 --- a/codegen/go_transform.go +++ b/codegen/go_transform.go @@ -1,15 +1,59 @@ +// This file generates Go transformations between compatible design types. +// Recursive helpers carry the package path for each side through nested named +// declarations so emitted references use the package selected during planning. package codegen import ( "bytes" "fmt" + "go/ast" + "go/parser" "reflect" + "slices" + "strconv" "strings" "text/template" "goa.design/goa/v3/expr" ) +type ( + // transformCopyAttributor passes the original expression to name + // lookups that recorded expressions before the transform copied them. + transformCopyAttributor struct { + attributor Attributor + copier *expr.AttributeGraphCopier + } + + // transformAttributePair identifies one exact pair in a plan. + transformAttributePair struct { + source *expr.AttributeExpr + target *expr.AttributeExpr + } + + // transformUnwrapChoice is the result of one planned UnwrapPair call. + transformUnwrapChoice struct { + source *expr.AttributeExpr + target *expr.AttributeExpr + directive *WrapDirective + } + + // transformStructuralChoices remembers what the two structural hooks + // returned while the transform was planned. + transformStructuralChoices struct { + unwrap func(*expr.AttributeExpr, *expr.AttributeExpr) (*expr.AttributeExpr, *expr.AttributeExpr, *WrapDirective) + fieldPair func(*expr.AttributeExpr, *expr.AttributeExpr) (*expr.AttributeExpr, *expr.AttributeExpr) + planUnionHelpers func(*expr.AttributeExpr, *expr.AttributeExpr, func(*expr.AttributeExpr, *expr.AttributeExpr)) + unchanged func() bool + mutationErr error + sourceCopier *expr.AttributeGraphCopier + targetCopier *expr.AttributeGraphCopier + unwrapPairs map[transformAttributePair]transformUnwrapChoice + fieldPairs map[transformAttributePair]transformAttributePair + planned bool + } +) + var transformGoArrayT, transformGoMapT, transformGoUnionT *template.Template // NOTE: can't initialize inline because https://github.com/golang/go/issues/1817 @@ -23,6 +67,179 @@ func init() { transformGoUnionT = template.Must(template.New("transformGoUnion").Funcs(fm).Parse(codegenTemplates.Read(transformGoUnionTmplName))) } +// original returns the caller expression that was copied into the plan. +func (a *transformCopyAttributor) original(attribute *expr.AttributeExpr) *expr.AttributeExpr { + return a.copier.Original(attribute) +} + +// Name gives the wrapped resolver the expression it used during name planning. +func (a *transformCopyAttributor) Name(attribute *expr.AttributeExpr, pkg string, pointer, useDefault bool) string { + return a.attributor.Name(a.original(attribute), pkg, pointer, useDefault) +} + +// Ref gives the wrapped resolver the expression it used during name planning. +func (a *transformCopyAttributor) Ref(attribute *expr.AttributeExpr, pkg string) string { + return a.attributor.Ref(a.original(attribute), pkg) +} + +// Field gives the wrapped resolver the field it used during name planning. +func (a *transformCopyAttributor) Field(attribute *expr.AttributeExpr, name string, firstUpper bool) string { + return a.attributor.Field(a.original(attribute), name, firstUpper) +} + +// Package gives the wrapped resolver the expression it used during planning. +func (a *transformCopyAttributor) Package(attribute *expr.AttributeExpr) string { + return a.attributor.Package(a.original(attribute)) +} + +// Enter retains the translation while entering the caller's planned child. +func (a *transformCopyAttributor) Enter(attribute *expr.AttributeExpr) Attributor { + return &transformCopyAttributor{ + attributor: a.attributor.Enter(a.original(attribute)), + copier: a.copier, + } +} + +// IsSumType reports the layout selected by the caller's attributor. +func (a *transformCopyAttributor) IsSumType() bool { + return a.attributor.IsSumType() +} + +// ValidatorCall gives the wrapped resolver the expression it used during +// validation name planning. +func (a *transformCopyAttributor) ValidatorCall(attribute *expr.AttributeExpr, view, target, path string) string { + return a.attributor.ValidatorCall(a.original(attribute), view, target, path) +} + +// Scope returns the name scope owned by the caller's resolver. +func (a *transformCopyAttributor) Scope() *NameScope { + return a.attributor.Scope() +} + +// OneofWrapper forwards the gRPC oneof lookup when the wrapped resolver owns +// that lookup. A different resolver cannot render a protobuf oneof. +func (a *transformCopyAttributor) OneofWrapper(attribute *expr.AttributeExpr) string { + resolver, ok := a.attributor.(interface { + OneofWrapper(*expr.AttributeExpr) string + }) + if !ok { + panic("transform name resolver cannot resolve a protobuf oneof wrapper") // bug + } + return resolver.OneofWrapper(a.original(attribute)) +} + +// UnionConstructor gives a copied transform attribute to the resolver that +// retained the generated service constructor. +func (a *transformCopyAttributor) UnionConstructor(attribute *expr.AttributeExpr, branch string) (string, error) { + resolver, ok := a.attributor.(interface { + UnionConstructor(*expr.AttributeExpr, string) (string, error) + }) + if !ok { + return "", fmt.Errorf("transform name resolver cannot resolve a service OneOf constructor") + } + return resolver.UnionConstructor(a.original(attribute), branch) +} + +// GoTypeLayout gives the wrapped resolver the expression it used while +// planning the generated type. +func (a *transformCopyAttributor) GoTypeLayout(attribute *expr.AttributeExpr, policy GoLayoutPolicy) (LinkedGoType, error) { + resolver, ok := a.attributor.(GoTypeLayoutResolver) + if !ok { + return planGoTypeWithAttributor(a.original(attribute), policy, a.attributor) + } + return resolver.GoTypeLayout(a.original(attribute), policy) +} + +// captureTransformStructuralHooks copies the hook set and replaces planning +// callbacks with memoized versions. Planning may add a choice; rendering may +// only read one that planning already made. unchanged checks that a hook left +// the plan-owned expression graphs intact. +func captureTransformStructuralHooks(hooks *TransformHooks, sourceCopier, targetCopier *expr.AttributeGraphCopier, unchanged func() bool) (*TransformHooks, *transformStructuralChoices) { + if hooks == nil { + return nil, nil + } + copied := *hooks + choices := &transformStructuralChoices{ + unwrap: copied.UnwrapPair, + fieldPair: copied.FieldPairAttrs, + planUnionHelpers: copied.PlanUnionHelpers, + unchanged: unchanged, + sourceCopier: sourceCopier, + targetCopier: targetCopier, + unwrapPairs: make(map[transformAttributePair]transformUnwrapChoice), + fieldPairs: make(map[transformAttributePair]transformAttributePair), + } + if choices.unwrap != nil { + copied.UnwrapPair = choices.unwrapPair + } + if choices.fieldPair != nil { + copied.FieldPairAttrs = choices.fieldPairAttrs + } + if choices.planUnionHelpers != nil { + copied.PlanUnionHelpers = choices.planUnionHelpersForPlan + } + return &copied, choices +} + +// unwrapPair returns the first attributes and wrapper instruction chosen for +// pair. After planning, a missing choice means rendering took a new path. +func (c *transformStructuralChoices) unwrapPair(source, target *expr.AttributeExpr) (*expr.AttributeExpr, *expr.AttributeExpr, *WrapDirective) { + pair := transformAttributePair{source: source, target: target} + if choice, ok := c.unwrapPairs[pair]; ok { + return choice.source, choice.target, choice.directive + } + if c.planned { + panic("transform render requested an unplanned unwrap choice") // bug + } + source, target, directive := c.unwrap(source, target) + c.recordMutation("UnwrapPair") + source = c.sourceCopier.Copy(source) + target = c.targetCopier.Copy(target) + if directive != nil { + copy := *directive + copy.Target = c.targetCopier.Copy(directive.Target) + directive = © + } + c.unwrapPairs[pair] = transformUnwrapChoice{ + source: source, + target: target, + directive: directive, + } + return source, target, directive +} + +// fieldPairAttrs returns the first attributes chosen for pair. +func (c *transformStructuralChoices) fieldPairAttrs(source, target *expr.AttributeExpr) (*expr.AttributeExpr, *expr.AttributeExpr) { + pair := transformAttributePair{source: source, target: target} + if choice, ok := c.fieldPairs[pair]; ok { + return choice.source, choice.target + } + if c.planned { + panic("transform render requested an unplanned field-pair choice") // bug + } + source, target = c.fieldPair(source, target) + c.recordMutation("FieldPairAttrs") + source = c.sourceCopier.Copy(source) + target = c.targetCopier.Copy(target) + c.fieldPairs[pair] = transformAttributePair{source: source, target: target} + return source, target +} + +// planUnionHelpersForPlan records the union helper choices and rejects a hook +// that changed a source or target expression while deciding those choices. +func (c *transformStructuralChoices) planUnionHelpersForPlan(source, target *expr.AttributeExpr, record func(*expr.AttributeExpr, *expr.AttributeExpr)) { + c.planUnionHelpers(source, target, record) + c.recordMutation("PlanUnionHelpers") +} + +// recordMutation preserves the first planning-hook violation so +// NewTransformPlan can return it instead of a downstream compatibility error. +func (c *transformStructuralChoices) recordMutation(name string) { + if c.mutationErr == nil && !c.unchanged() { + c.mutationErr = fmt.Errorf("transform planning hook %s changed the retained plan", name) + } +} + // GoTransform produces Go code that initializes the data structure defined // by target from an instance of the data structure described by source. // The data structures can be objects, arrays or maps. The algorithm @@ -35,12 +252,9 @@ func init() { // returns an error if target is not compatible with source (different type, // fields of different type etc). // -// As a special case GoTransform can map union types from and to object types -// with two attributes, one called "Value" which stores the value and one called -// "Type" which is of type string and contains the value type name (union types -// are otherwise implemented as a struct containing a single field: the current -// value - however having the kind explicitly stored is required to serialize to -// JSON for example). +// As a special case GoTransform can map a union to or from an object envelope. +// The envelope has a string "Type" field that names the selected branch and a +// "Value" field that contains that branch. // // source and target are the attributes used in the transformation // @@ -63,20 +277,502 @@ func GoTransform(source, target *expr.AttributeExpr, sourceVar, targetVar string } // GoTransformWithAttrs is GoTransform with a caller built TransformAttrs. It -// lets generators customize the transformation via TransformAttrs.Hooks, see +// plans the conversion before writing it so structural hooks run once. Returned +// helpers keep their released Name and a nil Declaration for existing plugins. +// Generators may customize the conversion through TransformAttrs.Hooks; see // TransformHooks. func GoTransformWithAttrs(source, target *expr.AttributeExpr, sourceVar, targetVar string, ta *TransformAttrs, newVar bool) (string, []*TransformFunctionData, error) { - code, err := TransformAttribute(source, target, sourceVar, targetVar, newVar, ta) + plan, err := NewTransformPlan(source, target, ta.Prefix, ta.Hooks) + if err != nil { + return "", nil, err + } + if err := plan.BindContexts(ta.SourceCtx, ta.TargetCtx); err != nil { + return "", nil, err + } + + // Existing callers choose exact helper names while writing a file. Use one + // declaration for every occurrence of the same released name so Render can + // verify that the shared function body is identical. + legacyPackage := newGeneratedPackage("legacy transform helpers", "goa.design/goa/v3/codegen/transform", "") + declarations := make(map[string]*NameDeclaration, len(plan.helpers)) + nameAttrs := &TransformAttrs{ + SourceCtx: plan.sourceCtx, + TargetCtx: plan.targetCtx, + Prefix: plan.prefix, + } + for _, helper := range plan.helpers { + name := legacyTransformHelperName(helper.Source, helper.Target, nameAttrs) + declaration := declarations[name] + if declaration == nil { + declaration = NewExactName(NameFunction, name) + if err := legacyPackage.DeclareName(declaration); err != nil { + return "", nil, err + } + declarations[name] = declaration + } + if err := plan.BindHelperDeclaration(helper.ID, declaration); err != nil { + return "", nil, err + } + } + if err := legacyPackage.freeze(); err != nil { + return "", nil, err + } + code, helpers, err := plan.Render(sourceVar, targetVar, newVar) if err != nil { return "", nil, err } + for _, helper := range helpers { + helper.ID = TransformHelperID{} + helper.Declaration = nil + } + return code, helpers, nil +} + +// NewTransformProgram copies non-nil hooks into a conversion program that can +// make several plans. The program exposes no mutable state, so a package-level +// helper registry may compare plans by their shared program identity. Callers +// that need Goa's built-in conversion use NewTransformPlan with nil hooks. It +// returns an error when hooks is nil. +func NewTransformProgram(hooks *TransformHooks) (*TransformProgram, error) { + if hooks == nil { + return nil, fmt.Errorf("transform program hooks must not be nil") + } + copied := *hooks + return &TransformProgram{hooks: &copied}, nil +} + +// Plan copies source and target and records the conversion selected by the +// program. Plans made by one program use the same immutable hook choices. +func (p *TransformProgram) Plan(source, target *expr.AttributeExpr, prefix string) (*TransformPlan, error) { + if p == nil { + return nil, fmt.Errorf("transform program must not be nil") + } + return newTransformPlan(source, target, prefix, p) +} + +// NewTransformPlan copies the source and target expression graphs and records +// every recursive conversion needed to turn one into the other. Distinct user +// types remain distinct even when they were copied from one declaration, and a +// true recursive edge points back to the same copied type. The plan walks only +// these copies during Render. It retains the original expression identities so +// name resolvers can find names they recorded before this call. +// +// NewTransformPlan calls UnwrapPair, FieldPairAttrs, and PlanUnionHelpers while +// planning and keeps their returned choices. The rendering hooks are called +// during Render. The caller may reuse or change source, target, and the +// TransformHooks value after this function returns; the plan owns its +// expression copies and its copied hook fields. A planning hook must inspect +// those copies without changing them; this function returns an error if it +// detects a mutation. +func NewTransformPlan(source, target *expr.AttributeExpr, prefix string, hooks *TransformHooks) (*TransformPlan, error) { + if hooks == nil { + return newTransformPlan(source, target, prefix, nil) + } + program, err := NewTransformProgram(hooks) + if err != nil { + return nil, err + } + return program.Plan(source, target, prefix) +} - funcs, err := collectHelpers(source, target, true, true, ta, make(map[string]*TransformFunctionData)) +// newTransformPlan records one conversion using program's retained hooks. A +// nil program selects Goa's built-in conversion and can share with every other +// plan that also has no custom hooks. +func newTransformPlan(source, target *expr.AttributeExpr, prefix string, program *TransformProgram) (*TransformPlan, error) { + sourceCopier := expr.NewAttributeGraphCopier() + targetCopier := expr.NewAttributeGraphCopier() + source = sourceCopier.Copy(source) + target = targetCopier.Copy(target) + baselineSource := expr.NewAttributeGraphCopier() + baselineTarget := expr.NewAttributeGraphCopier() + plan := &TransformPlan{ + source: source, + target: target, + rootSource: source, + rootTarget: target, + sourceBaseline: baselineSource.Copy(source), + targetBaseline: baselineTarget.Copy(target), + sourceCopier: sourceCopier, + targetCopier: targetCopier, + prefix: prefix, + program: program, + operations: []*transformOperation{{}}, + renders: make(map[transformRenderRequest]transformRenderResult), + } + var hooks *TransformHooks + if program != nil { + hooks = program.hooks + } + retainedHooks, structuralChoices := captureTransformStructuralHooks(hooks, sourceCopier, targetCopier, func() bool { + return !plan.changed() + }) + plan.hooks = retainedHooks + err := planTransformOperation(source, target, true, true, TransformHelperDefinitionLocation{}, plan.operations[0], make(map[transformPair]TransformHelperID), plan) + if structuralChoices != nil && structuralChoices.mutationErr != nil { + return nil, structuralChoices.mutationErr + } + if err != nil { + return nil, err + } + if structuralChoices != nil { + structuralChoices.planned = true + } + return plan, nil +} + +// Helpers returns the recursive conversion functions selected by the plan so a +// generator can declare their names before writing code. Changing the returned +// slice or its Source and Target attributes does not change the plan. Source +// and Target identify the caller attributes that the plan copied; Render keeps +// and uses separate private attributes. +func (p *TransformPlan) Helpers() []TransformHelper { + helpers := slices.Clone(p.helpers) + for index := range helpers { + sourceCopier := expr.NewAttributeGraphCopier() + targetCopier := expr.NewAttributeGraphCopier() + helpers[index].Source = sourceCopier.Copy(helpers[index].Source) + helpers[index].Target = targetCopier.Copy(helpers[index].Target) + } + return helpers +} + +// HelperDefinitions returns the distinct recursive conversion function bodies +// selected by the plan. Calls with the same retained source and target facts +// share one definition even when one call is required and another is optional. +// Changing the returned slice or its Source and Target attributes does not +// change the plan. +func (p *TransformPlan) HelperDefinitions() []TransformHelperDefinition { + definitions := slices.Clone(p.definitions) + for index := range definitions { + sourceCopier := expr.NewAttributeGraphCopier() + targetCopier := expr.NewAttributeGraphCopier() + definitions[index].Source = sourceCopier.Copy(definitions[index].Source) + definitions[index].Target = targetCopier.Copy(definitions[index].Target) + definitions[index].helpers = nil + } + return definitions +} + +// BindHelperDeclaration assigns the package-level function declared for one +// value returned by Helpers. Equivalent conversions may share a declaration; +// Render verifies that their generated definitions match. +func (p *TransformPlan) BindHelperDeclaration(id TransformHelperID, declaration *NameDeclaration) error { + if id.plan != p || id.index < 0 || id.index >= len(p.helpers) { + return fmt.Errorf("transform helper does not belong to this plan") + } + if declaration == nil { + return fmt.Errorf("transform helper declaration must not be nil") + } + if declaration.Kind() != NameFunction { + return fmt.Errorf("transform helper declaration must be a function, got %s", declaration.Kind()) + } + helper := &p.helpers[id.index] + if helper.Declaration != nil && helper.Declaration != declaration { + return fmt.Errorf("transform helper already has a different declaration") + } + helper.Declaration = declaration + return nil +} + +// BindHelperDefinition assigns one package-level function declaration to every +// equivalent helper call represented by a value returned by HelperDefinitions. +func (p *TransformPlan) BindHelperDefinition(id TransformHelperDefinitionID, declaration *NameDeclaration) error { + if id.plan != p || id.index < 0 || id.index >= len(p.definitions) { + return fmt.Errorf("transform helper definition does not belong to this plan") + } + if declaration == nil { + return fmt.Errorf("transform helper definition declaration must not be nil") + } + if declaration.Kind() != NameFunction { + return fmt.Errorf("transform helper definition declaration must be a function, got %s", declaration.Kind()) + } + definition := &p.definitions[id.index] + if definition.Declaration != nil && definition.Declaration != declaration { + return fmt.Errorf("transform helper definition already has a different declaration") + } + for _, index := range definition.helpers { + if bound := p.helpers[index].Declaration; bound != nil && bound != declaration { + return fmt.Errorf("transform helper occurrence %d already has a different declaration", index+1) + } + } + definition.Declaration = declaration + for _, index := range definition.helpers { + p.helpers[index].Declaration = declaration + } + return nil +} + +// BindContexts copies the source and target type resolvers used by every call +// and helper definition. Call it after helper declarations and package names +// are final. It may be called once. +func (p *TransformPlan) BindContexts(source, target *AttributeContext) error { + if source == nil || target == nil { + return fmt.Errorf("transform contexts must not be nil") + } + if p.sourceCtx != nil || p.targetCtx != nil { + return fmt.Errorf("transform contexts are already bound") + } + p.sourceCtx = source.Dup() + p.sourceCtx.Scope = &transformCopyAttributor{ + attributor: source.Scope, + copier: p.sourceCopier, + } + p.targetCtx = target.Dup() + p.targetCtx.Scope = &transformCopyAttributor{ + attributor: target.Scope, + copier: p.targetCopier, + } + return nil +} + +// Render writes the top-level conversion and its recursive function bodies. +// Every helper must have a declaration and BindContexts must have been called. +// Repeating the same source variable, target variable, and new-variable choice +// returns the exact first result without calling hooks again. Different +// variables are separate generation requests and can produce different code. +func (p *TransformPlan) Render(sourceVar, targetVar string, newVar bool) (code string, helpers []*TransformFunctionData, err error) { + if p.sourceCtx == nil || p.targetCtx == nil { + return "", nil, fmt.Errorf("transform contexts are not bound") + } + request := transformRenderRequest{sourceVar: sourceVar, targetVar: targetVar, newVar: newVar} + if cached, ok := p.renders[request]; ok { + return cached.code, copyTransformFunctionData(cached.helpers), cached.err + } + if p.changed() { + return "", nil, fmt.Errorf("transform render hook changed the retained plan") + } + defer func() { + if p.changed() { + code = "" + helpers = nil + err = fmt.Errorf("transform render hook changed the retained plan") + } + p.renders[request] = transformRenderResult{ + code: code, + helpers: copyTransformFunctionData(helpers), + err: err, + } + helpers = copyTransformFunctionData(helpers) + }() + var hooks *TransformHooks + if p.hooks != nil { + copied := *p.hooks + hooks = &copied + } + renderAttrs := TransformAttrs{ + SourceCtx: p.sourceCtx.Dup(), + TargetCtx: p.targetCtx.Dup(), + Prefix: p.prefix, + Hooks: hooks, + } + renderAttrs.locals, err = newTransformLocalScope(sourceVar, targetVar) if err != nil { return "", nil, err } + renderAttrs.helpers = make(map[TransformHelperID]TransformHelper, len(p.helpers)) + for _, planned := range p.helpers { + if planned.Declaration == nil { + return "", nil, fmt.Errorf("transform helper occurrence %d has no declaration", planned.Occurrence) + } + renderAttrs.helpers[planned.ID] = planned + } + renderAttrs.calls = &transformCallCursor{calls: p.operations[0].calls} + code, err = TransformAttribute(p.source, p.target, sourceVar, targetVar, newVar, &renderAttrs) + if err != nil { + return "", nil, err + } + if err := renderAttrs.calls.complete("top-level transform"); err != nil { + return "", nil, err + } + helpers = make([]*TransformFunctionData, 0, len(p.helpers)) + definitions := make(map[*NameDeclaration]*TransformFunctionData, len(p.helpers)) + for index, planned := range p.helpers { + entered := enterTransformAttrs(planned.Source, planned.Target, &renderAttrs) + entered.locals, err = newTransformLocalScope("v", "res") + if err != nil { + return "", nil, err + } + entered.calls = &transformCallCursor{calls: p.operations[index+1].calls} + helper, err := generateTransformHelper(planned, entered) + if err != nil { + return "", nil, err + } + if err := entered.calls.complete(fmt.Sprintf("transform helper occurrence %d", planned.Occurrence)); err != nil { + return "", nil, err + } + if previous := definitions[planned.Declaration]; previous != nil { + if !transformFunctionDefinitionsEqual(previous, helper) { + return "", nil, fmt.Errorf("transform helper declaration %q has different definitions", planned.Declaration.Name()) + } + continue + } + definitions[planned.Declaration] = helper + helpers = append(helpers, helper) + } + return strings.TrimRight(code, "\n"), helpers, nil +} + +// copyTransformFunctionData copies generated helper descriptions before they +// cross the plan boundary. Declaration is intentionally shared: it is the +// immutable package-level name selected before rendering. +func copyTransformFunctionData(helpers []*TransformFunctionData) []*TransformFunctionData { + if helpers == nil { + return nil + } + copied := make([]*TransformFunctionData, len(helpers)) + for index, helper := range helpers { + if helper == nil { + continue + } + value := *helper + copied[index] = &value + } + return copied +} + +// newTransformLocalScope reserves every variable referenced by the source and +// target expressions. Generated temporary variables can then use ordinary +// names without hiding a parameter or another value used by the conversion. +func newTransformLocalScope(expressions ...string) (*NameScope, error) { + names := make(map[string]struct{}) + for _, expression := range expressions { + parsed, err := parser.ParseExpr(expression) + if err != nil { + return nil, fmt.Errorf("parse transform expression %q: %w", expression, err) + } + collectTransformExpressionNames(parsed, names) + } + ordered := make([]string, 0, len(names)) + for name := range names { + ordered = append(ordered, name) + } + slices.Sort(ordered) + scope := NewNameScope() + for _, name := range ordered { + scope.Unique(name) + } + return scope, nil +} + +// collectTransformExpressionNames records variables used by expression. A +// selector field such as Field in value.Field is not a variable in the current +// Go block and therefore does not reserve a local name. +func collectTransformExpressionNames(expression ast.Expr, names map[string]struct{}) { + ast.Inspect(expression, func(node ast.Node) bool { + switch node := node.(type) { + case *ast.SelectorExpr: + collectTransformExpressionNames(node.X, names) + return false + case *ast.Ident: + if node.Name != "_" { + names[node.Name] = struct{}{} + } + } + return true + }) +} + +// changed reports whether code generation would read an expression different +// from the one retained when planning completed. Render hooks receive these +// expressions for inspection only; a mutation invalidates the render attempt. +func (p *TransformPlan) changed() bool { + return !transformAttributesEqual(p.source, p.sourceBaseline, make(map[transformAttributePair]struct{})) || + !transformAttributesEqual(p.target, p.targetBaseline, make(map[transformAttributePair]struct{})) +} + +// transformAttributesEqual compares the expression facts that conversion +// planning and rendering consume. It follows paired recursive attributes once. +func transformAttributesEqual(left, right *expr.AttributeExpr, seen map[transformAttributePair]struct{}) bool { + if left == nil || right == nil { + return left == right + } + pair := transformAttributePair{source: left, target: right} + if _, compared := seen[pair]; compared { + return true + } + seen[pair] = struct{}{} + if left.Description != right.Description || !reflect.DeepEqual(left.Docs, right.Docs) || + !reflect.DeepEqual(left.Validation, right.Validation) || !reflect.DeepEqual(left.Meta, right.Meta) || + !reflect.DeepEqual(left.DefaultValue, right.DefaultValue) || len(left.Bases) != len(right.Bases) || + len(left.References) != len(right.References) || len(left.UserExamples) != len(right.UserExamples) { + return false + } + for index := range left.Bases { + if !transformDataTypesEqual(left.Bases[index], right.Bases[index], seen) { + return false + } + } + for index := range left.References { + if !transformDataTypesEqual(left.References[index], right.References[index], seen) { + return false + } + } + for index := range left.UserExamples { + if left.UserExamples[index] == nil || right.UserExamples[index] == nil { + if left.UserExamples[index] != right.UserExamples[index] { + return false + } + continue + } + if left.UserExamples[index].Summary != right.UserExamples[index].Summary || + left.UserExamples[index].Description != right.UserExamples[index].Description || + !reflect.DeepEqual(left.UserExamples[index].Value, right.UserExamples[index].Value) { + return false + } + } + return transformDataTypesEqual(left.Type, right.Type, seen) +} - return strings.TrimRight(code, "\n"), funcs, nil +// transformDataTypesEqual compares the expression type graph below two +// attributes without comparing DSL functions or expression pointer identities. +func transformDataTypesEqual(left, right expr.DataType, seen map[transformAttributePair]struct{}) bool { + if left == nil || right == nil { + return left == right + } + if reflect.TypeOf(left) != reflect.TypeOf(right) { + return false + } + switch left := left.(type) { + case expr.Primitive: + return left == right.(expr.Primitive) + case *expr.Array: + right := right.(*expr.Array) + return left.NonNullableElems == right.NonNullableElems && transformAttributesEqual(left.ElemType, right.ElemType, seen) + case *expr.Object: + right := right.(*expr.Object) + if len(*left) != len(*right) { + return false + } + for index, attribute := range *left { + other := (*right)[index] + if attribute.Name != other.Name || !transformAttributesEqual(attribute.Attribute, other.Attribute, seen) { + return false + } + } + return true + case *expr.Map: + right := right.(*expr.Map) + return transformAttributesEqual(left.KeyType, right.KeyType, seen) && + transformAttributesEqual(left.ElemType, right.ElemType, seen) + case *expr.Union: + right := right.(*expr.Union) + if left.TypeName != right.TypeName || left.TypeKey != right.TypeKey || left.ValueKey != right.ValueKey || len(left.Values) != len(right.Values) { + return false + } + for index, attribute := range left.Values { + other := right.Values[index] + if attribute.Name != other.Name || !transformAttributesEqual(attribute.Attribute, other.Attribute, seen) { + return false + } + } + return true + case expr.UserType: + right := right.(expr.UserType) + return left.Origin() == right.Origin() && left.Name() == right.Name() && + transformAttributesEqual(left.Attribute(), right.Attribute(), seen) + default: + panic(fmt.Sprintf("cannot compare transform type %T", left)) // bug + } } // TransformAttribute returns the code to transform source attribute to target @@ -84,12 +780,31 @@ func GoTransformWithAttrs(source, target *expr.AttributeExpr, sourceVar, targetV // transformation. It is exported so that TransformHooks implementations can // recurse into the transform engine from the code they render. func TransformAttribute(source, target *expr.AttributeExpr, sourceVar, targetVar string, newVar bool, ta *TransformAttrs) (string, error) { - var prelude string + if ta.locals == nil { + var err error + ta.locals, err = newTransformLocalScope(sourceVar, targetVar) + if err != nil { + return "", err + } + } + var ( + prelude string + sourcePointer, targetPointer bool + ) if h := ta.Hooks; h != nil && h.UnwrapPair != nil { + wrappedSource, wrappedTarget := source, target var dir *WrapDirective source, target, dir = h.UnwrapPair(source, target) - prelude = dir.apply(&sourceVar, &targetVar, &newVar) + prelude = dir.apply(&sourceVar, &targetVar, &newVar, ta) + if dir != nil { + if dir.WrapTarget { + targetPointer = wrappedPrimitivePointer(wrappedTarget, ta.TargetCtx) + } else { + sourcePointer = wrappedPrimitivePointer(wrappedSource, ta.SourceCtx) + } + } } + ta = enterTransformAttrs(source, target, ta) if err := IsCompatible(source.Type, target.Type, sourceVar, targetVar); err != nil { return "", err } @@ -119,7 +834,7 @@ func TransformAttribute(source, target *expr.AttributeExpr, sourceVar, targetVar case expr.IsObject(source.Type): code, err = transformObject(source, target, sourceVar, targetVar, newVar, ta) default: - code = transformPrimitive(source, target, sourceVar, targetVar, newVar, ta) + code = transformPrimitive(source, target, sourceVar, targetVar, newVar, sourcePointer, targetPointer, ta) } if err != nil { return "", err @@ -127,20 +842,31 @@ func TransformAttribute(source, target *expr.AttributeExpr, sourceVar, targetVar return prelude + code, nil } -// TransformHelperName returns the transformation function name to initialize a -// target user type from an instance of a source user type. It is exported so -// that TransformHooks implementations can compute the names of the helper -// functions the engine collects. +// TransformHelperName returns the recursive function used to initialize target +// from source. A TransformPlan calls it only for named object pairs. +// GoTransformWithAttrs still chooses its helper name while writing code. func TransformHelperName(source, target *expr.AttributeExpr, ta *TransformAttrs) string { + if ta.calls != nil { + call := ta.calls.consume() + helper, ok := ta.helpers[call.helper] + if !ok { + panic("planned transform call references an unknown helper") // bug + } + return helper.Declaration.Name() + } + return legacyTransformHelperName(source, target, ta) +} + +// legacyTransformHelperName preserves the naming strategy used by generators +// that have not yet bound their package-level helper declarations. +func legacyTransformHelperName(source, target *expr.AttributeExpr, ta *TransformAttrs) string { var ( sname string tname string prefix string ) { - if h := ta.Hooks; h != nil && h.HelperNameAttrs != nil { - source, target = h.HelperNameAttrs(source, target) - } + ta = enterTransformAttrs(source, target, ta) sname = Goify(ta.SourceCtx.Scope.Name(source, ta.SourceCtx.Pkg(source), ta.SourceCtx.Pointer, ta.SourceCtx.UseDefault), true) tname = Goify(ta.TargetCtx.Scope.Name(target, ta.TargetCtx.Pkg(target), ta.TargetCtx.Pointer, ta.TargetCtx.UseDefault), true) prefix = ta.Prefix @@ -151,26 +877,72 @@ func TransformHelperName(source, target *expr.AttributeExpr, ta *TransformAttrs) return Goify(prefix+sname+"To"+tname, false) } +// usesTransformHelper reports whether source and target can define one named +// helper function signature. Anonymous objects are rendered inline because +// they have no package-level parameter or result declaration. +func usesTransformHelper(source, target *expr.AttributeExpr) bool { + _, sourceNamed := source.Type.(expr.UserType) + _, targetNamed := target.Type.(expr.UserType) + return sourceNamed && targetNamed && expr.IsObject(source.Type) && expr.IsObject(target.Type) +} + +// transformFunctionDefinitionsEqual reports whether one function can serve +// every call assigned to the same declaration. +func transformFunctionDefinitionsEqual(left, right *TransformFunctionData) bool { + return left.ParamTypeRef == right.ParamTypeRef && + left.ResultTypeRef == right.ResultTypeRef && + left.Code == right.Code +} + // transformPrimitive returns the code to transform source primitive type to // target primitive type. The caller (TransformAttribute) already verified that -// source and target are compatible. -func transformPrimitive(source, target *expr.AttributeExpr, sourceVar, targetVar string, newVar bool, ta *TransformAttrs) string { +// source and target are compatible. The pointer flags describe primitive +// fields inside wrappers removed before this function runs. +func transformPrimitive( + source, target *expr.AttributeExpr, + sourceVar, targetVar string, + newVar, sourcePointer, targetPointer bool, + ta *TransformAttrs, +) string { assign := "=" if newVar { assign = ":=" } + handled := false + expression := sourceVar if h := ta.Hooks; h != nil && h.ConvertPrimitive != nil { - if exp, ok := h.ConvertPrimitive(source, target, sourceVar, false, false, ta); ok { - return fmt.Sprintf("%s %s %s\n", targetVar, assign, exp) + if result, ok := h.ConvertPrimitive(source, target, sourceVar, sourcePointer, targetPointer, ta); ok { + expression = result + handled = true + } + } + if !handled { + srcRef := ta.SourceCtx.Scope.Ref(source, ta.SourceCtx.Pkg(source)) + tgtRef := ta.TargetCtx.Scope.Ref(target, ta.TargetCtx.Pkg(target)) + if sourcePointer { + expression = "*" + expression + } + if srcRef != tgtRef { + expression = fmt.Sprintf("%s(%s)", tgtRef, expression) } } - srcRef := ta.SourceCtx.Scope.Ref(source, ta.SourceCtx.Pkg(source)) - tgtRef := ta.TargetCtx.Scope.Ref(target, ta.TargetCtx.Pkg(target)) - if srcRef != tgtRef { - return fmt.Sprintf("%s %s %s(%s)\n", targetVar, assign, tgtRef, sourceVar) + if targetPointer { + targetRef := ta.TargetCtx.Scope.Ref(target, ta.TargetCtx.Pkg(target)) + return fmt.Sprintf("%s = new(%s)\n*%s = %s\n", targetVar, targetRef, targetVar, expression) + } + return fmt.Sprintf("%s %s %s\n", targetVar, assign, expression) +} + +// wrappedPrimitivePointer reports whether the single field exposed by a +// wrapper uses a pointer in the generated source or target type. +func wrappedPrimitivePointer(wrapper *expr.AttributeExpr, context *AttributeContext) bool { + object := expr.AsObject(wrapper.Type) + if object == nil || len(*object) != 1 { + panic("transform wrapper must contain exactly one field") } - return fmt.Sprintf("%s %s %s\n", targetVar, assign, sourceVar) + field := (*object)[0] + return expr.IsPrimitive(field.Attribute.Type) && context.IsPrimitivePointer(field.Name, wrapper) } // transformObject generates Go code to transform source object to target @@ -184,6 +956,9 @@ func transformObject(source, target *expr.AttributeExpr, sourceVar, targetVar st { // walk through primitives first to initialize the struct walkMatches(source, target, func(srcMatt, tgtMatt *expr.MappedAttributeExpr, srcc, tgtc *expr.AttributeExpr, n string) { + if err != nil { + return + } if !expr.IsPrimitive(srcc.Type) { return } @@ -224,7 +999,7 @@ func transformObject(source, target *expr.AttributeExpr, sourceVar, targetVar st if srcPtr && !srcMatt.IsRequired(n) { postInitCode += fmt.Sprintf("if %s != nil {\n", srcField) if tgtPtr { - tmp := Goify(tgtMatt.ElemName(n), false) + tmp := ta.enterLocalBlock().uniqueLocal(Goify(tgtMatt.ElemName(n), false)) postInitCode += fmt.Sprintf("%s := %s\n%s.%s = &%s\n", tmp, exp, targetVar, tgtField, tmp) } else { postInitCode += fmt.Sprintf("%s.%s = %s\n", targetVar, tgtField, exp) @@ -232,7 +1007,7 @@ func transformObject(source, target *expr.AttributeExpr, sourceVar, targetVar st postInitCode += "}\n" return } else if tgtPtr { - tmp := Goify(tgtMatt.ElemName(n), false) + tmp := ta.uniqueLocal(Goify(tgtMatt.ElemName(n), false)) postInitCode += fmt.Sprintf("%s := %s\n%s.%s = &%s\n", tmp, exp, targetVar, tgtField, tmp) return } @@ -276,13 +1051,18 @@ func transformObject(source, target *expr.AttributeExpr, sourceVar, targetVar st // iterate through attributes to initialize rest of the struct fields and // handle default values walkMatches(source, target, func(srcMatt, tgtMatt *expr.MappedAttributeExpr, srcc, tgtc *expr.AttributeExpr, n string) { + if err != nil { + return + } + srcField := ta.SourceCtx.Scope.Field(srcc, srcMatt.ElemName(n), true) + tgtField := ta.TargetCtx.Scope.Field(tgtc, tgtMatt.ElemName(n), true) h := ta.Hooks if h != nil && h.FieldPairAttrs != nil { srcc, tgtc = h.FieldPairAttrs(srcc, tgtc) } var ( - srcVar = sourceVar + "." + ta.SourceCtx.Scope.Field(srcc, srcMatt.ElemName(n), true) - tgtVar = targetVar + "." + ta.TargetCtx.Scope.Field(tgtc, tgtMatt.ElemName(n), true) + srcVar = sourceVar + "." + srcField + tgtVar = targetVar + "." + tgtField ) var dir *WrapDirective if h != nil && h.UnwrapPair != nil { @@ -291,6 +1071,7 @@ func transformObject(source, target *expr.AttributeExpr, sourceVar, targetVar st if err = IsCompatible(srcc.Type, tgtc.Type, sourceVar, targetVar); err != nil { return } + fieldAttrs := enterTransformAttrs(srcc, tgtc, ta) var code string { @@ -298,39 +1079,36 @@ func transformObject(source, target *expr.AttributeExpr, sourceVar, targetVar st // transforming the field value: nil guards and default value // handling keep using the unwrapped field variables. dispatchSrcVar, dispatchTgtVar, dispatchNewVar := srcVar, tgtVar, false - prelude := dir.apply(&dispatchSrcVar, &dispatchTgtVar, &dispatchNewVar) + prelude := dir.apply(&dispatchSrcVar, &dispatchTgtVar, &dispatchNewVar, ta) var postlude string if expr.IsUnion(tgtc.Type) && ta.TargetCtx.IsFieldPointer(n, tgtMatt.AttributeExpr) { unionVar := Goify(tgtMatt.ElemName(n), false) + "Value" - unionRef := ta.TargetCtx.Scope.Name(tgtc, ta.TargetCtx.Pkg(tgtc), false, ta.TargetCtx.UseDefault) + unionRef := fieldAttrs.TargetCtx.Scope.Name(tgtc, fieldAttrs.TargetCtx.Pkg(tgtc), false, fieldAttrs.TargetCtx.UseDefault) prelude += fmt.Sprintf("var %s %s\n", unionVar, unionRef) dispatchTgtVar = unionVar postlude = fmt.Sprintf("%s = &%s\n", tgtVar, unionVar) } - _, ok := srcc.Type.(expr.UserType) switch { case expr.IsArray(srcc.Type): if h != nil && h.TransformArray != nil { - code, err = h.TransformArray(expr.AsArray(srcc.Type), expr.AsArray(tgtc.Type), dispatchSrcVar, dispatchTgtVar, dispatchNewVar, ta) + code, err = h.TransformArray(expr.AsArray(srcc.Type), expr.AsArray(tgtc.Type), dispatchSrcVar, dispatchTgtVar, dispatchNewVar, fieldAttrs) } else { - code, err = transformArray(expr.AsArray(srcc.Type), expr.AsArray(tgtc.Type), dispatchSrcVar, dispatchTgtVar, dispatchNewVar, ta) + code, err = transformArray(expr.AsArray(srcc.Type), expr.AsArray(tgtc.Type), dispatchSrcVar, dispatchTgtVar, dispatchNewVar, fieldAttrs) } case expr.IsMap(srcc.Type): if h != nil && h.TransformMap != nil { - code, err = h.TransformMap(expr.AsMap(srcc.Type), expr.AsMap(tgtc.Type), dispatchSrcVar, dispatchTgtVar, dispatchNewVar, ta) + code, err = h.TransformMap(expr.AsMap(srcc.Type), expr.AsMap(tgtc.Type), dispatchSrcVar, dispatchTgtVar, dispatchNewVar, fieldAttrs) } else { - code, err = transformMap(expr.AsMap(srcc.Type), expr.AsMap(tgtc.Type), dispatchSrcVar, dispatchTgtVar, dispatchNewVar, ta) + code, err = transformMap(expr.AsMap(srcc.Type), expr.AsMap(tgtc.Type), dispatchSrcVar, dispatchTgtVar, dispatchNewVar, fieldAttrs) } case expr.IsUnion(srcc.Type): if h != nil && h.TransformUnion != nil { - code, err = h.TransformUnion(srcc, tgtc, dispatchSrcVar, dispatchTgtVar, dispatchNewVar, source, target, ta) + code, err = h.TransformUnion(srcc, tgtc, dispatchSrcVar, dispatchTgtVar, dispatchNewVar, source, target, fieldAttrs) } else { - code, err = transformUnion(srcc, tgtc, dispatchSrcVar, dispatchTgtVar, dispatchNewVar, ta) - } - case ok: - if !expr.IsPrimitive(srcc.Type) { - code = fmt.Sprintf("%s = %s(%s)\n", dispatchTgtVar, TransformHelperName(srcc, tgtc, ta), dispatchSrcVar) + code, err = transformUnion(srcc, tgtc, dispatchSrcVar, dispatchTgtVar, dispatchNewVar, fieldAttrs) } + case usesTransformHelper(srcc, tgtc): + code = fmt.Sprintf("%s = %s(%s)\n", dispatchTgtVar, TransformHelperName(srcc, tgtc, ta), dispatchSrcVar) case expr.IsObject(srcc.Type): code, err = TransformAttribute(srcc, tgtc, dispatchSrcVar, dispatchTgtVar, dispatchNewVar, ta) } @@ -372,7 +1150,8 @@ func transformObject(source, target *expr.AttributeExpr, sourceVar, targetVar st } code = fmt.Sprintf("%s\t%s}", cond, code) if expr.IsArray(srcc.Type) && srcMatt.IsRequired(n) { - code += fmt.Sprintf("else {\n\t%s = []%s{}\n}\n", tgtVar, ta.TargetCtx.Scope.Ref(expr.AsArray(tgtc.Type).ElemType, ta.TargetCtx.Pkg(expr.AsArray(tgtc.Type).ElemType))) + elem := expr.AsArray(tgtc.Type).ElemType + code += fmt.Sprintf("else {\n\t%s = []%s{}\n}\n", tgtVar, fieldAttrs.TargetCtx.Scope.Ref(elem, fieldAttrs.TargetCtx.Pkg(elem))) } else { code += "\n" } @@ -384,67 +1163,47 @@ func transformObject(source, target *expr.AttributeExpr, sourceVar, targetVar st // non-pointers) and has a default value set. if tdef := tgtMatt.GetDefault(n); tdef != nil && ta.TargetCtx.UseDefault && !ta.TargetCtx.Pointer && !srcMatt.IsRequired(n) { switch { - case ta.SourceCtx.IsPrimitivePointer(n, srcMatt.AttributeExpr) || !expr.IsPrimitive(srcc.Type): - // source attribute is a primitive pointer or not a primitive - code += fmt.Sprintf("if %s == nil {\n\t", srcVar) - switch { - case ta.TargetCtx.IsPrimitivePointer(n, tgtMatt.AttributeExpr) && expr.IsPrimitive(tgtc.Type): - typeName := GoNativeTypeName(tgtc.Type) - if h != nil && h.ZeroTypeName != nil { - if zn, ok := h.ZeroTypeName(tgtc); ok { - typeName = zn - } - } - code += fmt.Sprintf("var tmp %s = %#v\n\t%s = &tmp\n", typeName, tdef, tgtVar) - case expr.IsArray(tgtc.Type): - arr := expr.AsArray(tgtc.Type) - if expr.IsAlias(arr.ElemType.Type) { - // Render typed array default literals for aliased element types, - // e.g. []pkg.EnumType{pkg.EnumType("val")}. - elemRef := ta.TargetCtx.Scope.Ref(arr.ElemType, ta.TargetCtx.Pkg(arr.ElemType)) - rv := reflect.ValueOf(tdef) - if rv.Kind() != reflect.Slice { - panic(fmt.Sprintf("unsupported default value type %T for aliased array element", tdef)) // bug - } - items := make([]string, rv.Len()) - for i := range items { - items[i] = fmt.Sprintf("%s(%#v)", elemRef, rv.Index(i).Interface()) - } - if len(items) > 0 { - code += fmt.Sprintf("%s = []%s{%s}\n", tgtVar, elemRef, strings.Join(items, ", ")) - } - } else { - // Non-alias element type: use raw default without casting elements - code += fmt.Sprintf("%s = %#v\n", tgtVar, tdef) - } - default: - code += fmt.Sprintf("%s = %#v\n", tgtVar, tdef) + case transformFieldUsesNilPresence(ta.SourceCtx, n, srcMatt.AttributeExpr): + // A nil source means the field was omitted. This includes + // slices and message values as well as explicit pointers. + assignment, renderErr := renderTransformDefault( + tgtc, + tdef, + fieldAttrs.TargetCtx, + ta.TargetCtx.IsFieldPointer(n, tgtMatt.AttributeExpr), + tgtVar, + tgtMatt.ElemName(n)+"Default", + ) + if renderErr != nil { + err = renderErr + return } - code += "}\n" + code += fmt.Sprintf("if %s == nil {\n%s}\n", srcVar, Indent(assignment, "\t")) case expr.IsPrimitive(srcc.Type) && srcMatt.HasDefaultValue(n) && ta.SourceCtx.UseDefault: // source attribute is a primitive with default value // (the field is not a pointer in this case) - code += "{\n\t" - var ( - zeroName string - nilable bool + assignment, renderErr := renderTransformDefault( + tgtc, + tdef, + fieldAttrs.TargetCtx, + ta.TargetCtx.IsFieldPointer(n, tgtMatt.AttributeExpr), + tgtVar, + tgtMatt.ElemName(n)+"Default", ) + if renderErr != nil { + err = renderErr + return + } + code += "{\n\t" + var zeroName string + nilable := IsNilable(tgtc.Type) || valueIsNilable(tdef) if h != nil && h.ZeroTypeName != nil { - var ok bool - if zeroName, ok = h.ZeroTypeName(tgtc); ok { - nilable = typeStringIsNilable(zeroName) + if name, ok := h.ZeroTypeName(tgtc); ok { + zeroName = name } } if zeroName == "" { - if typeName, _ := GetMetaType(tgtc); typeName != "" { - zeroName = typeName - nilable = typeStringIsNilable(typeName) - } else if _, ok := tgtc.Type.(expr.UserType); ok { - // aliased primitive - zeroName = ta.TargetCtx.Scope.Ref(tgtc, ta.TargetCtx.Pkg(tgtc)) - } else { - zeroName = GoNativeTypeName(tgtc.Type) - } + zeroName = ta.TargetCtx.Scope.Ref(tgtc, ta.TargetCtx.Pkg(tgtc)) } if !nilable { code += fmt.Sprintf("var zero %s\n\t", zeroName) @@ -452,7 +1211,7 @@ func transformObject(source, target *expr.AttributeExpr, sourceVar, targetVar st } else { code += fmt.Sprintf("if %s == nil ", tgtVar) } - code += fmt.Sprintf("{\n\t%s = %#v\n}\n", tgtVar, tdef) + code += fmt.Sprintf("{\n%s}\n", Indent(assignment, "\t")) code += "}\n" } } @@ -465,10 +1224,54 @@ func transformObject(source, target *expr.AttributeExpr, sourceVar, targetVar st return buffer.String(), nil } -// typeStringIsNilable takes a go type as a string and checks for a '[]' or -// 'map[' prefix to see if it's a nilable primitive type. -func typeStringIsNilable(typeName string) bool { - return strings.HasPrefix(typeName, "[]") || strings.HasPrefix(typeName, "map[") +// renderTransformDefault writes the declarations and assignment needed when a +// source field is absent. The target context supplies the exact generated Go +// names and pointer layout. +func renderTransformDefault(attribute *expr.AttributeExpr, value any, context *AttributeContext, pointer bool, target, localPrefix string) (string, error) { + resolver, ok := context.Scope.(GoTypeLayoutResolver) + if !ok { + return "", fmt.Errorf("render transform default: target package has no linked Go type resolver") + } + layout, err := resolver.GoTypeLayout(attribute, context.LayoutPolicy()) + if err != nil { + return "", err + } + var resolveUnion UnionConstructorResolver + if resolver, ok := context.Scope.(interface { + UnionConstructor(*expr.AttributeExpr, string) (string, error) + }); ok { + resolveUnion = resolver.UnionConstructor + } + rendered, err := RenderGoValue(attribute, value, layout, pointer, resolveUnion, localPrefix) + if err != nil { + return "", err + } + var code strings.Builder + for _, declaration := range rendered.Declarations { + code.WriteString(declaration) + code.WriteByte('\n') + } + fmt.Fprintf(&code, "%s = %s\n", target, rendered.Expression) + return code.String(), nil +} + +// transformFieldUsesNilPresence reports whether nil means that a generated +// source field was omitted. Bytes and Any can preserve this distinction even +// though Goa does not add another pointer around them. +func transformFieldUsesNilPresence(context *AttributeContext, name string, parent *expr.AttributeExpr) bool { + field := expr.AsObject(parent.Type).Attribute(name) + return !expr.IsPrimitive(field.Type) || + context.IsPrimitivePointer(name, parent) || + !context.UseDefault && IsNilable(field.Type) +} + +// valueIsNilable reports whether a typed default can be compared only with +// nil. It preserves named slices, maps, pointers, functions, and channels +// without inspecting the Go name supplied by field metadata. +func valueIsNilable(value any) bool { + kind := reflect.TypeOf(value).Kind() + return kind == reflect.Chan || kind == reflect.Func || kind == reflect.Interface || + kind == reflect.Map || kind == reflect.Pointer || kind == reflect.Slice } // transformArray generates Go code to transform source array to target array. @@ -476,16 +1279,32 @@ func transformArray(source, target *expr.Array, sourceVar, targetVar string, new if err := IsCompatible(source.ElemType.Type, target.ElemType.Type, sourceVar+"[0]", targetVar+"[0]"); err != nil { return "", err } + sourceElement := "val" + if ta.SourceCtx.IsArrayElementPointer(source) { + sourceElement = "*val" + } + loopVar, childAttrs := ta.EnterCollection() + localExpressions := []string{sourceVar, targetVar, loopVar, "val"} + if ta.TargetCtx.IsArrayElementPointer(target) { + localExpressions = append(localExpressions, "transformed") + } + childAttrs, err := childAttrs.EnterLocalBlock(localExpressions...) + if err != nil { + return "", err + } data := map[string]any{ - "ElemTypeRef": ta.TargetCtx.Scope.Ref(target.ElemType, ta.TargetCtx.Pkg(target.ElemType)), - "SourceElem": source.ElemType, - "TargetElem": target.ElemType, - "SourceVar": sourceVar, - "TargetVar": targetVar, - "NewVar": newVar, - "TransformAttrs": ta, - "LoopVar": string(rune(105 + strings.Count(targetVar, "["))), - "IsStruct": expr.IsObject(target.ElemType.Type), + "ElemTypeRef": ta.TargetCtx.Scope.Ref(target.ElemType, ta.TargetCtx.Pkg(target.ElemType)), + "SourceElem": source.ElemType, + "SourceElement": sourceElement, + "TargetElem": target.ElemType, + "SourceVar": sourceVar, + "TargetVar": targetVar, + "NewVar": newVar, + "TransformAttrs": childAttrs, + "LoopVar": loopVar, + "SourceIsObject": expr.IsObject(source.ElemType.Type), + "TargetElemPointer": ta.TargetCtx.IsArrayElementPointer(target), + "UseHelper": usesTransformHelper(source.ElemType, target.ElemType), } var buf bytes.Buffer if err := transformGoArrayT.Execute(&buf, data); err != nil { @@ -502,6 +1321,14 @@ func transformMap(source, target *expr.Map, sourceVar, targetVar string, newVar if err := IsCompatible(source.ElemType.Type, target.ElemType.Type, sourceVar+"[*]", targetVar+"[*]"); err != nil { return "", err } + loopVar := "" + if depth := MapDepth(target); depth > 0 { + loopVar = string(rune(97 + depth)) + } + mapAttrs, err := ta.EnterLocalBlock(sourceVar, targetVar, "key", "val", "tk", "tv"+loopVar) + if err != nil { + return "", err + } data := map[string]any{ "KeyTypeRef": ta.TargetCtx.Scope.Ref(target.KeyType, ta.TargetCtx.Pkg(target.KeyType)), "ElemTypeRef": ta.TargetCtx.Scope.Ref(target.ElemType, ta.TargetCtx.Pkg(target.ElemType)), @@ -512,13 +1339,11 @@ func transformMap(source, target *expr.Map, sourceVar, targetVar string, newVar "SourceVar": sourceVar, "TargetVar": targetVar, "NewVar": newVar, - "TransformAttrs": ta, - "LoopVar": "", - "IsKeyStruct": expr.IsObject(target.KeyType.Type), - "IsElemStruct": expr.IsObject(target.ElemType.Type), - } - if depth := MapDepth(target); depth > 0 { - data["LoopVar"] = string(rune(97 + depth)) + "TransformAttrs": mapAttrs, + "LoopVar": loopVar, + "ElemIsObject": expr.IsObject(source.ElemType.Type), + "UseKeyHelper": usesTransformHelper(source.KeyType, target.KeyType), + "UseElemHelper": usesTransformHelper(source.ElemType, target.ElemType), } var buf bytes.Buffer if err := transformGoMapT.Execute(&buf, data); err != nil { @@ -553,29 +1378,26 @@ func transformUnion(source, target *expr.AttributeExpr, sourceVar, targetVar str unionPkg := ta.TargetCtx.Pkg(target) typeRef := ta.TargetCtx.Scope.Ref(target, unionPkg) - // Use deterministic temp var: 'obj' at top-level, 'tmp' for nested - // assignments. A "obj." prefix means this transform is emitted inside a - // case body of an enclosing union transform which already declared 'obj'. + // The outer union keeps Goa's released local spelling. Nested unions use + // numbered locals selected from traversal depth, never from caller code. tempVarName := "obj" - if strings.HasPrefix(targetVar, "obj.") { + if ta.unionDepth > 0 { tempVarName = "tmp" + tempVarName += strconv.Itoa(ta.unionDepth + 1) } + childAttrs := *ta + childAttrs.unionDepth++ cases := make([]map[string]any, 0, len(srcUnion.Values)) for i, st := range srcUnion.Values { tt := tgtUnion.Values[i] - castPkg := ta.TargetCtx.Pkg(tt.Attribute) - // When generating transforms outside of the type's package, some nested - // helper user types may not carry struct:pkg:path metadata. In that case - // default to the union type package rather than the current file package. - if castPkg == ta.TargetCtx.DefaultPkg && unionPkg != "" && unionPkg != ta.TargetCtx.DefaultPkg { - castPkg = unionPkg - } - useHelper := false - if _, ok := st.Attribute.Type.(expr.UserType); ok && expr.IsObject(st.Attribute.Type) { - if _, ok := tt.Attribute.Type.(expr.UserType); ok && expr.IsObject(tt.Attribute.Type) { - useHelper = true - } + branchAttrs := *ta + branchAttrs.SourceCtx = ta.SourceCtx.Enter(st.Attribute) + branchAttrs.TargetCtx = ta.TargetCtx.Enter(tt.Attribute) + useHelper := usesTransformHelper(st.Attribute, tt.Attribute) + helperName := "" + if useHelper { + helperName = TransformHelperName(st.Attribute, tt.Attribute, &branchAttrs) } cases = append(cases, map[string]any{ "CaseName": st.Name, @@ -583,22 +1405,22 @@ func transformUnion(source, target *expr.AttributeExpr, sourceVar, targetVar str "TargetFieldName": Goify(tt.Name, true), "SourceAttr": st.Attribute, "TargetAttr": tt.Attribute, - "TargetCastType": ta.TargetCtx.Scope.Ref(tt.Attribute, castPkg), + "TargetCastType": branchAttrs.TargetCtx.Scope.Ref(tt.Attribute, branchAttrs.TargetCtx.Pkg(tt.Attribute)), + "SourceNilable": IsNilable(st.Attribute.Type), "UseHelper": useHelper, - "HelperName": TransformHelperName(st.Attribute, tt.Attribute, ta), + "HelperName": helperName, }) } data := map[string]any{ - "SourceVar": sourceVar, - "TargetVar": targetVar, - "NewVar": newVar, - "TypeRef": typeRef, - "TargetIsPointer": strings.HasPrefix(typeRef, "*"), - "ValueTypeRef": strings.TrimPrefix(typeRef, "*"), - "TempVarName": tempVarName, - "Cases": cases, - "TransformAttrs": ta, + "SourceVar": sourceVar, + "TargetVar": targetVar, + "NewVar": newVar, + "TypeRef": typeRef, + "ValueTypeRef": ta.TargetCtx.Scope.Name(target, unionPkg, false, ta.TargetCtx.UseDefault), + "TempVarName": tempVarName, + "Cases": cases, + "TransformAttrs": &childAttrs, } var buf bytes.Buffer @@ -608,107 +1430,304 @@ func transformUnion(source, target *expr.AttributeExpr, sourceVar, targetVar str return buf.String(), nil } -// collectHelpers recurses through the given attributes and returns the -// transform helper functions required by the code GoTransform produces. The -// top-level call (topLevel true) does not generate a helper for the top-most -// user type because the generated code inlines that transformation; children -// of composite top-level types always get helpers. -// -// seen keeps track of generated transform functions to avoid infinite -// recursion on recursive types. -func collectHelpers(source, target *expr.AttributeExpr, req, topLevel bool, ta *TransformAttrs, seen map[string]*TransformFunctionData) (helpers []*TransformFunctionData, err error) { - if h := ta.Hooks; h != nil && h.UnwrapPair != nil { - source, target, _ = h.UnwrapPair(source, target) +// planTransformOperation records the recursive calls made by the main +// conversion or one generated function. It reuses a function when that same +// source and target pair is already being converted. +func planTransformOperation(source, target *expr.AttributeExpr, required, topLevel bool, location TransformHelperDefinitionLocation, operation *transformOperation, active map[transformPair]TransformHelperID, plan *TransformPlan) error { + return planTransformOperationWithHelper(source, target, required, topLevel, false, location, operation, active, plan) +} + +// planTransformOperationWithHelper records one conversion. forceHelper is true +// when a custom union renderer declares that it calls TransformHelperName for +// this pair, including named arrays and aliases that the default renderer +// writes inline. +func planTransformOperationWithHelper(source, target *expr.AttributeExpr, required, topLevel, forceHelper bool, location TransformHelperDefinitionLocation, operation *transformOperation, active map[transformPair]TransformHelperID, plan *TransformPlan) error { + helperSource, helperTarget := source, target + if forceHelper { + _, sourceNamed := source.Type.(expr.UserType) + _, targetNamed := target.Type.(expr.UserType) + if !sourceNamed && !targetNamed { + return fmt.Errorf("custom union transform helper requires a named source or target type") + } + } + var rootWrap *WrapDirective + if plan.hooks != nil && plan.hooks.UnwrapPair != nil { + source, target, rootWrap = plan.hooks.UnwrapPair(source, target) + } + if location.encoded == "" { + plan.rootSource = source + plan.rootTarget = target + plan.rootWrap = rootWrap + } + if !forceHelper { + helperSource, helperTarget = source, target + } + if err := IsCompatible(source.Type, target.Type, "source", "target"); err != nil { + return err } if topLevel { - req = true - } else { - name := TransformHelperName(source, target, ta) - if _, ok := seen[name]; ok { - return helpers, err - } - if _, ok := source.Type.(expr.UserType); ok && expr.IsObject(source.Type) { - var h *TransformFunctionData - if h, err = generateHelper(source, target, req, ta, seen); h != nil { - helpers = append(helpers, h) + required = true + } else if forceHelper || usesTransformHelper(source, target) { + pair := transformPair{ + source: source.Type, + target: target.Type, + } + if ancestor, recursive := active[pair]; recursive { + operation.calls = append(operation.calls, transformCall{ + helper: ancestor, + }) + return nil + } + + id := TransformHelperID{plan: plan, index: len(plan.helpers)} + plan.helpers = append(plan.helpers, TransformHelper{ + ID: id, + Source: helperSource, + Target: helperTarget, + Required: required, + Occurrence: id.index + 1, + location: location, + }) + plan.recordHelperDefinition(id, helperSource, helperTarget, location) + operation.calls = append(operation.calls, transformCall{ + helper: id, + }) + body := &transformOperation{} + plan.operations = append(plan.operations, body) + active[pair] = id + bodySource, bodyTarget := source, target + if !forceHelper && plan.hooks != nil && plan.hooks.UnwrapPair != nil { + bodySource, bodyTarget, _ = plan.hooks.UnwrapPair(bodySource, bodyTarget) + } + if err := IsCompatible(bodySource.Type, bodyTarget.Type, "source", "target"); err != nil { + delete(active, pair) + return err + } + err := planTransformChildren(bodySource, bodyTarget, required, location, body, active, plan) + delete(active, pair) + return err + } + return planTransformChildren(source, target, required, location, operation, active, plan) +} + +// recordHelperDefinition groups calls with the same retained source and target +// facts. Hooks may inspect metadata, defaults, and validation when writing a +// function body, so matching only the data type would merge different code. +// Requiredness stays on the call because it only decides whether the caller +// checks for nil. +func (p *TransformPlan) recordHelperDefinition(id TransformHelperID, source, target *expr.AttributeExpr, location TransformHelperDefinitionLocation) { + for index := range p.definitions { + definition := &p.definitions[index] + same := transformAttributesEqual(definition.Source, source, make(map[transformAttributePair]struct{})) && + transformAttributesEqual(definition.Target, target, make(map[transformAttributePair]struct{})) + if p.hooks != nil && p.hooks.SameHelperDefinition != nil { + same = sameTransformUserTypeOrigin(definition.Source, source) && + sameTransformUserTypeOrigin(definition.Target, target) && + p.hooks.SameHelperDefinition(definition.Source, definition.Target, source, target) + } + if same { + definition.helpers = append(definition.helpers, id.index) + if location.Compare(definition.Location) < 0 { + definition.Location = location } + return } } - // Renderers which inline composite element construction do not call - // element transform helpers: skip helper generation for the elements - // themselves by treating them as top-level attributes. - elemTop := ta.Hooks != nil && ta.Hooks.InlineCompositeElems - var other []*TransformFunctionData + definitionID := TransformHelperDefinitionID{plan: p, index: len(p.definitions)} + p.definitions = append(p.definitions, TransformHelperDefinition{ + ID: definitionID, + Source: source, + Target: target, + Location: location, + helpers: []int{id.index}, + }) +} + +// sameTransformUserTypeOrigin prevents a hook from merging functions for two +// separately authored named types. A named type's origin owns its generated Go +// declaration even when another type has the same name and fields. +func sameTransformUserTypeOrigin(first, next *expr.AttributeExpr) bool { + firstType, firstNamed := first.Type.(expr.UserType) + nextType, nextNamed := next.Type.(expr.UserType) + if firstNamed != nextNamed { + return false + } + return !firstNamed || firstType.Origin() == nextType.Origin() +} + +// append adds one authored path component. NUL bytes inside names are escaped, +// and two NUL bytes end each component, so empty and nested names remain +// distinct while string comparison preserves their lexical order. +func (l TransformHelperDefinitionLocation) append(kind byte, name string) TransformHelperDefinitionLocation { + var encoded strings.Builder + encoded.Grow(len(l.encoded) + len(name) + 3) + encoded.WriteString(l.encoded) + encoded.WriteByte(kind) + for index := range len(name) { + if name[index] == 0 { + encoded.WriteByte(0) + encoded.WriteByte(0xff) + continue + } + encoded.WriteByte(name[index]) + } + encoded.WriteByte(0) + encoded.WriteByte(0) + return TransformHelperDefinitionLocation{encoded: encoded.String()} +} + +// objectField returns the location below one authored object field. +func (l TransformHelperDefinitionLocation) objectField(name string) TransformHelperDefinitionLocation { + return l.append(transformObjectFieldLocation, name) +} + +// arrayElement returns the location below an array element. +func (l TransformHelperDefinitionLocation) arrayElement() TransformHelperDefinitionLocation { + return l.append(transformArrayElementLocation, "") +} + +// mapKey returns the location below a map key. +func (l TransformHelperDefinitionLocation) mapKey() TransformHelperDefinitionLocation { + return l.append(transformMapKeyLocation, "") +} + +// mapValue returns the location below a map value. +func (l TransformHelperDefinitionLocation) mapValue() TransformHelperDefinitionLocation { + return l.append(transformMapValueLocation, "") +} + +// unionBranch returns the location below an authored union branch. +func (l TransformHelperDefinitionLocation) unionBranch(name string) TransformHelperDefinitionLocation { + return l.append(transformUnionBranchLocation, name) +} + +// transformUnionHelperBranch returns the authored branch name selected by a +// custom union hook. Both attributes must be the retained authored branch +// pair so the location cannot silently identify a different function body. +func transformUnionHelperBranch(source, target *expr.Union, sourceBranch, targetBranch *expr.AttributeExpr) (string, bool) { + for index, branch := range source.Values { + if branch.Attribute == sourceBranch && target.Values[index].Attribute == targetBranch { + return branch.Name, true + } + } + return "", false +} + +// planTransformChildren walks child transformations in the same order as the +// core templates consume helper names. +func planTransformChildren(source, target *expr.AttributeExpr, required bool, location TransformHelperDefinitionLocation, operation *transformOperation, active map[transformPair]TransformHelperID, plan *TransformPlan) error { + collect := func(source, target *expr.AttributeExpr, childRequired bool, top bool, childLocation TransformHelperDefinitionLocation) error { + return planTransformOperation(source, target, childRequired, top, childLocation, operation, active, plan) + } + elementTop := plan.hooks != nil && plan.hooks.InlineCompositeElems switch { case expr.IsArray(source.Type): - if other, err = collectHelpers(expr.AsArray(source.Type).ElemType, expr.AsArray(target.Type).ElemType, req, elemTop, ta, seen); err == nil { - helpers = append(helpers, other...) - } + return collect(expr.AsArray(source.Type).ElemType, expr.AsArray(target.Type).ElemType, required, elementTop, location.arrayElement()) case expr.IsMap(source.Type): - sm, tm := expr.AsMap(source.Type), expr.AsMap(target.Type) - if other, err = collectHelpers(sm.ElemType, tm.ElemType, req, elemTop, ta, seen); err == nil { - helpers = append(helpers, other...) - if other, err = collectHelpers(sm.KeyType, tm.KeyType, req, elemTop, ta, seen); err == nil { - helpers = append(helpers, other...) - } + sourceMap, targetMap := expr.AsMap(source.Type), expr.AsMap(target.Type) + if err := collect(sourceMap.KeyType, targetMap.KeyType, required, elementTop, location.mapKey()); err != nil { + return err } + return collect(sourceMap.ElemType, targetMap.ElemType, required, elementTop, location.mapValue()) case expr.IsUnion(source.Type): - tt := expr.AsUnion(target.Type) - if tt == nil { - return helpers, err + targetUnion := expr.AsUnion(target.Type) + if targetUnion == nil { + return nil + } + sourceUnion := expr.AsUnion(source.Type) + if plan.hooks != nil && plan.hooks.TransformUnion != nil { + if plan.hooks.PlanUnionHelpers == nil { + return nil + } + var planErr error + plan.hooks.PlanUnionHelpers(source, target, func(sourceBranch, targetBranch *expr.AttributeExpr) { + if planErr != nil { + return + } + branch, ok := transformUnionHelperBranch(sourceUnion, targetUnion, sourceBranch, targetBranch) + if !ok { + planErr = fmt.Errorf("custom union transform helper must use retained authored branch attributes") + return + } + planErr = planTransformOperationWithHelper(sourceBranch, targetBranch, required, false, true, location.unionBranch(branch), operation, active, plan) + }) + return planErr + } + if len(sourceUnion.Values) != len(targetUnion.Values) { + return fmt.Errorf("cannot transform union: number of union types differ (%s has %d, %s has %d)", + source.Type.Name(), len(sourceUnion.Values), target.Type.Name(), len(targetUnion.Values)) } - for i, st := range expr.AsUnion(source.Type).Values { - if other, err = collectHelpers(st.Attribute, tt.Values[i].Attribute, req, false, ta, seen); err == nil { - helpers = append(helpers, other...) + for index, branch := range sourceUnion.Values { + if err := IsCompatible(branch.Attribute.Type, targetUnion.Values[index].Attribute.Type, "source", "target"); err != nil { + return fmt.Errorf("cannot transform union %s to %s: type at index %d: %w", + source.Type.Name(), target.Type.Name(), index, err) + } + } + for index, branch := range sourceUnion.Values { + if err := collect(branch.Attribute, targetUnion.Values[index].Attribute, required, false, location.unionBranch(branch.Name)); err != nil { + return err } } case expr.IsObject(source.Type): if expr.IsUnion(target.Type) { - return helpers, err + return nil } - walkMatches(source, target, func(srcMatt, _ *expr.MappedAttributeExpr, srcc, tgtc *expr.AttributeExpr, n string) { - if err != nil { - return - } - if other, err = collectHelpers(srcc, tgtc, srcMatt.IsRequired(n), false, ta, seen); err == nil { - helpers = append(helpers, other...) + var walkErr error + walkMatches(source, target, func(sourceMapped, _ *expr.MappedAttributeExpr, sourceChild, targetChild *expr.AttributeExpr, name string) { + if walkErr == nil { + if plan.hooks != nil && plan.hooks.FieldPairAttrs != nil { + sourceChild, targetChild = plan.hooks.FieldPairAttrs(sourceChild, targetChild) + } + walkErr = collect(sourceChild, targetChild, sourceMapped.IsRequired(name), false, location.objectField(name)) } }) + return walkErr } - return helpers, err + return nil } -// generateHelper generates the code that transforms instances of source into -// target. Both source and target must be user types. The caller -// (collectHelpers) guarantees no helper was generated yet for the pair. -func generateHelper(source, target *expr.AttributeExpr, req bool, ta *TransformAttrs, seen map[string]*TransformFunctionData) (*TransformFunctionData, error) { - name := TransformHelperName(source, target, ta) +// consume returns the next recursive call recorded by NewTransformPlan. +func (c *transformCallCursor) consume() transformCall { + if c.next >= len(c.calls) { + panic("transform render consumed more helper calls than the plan retained") // bug + } + call := c.calls[c.next] + c.next++ + return call +} - // When transforming into a user type defined in an external package, assume - // nested anonymous types (e.g., union sum types) belong to the same target - // package unless they explicitly specify a different location. Work on a - // copy of the context so the caller's context is never mutated. - if pkg := ta.TargetCtx.Pkg(target); pkg != "" && pkg != ta.TargetCtx.DefaultPkg { - tgtCtx := ta.TargetCtx.Dup() - tgtCtx.DefaultPkg = pkg - tgtCtx.SamePackageConversion = false - ta = &TransformAttrs{SourceCtx: ta.SourceCtx, TargetCtx: tgtCtx, Prefix: ta.Prefix, Hooks: ta.Hooks} +// complete reports whether Render skipped any recorded recursive calls. +func (c *transformCallCursor) complete(owner string) error { + if c.next != len(c.calls) { + return fmt.Errorf("%s rendered %d of %d retained helper calls", owner, c.next, len(c.calls)) } + return nil +} + +// enterTransformAttrs returns a copy that looks up fields and types beneath the +// supplied source and target. +func enterTransformAttrs(source, target *expr.AttributeExpr, attributes *TransformAttrs) *TransformAttrs { + entered := *attributes + entered.SourceCtx = attributes.SourceCtx.Enter(source) + entered.TargetCtx = attributes.TargetCtx.Enter(target) + return &entered +} - code, err := TransformAttribute(source, target, "v", "res", true, ta) +// generateTransformHelper writes one recursive conversion function selected by +// TransformPlan. +func generateTransformHelper(helper TransformHelper, ta *TransformAttrs) (*TransformFunctionData, error) { + code, err := TransformAttribute(helper.Source, helper.Target, "v", "res", true, ta) if err != nil { return nil, err } - if !req && !expr.IsPrimitive(source.Type) { - code = "if v == nil {\n\treturn nil\n}\n" + code - } tfd := &TransformFunctionData{ - Name: name, - ParamTypeRef: ta.SourceCtx.Scope.Ref(source, ta.SourceCtx.Pkg(source)), - ResultTypeRef: ta.TargetCtx.Scope.Ref(target, ta.TargetCtx.Pkg(target)), + ID: helper.ID, + Declaration: helper.Declaration, + Name: helper.Declaration.Name(), + ParamTypeRef: ta.SourceCtx.Scope.Ref(helper.Source, ta.SourceCtx.Pkg(helper.Source)), + ResultTypeRef: ta.TargetCtx.Scope.Ref(helper.Target, ta.TargetCtx.Pkg(helper.Target)), Code: code, } - seen[name] = tfd return tfd, nil } @@ -719,11 +1738,25 @@ func generateHelper(source, target *expr.AttributeExpr, req bool, ta *TransformA func walkMatches(source, target *expr.AttributeExpr, walker func(src, tgt *expr.MappedAttributeExpr, srcc, tgtc *expr.AttributeExpr, n string)) { srcMatt := expr.NewMappedAttributeExpr(source) tgtMatt := expr.NewMappedAttributeExpr(target) + srcFields := originalMappedFields(source) + tgtFields := originalMappedFields(target) srcObj := expr.AsObject(srcMatt.Type) tgtObj := expr.AsObject(tgtMatt.Type) for _, nat := range *srcObj { if att := tgtObj.Attribute(nat.Name); att != nil { - walker(srcMatt, tgtMatt, nat.Attribute, att, nat.Name) + walker(srcMatt, tgtMatt, srcFields[nat.Name], tgtFields[nat.Name], nat.Name) } } } + +// originalMappedFields returns each child under the name used for matching. +// The returned children are the values supplied by the caller, not copies. +func originalMappedFields(attribute *expr.AttributeExpr) map[string]*expr.AttributeExpr { + object := expr.AsObject(attribute.Type) + fields := make(map[string]*expr.AttributeExpr, len(*object)) + for _, named := range *object { + name := strings.SplitN(named.Name, ":", 2)[0] + fields[name] = named.Attribute + } + return fields +} diff --git a/codegen/go_transform_hooks.go b/codegen/go_transform_hooks.go index 16432a7f33..c6cf06f293 100644 --- a/codegen/go_transform_hooks.go +++ b/codegen/go_transform_hooks.go @@ -1,3 +1,6 @@ +// This file lets generators change the few conversion steps that differ from +// Goa's normal Go type conversion. The gRPC generator uses these functions for +// protobuf wrapper messages, fields, collections, unions, and nil checks. package codegen import ( @@ -7,37 +10,23 @@ import ( ) type ( - // TransformHooks are optional extension points consulted by the Go - // transform engine (GoTransformWithAttrs and the functions it drives). - // They let transport-specific generators—today the gRPC generator—alter - // well-defined aspects of the generated transformation code while - // sharing the engine driver (attribute walking, struct initialization, - // nil guards, default value handling and helper function collection). - // - // All fields are optional: a nil hook (or a nil Hooks pointer - // altogether) selects the engine default so that consumers which do not - // set hooks generate exactly the same code as before the hooks were - // introduced. + // TransformHooks lets a generator change specific parts of + // GoTransformWithAttrs. A nil function uses Goa's normal conversion. Hooks + // may inspect their expression arguments but must not mutate or retain them + // for later mutation. NewTransformPlan rejects a planning hook that changes + // its copied expressions. Render rejects later rendering mutations and caches + // each exact Render request, so hook state cannot produce different code when + // that request is repeated. TransformHooks struct { - // UnwrapPair adapts a source/target attribute pair before - // compatibility checks and code generation. It returns the - // attributes to use in place of src and tgt and a non-nil - // WrapDirective when one side referenced a synthetic wrapper - // message that was unwrapped (the gRPC generator wraps - // non-object protobuf message types in single-field messages). - // The engine applies the directive by initializing the wrapper - // and redirecting the source or target variable to the wrapper - // field. UnwrapPair is consulted by TransformAttribute, by - // transformObject for each matched field pair and by - // collectHelpers for each attribute pair it recurses into. + // UnwrapPair replaces a source and target before Goa checks or + // converts them. It also tells Goa whether generated code must + // create or read a protobuf wrapper message. UnwrapPair func(src, tgt *expr.AttributeExpr) (*expr.AttributeExpr, *expr.AttributeExpr, *WrapDirective) - // FieldPairAttrs normalizes a matched object field attribute - // pair before the engine generates the field transformation, - // the nil guard and the default value handling. The gRPC - // generator resolves primitive alias user types to their - // underlying primitive attribute. The hook runs before - // UnwrapPair when transforming object fields. + // FieldPairAttrs changes a matched pair after their field names + // are known and before their values are converted. The gRPC + // generator replaces primitive aliases with their primitive + // types. The hook runs before UnwrapPair. FieldPairAttrs func(src, tgt *expr.AttributeExpr) (*expr.AttributeExpr, *expr.AttributeExpr) // ConvertPrimitive returns the expression that initializes a @@ -48,8 +37,8 @@ type ( // returned expression must dereference srcVar when srcPtr is // true. ok must be false to use the engine default conversion. // The expression may be srcVar itself when no conversion is - // needed; the engine compares the result against srcVar to - // decide whether a conversion is taking place. + // needed. When ok is true, the engine uses the expression as + // returned. ConvertPrimitive func(src, tgt *expr.AttributeExpr, srcVar string, srcPtr, tgtPtr bool, ta *TransformAttrs) (string, bool) // TransformArray overrides the rendering of array @@ -65,26 +54,33 @@ type ( // TransformUnion overrides the rendering of union // transformations. srcParent and tgtParent are the attributes // of the object being transformed when the union is an object - // field and nil when the union is transformed directly: the - // gRPC generator derives the protoc-generated oneof wrapper - // type names from the parent message type name. + // field and nil when the union is transformed directly. A + // generator can use them when its union conversion depends on + // the enclosing object. TransformUnion func(source, target *expr.AttributeExpr, sourceVar, targetVar string, newVar bool, srcParent, tgtParent *expr.AttributeExpr, ta *TransformAttrs) (string, error) - // HelperNameAttrs normalizes a source/target attribute pair - // before the transform helper function name is computed. The - // gRPC generator strips struct:pkg:path metadata from the - // protobuf side because protoc-generated types ignore package - // overrides. - HelperNameAttrs func(src, tgt *expr.AttributeExpr) (*expr.AttributeExpr, *expr.AttributeExpr) + // PlanUnionHelpers lists the helper-name calls made by TransformUnion. + // It is called only when TransformUnion is set. Call record once for each + // source and target branch pair, in the same order that TransformUnion + // calls TransformHelperName. The shared planner writes each helper body + // and records any recursive calls it makes. A nil function means the + // custom union renderer does not call TransformHelperName. + PlanUnionHelpers func(source, target *expr.AttributeExpr, record func(source, target *expr.AttributeExpr)) + + // SameHelperDefinition reports whether two helper calls can use one + // function body. It may return true only when every transform hook will + // produce the same code for both source and target pairs. A nil function + // compares the complete retained attributes. + SameHelperDefinition func(firstSource, firstTarget, nextSource, nextTarget *expr.AttributeExpr) bool // GuardCondition returns the condition that guards the // transformation code of an object field, e.g. // "if p.Name != nil {\n". src is the (possibly normalized) // field attribute, srcVar the source field variable, required // reports whether the field is required and srcPtr whether the - // source field is pointer-backed. An empty condition + // source field uses a pointer. An empty condition // with ok true means the field transformation must not be - // guarded. ok must be false to use the engine default policy + // guarded. ok must be false to use Goa's normal check // (the gRPC generator always guards non-primitives because // proto3 message fields are always nilable). GuardCondition func(src *expr.AttributeExpr, srcVar string, required, srcPtr bool) (string, bool) @@ -113,8 +109,8 @@ type ( InlineCompositeElems bool } - // WrapDirective describes how the engine must account for a synthetic - // wrapper message unwrapped by the UnwrapPair hook. + // WrapDirective tells TransformAttribute how to create or read a protobuf + // wrapper message removed by UnwrapPair. WrapDirective struct { // WrapTarget is true when the target attribute was the // wrapper: the engine initializes the wrapper value and @@ -122,19 +118,19 @@ type ( // the source attribute was the wrapper and the engine reads // the value being transformed from the wrapper field. WrapTarget bool - // InitTypeName is the Go type name used to initialize the - // wrapper when WrapTarget is true. - InitTypeName string + // Target is the wrapper type initialized when WrapTarget is true. + // The transform resolves its Go name after package names are fixed. + Target *expr.AttributeExpr // FieldName is the Go name of the wrapper field holding the // wrapped value. FieldName string } ) -// apply rewrites the transformation variables per the directive and returns -// the code that initializes the wrapper when the target is wrapped. A nil -// directive leaves the variables untouched and returns an empty string. -func (d *WrapDirective) apply(sourceVar, targetVar *string, newVar *bool) string { +// apply changes the source or target variable to use the wrapper field. It +// returns the code that creates the wrapper when the target uses one. A nil +// WrapDirective leaves the variables unchanged and returns an empty string. +func (d *WrapDirective) apply(sourceVar, targetVar *string, newVar *bool, attrs *TransformAttrs) string { if d == nil { return "" } @@ -146,7 +142,8 @@ func (d *WrapDirective) apply(sourceVar, targetVar *string, newVar *bool) string if *newVar { assign = ":=" } - code := fmt.Sprintf("%s %s &%s{}\n", *targetVar, assign, d.InitTypeName) + name := attrs.TargetCtx.Scope.Name(d.Target, attrs.TargetCtx.Pkg(d.Target), attrs.TargetCtx.Pointer, attrs.TargetCtx.UseDefault) + code := fmt.Sprintf("%s %s &%s{}\n", *targetVar, assign, name) *targetVar += "." + d.FieldName *newVar = false return code diff --git a/codegen/go_transform_test.go b/codegen/go_transform_test.go index 515c34cbbc..bd05de74ab 100644 --- a/codegen/go_transform_test.go +++ b/codegen/go_transform_test.go @@ -1,6 +1,14 @@ +// This file verifies Go transformations across primitive, composite, named, +// union, service, and transport-owned attribute contexts. package codegen import ( + "encoding/json" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" "testing" "github.com/stretchr/testify/require" @@ -10,6 +18,33 @@ import ( "goa.design/goa/v3/expr" ) +type ( + // transformOwnerAttributor records each nested type name selected while a + // test plans and writes a conversion. + transformOwnerAttributor struct { + prefix string + owner string + scope *NameScope + entered *[]string + } + + // transformIdentityAttributor records the exact attributes passed to field + // name lookups while a plan renders its copied expressions. + transformIdentityAttributor struct { + Attributor + fields *[]*expr.AttributeExpr + } + + // transformTestOrderFactory combines a test conversion key with each exact + // helper location supplied by the registry. + transformTestOrderFactory string +) + +// order returns one stable package name order for a helper occurrence. +func (f transformTestOrderFactory) order(location TransformHelperDefinitionLocation) PackageNameOrder { + return testNameOrder{value: string(f) + location.encoded} +} + func TestGoTransform(t *testing.T) { root := RunDSL(t, testdata.TestTypesDSL) var ( @@ -255,3 +290,2340 @@ func TestGoTransformUnionAcrossTransportBoundary(t *testing.T) { require.NotContains(t, transportToService, "scopeValue") require.NotContains(t, transportToService, "target.Scope = &") } + +// TestGoTransformUnionKeepsNilSelectedBranch verifies that conversion leaves a +// selected nil branch for the destination validator to report. +func TestGoTransformUnionKeepsNilSelectedBranch(t *testing.T) { + details := goTypeTestUserType("Details", &expr.Object{ + {Name: "name", Attribute: &expr.AttributeExpr{Type: expr.String}}, + }) + union := &expr.Union{ + TypeName: "State", + Values: []*expr.NamedAttributeExpr{ + {Name: "details", Attribute: &expr.AttributeExpr{Type: details}}, + {Name: "empty", Attribute: &expr.AttributeExpr{Type: expr.Empty}}, + {Name: "aliases", Attribute: &expr.AttributeExpr{Type: &expr.Array{ + ElemType: &expr.AttributeExpr{Type: expr.String}, + }}}, + {Name: "labels", Attribute: &expr.AttributeExpr{Type: &expr.Map{ + KeyType: &expr.AttributeExpr{Type: expr.String}, + ElemType: &expr.AttributeExpr{Type: expr.String}, + }}}, + {Name: "blob", Attribute: &expr.AttributeExpr{Type: expr.Bytes}}, + {Name: "anything", Attribute: &expr.AttributeExpr{Type: expr.Any}}, + {Name: "name", Attribute: &expr.AttributeExpr{Type: expr.String}}, + }, + } + context := NewAttributeContext(false, false, true, "", NewNameScope()) + + code, _, err := GoTransform( + &expr.AttributeExpr{Type: union}, + &expr.AttributeExpr{Type: union}, + "source", + "target", + context, + context, + "", + true, + ) + require.NoError(t, err) + code = FormatTestCode(t, "package foo\nfunc transform(){\n"+code+"}") + testutil.AssertGo(t, "testdata/golden/go_transform_union_nil_branch.go.golden", code) +} + +// TestGoTransformRequiredPrimitiveArrayElements verifies that JSON presence +// pointers are removed after validation and added only when encoding that form. +func TestGoTransformRequiredPrimitiveArrayElements(t *testing.T) { + alias := goTypeTestUserType("Alias", expr.String) + array := &expr.AttributeExpr{Type: &expr.Array{ + ElemType: &expr.AttributeExpr{Type: alias}, + NonNullableElems: true, + }} + scope := NewNameScope() + service := NewAttributeContext(false, false, true, "", scope) + jsonBody := service.Dup() + jsonBody.ArrayElementPointer = true + + decode, _, err := GoTransform(array, array, "source", "target", jsonBody, service, "", true) + require.NoError(t, err) + require.Contains(t, decode, "target := make([]Alias, len(source))") + require.Contains(t, decode, "target[i] = *val") + + encode, _, err := GoTransform(array, array, "source", "target", service, jsonBody, "", true) + require.NoError(t, err) + require.Contains(t, encode, "target := make([]*Alias, len(source))") + require.Contains(t, encode, "var transformed Alias") + require.Contains(t, encode, "target[i] = &transformed") + + ordinary := expr.DupAtt(array) + expr.AsArray(ordinary.Type).NonNullableElems = false + unchanged, _, err := GoTransform(ordinary, ordinary, "source", "target", jsonBody, service, "", true) + require.NoError(t, err) + require.NotContains(t, unchanged, "*val") +} + +// TestGoTransformUsesDesignNilabilityForCustomTypes verifies that default +// handling does not infer comparability from a generated Go type spelling. +func TestGoTransformUsesDesignNilabilityForCustomTypes(t *testing.T) { + raw := &expr.AttributeExpr{ + Type: expr.String, + DefaultValue: json.RawMessage("foo"), + Meta: expr.MetaExpr{ + "struct:field:type": {"json.RawMessage", "encoding/json", "json"}, + }, + } + defaults := goTypeTestUserType("WithRaw", &expr.Object{ + {Name: "raw", Attribute: raw}, + }) + context := NewAttributeContext(false, false, true, "", NewNameScope()) + + code, _, err := GoTransform( + &expr.AttributeExpr{Type: defaults}, + &expr.AttributeExpr{Type: defaults}, + "source", + "target", + context, + context, + "", + true, + ) + require.NoError(t, err) + require.Contains(t, code, "if target.Raw == nil") + require.NotContains(t, code, "var zero json.RawMessage") + compileTransformSource(t, `package transformtest + +import "encoding/json" + +type WithRaw struct { + Raw json.RawMessage +} + +func transform(source *WithRaw) *WithRaw { +`+code+` + return target +} +`) +} + +// TestGoTransformDefaultUsesFinalCustomTypeImportAlias verifies that the +// zero-value check uses the same package name as the generated import. +func TestGoTransformDefaultUsesFinalCustomTypeImportAlias(t *testing.T) { + const customPath = "example.com/custom/strconv" + generation := mustTestGeneration(t, "generated.local/gen", nil) + pkg := mustClaimTestPackage(t, generation, "generated.local/gen/service") + field := &expr.AttributeExpr{ + Type: expr.String, + DefaultValue: "ready", + Meta: expr.MetaExpr{ + "struct:field:type": {"strconv.Token", customPath, "strconv"}, + }, + } + withDefault := goTypeTestUserType("WithDefault", &expr.Object{ + {Name: "token", Attribute: field}, + }) + _, err := pkg.DeclareUserType(withDefault) + require.NoError(t, err) + require.NoError(t, pkg.DeclareImport(NewImport("strconv", customPath))) + require.NoError(t, pkg.RequireImport(SimpleImport("strconv"))) + require.NoError(t, generation.Freeze()) + require.Equal(t, "strconv2", pkg.ImportName(customPath)) + context := NewAttributeContext(false, false, true, "", pkg.Scope()) + + code, _, err := GoTransform( + &expr.AttributeExpr{Type: withDefault}, + &expr.AttributeExpr{Type: withDefault}, + "source", + "target", + context, + context, + "", + true, + ) + require.NoError(t, err) + require.Contains(t, code, "var zero strconv2.Token") + require.NotContains(t, code, "var zero strconv.Token") +} + +// TestGoTransformReturnsFirstDefaultError verifies that a later field cannot +// hide an invalid earlier default while the generator walks an object. +func TestGoTransformReturnsFirstDefaultError(t *testing.T) { + invalid := &expr.AttributeExpr{ + Type: expr.String, + DefaultValue: json.RawMessage("foo"), + Meta: expr.MetaExpr{ + "struct:field:type": {"json.RawMessage", "example.com/not-json", "json"}, + }, + } + valid := &expr.AttributeExpr{Type: expr.String, DefaultValue: "ready"} + defaults := goTypeTestUserType("Defaults", &expr.Object{ + {Name: "invalid", Attribute: invalid}, + {Name: "valid", Attribute: valid}, + }) + context := NewAttributeContext(false, false, true, "", NewNameScope()) + + _, _, err := GoTransform( + &expr.AttributeExpr{Type: defaults}, + &expr.AttributeExpr{Type: defaults}, + "source", + "target", + context, + context, + "", + true, + ) + require.EqualError(t, err, `render Go value for string: default for custom Go type "json.RawMessage" has Go type json.RawMessage`) +} + +// TestGoTransformArrayLoopNameUsesNestingDepth verifies that brackets in a +// caller expression do not change the generated loop variable. +func TestGoTransformArrayLoopNameUsesNestingDepth(t *testing.T) { + array := &expr.AttributeExpr{Type: &expr.Array{ + ElemType: &expr.AttributeExpr{Type: expr.String}, + }} + context := NewAttributeContext(false, false, true, "", NewNameScope()) + + code, _, err := GoTransform(array, array, "source", "target[index]", context, context, "", false) + require.NoError(t, err) + require.Contains(t, code, "for i, val := range source") + require.NotContains(t, code, "for j, val := range source") +} + +// TestGoTransformUnionTemporaryUsesNestingDepth verifies that a caller's +// destination spelling does not select the local used for a union branch. +func TestGoTransformUnionTemporaryUsesNestingDepth(t *testing.T) { + source := &expr.AttributeExpr{Type: &expr.Union{ + TypeName: "SourceChoice", + Values: []*expr.NamedAttributeExpr{ + {Name: "name", Attribute: &expr.AttributeExpr{Type: expr.String}}, + }, + }} + target := &expr.AttributeExpr{Type: &expr.Union{ + TypeName: "TargetChoice", + Values: []*expr.NamedAttributeExpr{ + {Name: "name", Attribute: &expr.AttributeExpr{Type: expr.String}}, + }, + }} + context := NewAttributeContext(false, false, true, "", NewNameScope()) + + code, _, err := GoTransform(source, target, "source", "target.Selected", context, context, "", false) + require.NoError(t, err) + require.Contains(t, code, "obj := actual") + require.NotContains(t, code, "tmp := actual") + + nested, err := transformUnion(source, target, "source", "target.Selected", false, &TransformAttrs{ + SourceCtx: context, + TargetCtx: context, + unionDepth: 1, + }) + require.NoError(t, err) + require.Contains(t, nested, "tmp2 := actual") + require.NotContains(t, nested, "obj := actual") +} + +func TestGoTransformEntersSourceAndTargetOwnersIndependently(t *testing.T) { + source := transformOwnerTestType("SourceEnvelope", "SourceChoice", "source/types") + target := transformOwnerTestType("TargetEnvelope", "TargetSelection", "target/models") + sourceOwner := newTransformOwnerAttributor("source") + targetOwner := newTransformOwnerAttributor("target") + + _, helpers, err := GoTransform( + &expr.AttributeExpr{Type: source}, + &expr.AttributeExpr{Type: target}, + "source", + "target", + &AttributeContext{UseDefault: true, Scope: sourceOwner}, + &AttributeContext{UseDefault: true, Scope: targetOwner}, + "", + true, + ) + require.NoError(t, err) + require.NotEmpty(t, helpers) + require.Contains(t, helpers[0].ParamTypeRef, "sourceSourceChoiceContainer.SourceChoiceContainer") + require.Contains(t, helpers[0].ResultTypeRef, "targetTargetSelectionContainer.TargetSelectionContainer") + require.Contains(t, *sourceOwner.entered, "sourceSourceEnvelope") + require.Contains(t, *sourceOwner.entered, "sourceSourceChoiceContainer") + require.Contains(t, *targetOwner.entered, "targetTargetEnvelope") + require.Contains(t, *targetOwner.entered, "targetTargetSelectionContainer") + + reverseSource := newTransformOwnerAttributor("source") + reverseTarget := newTransformOwnerAttributor("target") + _, reverseHelpers, err := GoTransform( + &expr.AttributeExpr{Type: target}, + &expr.AttributeExpr{Type: source}, + "source", + "target", + &AttributeContext{UseDefault: true, Scope: reverseTarget}, + &AttributeContext{UseDefault: true, Scope: reverseSource}, + "", + true, + ) + require.NoError(t, err) + require.NotEmpty(t, reverseHelpers) + require.Contains(t, reverseHelpers[0].ParamTypeRef, "targetTargetSelectionContainer.TargetSelectionContainer") + require.Contains(t, reverseHelpers[0].ResultTypeRef, "sourceSourceChoiceContainer.SourceChoiceContainer") +} + +// TestGoTransformEntersArrayFieldOwners verifies that a named array element +// is resolved from the array field rather than the object that contains it. +func TestGoTransformEntersArrayFieldOwners(t *testing.T) { + sourceComponent := goTypeTestUserType("SourceComponent", &expr.Object{ + {Name: "name", Attribute: &expr.AttributeExpr{Type: expr.String}}, + }) + targetComponent := goTypeTestUserType("TargetComponent", &expr.Object{ + {Name: "name", Attribute: &expr.AttributeExpr{Type: expr.String}}, + }) + source := goTypeTestUserType("SourceEnvelope", &expr.Object{ + {Name: "components", Attribute: &expr.AttributeExpr{Type: &expr.Array{ + ElemType: &expr.AttributeExpr{Type: sourceComponent}, + }}}, + }) + target := goTypeTestUserType("TargetEnvelope", &expr.Object{ + {Name: "components", Attribute: &expr.AttributeExpr{Type: &expr.Array{ + ElemType: &expr.AttributeExpr{Type: targetComponent}, + }}}, + }) + sourceOwner := newTransformOwnerAttributor("source") + targetOwner := newTransformOwnerAttributor("target") + + code, _, err := GoTransform( + &expr.AttributeExpr{Type: source}, + &expr.AttributeExpr{Type: target}, + "source", + "target", + &AttributeContext{UseDefault: true, Scope: sourceOwner}, + &AttributeContext{UseDefault: true, Scope: targetOwner}, + "", + true, + ) + require.NoError(t, err) + require.Contains(t, code, "make([]*targetArray.TargetComponent") + require.Contains(t, *sourceOwner.entered, "sourceArray") + require.Contains(t, *targetOwner.entered, "targetArray") +} + +func TestTransformPlanUsesRetainedHelperIdentityDuringRender(t *testing.T) { + root := RunDSL(t, testdata.TestTypesDSL) + deep := root.UserType("Deep") + plan, err := NewTransformPlan( + &expr.AttributeExpr{Type: deep}, + &expr.AttributeExpr{Type: deep}, + "", + nil, + ) + require.NoError(t, err) + + planned := plan.Helpers() + require.Len(t, planned, 2) + declarations := make(map[TransformHelperID]*NameDeclaration, len(planned)) + plannedByID := make(map[TransformHelperID]TransformHelper, len(planned)) + packageCatalog := newGeneratedPackage("test", "example.com/test", "gen") + for index, helper := range planned { + declaration := NewExactName(NameFunction, fmt.Sprintf("canonicalHelper%d", index+1)) + require.NoError(t, packageCatalog.DeclareName(declaration)) + declarations[helper.ID] = declaration + plannedByID[helper.ID] = helper + require.NoError(t, plan.BindHelperDeclaration(helper.ID, declaration)) + } + require.NoError(t, packageCatalog.freeze()) + + attrs := &TransformAttrs{ + SourceCtx: NewAttributeContext(false, false, true, "", NewNameScope()), + TargetCtx: NewAttributeContext(false, false, true, "", NewNameScope()), + } + require.NoError(t, plan.BindContexts(attrs.SourceCtx, attrs.TargetCtx)) + code, helpers, err := plan.Render("source", "target", true) + require.NoError(t, err) + require.Len(t, helpers, len(planned)) + rendered := code + for index, helper := range helpers { + require.Equal(t, planned[index].ID, helper.ID) + require.Same(t, declarations[helper.ID], helper.Declaration) + require.NotSame(t, plannedByID[helper.ID].Source, plan.Helpers()[index].Source) + require.NotSame(t, plannedByID[helper.ID].Target, plan.Helpers()[index].Target) + require.Equal(t, plannedByID[helper.ID].Source.Type.Name(), plan.Helpers()[index].Source.Type.Name()) + require.Equal(t, plannedByID[helper.ID].Target.Type.Name(), plan.Helpers()[index].Target.Type.Name()) + require.Equal(t, helper.Declaration.Name(), helper.Name) + rendered += helper.Code + } + for _, helper := range helpers { + require.Contains(t, rendered, helper.Declaration.Name()) + } +} + +func TestTransformPlanKeepsDistinctCopiesWithOneOrigin(t *testing.T) { + sourceOrigin := transformObjectAttribute("SourceNode", true).Type.(expr.UserType) + targetOrigin := transformObjectAttribute("TargetNode", true).Type.(expr.UserType) + sourceOuter := sourceOrigin.Dup(nil) + sourceInner := sourceOrigin.Dup(nil) + targetOuter := targetOrigin.Dup(nil) + targetInner := targetOrigin.Dup(nil) + sourceInner.SetAttribute(&expr.AttributeExpr{Type: &expr.Object{ + {Name: "value", Attribute: &expr.AttributeExpr{Type: expr.String}}, + }}) + targetInner.SetAttribute(&expr.AttributeExpr{Type: &expr.Object{ + {Name: "value", Attribute: &expr.AttributeExpr{Type: expr.String}}, + }}) + sourceOuter.SetAttribute(&expr.AttributeExpr{Type: &expr.Object{ + {Name: "child", Attribute: &expr.AttributeExpr{Type: sourceInner}}, + }}) + targetOuter.SetAttribute(&expr.AttributeExpr{Type: &expr.Object{ + {Name: "child", Attribute: &expr.AttributeExpr{Type: targetInner}}, + }}) + + plan, err := NewTransformPlan( + &expr.AttributeExpr{Type: &expr.Object{ + {Name: "root", Attribute: &expr.AttributeExpr{Type: sourceOuter}}, + }}, + &expr.AttributeExpr{Type: &expr.Object{ + {Name: "root", Attribute: &expr.AttributeExpr{Type: targetOuter}}, + }}, + "", + nil, + ) + require.NoError(t, err) + require.Len(t, plan.Helpers(), 2) + require.NotSame(t, plan.Helpers()[0].Source.Type, plan.Helpers()[1].Source.Type) + require.NotSame(t, plan.Helpers()[0].Target.Type, plan.Helpers()[1].Target.Type) + + code, definitions := renderTransformPlan(t, plan) + require.Len(t, definitions, 2) + require.Contains(t, code, definitions[0].Declaration.Name()+"(source.Root)") + require.Contains(t, definitions[0].Code, definitions[1].Declaration.Name()+"(v.Child)") +} + +func TestTransformPlanClosesExactRecursiveCycle(t *testing.T) { + sourceNode := &expr.UserTypeExpr{TypeName: "SourceNode"} + targetNode := &expr.UserTypeExpr{TypeName: "TargetNode"} + sourceNode.SetAttribute(&expr.AttributeExpr{Type: &expr.Object{ + {Name: "next", Attribute: &expr.AttributeExpr{Type: sourceNode}}, + }}) + targetNode.SetAttribute(&expr.AttributeExpr{Type: &expr.Object{ + {Name: "next", Attribute: &expr.AttributeExpr{Type: targetNode}}, + }}) + + plan, err := NewTransformPlan( + &expr.AttributeExpr{Type: &expr.Object{ + {Name: "root", Attribute: &expr.AttributeExpr{Type: sourceNode}}, + }}, + &expr.AttributeExpr{Type: &expr.Object{ + {Name: "root", Attribute: &expr.AttributeExpr{Type: targetNode}}, + }}, + "", + nil, + ) + require.NoError(t, err) + require.Len(t, plan.Helpers(), 1) + + code, definitions := renderTransformPlan(t, plan) + require.Len(t, definitions, 1) + require.Contains(t, code, definitions[0].Declaration.Name()+"(source.Root)") + require.Contains(t, definitions[0].Code, definitions[0].Declaration.Name()+"(v.Next)") +} + +func TestTransformPlanCopiesCallerExpressions(t *testing.T) { + sourceObject := &expr.Object{ + {Name: "value", Attribute: &expr.AttributeExpr{Type: expr.String}}, + } + targetObject := &expr.Object{ + {Name: "value", Attribute: &expr.AttributeExpr{Type: expr.String}}, + } + sourceType := &expr.UserTypeExpr{ + TypeName: "Source", + AttributeExpr: &expr.AttributeExpr{Type: sourceObject}, + } + targetType := &expr.UserTypeExpr{ + TypeName: "Target", + AttributeExpr: &expr.AttributeExpr{Type: targetObject}, + } + plan, err := NewTransformPlan( + &expr.AttributeExpr{Type: sourceType}, + &expr.AttributeExpr{Type: targetType}, + "", + nil, + ) + require.NoError(t, err) + sourceObject.Set("late", &expr.AttributeExpr{Type: expr.String}) + targetObject.Set("late", &expr.AttributeExpr{Type: expr.String}) + + code, definitions := renderTransformPlan(t, plan) + require.Empty(t, definitions) + require.Contains(t, code, "Value: source.Value") + require.NotContains(t, code, "Late") +} + +func TestTransformPlanCopiesTypedCollectionDefaults(t *testing.T) { + tests := []struct { + name string + dataType expr.DataType + defaultValue any + planned string + changed string + }{ + { + name: "slice", + dataType: &expr.Array{ElemType: &expr.AttributeExpr{Type: expr.String}}, + defaultValue: []string{"planned"}, + planned: `[]string{"planned"}`, + changed: `[]string{"changed"}`, + }, + { + name: "string-keyed-map", + dataType: &expr.Map{ + KeyType: &expr.AttributeExpr{Type: expr.String}, + ElemType: &expr.AttributeExpr{Type: expr.Int}, + }, + defaultValue: map[string]int{"value": 1}, + planned: `map[string]int{"value":1}`, + changed: `map[string]int{"value":2}`, + }, + { + name: "integer-keyed-map", + dataType: &expr.Map{ + KeyType: &expr.AttributeExpr{Type: expr.Int}, + ElemType: &expr.AttributeExpr{Type: expr.String}, + }, + defaultValue: map[int]string{1: "planned"}, + planned: `map[int]string{1:"planned"}`, + changed: `map[int]string{1:"changed"}`, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + sourceField := &expr.AttributeExpr{Type: test.dataType} + targetField := &expr.AttributeExpr{Type: test.dataType, DefaultValue: test.defaultValue} + source := &expr.AttributeExpr{Type: &expr.UserTypeExpr{ + TypeName: "Source", + AttributeExpr: &expr.AttributeExpr{Type: &expr.Object{ + {Name: "value", Attribute: sourceField}, + }}, + }} + target := &expr.AttributeExpr{Type: &expr.UserTypeExpr{ + TypeName: "Target", + AttributeExpr: &expr.AttributeExpr{Type: &expr.Object{ + {Name: "value", Attribute: targetField}, + }}, + }} + plan, err := NewTransformPlan(source, target, "", nil) + require.NoError(t, err) + + switch value := test.defaultValue.(type) { + case []string: + value[0] = "changed" + case map[string]int: + value["value"] = 2 + case map[int]string: + value[1] = "changed" + default: + t.Fatalf("missing mutation for %T", test.defaultValue) + } + code, definitions := renderTransformPlan(t, plan) + require.Empty(t, definitions) + require.Contains(t, code, test.planned) + require.NotContains(t, code, test.changed) + }) + } +} + +func TestTransformPlanNameLookupsUseOriginalAttributes(t *testing.T) { + sourceField := &expr.AttributeExpr{Type: expr.String} + targetField := &expr.AttributeExpr{Type: expr.String} + source := &expr.AttributeExpr{Type: &expr.Object{ + {Name: "value", Attribute: sourceField}, + }} + target := &expr.AttributeExpr{Type: &expr.Object{ + {Name: "value", Attribute: targetField}, + }} + plan, err := NewTransformPlan(source, target, "", nil) + require.NoError(t, err) + + var sourceFields, targetFields []*expr.AttributeExpr + sourceContext := NewAttributeContext(false, false, true, "", NewNameScope()) + sourceContext.Scope = &transformIdentityAttributor{ + Attributor: sourceContext.Scope, + fields: &sourceFields, + } + targetContext := NewAttributeContext(false, false, true, "", NewNameScope()) + targetContext.Scope = &transformIdentityAttributor{ + Attributor: targetContext.Scope, + fields: &targetFields, + } + require.NoError(t, plan.BindContexts(sourceContext, targetContext)) + _, _, err = plan.Render("source", "target", true) + require.NoError(t, err) + require.NotEmpty(t, sourceFields) + require.NotEmpty(t, targetFields) + for _, field := range sourceFields { + require.Same(t, sourceField, field) + } + for _, field := range targetFields { + require.Same(t, targetField, field) + } +} + +func TestTransformPlanCapturesStructuralHookChoices(t *testing.T) { + attribute := &expr.AttributeExpr{Type: &expr.Object{ + {Name: "value", Attribute: &expr.AttributeExpr{Type: expr.String}}, + }} + var unwrapCalls, fieldCalls int + hooks := &TransformHooks{ + UnwrapPair: func(source, target *expr.AttributeExpr) (*expr.AttributeExpr, *expr.AttributeExpr, *WrapDirective) { + unwrapCalls++ + return source, target, nil + }, + FieldPairAttrs: func(source, target *expr.AttributeExpr) (*expr.AttributeExpr, *expr.AttributeExpr) { + fieldCalls++ + return source, target + }, + } + plan, err := NewTransformPlan(attribute, attribute, "", hooks) + require.NoError(t, err) + plannedUnwrapCalls, plannedFieldCalls := unwrapCalls, fieldCalls + require.Positive(t, plannedUnwrapCalls) + require.Positive(t, plannedFieldCalls) + + code, definitions := renderTransformPlan(t, plan) + require.Empty(t, definitions) + require.Contains(t, code, "Value: source.Value") + require.Equal(t, plannedUnwrapCalls, unwrapCalls) + require.Equal(t, plannedFieldCalls, fieldCalls) +} + +// TestTransformPrimitiveUsesHandledHookExpression checks that a hook may +// intentionally return the source expression without triggering Goa's normal +// type conversion. +func TestTransformPrimitiveUsesHandledHookExpression(t *testing.T) { + source := &expr.AttributeExpr{Type: &expr.UserTypeExpr{ + TypeName: "SourceAlias", + AttributeExpr: &expr.AttributeExpr{Type: expr.String}, + }} + target := &expr.AttributeExpr{Type: &expr.UserTypeExpr{ + TypeName: "TargetAlias", + AttributeExpr: &expr.AttributeExpr{Type: expr.String}, + }} + context := NewAttributeContext(false, false, false, "", NewNameScope()) + attributes := &TransformAttrs{ + SourceCtx: context, + TargetCtx: context, + Hooks: &TransformHooks{ + ConvertPrimitive: func(_, _ *expr.AttributeExpr, sourceVar string, _, _ bool, _ *TransformAttrs) (string, bool) { + return sourceVar, true + }, + }, + } + + generated, err := TransformAttribute(source, target, "source", "target", true, attributes) + require.NoError(t, err) + require.Equal(t, "target := source\n", generated) +} + +// TestTransformAttributeDerivesWrappedPrimitivePointers checks that wrapper +// conversion follows the pointer layout defined by each type context. +func TestTransformAttributeDerivesWrappedPrimitivePointers(t *testing.T) { + wrapper := &expr.UserTypeExpr{ + TypeName: "PrimitiveWrapper", + AttributeExpr: &expr.AttributeExpr{Type: &expr.Object{ + {Name: "field", Attribute: &expr.AttributeExpr{Type: expr.String}}, + }}, + } + wrapperAttribute := &expr.AttributeExpr{Type: wrapper} + primitiveAttribute := &expr.AttributeExpr{Type: expr.String} + valueContext := NewAttributeContext(false, false, false, "", NewNameScope()) + pointerContext := NewAttributeContext(true, false, false, "", NewNameScope()) + + t.Run("target wrapper", func(t *testing.T) { + attributes := &TransformAttrs{ + SourceCtx: valueContext, + TargetCtx: pointerContext, + Hooks: &TransformHooks{UnwrapPair: func(source, target *expr.AttributeExpr) (*expr.AttributeExpr, *expr.AttributeExpr, *WrapDirective) { + return source, expr.AsObject(target.Type).Attribute("field"), &WrapDirective{ + WrapTarget: true, + Target: target, + FieldName: "Field", + } + }}, + } + + generated, err := TransformAttribute(primitiveAttribute, wrapperAttribute, "source", "target", true, attributes) + require.NoError(t, err) + require.Equal(t, "target := &PrimitiveWrapper{}\ntarget.Field = new(string)\n*target.Field = source\n", generated) + }) + + t.Run("source wrapper", func(t *testing.T) { + attributes := &TransformAttrs{ + SourceCtx: pointerContext, + TargetCtx: valueContext, + Hooks: &TransformHooks{UnwrapPair: func(source, target *expr.AttributeExpr) (*expr.AttributeExpr, *expr.AttributeExpr, *WrapDirective) { + return expr.AsObject(source.Type).Attribute("field"), target, &WrapDirective{FieldName: "Field"} + }}, + } + + generated, err := TransformAttribute(wrapperAttribute, primitiveAttribute, "source", "target", true, attributes) + require.NoError(t, err) + require.Equal(t, "target := *source.Field\n", generated) + }) +} + +func TestTransformPlanRejectsPlanningUnwrapPairMutation(t *testing.T) { + sourceField := &expr.AttributeExpr{Type: expr.String, DefaultValue: "before"} + targetField := &expr.AttributeExpr{Type: expr.String} + source := &expr.AttributeExpr{Type: &expr.Object{{Name: "value", Attribute: sourceField}}} + target := &expr.AttributeExpr{Type: &expr.Object{{Name: "value", Attribute: targetField}}} + + _, err := NewTransformPlan(source, target, "", &TransformHooks{ + UnwrapPair: func(source, target *expr.AttributeExpr) (*expr.AttributeExpr, *expr.AttributeExpr, *WrapDirective) { + if object := expr.AsObject(source.Type); object != nil { + object.Attribute("value").DefaultValue = "after" + } + return source, target, nil + }, + }) + + require.EqualError(t, err, "transform planning hook UnwrapPair changed the retained plan") +} + +func TestTransformPlanRejectsPlanningFieldPairMutation(t *testing.T) { + sourceField := &expr.AttributeExpr{Type: expr.String} + targetField := &expr.AttributeExpr{Type: expr.String} + source := &expr.AttributeExpr{Type: &expr.Object{{Name: "value", Attribute: sourceField}}} + target := &expr.AttributeExpr{Type: &expr.Object{{Name: "value", Attribute: targetField}}} + + _, err := NewTransformPlan(source, target, "", &TransformHooks{ + FieldPairAttrs: func(source, target *expr.AttributeExpr) (*expr.AttributeExpr, *expr.AttributeExpr) { + target.Meta = expr.MetaExpr{"mutated": {"yes"}} + return source, target + }, + }) + + require.EqualError(t, err, "transform planning hook FieldPairAttrs changed the retained plan") +} + +func TestTransformPlanRejectsPlanningUnionHelperMutation(t *testing.T) { + sourceBranch := &expr.AttributeExpr{Type: expr.String} + targetBranch := &expr.AttributeExpr{Type: expr.String} + source := &expr.AttributeExpr{Type: &expr.Union{Values: []*expr.NamedAttributeExpr{{Name: "value", Attribute: sourceBranch}}}} + target := &expr.AttributeExpr{Type: &expr.Union{Values: []*expr.NamedAttributeExpr{{Name: "value", Attribute: targetBranch}}}} + + _, err := NewTransformPlan(source, target, "", &TransformHooks{ + TransformUnion: func(_ *expr.AttributeExpr, _ *expr.AttributeExpr, _, _ string, _ bool, _, _ *expr.AttributeExpr, _ *TransformAttrs) (string, error) { + return "", nil + }, + PlanUnionHelpers: func(source, target *expr.AttributeExpr, _ func(*expr.AttributeExpr, *expr.AttributeExpr)) { + expr.AsUnion(source.Type).Values[0].Attribute.DefaultValue = "after" + expr.AsUnion(target.Type).Values[0].Attribute.Meta = expr.MetaExpr{"mutated": {"yes"}} + }, + }) + + require.EqualError(t, err, "transform planning hook PlanUnionHelpers changed the retained plan") +} + +func TestTransformPlanRejectsDerivedUnionHelperBranches(t *testing.T) { + sourceType := transformObjectAttribute("SourceBranch", true).Type + targetType := transformObjectAttribute("TargetBranch", true).Type + source := &expr.AttributeExpr{Type: &expr.Union{Values: []*expr.NamedAttributeExpr{ + {Name: "value", Attribute: &expr.AttributeExpr{Type: sourceType}}, + }}} + target := &expr.AttributeExpr{Type: &expr.Union{Values: []*expr.NamedAttributeExpr{ + {Name: "value", Attribute: &expr.AttributeExpr{Type: targetType}}, + }}} + + _, err := NewTransformPlan(source, target, "", &TransformHooks{ + TransformUnion: func(_ *expr.AttributeExpr, _ *expr.AttributeExpr, _, _ string, _ bool, _, _ *expr.AttributeExpr, _ *TransformAttrs) (string, error) { + return "", nil + }, + PlanUnionHelpers: func(source, target *expr.AttributeExpr, record func(*expr.AttributeExpr, *expr.AttributeExpr)) { + record( + expr.DupAtt(expr.AsUnion(source.Type).Values[0].Attribute), + expr.DupAtt(expr.AsUnion(target.Type).Values[0].Attribute), + ) + }, + }) + require.EqualError(t, err, "custom union transform helper must use retained authored branch attributes") +} + +func TestGoTransformWithAttrsCallsStructuralHookOnce(t *testing.T) { + attribute := &expr.AttributeExpr{Type: expr.String} + var calls int + attrs := &TransformAttrs{ + SourceCtx: NewAttributeContext(false, false, true, "", NewNameScope()), + TargetCtx: NewAttributeContext(false, false, true, "", NewNameScope()), + Hooks: &TransformHooks{ + UnwrapPair: func(source, target *expr.AttributeExpr) (*expr.AttributeExpr, *expr.AttributeExpr, *WrapDirective) { + calls++ + return source, target, nil + }, + }, + } + + code, helpers, err := GoTransformWithAttrs(attribute, attribute, "source", "target", attrs, true) + require.NoError(t, err) + require.Empty(t, helpers) + require.Equal(t, "target := source", code) + require.Equal(t, 1, calls) +} + +func TestGoTransformWithAttrsSharesRequiredAndOptionalHelper(t *testing.T) { + root := RunDSL(t, testdata.TestTypesDSL) + recursive := root.UserType("Recursive") + fields := &expr.Object{} + fields.Set("left", &expr.AttributeExpr{Type: recursive}) + fields.Set("right", &expr.AttributeExpr{Type: recursive}) + container := &expr.UserTypeExpr{ + AttributeExpr: &expr.AttributeExpr{ + Type: fields, + Validation: &expr.ValidationExpr{Required: []string{"left"}}, + }, + TypeName: "Container", + } + attribute := &expr.AttributeExpr{Type: container} + context := NewAttributeContext(false, false, true, "", NewNameScope()) + + code, helpers, err := GoTransformWithAttrs(attribute, attribute, "source", "target", &TransformAttrs{ + SourceCtx: context, + TargetCtx: context, + }, true) + require.NoError(t, err) + require.Len(t, helpers, 1) + require.NotContains(t, helpers[0].Code, "if v == nil") + require.Contains(t, code, "target.Left = transformRecursiveToRecursive(source.Left)") + require.Contains(t, code, "if source.Right != nil {") + require.Contains(t, code, "target.Right = transformRecursiveToRecursive(source.Right)") +} + +func TestTransformPlanUsesCustomUnionHelperOrderForNamedArraysAndAliases(t *testing.T) { + sourceAlias := &expr.UserTypeExpr{ + AttributeExpr: &expr.AttributeExpr{Type: expr.String}, + TypeName: "SourceAlias", + } + targetAlias := &expr.UserTypeExpr{ + AttributeExpr: &expr.AttributeExpr{Type: expr.String}, + TypeName: "TargetAlias", + } + sourceArray := &expr.UserTypeExpr{ + AttributeExpr: &expr.AttributeExpr{Type: &expr.Array{ElemType: &expr.AttributeExpr{Type: expr.String}}}, + TypeName: "SourceArray", + } + targetArray := &expr.UserTypeExpr{ + AttributeExpr: &expr.AttributeExpr{Type: &expr.Array{ElemType: &expr.AttributeExpr{Type: expr.String}}}, + TypeName: "TargetArray", + } + source := &expr.AttributeExpr{Type: &expr.Union{Values: []*expr.NamedAttributeExpr{ + {Name: "alias", Attribute: &expr.AttributeExpr{Type: sourceAlias}}, + {Name: "array", Attribute: &expr.AttributeExpr{Type: sourceArray}}, + }}} + target := &expr.AttributeExpr{Type: &expr.Union{Values: []*expr.NamedAttributeExpr{ + {Name: "alias", Attribute: &expr.AttributeExpr{Type: targetAlias}}, + {Name: "array", Attribute: &expr.AttributeExpr{Type: targetArray}}, + }}} + hooks := &TransformHooks{ + PlanUnionHelpers: func(source, target *expr.AttributeExpr, record func(*expr.AttributeExpr, *expr.AttributeExpr)) { + sourceUnion, targetUnion := expr.AsUnion(source.Type), expr.AsUnion(target.Type) + record(sourceUnion.Values[1].Attribute, targetUnion.Values[1].Attribute) + record(sourceUnion.Values[0].Attribute, targetUnion.Values[0].Attribute) + }, + TransformUnion: func(source, target *expr.AttributeExpr, _, _ string, _ bool, _, _ *expr.AttributeExpr, attrs *TransformAttrs) (string, error) { + sourceUnion, targetUnion := expr.AsUnion(source.Type), expr.AsUnion(target.Type) + arrayName := TransformHelperName(sourceUnion.Values[1].Attribute, targetUnion.Values[1].Attribute, attrs) + aliasName := TransformHelperName(sourceUnion.Values[0].Attribute, targetUnion.Values[0].Attribute, attrs) + return arrayName + "(source.Array)\n" + aliasName + "(source.Alias)\n", nil + }, + } + plan, err := NewTransformPlan(source, target, "", hooks) + require.NoError(t, err) + helpers := plan.Helpers() + require.Len(t, helpers, 2) + require.Equal(t, "SourceArray", helpers[0].Source.Type.Name()) + require.Equal(t, "SourceAlias", helpers[1].Source.Type.Name()) + arrayDeclaration := NewExactName(NameFunction, "arrayHelper") + aliasDeclaration := NewExactName(NameFunction, "aliasHelper") + generatedPackage := newGeneratedPackage("test", "example.com/test", "gen") + require.NoError(t, generatedPackage.DeclareName(arrayDeclaration)) + require.NoError(t, generatedPackage.DeclareName(aliasDeclaration)) + require.NoError(t, plan.BindHelperDeclaration(helpers[0].ID, arrayDeclaration)) + require.NoError(t, plan.BindHelperDeclaration(helpers[1].ID, aliasDeclaration)) + require.NoError(t, generatedPackage.freeze()) + require.NoError(t, plan.BindContexts( + NewAttributeContext(false, false, true, "", NewNameScope()), + NewAttributeContext(false, false, true, "", NewNameScope()), + )) + + code, definitions, err := plan.Render("source", "target", true) + require.NoError(t, err) + require.Len(t, definitions, 2) + require.Contains(t, code, "arrayHelper") + require.Contains(t, code, "aliasHelper") + require.Less(t, strings.Index(code, "arrayHelper"), strings.Index(code, "aliasHelper")) +} + +func TestTransformPlanCapturesUnwrappedHelperBody(t *testing.T) { + sourceNode := transformObjectAttribute("SourceNode", true) + targetNode := transformObjectAttribute("TargetNode", true) + wrapper := &expr.UserTypeExpr{ + TypeName: "TargetWrapper", + AttributeExpr: &expr.AttributeExpr{Type: &expr.Object{ + {Name: "field", Attribute: targetNode}, + }}, + } + source := &expr.AttributeExpr{Type: &expr.Object{ + {Name: "root", Attribute: sourceNode}, + }} + target := &expr.AttributeExpr{Type: &expr.Object{ + {Name: "root", Attribute: &expr.AttributeExpr{Type: wrapper}}, + }} + hooks := &TransformHooks{ + UnwrapPair: func(source, target *expr.AttributeExpr) (*expr.AttributeExpr, *expr.AttributeExpr, *WrapDirective) { + if target.Type.Name() != wrapper.TypeName { + return source, target, nil + } + return source, expr.AsObject(target.Type).Attribute("field"), &WrapDirective{ + WrapTarget: true, + Target: target, + FieldName: "Field", + } + }, + } + plan, err := NewTransformPlan(source, target, "", hooks) + require.NoError(t, err) + require.Len(t, plan.Helpers(), 1) + + code, definitions := renderTransformPlan(t, plan) + require.Len(t, definitions, 1) + require.Contains(t, code, definitions[0].Declaration.Name()+"(source.Root)") +} + +func TestTransformPlanRetainsWrapperAndInlineArrayCalls(t *testing.T) { + node := &expr.UserTypeExpr{TypeName: "Node"} + node.AttributeExpr = &expr.AttributeExpr{Type: &expr.Object{}} + expr.AsObject(node.Type).Set("next", &expr.AttributeExpr{Type: node}) + source := &expr.AttributeExpr{Type: &expr.Array{ElemType: &expr.AttributeExpr{Type: node}}} + target := expr.DupAtt(source) + hooks := &TransformHooks{ + InlineCompositeElems: true, + TransformArray: func(source, target *expr.Array, sourceVar, targetVar string, newVar bool, attrs *TransformAttrs) (string, error) { + return TransformAttribute(source.ElemType, target.ElemType, sourceVar+"[0]", targetVar+"[0]", newVar, attrs) + }, + } + plan, err := NewTransformPlan(source, target, "copied", hooks) + require.NoError(t, err) + require.Len(t, plan.Helpers(), 1) + + declaration := NewExactName(NameFunction, "copyNode") + packageCatalog := newGeneratedPackage("test", "example.com/test", "gen") + require.NoError(t, packageCatalog.DeclareName(declaration)) + require.NoError(t, plan.BindHelperDeclaration(plan.Helpers()[0].ID, declaration)) + require.NoError(t, packageCatalog.freeze()) + require.NoError(t, plan.BindContexts( + NewAttributeContext(false, false, true, "", NewNameScope()), + NewAttributeContext(false, false, true, "", NewNameScope()), + )) + code, helpers, err := plan.Render("source", "target", true) + require.NoError(t, err) + require.Len(t, helpers, 1) + require.Contains(t, code+helpers[0].Code, "copyNode") + + wrapper := &expr.UserTypeExpr{ + TypeName: "WrappedNode", + AttributeExpr: &expr.AttributeExpr{Type: &expr.Object{ + &expr.NamedAttributeExpr{Name: "field", Attribute: &expr.AttributeExpr{Type: node}}, + }}, + } + wrapperTarget := &expr.AttributeExpr{Type: wrapper} + wrapperHooks := &TransformHooks{ + UnwrapPair: func(source, target *expr.AttributeExpr) (*expr.AttributeExpr, *expr.AttributeExpr, *WrapDirective) { + if target.Type.Name() != wrapper.TypeName { + return source, target, nil + } + return source, expr.AsObject(target.Type).Attribute("field"), &WrapDirective{ + WrapTarget: true, + Target: target, + FieldName: "Field", + } + }, + } + wrapperPlan, err := NewTransformPlan(&expr.AttributeExpr{Type: node}, wrapperTarget, "wrapped", wrapperHooks) + require.NoError(t, err) + require.Len(t, wrapperPlan.Helpers(), 1) + wrapperDeclaration := NewExactName(NameFunction, "copyWrappedNode") + wrapperPackage := newGeneratedPackage("wrapper", "example.com/wrapper", "gen") + require.NoError(t, wrapperPackage.DeclareName(wrapperDeclaration)) + require.NoError(t, wrapperPlan.BindHelperDeclaration(wrapperPlan.Helpers()[0].ID, wrapperDeclaration)) + require.NoError(t, wrapperPackage.freeze()) + require.NoError(t, wrapperPlan.BindContexts( + NewAttributeContext(false, false, true, "", NewNameScope()), + NewAttributeContext(false, false, true, "", NewNameScope()), + )) + wrapperCode, _, err := wrapperPlan.Render("source", "target", true) + require.NoError(t, err) + require.Contains(t, wrapperCode, "target := &WrappedNode{}") + require.Contains(t, wrapperCode, "target.Field") +} + +func TestTransformPlanRetainsSameTypeSiblingOccurrences(t *testing.T) { + plan := siblingTransformPlan(t) + require.Len(t, plan.Helpers(), 2) + require.NotEqual(t, plan.Helpers()[0].ID, plan.Helpers()[1].ID) +} + +func TestTransformPlanHelperDescriptionsCannotChangeRenderGraph(t *testing.T) { + plan := siblingTransformPlan(t) + helpers := plan.Helpers() + require.Len(t, helpers, 2) + + // Helper descriptions are used to choose declarations. Mutating one must + // never alter the private attributes that Render uses to write those + // declarations. + helpers[0].Source.Type = expr.String + + _, definitions := renderTransformPlan(t, plan) + require.Len(t, definitions, 2) + require.Equal(t, "*Recursive", definitions[0].ParamTypeRef) +} + +func TestTransformPlanRejectsRenderHookMutation(t *testing.T) { + source := &expr.AttributeExpr{Type: &expr.Array{ElemType: &expr.AttributeExpr{Type: expr.String}}} + target := expr.DupAtt(source) + plan, err := NewTransformPlan(source, target, "", &TransformHooks{ + TransformArray: func(source, _ *expr.Array, _, _ string, _ bool, _ *TransformAttrs) (string, error) { + source.ElemType.Type = expr.Int + return "", nil + }, + }) + require.NoError(t, err) + require.NoError(t, plan.BindContexts( + NewAttributeContext(false, false, true, "", NewNameScope()), + NewAttributeContext(false, false, true, "", NewNameScope()), + )) + + _, _, err = plan.Render("source", "target", true) + require.EqualError(t, err, "transform render hook changed the retained plan") +} + +func TestTransformPlanCachesResultAgainstMutationRetainedByRenderHook(t *testing.T) { + source := &expr.AttributeExpr{Type: &expr.Array{ElemType: &expr.AttributeExpr{Type: expr.String}}} + target := expr.DupAtt(source) + var retained *expr.Array + plan, err := NewTransformPlan(source, target, "", &TransformHooks{ + TransformArray: func(source, _ *expr.Array, _, _ string, _ bool, _ *TransformAttrs) (string, error) { + retained = source + return "", nil + }, + }) + require.NoError(t, err) + require.NoError(t, plan.BindContexts( + NewAttributeContext(false, false, true, "", NewNameScope()), + NewAttributeContext(false, false, true, "", NewNameScope()), + )) + _, _, err = plan.Render("source", "target", true) + require.NoError(t, err) + + retained.ElemType.Type = expr.Int + _, _, err = plan.Render("source", "target", true) + require.NoError(t, err) +} + +func TestTransformPlanCachesRepeatedRender(t *testing.T) { + source := &expr.AttributeExpr{Type: &expr.Array{ElemType: &expr.AttributeExpr{Type: expr.String}}} + target := expr.DupAtt(source) + var renders int + plan, err := NewTransformPlan(source, target, "", &TransformHooks{ + TransformArray: func(_ *expr.Array, _ *expr.Array, _, _ string, _ bool, _ *TransformAttrs) (string, error) { + renders++ + return fmt.Sprintf("render%d", renders), nil + }, + }) + require.NoError(t, err) + require.NoError(t, plan.BindContexts( + NewAttributeContext(false, false, true, "", NewNameScope()), + NewAttributeContext(false, false, true, "", NewNameScope()), + )) + + code, _, err := plan.Render("source", "target", true) + require.NoError(t, err) + require.Equal(t, "render1", code) + code, _, err = plan.Render("source", "target", true) + require.NoError(t, err) + require.Equal(t, "render1", code) + require.Equal(t, 1, renders) +} + +func TestTransformPlanSharesOneDeclarationForEquivalentHelpers(t *testing.T) { + plan := siblingTransformPlan(t) + helpers := plan.Helpers() + require.Len(t, helpers, 2) + declaration := NewExactName(NameFunction, "transformRecursive") + pkg := newGeneratedPackage("test", "example.com/test", "gen") + require.NoError(t, pkg.DeclareName(declaration)) + require.NoError(t, pkg.freeze()) + + require.NoError(t, plan.BindHelperDeclaration(helpers[0].ID, declaration)) + require.NoError(t, plan.BindHelperDeclaration(helpers[1].ID, declaration)) + require.NoError(t, plan.BindContexts( + NewAttributeContext(false, false, true, "", NewNameScope()), + NewAttributeContext(false, false, true, "", NewNameScope()), + )) + code, definitions, err := plan.Render("source", "target", true) + require.NoError(t, err) + require.Len(t, definitions, 1) + require.Contains(t, code, "target.Left = transformRecursive(source.Left)") + require.Contains(t, code, "target.Right = transformRecursive(source.Right)") +} + +func TestTransformPlanSharesDeclarationAcrossRequiredAndOptionalCalls(t *testing.T) { + plan := mixedSiblingTransformPlan(t) + helpers := plan.Helpers() + require.Len(t, helpers, 2) + declaration := NewExactName(NameFunction, "transformRecursive") + pkg := newGeneratedPackage("test", "example.com/test", "gen") + require.NoError(t, pkg.DeclareName(declaration)) + require.NoError(t, pkg.freeze()) + + require.NoError(t, plan.BindHelperDeclaration(helpers[0].ID, declaration)) + require.NoError(t, plan.BindHelperDeclaration(helpers[1].ID, declaration)) + require.NoError(t, plan.BindContexts( + NewAttributeContext(false, false, true, "", NewNameScope()), + NewAttributeContext(false, false, true, "", NewNameScope()), + )) + code, definitions, err := plan.Render("source", "target", true) + require.NoError(t, err) + require.Len(t, definitions, 1) + require.Contains(t, code, "target.Left = transformRecursive(source.Left)") + require.Contains(t, code, "if source.Right != nil {") + require.Contains(t, code, "target.Right = transformRecursive(source.Right)") +} + +func TestTransformPlanGroupsEquivalentHelpersIntoOneDefinition(t *testing.T) { + plan := mixedSiblingTransformPlan(t) + definitions := plan.HelperDefinitions() + require.Len(t, definitions, 1) + require.NotSame(t, plan.Helpers()[0].Source, definitions[0].Source) + require.NotSame(t, plan.Helpers()[0].Target, definitions[0].Target) + + declaration := NewExactName(NameFunction, "transformRecursive") + require.NoError(t, plan.BindHelperDefinition(definitions[0].ID, declaration)) + for _, helper := range plan.Helpers() { + require.Same(t, declaration, helper.Declaration) + } +} + +func TestTransformHelperRegistryGroupsEquivalentPlans(t *testing.T) { + first := siblingTransformPlan(t) + second := siblingTransformPlan(t) + registry := NewTransformHelperRegistry() + require.NoError(t, registry.Collect( + first, + transformTestLayout(t, first.sourceCopier.Original(first.source), GoLayoutPolicy{UseDefault: true}), + transformTestLayout(t, first.targetCopier.Original(first.target), GoLayoutPolicy{UseDefault: true}), + transformTestOrderFactory("first").order, + )) + require.NoError(t, registry.Collect( + second, + transformTestLayout(t, second.sourceCopier.Original(second.source), GoLayoutPolicy{UseDefault: true}), + transformTestLayout(t, second.targetCopier.Original(second.target), GoLayoutPolicy{UseDefault: true}), + transformTestOrderFactory("second").order, + )) + + groups, err := registry.Finalize() + require.NoError(t, err) + require.Len(t, groups, 1) + declaration := NewExactName(NameFunction, "transformRecursive") + require.NoError(t, groups[0].Bind(declaration)) + for _, plan := range []*TransformPlan{first, second} { + for _, helper := range plan.Helpers() { + require.Same(t, declaration, helper.Declaration) + } + } +} + +func TestTransformHelperRegistryFollowsRootWrapper(t *testing.T) { + for _, wrapTarget := range []bool{false, true} { + plan, source, target := rootWrappedTransformPlan(t, wrapTarget) + registry := NewTransformHelperRegistry() + err := registry.Collect( + plan, + transformTestLayout(t, source, GoLayoutPolicy{UseDefault: true}), + transformTestLayout(t, target, GoLayoutPolicy{UseDefault: true}), + transformTestOrderFactory("wrapped").order, + ) + require.NoError(t, err) + } +} + +func TestTransformHelperRegistryRejectsInvalidRootWrapper(t *testing.T) { + tests := []struct { + name string + change func(*TransformPlan, *GoTypePlan) + err string + }{ + { + name: "missing generated field", + change: func(plan *TransformPlan, _ *GoTypePlan) { + plan.rootWrap.FieldName = "Missing" + }, + err: `select target root wrapper: wrapper field "Missing" is missing`, + }, + { + name: "ambiguous generated field", + change: func(_ *TransformPlan, layout *GoTypePlan) { + layout.fields[1].fieldNameUpper = "Field" + }, + err: `select target root wrapper: wrapper field "Field" is ambiguous`, + }, + { + name: "different design field", + change: func(plan *TransformPlan, _ *GoTypePlan) { + plan.rootWrap.FieldName = "Other" + }, + err: `select target root wrapper: wrapper field "Other" does not hold the selected value`, + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + plan, source, target := rootWrappedTransformPlan(t, true) + targetLayout := transformTestLayout(t, target, GoLayoutPolicy{UseDefault: true}) + test.change(plan, targetLayout) + registry := NewTransformHelperRegistry() + err := registry.Collect( + plan, + transformTestLayout(t, source, GoLayoutPolicy{UseDefault: true}), + targetLayout, + transformTestOrderFactory("wrapped").order, + ) + require.EqualError(t, err, test.err) + }) + } +} + +func TestNewTransformProgramRejectsNilHooks(t *testing.T) { + program, err := NewTransformProgram(nil) + require.Nil(t, program) + require.EqualError(t, err, "transform program hooks must not be nil") +} + +func TestTransformHelperRegistryGroupsPlansFromOneProgram(t *testing.T) { + program, err := NewTransformProgram(&TransformHooks{}) + require.NoError(t, err) + first := siblingTransformProgramPlan(t, program) + second, err := program.Plan( + first.sourceCopier.Original(first.source), + first.targetCopier.Original(first.target), + "", + ) + require.NoError(t, err) + registry := NewTransformHelperRegistry() + for index, plan := range []*TransformPlan{first, second} { + require.NoError(t, registry.Collect( + plan, + transformTestLayout(t, plan.sourceCopier.Original(plan.source), GoLayoutPolicy{UseDefault: true}), + transformTestLayout(t, plan.targetCopier.Original(plan.target), GoLayoutPolicy{UseDefault: true}), + transformTestOrderFactory(fmt.Sprintf("plan-%d", index)).order, + )) + } + + groups, err := registry.Finalize() + require.NoError(t, err) + require.Len(t, groups, 1) +} + +func TestTransformHelperRegistryGroupsSeparateTypeWrappersForOneDeclaration(t *testing.T) { + first := siblingTransformPlan(t) + second := siblingTransformPlan(t) + declaration := NewExactName(NameType, "Recursive") + generation, err := NewGeneration("example.com/generated", nil) + require.NoError(t, err) + pkg, err := generation.ClaimPackage("example.com/generated") + require.NoError(t, err) + require.NoError(t, pkg.DeclareName(declaration)) + registry := NewTransformHelperRegistry() + for index, plan := range []*TransformPlan{first, second} { + require.NoError(t, registry.Collect( + plan, + transformTestLayoutWithDeclaration(t, plan.sourceCopier.Original(plan.source), declaration), + transformTestLayoutWithDeclaration(t, plan.targetCopier.Original(plan.target), declaration), + transformTestOrderFactory(fmt.Sprintf("plan-%d", index)).order, + )) + } + + groups, err := registry.Finalize() + require.NoError(t, err) + require.Len(t, groups, 1) +} + +func TestTransformHelperRegistryKeepsSeparateDeclarationsApart(t *testing.T) { + first := siblingTransformPlan(t) + second := siblingTransformPlan(t) + generation, err := NewGeneration("example.com/generated", nil) + require.NoError(t, err) + pkg, err := generation.ClaimPackage("example.com/generated") + require.NoError(t, err) + firstDeclaration := NewPreferredName(NameType, "Recursive", ExportedName, testNameOrder{value: "first"}) + secondDeclaration := NewPreferredName(NameType, "Recursive", ExportedName, testNameOrder{value: "second"}) + require.NoError(t, pkg.DeclareName(firstDeclaration)) + require.NoError(t, pkg.DeclareName(secondDeclaration)) + registry := NewTransformHelperRegistry() + for index, entry := range []struct { + plan *TransformPlan + declaration *NameDeclaration + }{ + {plan: first, declaration: firstDeclaration}, + {plan: second, declaration: secondDeclaration}, + } { + require.NoError(t, registry.Collect( + entry.plan, + transformTestLayoutWithDeclaration(t, entry.plan.sourceCopier.Original(entry.plan.source), entry.declaration), + transformTestLayoutWithDeclaration(t, entry.plan.targetCopier.Original(entry.plan.target), entry.declaration), + transformTestOrderFactory(fmt.Sprintf("plan-%d", index)).order, + )) + } + + groups, err := registry.Finalize() + require.NoError(t, err) + require.Len(t, groups, 2) +} + +func TestTransformHelperRegistryKeepsGroupSpecificLocations(t *testing.T) { + plan := siblingTransformPlan(t) + sourceLayout := transformTestLayout( + t, + plan.sourceCopier.Original(plan.source), + GoLayoutPolicy{UseDefault: true}, + ) + targetLayout := transformTestLayout( + t, + plan.targetCopier.Original(plan.target), + GoLayoutPolicy{UseDefault: true}, + ) + firstDeclaration := NewExactName(NameType, "FirstRecursive") + secondDeclaration := NewExactName(NameType, "SecondRecursive") + for _, layout := range []*GoTypePlan{sourceLayout, targetLayout} { + layout.value.fields[0].declaration = firstDeclaration + layout.value.fields[1].declaration = secondDeclaration + } + registry := NewTransformHelperRegistry() + require.NoError(t, registry.Collect(plan, sourceLayout, targetLayout, transformTestOrderFactory("plan").order)) + + groups, err := registry.Finalize() + require.NoError(t, err) + require.Len(t, groups, 2) + require.NotEqual(t, groups[0].Definition().Location, groups[1].Definition().Location) +} + +func TestTransformHelperRegistryKeepsOnePlanAndLocationTogether(t *testing.T) { + first := siblingTransformPlan(t) + second := siblingTransformPlan(t) + firstSource := transformTestLayout(t, first.sourceCopier.Original(first.source), GoLayoutPolicy{UseDefault: true}) + firstTarget := transformTestLayout(t, first.targetCopier.Original(first.target), GoLayoutPolicy{UseDefault: true}) + secondSource := transformTestLayout(t, second.sourceCopier.Original(second.source), GoLayoutPolicy{UseDefault: true}) + secondTarget := transformTestLayout(t, second.targetCopier.Original(second.target), GoLayoutPolicy{UseDefault: true}) + alpha := NewExactName(NameType, "AlphaRecursive") + beta := NewExactName(NameType, "BetaRecursive") + for _, layout := range []*GoTypePlan{firstSource, firstTarget} { + layout.value.fields[0].declaration = alpha + layout.value.fields[1].declaration = beta + } + for _, layout := range []*GoTypePlan{secondSource, secondTarget} { + layout.value.fields[0].declaration = beta + layout.value.fields[1].declaration = alpha + } + + registry := NewTransformHelperRegistry() + require.NoError(t, registry.Collect(first, firstSource, firstTarget, transformTestOrderFactory("first").order)) + require.NoError(t, registry.Collect(second, secondSource, secondTarget, transformTestOrderFactory("second").order)) + groups, err := registry.Finalize() + require.NoError(t, err) + require.Len(t, groups, 2) + firstOrder := groups[0].Order().(testNameOrder) + secondOrder := groups[1].Order().(testNameOrder) + require.True(t, strings.HasPrefix(firstOrder.value, "first")) + require.True(t, strings.HasPrefix(secondOrder.value, "first")) + require.NotEqual(t, firstOrder, secondOrder) + require.NotEqual(t, groups[0].Definition().Location, groups[1].Definition().Location) +} + +func TestTransformHelperRegistrySharesFieldAndMapValueHelpers(t *testing.T) { + root := RunDSL(t, testdata.TestTypesDSL) + recursive := root.UserType("Recursive") + container := &expr.AttributeExpr{Type: &expr.Object{ + {Name: "direct", Attribute: &expr.AttributeExpr{Type: recursive}}, + {Name: "mapped", Attribute: &expr.AttributeExpr{Type: &expr.Map{ + KeyType: &expr.AttributeExpr{Type: expr.String}, + ElemType: &expr.AttributeExpr{Type: recursive}, + }}}, + }} + plan, err := NewTransformPlan(container, container, "", nil) + require.NoError(t, err) + require.Len(t, plan.helpers, 2) + require.NotEqual(t, plan.helpers[0].location, plan.helpers[1].location) + registry := NewTransformHelperRegistry() + require.NoError(t, registry.Collect( + plan, + transformTestLayout(t, container, GoLayoutPolicy{UseDefault: true}), + transformTestLayout(t, container, GoLayoutPolicy{UseDefault: true}), + transformTestOrderFactory("plan").order, + )) + + groups, err := registry.Finalize() + require.NoError(t, err) + require.Len(t, groups, 1) +} + +func TestTransformHelperRegistryFollowsNamedAliasChains(t *testing.T) { + node := &expr.UserTypeExpr{TypeName: "Node"} + node.AttributeExpr = &expr.AttributeExpr{Type: &expr.Object{ + {Name: "next", Attribute: &expr.AttributeExpr{Type: node}}, + }} + alias := &expr.UserTypeExpr{ + TypeName: "Alias", + AttributeExpr: &expr.AttributeExpr{Type: node}, + } + container := &expr.AttributeExpr{Type: &expr.Object{ + {Name: "alias", Attribute: &expr.AttributeExpr{Type: alias}}, + }} + plan, err := NewTransformPlan(container, container, "", nil) + require.NoError(t, err) + registry := NewTransformHelperRegistry() + + err = registry.Collect( + plan, + transformTestLayout(t, container, GoLayoutPolicy{UseDefault: true}), + transformTestLayout(t, container, GoLayoutPolicy{UseDefault: true}), + transformTestOrderFactory("plan").order, + ) + require.NoError(t, err) +} + +func TestTransformHelperRegistryKeepsDifferentPlansSeparate(t *testing.T) { + tests := []struct { + name string + first func(*testing.T) *TransformPlan + second func(*testing.T) *TransformPlan + firstRule GoLayoutPolicy + otherRule GoLayoutPolicy + }{ + { + name: "pointer policy", + first: siblingTransformPlan, + second: siblingTransformPlan, + firstRule: GoLayoutPolicy{UseDefault: true}, + otherRule: GoLayoutPolicy{Pointer: true}, + }, + { + name: "root default", + first: func(t *testing.T) *TransformPlan { return singleTransformHelperPlan(t, "first", nil) }, + second: func(t *testing.T) *TransformPlan { return singleTransformHelperPlan(t, "second", nil) }, + }, + { + name: "required collection", + first: func(t *testing.T) *TransformPlan { return collectionTransformHelperPlan(t, true) }, + second: func(t *testing.T) *TransformPlan { return collectionTransformHelperPlan(t, false) }, + }, + { + name: "custom hook", + first: func(t *testing.T) *TransformPlan { return singleTransformHelperPlan(t, nil, &TransformHooks{}) }, + second: func(t *testing.T) *TransformPlan { + return singleTransformHelperPlan(t, nil, &TransformHooks{}) + }, + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + first := test.first(t) + second := test.second(t) + registry := NewTransformHelperRegistry() + for index, entry := range []struct { + plan *TransformPlan + policy GoLayoutPolicy + }{ + {plan: first, policy: test.firstRule}, + {plan: second, policy: test.otherRule}, + } { + require.NoError(t, registry.Collect( + entry.plan, + transformTestLayout(t, entry.plan.sourceCopier.Original(entry.plan.source), entry.policy), + transformTestLayout(t, entry.plan.targetCopier.Original(entry.plan.target), entry.policy), + transformTestOrderFactory(fmt.Sprintf("plan-%d", index)).order, + )) + } + groups, err := registry.Finalize() + require.NoError(t, err) + require.Len(t, groups, 2) + }) + } +} + +func TestTransformHelperRegistryKeepsHookDefinitionsSeparate(t *testing.T) { + root := RunDSL(t, testdata.TestTypesDSL) + recursive := root.UserType("Recursive") + source := &expr.AttributeExpr{Type: &expr.Object{ + {Name: "left", Attribute: &expr.AttributeExpr{Type: recursive}}, + {Name: "right", Attribute: &expr.AttributeExpr{Type: recursive}}, + }} + target := &expr.AttributeExpr{Type: &expr.Object{ + {Name: "left", Attribute: &expr.AttributeExpr{Type: recursive}}, + {Name: "right", Attribute: &expr.AttributeExpr{Type: recursive}}, + }} + plan, err := NewTransformPlan(source, target, "", &TransformHooks{ + SameHelperDefinition: func(_, _, _, _ *expr.AttributeExpr) bool { + return false + }, + }) + require.NoError(t, err) + require.Len(t, plan.definitions, 2) + registry := NewTransformHelperRegistry() + require.NoError(t, registry.Collect( + plan, + transformTestLayout(t, source, GoLayoutPolicy{UseDefault: true}), + transformTestLayout(t, target, GoLayoutPolicy{UseDefault: true}), + transformTestOrderFactory("plan").order, + )) + + groups, err := registry.Finalize() + require.NoError(t, err) + require.Len(t, groups, 2) +} + +// collectionTransformHelperPlan builds a named object whose slice field may be +// required. Required slices emit an empty target slice when the source is nil; +// optional slices leave the target field unset. +func collectionTransformHelperPlan(t *testing.T, required bool) *TransformPlan { + t.Helper() + value := &expr.UserTypeExpr{ + TypeName: "CollectionValue", + AttributeExpr: &expr.AttributeExpr{Type: &expr.Object{ + {Name: "items", Attribute: &expr.AttributeExpr{Type: &expr.Array{ElemType: &expr.AttributeExpr{Type: expr.String}}}}, + }}, + } + if required { + value.Attribute().Validation = &expr.ValidationExpr{Required: []string{"items"}} + } + container := &expr.AttributeExpr{Type: &expr.Object{ + {Name: "value", Attribute: &expr.AttributeExpr{Type: value}}, + }} + plan, err := NewTransformPlan(container, container, "", nil) + require.NoError(t, err) + return plan +} + +// rootWrappedTransformPlan builds an array conversion where one side stores +// the array in a generated wrapper field before nested item helpers run. +func rootWrappedTransformPlan(t *testing.T, wrapTarget bool) (*TransformPlan, *expr.AttributeExpr, *expr.AttributeExpr) { + t.Helper() + sourceElement := transformObjectAttribute("SourceItem", true) + targetElement := transformObjectAttribute("TargetItem", true) + sourceValue := &expr.AttributeExpr{Type: &expr.Array{ElemType: sourceElement}} + targetValue := &expr.AttributeExpr{Type: &expr.Array{ElemType: targetElement}} + wrap := func(value *expr.AttributeExpr) *expr.AttributeExpr { + return &expr.AttributeExpr{Type: &expr.Object{ + {Name: "field", Attribute: value}, + {Name: "other", Attribute: &expr.AttributeExpr{Type: expr.String}}, + }} + } + source, target := sourceValue, wrap(targetValue) + if !wrapTarget { + source, target = wrap(sourceValue), targetValue + } + program, err := NewTransformProgram(&TransformHooks{ + UnwrapPair: func(source, target *expr.AttributeExpr) (*expr.AttributeExpr, *expr.AttributeExpr, *WrapDirective) { + wrapper := target + if !wrapTarget { + wrapper = source + } + object := expr.AsObject(wrapper.Type) + if object == nil || object.Attribute("field") == nil { + return source, target, nil + } + directive := &WrapDirective{WrapTarget: wrapTarget, FieldName: "Field"} + if wrapTarget { + directive.Target = target + return source, object.Attribute("field"), directive + } + return object.Attribute("field"), target, directive + }, + }) + require.NoError(t, err) + plan, err := program.Plan(source, target, "") + require.NoError(t, err) + require.Len(t, plan.Helpers(), 1) + return plan, source, target +} + +func TestTransformHelperGroupBindIsAtomic(t *testing.T) { + first := siblingTransformPlan(t) + second := siblingTransformPlan(t) + registry := NewTransformHelperRegistry() + for index, plan := range []*TransformPlan{first, second} { + require.NoError(t, registry.Collect( + plan, + transformTestLayout(t, plan.sourceCopier.Original(plan.source), GoLayoutPolicy{UseDefault: true}), + transformTestLayout(t, plan.targetCopier.Original(plan.target), GoLayoutPolicy{UseDefault: true}), + transformTestOrderFactory(fmt.Sprintf("plan-%d", index)).order, + )) + } + groups, err := registry.Finalize() + require.NoError(t, err) + require.Len(t, groups, 1) + existing := NewExactName(NameFunction, "existingTransform") + require.NoError(t, second.BindHelperDeclaration(second.Helpers()[0].ID, existing)) + + err = groups[0].Bind(NewExactName(NameFunction, "sharedTransform")) + require.ErrorContains(t, err, "already has a different declaration") + for _, helper := range first.Helpers() { + require.Nil(t, helper.Declaration) + } +} + +// singleTransformHelperPlan builds one helper whose root target attribute can +// carry a default and whose hook set is retained by the plan. +func singleTransformHelperPlan(t *testing.T, targetDefault any, hooks *TransformHooks) *TransformPlan { + t.Helper() + root := RunDSL(t, testdata.TestTypesDSL) + recursive := root.UserType("Recursive") + sourceChild := &expr.AttributeExpr{Type: recursive} + targetChild := &expr.AttributeExpr{Type: recursive, DefaultValue: targetDefault} + source := &expr.AttributeExpr{Type: &expr.Object{{Name: "child", Attribute: sourceChild}}} + target := &expr.AttributeExpr{Type: &expr.Object{{Name: "child", Attribute: targetChild}}} + plan, err := NewTransformPlan(source, target, "", hooks) + require.NoError(t, err) + return plan +} + +// transformTestLayout records a complete Go type before final package names +// are chosen. Equal authored type names use the same fixed declaration so the +// tests compare conversion behavior instead of Goa value addresses. +func transformTestLayout(t *testing.T, attribute *expr.AttributeExpr, policy GoLayoutPolicy) *GoTypePlan { + t.Helper() + layout, err := PlanGoType(attribute, GoTypePlanOptions{ + Owner: "example.com/generated", + Policy: policy, + RetainNamedValue: true, + Bind: func(request GoTypeBindingRequest) (GoTypeBinding, error) { + return GoTypeBinding{ + Owner: "example.com/generated", + name: request.Attribute.Type.Name(), + }, nil + }, + }) + require.NoError(t, err) + return layout +} + +// transformTestLayoutWithDeclaration builds new type wrappers around one +// package declaration, matching independent planners that resolve the same Go +// type through separate wrapper values. +func transformTestLayoutWithDeclaration(t *testing.T, attribute *expr.AttributeExpr, declaration *NameDeclaration) *GoTypePlan { + t.Helper() + layout, err := PlanGoType(attribute, GoTypePlanOptions{ + Owner: "example.com/generated", + Policy: GoLayoutPolicy{UseDefault: true}, + RetainNamedValue: true, + Bind: func(GoTypeBindingRequest) (GoTypeBinding, error) { + return GoTypeBinding{ + Owner: "example.com/generated", + Type: &TypeDeclaration{declaration: declaration}, + }, nil + }, + }) + require.NoError(t, err) + return layout +} + +func TestTransformHelperDefinitionLocationComparesStructuralPaths(t *testing.T) { + root := TransformHelperDefinitionLocation{} + emptyField := root.objectField("") + nulField := root.objectField("\x00") + letterField := root.objectField("a") + require.Less(t, emptyField.Compare(nulField), 0) + require.Less(t, nulField.Compare(letterField), 0) + require.NotEqual(t, root.objectField("a\x00b"), root.objectField("a").objectField("b")) + require.NotEqual(t, root.arrayElement(), root.mapKey()) + require.NotEqual(t, root.mapValue(), root.unionBranch("")) +} + +func TestTransformPlanDefinitionLocationIgnoresSiblingFieldOrder(t *testing.T) { + plan := func(reverse bool) *TransformPlan { + root := RunDSL(t, testdata.TestTypesDSL) + recursive := root.UserType("Recursive") + source, target := &expr.Object{}, &expr.Object{} + add := func(name string) { + source.Set(name, &expr.AttributeExpr{Type: recursive}) + target.Set(name, &expr.AttributeExpr{Type: recursive}) + } + if reverse { + add("right") + add("left") + } else { + add("left") + add("right") + } + planned, err := NewTransformPlan( + &expr.AttributeExpr{Type: source}, + &expr.AttributeExpr{Type: target}, + "", + nil, + ) + require.NoError(t, err) + return planned + } + + forward := plan(false).HelperDefinitions() + reverse := plan(true).HelperDefinitions() + require.Len(t, forward, 1) + require.Len(t, reverse, 1) + require.Equal(t, forward[0].Location, reverse[0].Location) +} + +func TestTransformPlanKeepsDifferentHelperBodiesInSeparateDefinitions(t *testing.T) { + sourceOrigin := transformObjectAttribute("SourceNode", true).Type.(expr.UserType) + targetOrigin := transformObjectAttribute("TargetNode", true).Type.(expr.UserType) + sourceLeft := sourceOrigin.Dup(nil) + sourceRight := sourceOrigin.Dup(nil) + targetLeft := targetOrigin.Dup(nil) + targetRight := targetOrigin.Dup(nil) + sourceLeft.SetAttribute(&expr.AttributeExpr{Type: &expr.Object{ + {Name: "left", Attribute: &expr.AttributeExpr{Type: expr.String}}, + }}) + sourceRight.SetAttribute(&expr.AttributeExpr{Type: &expr.Object{ + {Name: "right", Attribute: &expr.AttributeExpr{Type: expr.String}}, + }}) + targetLeft.SetAttribute(&expr.AttributeExpr{Type: &expr.Object{ + {Name: "left", Attribute: &expr.AttributeExpr{Type: expr.String}}, + }}) + targetRight.SetAttribute(&expr.AttributeExpr{Type: &expr.Object{ + {Name: "right", Attribute: &expr.AttributeExpr{Type: expr.String}}, + }}) + + plan, err := NewTransformPlan( + &expr.AttributeExpr{Type: &expr.Object{ + {Name: "left", Attribute: &expr.AttributeExpr{Type: sourceLeft}}, + {Name: "right", Attribute: &expr.AttributeExpr{Type: sourceRight}}, + }}, + &expr.AttributeExpr{Type: &expr.Object{ + {Name: "left", Attribute: &expr.AttributeExpr{Type: targetLeft}}, + {Name: "right", Attribute: &expr.AttributeExpr{Type: targetRight}}, + }}, + "", + nil, + ) + require.NoError(t, err) + require.Len(t, plan.HelperDefinitions(), 2) +} + +func TestTransformPlanKeepsDifferentAuthoredTypesInSeparateDefinitions(t *testing.T) { + newType := func(uid string) expr.UserType { + return &expr.UserTypeExpr{ + TypeName: "Node", + UID: uid, + AttributeExpr: &expr.AttributeExpr{Type: &expr.Object{ + {Name: "value", Attribute: &expr.AttributeExpr{Type: expr.String}}, + }}, + } + } + sourceLeft, sourceRight := newType("source-left"), newType("source-right") + targetLeft, targetRight := newType("target-left"), newType("target-right") + plan, err := NewTransformPlan( + &expr.AttributeExpr{Type: &expr.Object{ + {Name: "left", Attribute: &expr.AttributeExpr{Type: sourceLeft}}, + {Name: "right", Attribute: &expr.AttributeExpr{Type: sourceRight}}, + }}, + &expr.AttributeExpr{Type: &expr.Object{ + {Name: "left", Attribute: &expr.AttributeExpr{Type: targetLeft}}, + {Name: "right", Attribute: &expr.AttributeExpr{Type: targetRight}}, + }}, + "", + &TransformHooks{ + SameHelperDefinition: func(_, _, _, _ *expr.AttributeExpr) bool { + return true + }, + }, + ) + require.NoError(t, err) + require.Len(t, plan.HelperDefinitions(), 2) +} + +func TestTransformPlanKeepsHookVisibleRootFactsInSeparateDefinitions(t *testing.T) { + root := RunDSL(t, testdata.TestTypesDSL) + recursive := root.UserType("Recursive") + sourceLeft := &expr.AttributeExpr{Type: recursive} + sourceRight := &expr.AttributeExpr{Type: recursive} + targetLeft := &expr.AttributeExpr{Type: recursive} + targetRight := &expr.AttributeExpr{Type: recursive} + targetRight.AddMeta("helper:deref", "value") + + plan, err := NewTransformPlan( + &expr.AttributeExpr{Type: &expr.Object{ + {Name: "left", Attribute: sourceLeft}, + {Name: "right", Attribute: sourceRight}, + }}, + &expr.AttributeExpr{Type: &expr.Object{ + {Name: "left", Attribute: targetLeft}, + {Name: "right", Attribute: targetRight}, + }}, + "", + &TransformHooks{ + ObjectDeref: func(target *expr.AttributeExpr) (string, bool) { + if len(target.Meta["helper:deref"]) > 0 { + return "", true + } + return "&", true + }, + }, + ) + require.NoError(t, err) + require.Len(t, plan.Helpers(), 2) + require.Len(t, plan.HelperDefinitions(), 2) +} + +func TestTransformPlanKeepsDifferentRootDefaultsInSeparateDefinitions(t *testing.T) { + root := RunDSL(t, testdata.TestTypesDSL) + recursive := root.UserType("Recursive") + targetLeft := &expr.AttributeExpr{Type: recursive} + targetRight := &expr.AttributeExpr{Type: recursive, DefaultValue: "right"} + + plan, err := NewTransformPlan( + &expr.AttributeExpr{Type: &expr.Object{ + {Name: "left", Attribute: &expr.AttributeExpr{Type: recursive}}, + {Name: "right", Attribute: &expr.AttributeExpr{Type: recursive}}, + }}, + &expr.AttributeExpr{Type: &expr.Object{ + {Name: "left", Attribute: targetLeft}, + {Name: "right", Attribute: targetRight}, + }}, + "", + nil, + ) + require.NoError(t, err) + require.Len(t, plan.HelperDefinitions(), 2) +} + +func TestTransformPlanKeepsDifferentRootValidationInSeparateDefinitions(t *testing.T) { + root := RunDSL(t, testdata.TestTypesDSL) + recursive := root.UserType("Recursive") + targetLeft := &expr.AttributeExpr{Type: recursive} + targetRight := &expr.AttributeExpr{ + Type: recursive, + Validation: &expr.ValidationExpr{Required: []string{"name"}}, + } + + plan, err := NewTransformPlan( + &expr.AttributeExpr{Type: &expr.Object{ + {Name: "left", Attribute: &expr.AttributeExpr{Type: recursive}}, + {Name: "right", Attribute: &expr.AttributeExpr{Type: recursive}}, + }}, + &expr.AttributeExpr{Type: &expr.Object{ + {Name: "left", Attribute: targetLeft}, + {Name: "right", Attribute: targetRight}, + }}, + "", + nil, + ) + require.NoError(t, err) + require.Len(t, plan.HelperDefinitions(), 2) +} + +func TestTransformPlanLetsHooksApproveOneHelperDefinition(t *testing.T) { + root := RunDSL(t, testdata.TestTypesDSL) + recursive := root.UserType("Recursive") + targetLeft := &expr.AttributeExpr{Type: recursive} + targetRight := &expr.AttributeExpr{Type: recursive, DefaultValue: "right"} + + plan, err := NewTransformPlan( + &expr.AttributeExpr{Type: &expr.Object{ + {Name: "left", Attribute: &expr.AttributeExpr{Type: recursive}}, + {Name: "right", Attribute: &expr.AttributeExpr{Type: recursive}}, + }}, + &expr.AttributeExpr{Type: &expr.Object{ + {Name: "left", Attribute: targetLeft}, + {Name: "right", Attribute: targetRight}, + }}, + "", + &TransformHooks{ + SameHelperDefinition: func(_, _, _, _ *expr.AttributeExpr) bool { + return true + }, + }, + ) + require.NoError(t, err) + require.Len(t, plan.Helpers(), 2) + require.Len(t, plan.HelperDefinitions(), 1) +} + +func TestTransformPlanRejectsForeignHelperDefinition(t *testing.T) { + first := siblingTransformPlan(t) + second := siblingTransformPlan(t) + err := first.BindHelperDefinition( + second.HelperDefinitions()[0].ID, + NewExactName(NameFunction, "transformRecursive"), + ) + require.EqualError(t, err, "transform helper definition does not belong to this plan") +} + +func TestTransformPlanRequiresEveryHelperDeclaration(t *testing.T) { + plan := siblingTransformPlan(t) + helpers := plan.Helpers() + require.Len(t, helpers, 2) + require.NoError(t, plan.BindHelperDeclaration( + helpers[0].ID, + NewExactName(NameFunction, "transformLeftRecursive"), + )) + require.NoError(t, plan.BindContexts( + NewAttributeContext(false, false, true, "", NewNameScope()), + NewAttributeContext(false, false, true, "", NewNameScope()), + )) + + _, _, err := plan.Render("source", "target", true) + require.EqualError(t, err, "transform helper occurrence 2 has no declaration") +} + +func TestTransformPlanBindsContextsOnce(t *testing.T) { + plan := siblingTransformPlan(t) + source := NewAttributeContext(false, false, true, "", NewNameScope()) + target := NewAttributeContext(false, false, true, "", NewNameScope()) + require.NoError(t, plan.BindContexts(source, target)) + require.EqualError(t, plan.BindContexts(source, target), "transform contexts are already bound") +} + +func TestTransformPlanUsesFinalTypeLayoutNames(t *testing.T) { + const owner = "example.com/generated" + branch := &expr.UserTypeExpr{ + TypeName: "LegacySince", + AttributeExpr: &expr.AttributeExpr{Type: expr.String}, + } + choice := &expr.Union{ + TypeName: "Choice", + Values: []*expr.NamedAttributeExpr{ + {Name: "since", Attribute: &expr.AttributeExpr{Type: branch}}, + }, + } + attribute := &expr.AttributeExpr{Type: choice} + + generation, err := NewGeneration(owner, nil) + require.NoError(t, err) + pkg, err := generation.ClaimPackage(owner) + require.NoError(t, err) + choiceName := NewExactName(NameType, "Choice") + branchName := NewExactName(NameType, "ChoiceBranchSince") + require.NoError(t, pkg.DeclareName(choiceName)) + require.NoError(t, pkg.DeclareName(branchName)) + require.NoError(t, generation.Freeze()) + + layout, err := PlanGoType(attribute, GoTypePlanOptions{ + Owner: owner, + Policy: GoLayoutPolicy{UseDefault: true, SumType: true}, + RetainNamedValue: true, + Bind: func(request GoTypeBindingRequest) (GoTypeBinding, error) { + switch request.Kind { + case GoUnion: + return GoTypeBinding{ + Owner: owner, + Union: &UnionDeclaration{declaration: choiceName}, + }, nil + case GoNamed: + return GoTypeBinding{ + Owner: owner, + Type: &TypeDeclaration{declaration: branchName}, + }, nil + default: + return GoTypeBinding{}, fmt.Errorf("unexpected type kind %s", request.Kind) + } + }, + }) + require.NoError(t, err) + + plan, err := NewTransformPlan(attribute, attribute, "", nil) + require.NoError(t, err) + context := NewAttributeContext(false, false, true, "", NewNameScope()) + linked := layout.Link(owner, func(string) string { return "" }) + plannedContext, err := context.WithGoTypeLayout(linked) + require.NoError(t, err) + require.NoError(t, plan.BindContexts(plannedContext, plannedContext)) + code, helpers, err := plan.Render("in", "out", false) + require.NoError(t, err) + require.Empty(t, helpers) + require.Contains(t, code, "u.SetSince((ChoiceBranchSince)(obj))") + require.NotContains(t, code, "LegacySince") +} + +func TestAttributeContextRejectsGoTypeLayoutWithDifferentPointerRules(t *testing.T) { + attribute := &expr.AttributeExpr{Type: expr.String} + layout, err := PlanGoType(attribute, GoTypePlanOptions{ + Owner: "example.com/generated", + Policy: GoLayoutPolicy{Pointer: true, SumType: true}, + }) + require.NoError(t, err) + context := NewAttributeContext(false, false, true, "", NewNameScope()) + + _, err = context.WithGoTypeLayout(layout.Link("example.com/generated", nil)) + require.EqualError(t, err, "attach Go type layout: pointer and default rules do not match the attribute context") +} + +func TestAttributeContextUsesRequestedNameForReusedField(t *testing.T) { + shared := &expr.AttributeExpr{Type: expr.String} + attribute := &expr.AttributeExpr{Type: &expr.Object{ + {Name: "first", Attribute: shared}, + {Name: "second", Attribute: shared}, + }} + layout, err := PlanGoType(attribute, GoTypePlanOptions{ + Owner: "example.com/generated", + Policy: GoLayoutPolicy{UseDefault: true, SumType: true}, + }) + require.NoError(t, err) + context := NewAttributeContext(false, false, true, "", NewNameScope()) + context, err = context.WithGoTypeLayout(layout.Link("example.com/generated", nil)) + require.NoError(t, err) + + require.Equal(t, "First", context.Scope.Field(shared, "first", true)) + require.Equal(t, "Second", context.Scope.Field(shared, "second", true)) +} + +func TestTransformPlanRejectsNonFunctionHelperDeclaration(t *testing.T) { + plan := siblingTransformPlan(t) + err := plan.BindHelperDeclaration( + plan.Helpers()[0].ID, + NewExactName(NameType, "TransformRecursive"), + ) + require.EqualError(t, err, "transform helper declaration must be a function, got type") +} + +func TestTransformPlanHelperEligibilityMatchesCompositeRenderers(t *testing.T) { + shapes := map[string]func(source, target *expr.AttributeExpr) (*expr.AttributeExpr, *expr.AttributeExpr){ + "array": func(source, target *expr.AttributeExpr) (*expr.AttributeExpr, *expr.AttributeExpr) { + return &expr.AttributeExpr{Type: &expr.Array{ElemType: source}}, + &expr.AttributeExpr{Type: &expr.Array{ElemType: target}} + }, + "map": func(source, target *expr.AttributeExpr) (*expr.AttributeExpr, *expr.AttributeExpr) { + return &expr.AttributeExpr{Type: &expr.Map{KeyType: &expr.AttributeExpr{Type: expr.String}, ElemType: source}}, + &expr.AttributeExpr{Type: &expr.Map{KeyType: &expr.AttributeExpr{Type: expr.String}, ElemType: target}} + }, + "union": func(source, target *expr.AttributeExpr) (*expr.AttributeExpr, *expr.AttributeExpr) { + return &expr.AttributeExpr{Type: &expr.Union{TypeName: "SourceChoice", Values: []*expr.NamedAttributeExpr{{Name: "value", Attribute: source}}}}, + &expr.AttributeExpr{Type: &expr.Union{TypeName: "TargetChoice", Values: []*expr.NamedAttributeExpr{{Name: "value", Attribute: target}}}} + }, + } + pairs := map[string]struct { + source, target *expr.AttributeExpr + helpers int + }{ + "both-named": { + source: transformObjectAttribute("SourceNode", true), + target: transformObjectAttribute("TargetNode", true), + helpers: 1, + }, + "anonymous-source": { + source: transformObjectAttribute("", false), + target: transformObjectAttribute("TargetNode", true), + }, + "anonymous-target": { + source: transformObjectAttribute("SourceNode", true), + target: transformObjectAttribute("", false), + }, + } + + for shapeName, shape := range shapes { + for pairName, pair := range pairs { + t.Run(shapeName+"/"+pairName, func(t *testing.T) { + source, target := shape(pair.source, pair.target) + plan, err := NewTransformPlan(source, target, "", nil) + require.NoError(t, err) + require.Len(t, plan.Helpers(), pair.helpers) + + code, helpers := renderTransformPlan(t, plan) + require.Len(t, helpers, pair.helpers) + if pair.helpers == 0 { + require.NotContains(t, code, "canonicalHelper") + } else { + require.Contains(t, code, "canonicalHelper1") + } + }) + } + } +} + +func TestTransformPlanGuardsOptionalSiblingCallsBeforeStrictHelpers(t *testing.T) { + plan := mixedSiblingTransformPlan(t) + code, definitions := renderTransformPlan(t, plan) + require.Len(t, definitions, 2) + + var required, optional *TransformFunctionData + for _, definition := range definitions { + if plan.Helpers()[definition.ID.index].Required { + required = definition + } else { + optional = definition + } + } + require.NotNil(t, required) + require.NotNil(t, optional) + require.NotContains(t, required.Code, "if v == nil") + require.NotContains(t, optional.Code, "if v == nil") + require.Contains(t, code, "target.Left = "+required.Declaration.Name()+"(source.Left)") + require.Contains(t, code, "if source.Right != nil {") + require.Contains(t, code, "target.Right = "+optional.Declaration.Name()+"(source.Right)") +} + +func TestTransformPlanMapKeyHelperReceivesKey(t *testing.T) { + sourceKey := transformObjectAttribute("SourceKey", true) + targetKey := transformObjectAttribute("TargetKey", true) + plan, err := NewTransformPlan( + &expr.AttributeExpr{Type: &expr.Map{ + KeyType: sourceKey, + ElemType: &expr.AttributeExpr{Type: expr.String}, + }}, + &expr.AttributeExpr{Type: &expr.Map{ + KeyType: targetKey, + ElemType: &expr.AttributeExpr{Type: expr.String}, + }}, + "", + nil, + ) + require.NoError(t, err) + require.Len(t, plan.Helpers(), 1) + require.NotSame(t, sourceKey, plan.Helpers()[0].Source) + require.NotSame(t, targetKey, plan.Helpers()[0].Target) + require.Equal(t, sourceKey.Type.Name(), plan.Helpers()[0].Source.Type.Name()) + require.Equal(t, targetKey.Type.Name(), plan.Helpers()[0].Target.Type.Name()) + + code, definitions := renderTransformPlan(t, plan) + require.Len(t, definitions, 1) + bound := plan.Helpers()[0] + require.Equal(t, bound.ID, definitions[0].ID) + require.Same(t, bound.Declaration, definitions[0].Declaration) + + generated := fmt.Sprintf(`package transformtest + +type SourceKey struct { + Value string +} + +type TargetKey struct { + Value string +} + +func transform(source map[*SourceKey]string) map[*TargetKey]string { +%s + return target +} + +func %s(v %s) %s { +%s + return res +} +`, code, definitions[0].Declaration.Name(), definitions[0].ParamTypeRef, + definitions[0].ResultTypeRef, definitions[0].Code) + compileTransformSource(t, generated) + require.Contains(t, code, definitions[0].Declaration.Name()+"(key)") +} + +// siblingTransformPlan builds two nonrecursive occurrences of the same named +// recursive type. Each field must own a helper even though both types share an +// authored origin. +func siblingTransformPlan(t *testing.T) *TransformPlan { + return siblingTransformProgramPlan(t, nil) +} + +// siblingTransformProgramPlan builds the sibling conversion with program. A +// nil program selects Goa's built-in conversion rules. +func siblingTransformProgramPlan(t *testing.T, program *TransformProgram) *TransformPlan { + t.Helper() + root := RunDSL(t, testdata.TestTypesDSL) + recursive := root.UserType("Recursive") + fields := &expr.Object{} + fields.Set("left", &expr.AttributeExpr{Type: recursive}) + fields.Set("right", &expr.AttributeExpr{Type: recursive}) + container := &expr.UserTypeExpr{ + AttributeExpr: &expr.AttributeExpr{Type: fields}, + TypeName: "Container", + } + source := &expr.AttributeExpr{Type: container} + target := &expr.AttributeExpr{Type: container} + var plan *TransformPlan + var err error + if program == nil { + plan, err = NewTransformPlan(source, target, "", nil) + } else { + plan, err = program.Plan(source, target, "") + } + require.NoError(t, err) + return plan +} + +// mixedSiblingTransformPlan builds required and optional occurrences of the +// same recursive named type in one transform operation. +func mixedSiblingTransformPlan(t *testing.T) *TransformPlan { + t.Helper() + root := RunDSL(t, testdata.TestTypesDSL) + recursive := root.UserType("Recursive") + fields := &expr.Object{} + fields.Set("left", &expr.AttributeExpr{Type: recursive}) + fields.Set("right", &expr.AttributeExpr{Type: recursive}) + container := &expr.UserTypeExpr{ + AttributeExpr: &expr.AttributeExpr{ + Type: fields, + Validation: &expr.ValidationExpr{Required: []string{"left"}}, + }, + TypeName: "Container", + } + plan, err := NewTransformPlan( + &expr.AttributeExpr{Type: container}, + &expr.AttributeExpr{Type: container}, + "", + nil, + ) + require.NoError(t, err) + return plan +} + +// transformObjectAttribute builds either a named or anonymous object with the +// same compatible field shape. +func transformObjectAttribute(name string, named bool) *expr.AttributeExpr { + object := &expr.Object{} + object.Set("value", &expr.AttributeExpr{Type: expr.String}) + if !named { + return &expr.AttributeExpr{Type: object} + } + return &expr.AttributeExpr{Type: &expr.UserTypeExpr{ + AttributeExpr: &expr.AttributeExpr{Type: object}, + TypeName: name, + }} +} + +// renderTransformPlan assigns fixed function declarations and contexts, then +// renders the stored operation and definitions. +func renderTransformPlan(t *testing.T, plan *TransformPlan) (string, []*TransformFunctionData) { + t.Helper() + packageCatalog := newGeneratedPackage("test", "example.com/test", "gen") + for index, helper := range plan.Helpers() { + declaration := NewExactName(NameFunction, fmt.Sprintf("canonicalHelper%d", index+1)) + require.NoError(t, packageCatalog.DeclareName(declaration)) + require.NoError(t, plan.BindHelperDeclaration(helper.ID, declaration)) + } + require.NoError(t, packageCatalog.freeze()) + require.NoError(t, plan.BindContexts( + NewAttributeContext(false, false, true, "", NewNameScope()), + NewAttributeContext(false, false, true, "", NewNameScope()), + )) + code, helpers, err := plan.Render("source", "target", true) + require.NoError(t, err) + return code, helpers +} + +// compileTransformSource proves that a rendered transform and its stored +// helper definitions agree on concrete Go argument and result types. +func compileTransformSource(t *testing.T, source string) { + t.Helper() + dir := t.TempDir() + require.NoError(t, os.WriteFile( + filepath.Join(dir, "go.mod"), + []byte("module example.com/transformtest\n\ngo 1.25.0\n"), + 0o600, + )) + require.NoError(t, os.WriteFile(filepath.Join(dir, "transform.go"), []byte(source), 0o600)) + command := exec.Command("go", "test", "./...") + command.Dir = dir + output, err := command.CombinedOutput() + if err != nil { + t.Fatalf("generated transform did not compile: %v\n%s", err, output) + } +} + +// newTransformOwnerAttributor creates a test type-name provider and records +// every nested type it enters. +func newTransformOwnerAttributor(prefix string) *transformOwnerAttributor { + entered := make([]string, 0) + return &transformOwnerAttributor{ + prefix: prefix, + scope: NewNameScope(), + entered: &entered, + } +} + +func (a *transformOwnerAttributor) Name(att *expr.AttributeExpr, _ string, _, _ bool) string { + return a.owner + "." + codegenTypeName(att) +} + +func (a *transformOwnerAttributor) Ref(att *expr.AttributeExpr, pkg string) string { + name := a.Name(att, pkg, false, false) + if expr.IsObject(att.Type) || expr.IsUnion(att.Type) { + return "*" + name + } + return name +} + +func (*transformOwnerAttributor) Field(_ *expr.AttributeExpr, name string, firstUpper bool) string { + return Goify(name, firstUpper) +} + +func (a *transformOwnerAttributor) Package(_ *expr.AttributeExpr) string { + return a.owner +} + +func (a *transformOwnerAttributor) Enter(att *expr.AttributeExpr) Attributor { + entered := *a + entered.owner = a.prefix + codegenTypeName(att) + *a.entered = append(*a.entered, entered.owner) + return &entered +} + +func (*transformOwnerAttributor) IsSumType() bool { + return true +} + +func (a *transformOwnerAttributor) ValidatorCall(att *expr.AttributeExpr, view, target, _ string) string { + name := "Validate" + a.Name(att, "", false, true) + Goify(view, true) + return fmt.Sprintf("%s(%s)", name, target) +} + +func (a *transformOwnerAttributor) Scope() *NameScope { + return a.scope +} + +// Field records the expression identity before delegating the field name. +func (a *transformIdentityAttributor) Field(attribute *expr.AttributeExpr, name string, firstUpper bool) string { + *a.fields = append(*a.fields, attribute) + return a.Attributor.Field(attribute, name, firstUpper) +} + +// Enter keeps recording identities below the supplied object. +func (a *transformIdentityAttributor) Enter(attribute *expr.AttributeExpr) Attributor { + return &transformIdentityAttributor{ + Attributor: a.Attributor.Enter(attribute), + fields: a.fields, + } +} + +// transformOwnerTestType builds a nested union type assigned to the requested +// generated package. +func transformOwnerTestType(name, unionName, location string) expr.UserType { + union := &expr.Union{ + TypeName: unionName, + Values: []*expr.NamedAttributeExpr{ + {Name: "text", Attribute: &expr.AttributeExpr{Type: expr.String}}, + {Name: "number", Attribute: &expr.AttributeExpr{Type: expr.Int}}, + }, + } + container := &expr.UserTypeExpr{ + TypeName: unionName + "Container", + AttributeExpr: &expr.AttributeExpr{ + Type: &expr.Object{ + {Name: "choice", Attribute: &expr.AttributeExpr{Type: union}}, + }, + Meta: expr.MetaExpr{"struct:pkg:path": {location}}, + }, + } + return &expr.UserTypeExpr{ + TypeName: name, + AttributeExpr: &expr.AttributeExpr{ + Type: &expr.Object{ + {Name: "inner", Attribute: &expr.AttributeExpr{Type: container}}, + }, + Meta: expr.MetaExpr{"struct:pkg:path": {location}}, + }, + } +} + +// codegenTypeName returns the Go name used by transformOwnerAttributor for one +// test type. +func codegenTypeName(att *expr.AttributeExpr) string { + if att.Type.Name() == "object" { + return "Object" + } + return Goify(att.Type.Name(), true) +} diff --git a/codegen/go_type_plan.go b/codegen/go_type_plan.go new file mode 100644 index 0000000000..62b6764e6a --- /dev/null +++ b/codegen/go_type_plan.go @@ -0,0 +1,869 @@ +// This file records each Go type before generated package names are final. +// Formatting later uses the copied field names, package paths, and child types +// without reading the Goa expressions again. +package codegen + +import ( + "fmt" + "strings" + + "goa.design/goa/v3/expr" +) + +type ( + // GoTypeKind states how a planned value is represented in Go. + GoTypeKind uint8 + + // GoTypeImport describes one package used in a planned Go type. + GoTypeImport struct { + // Name is the package name requested before the type, when present. + Name string + // Path is the Go import path. + Path string + } + + // GoTypeBindingRequest asks which generated declaration and package contain + // one attribute's named type or union. + GoTypeBindingRequest struct { + // Attribute is the expression being planned. + Attribute *expr.AttributeExpr + // InheritedOwner is the package path inherited from the enclosing type. + InheritedOwner string + // Kind says whether Attribute contains a named type or a union. + Kind GoTypeKind + } + + // GoTypeBinding gives a planned attribute the package path and generated + // declaration that will represent it. Service types set Type or Union; + // other generators set Declaration. + GoTypeBinding struct { + // Owner is the import path of the package containing the declaration. + Owner string + // Type is the generated declaration for a named user type. + Type *TypeDeclaration + // Union is the generated declaration for a union. + Union *UnionDeclaration + // Declaration is the generated type declaration when the owning generator + // does not register a service TypeDeclaration or UnionDeclaration. + Declaration *NameDeclaration + name string + } + + // GoTypeBinder returns the package path and generated declaration for a named + // type or union. PlanGoType does not choose these values itself. + GoTypeBinder func(GoTypeBindingRequest) (GoTypeBinding, error) + + // GoLayoutPolicy contains the pointer and validation choices used throughout + // one planned Go type. + GoLayoutPolicy struct { + // Pointer forces primitive object fields to use pointers. + Pointer bool + // IgnoreRequired suppresses required checks for primitive transport fields. + IgnoreRequired bool + // IgnoreRequiredCollections suppresses array and map presence checks when + // the transport represents absent and empty collections identically. + IgnoreRequiredCollections bool + // UseDefault keeps optional primitive fields with defaults as values. + UseDefault bool + // UnionPointer uses pointers for optional sum-type union fields and for + // required union fields when Pointer is also true. + UnionPointer bool + // ArrayElementPointer uses pointers for required primitive array elements + // when generated input validation must distinguish null from a zero value. + ArrayElementPointer bool + // SumType reports whether unions use Goa's generated struct form. + SumType bool + } + + // GoTypePlanOptions supplies the package, field name, and rules used to plan + // one attribute. + GoTypePlanOptions struct { + // Owner is the package path that will contain the top-level attribute. + Owner string + // FieldName is the design field name of the top-level attribute, when set. + FieldName string + // Policy contains the pointer and validation choices selected by the caller. + Policy GoLayoutPolicy + // Bind returns the generated declaration for every named type and union. + Bind GoTypeBinder + // RetainNamedValue records the fields or elements beneath named types for + // callers that render complete values. + RetainNamedValue bool + } + + // GoTypePlan stores the complete Go form copied from one attribute. It keeps + // expression pointers only so callers can find which plans came from the same + // attribute; its methods do not read those expressions. + GoTypePlan struct { + kind GoTypeKind + owner string + policy GoLayoutPolicy + occurrence *expr.AttributeExpr + fieldNameUpper string + fieldNameLower string + description string + comment string + tag string + fieldPointer bool + definitionPointer bool + referencePointer bool + referenceNilable bool + primitive string + directImport GoTypeImport + hasDirectImport bool + customQualifier string + typeDeclaration *TypeDeclaration + unionDeclaration *UnionDeclaration + declaration *NameDeclaration + fixedName string + fields []*GoTypePlan + branches []*GoTypePlan + element *GoTypePlan + key *GoTypePlan + value *GoTypePlan + } + + // GoTypeQualifier returns the final package name written before a type from + // the given import path. + GoTypeQualifier func(importPath string) string + + // LinkedGoType formats a planned type for one output package after all type + // names and imported package names are final. + LinkedGoType struct { + plan *GoTypePlan + outputPath string + qualifier GoTypeQualifier + } + + // goTypePlanner reads attributes and builds GoTypePlan values. + goTypePlanner struct { + policy GoLayoutPolicy + bind GoTypeBinder + activeNamedValues map[expr.UserType]*GoTypePlan + retainNamedValue bool + } +) + +const ( + // GoPrimitive is a built-in or explicitly imported primitive type. + GoPrimitive GoTypeKind = iota + 1 + // GoArray is a slice with one planned element type. + GoArray + // GoMap is a map with planned key and element types. + GoMap + // GoStruct is an anonymous struct with fields in source order. + GoStruct + // GoNamed is a user type with a generated type declaration. + GoNamed + // GoUnion is a union with a generated union declaration. + GoUnion + // GoEmpty is Goa's built-in empty service type. + GoEmpty + // GoServiceError is Goa's built-in service error type. + GoServiceError +) + +// PlanGoType copies the Go form of attribute before generated type and imported +// package names are final. Callers format the result after Generation.Freeze +// chooses those names. +func PlanGoType(attribute *expr.AttributeExpr, options GoTypePlanOptions) (*GoTypePlan, error) { + if attribute == nil { + return nil, fmt.Errorf("plan Go type: attribute must not be nil") + } + if options.Owner == "" { + return nil, fmt.Errorf("plan Go type: inherited owner must not be empty") + } + planner := goTypePlanner{ + policy: options.Policy, + bind: options.Bind, + activeNamedValues: make(map[expr.UserType]*GoTypePlan), + retainNamedValue: options.RetainNamedValue, + } + return planner.plan(attribute, options.Owner, options.FieldName, nil, false) +} + +// String returns the name of the Go type kind used in error messages. +func (k GoTypeKind) String() string { + switch k { + case GoPrimitive: + return "primitive" + case GoArray: + return "array" + case GoMap: + return "map" + case GoStruct: + return "struct" + case GoNamed: + return "named type" + case GoUnion: + return "union" + case GoEmpty: + return "empty type" + case GoServiceError: + return "service error" + default: + return "unknown" + } +} + +// Kind returns how this planned value is represented in Go. +func (p *GoTypePlan) Kind() GoTypeKind { + return p.kind +} + +// Owner returns the import path of the package containing this type. +func (p *GoTypePlan) Owner() string { + return p.owner +} + +// Policy returns the pointer and validation choices used for this type. +func (p *GoTypePlan) Policy() GoLayoutPolicy { + return p.policy +} + +// MatchesOccurrence reports whether PlanGoType built this plan from attribute. +// It compares pointers without reading the expression. +func (p *GoTypePlan) MatchesOccurrence(attribute *expr.AttributeExpr) bool { + return p.occurrence == attribute +} + +// PlansForOccurrence returns every child plan built from attribute. The same +// attribute may produce several plans with different field names, package +// paths, or pointer choices. +func (p *GoTypePlan) PlansForOccurrence(attribute *expr.AttributeExpr) []*GoTypePlan { + var matches []*GoTypePlan + p.walk(func(candidate *GoTypePlan) { + if candidate.occurrence == attribute { + matches = append(matches, candidate) + } + }) + return matches +} + +// TypeDeclaration returns the generated declaration for a named user type. It +// returns nil for every other kind. +func (p *GoTypePlan) TypeDeclaration() *TypeDeclaration { + return p.typeDeclaration +} + +// UnionDeclaration returns the generated declaration for a union. It returns +// nil for every other kind. +func (p *GoTypePlan) UnionDeclaration() *UnionDeclaration { + return p.unionDeclaration +} + +// FieldName returns the copied Go field name. It returns an exported name when +// firstUpper is true and an unexported name otherwise. +func (p *GoTypePlan) FieldName(firstUpper bool) string { + if firstUpper { + return p.fieldNameUpper + } + return p.fieldNameLower +} + +// Description returns the description copied from the attribute. +func (p *GoTypePlan) Description() string { + return p.description +} + +// Tag returns the complete copied Go struct tag, including its leading space. +func (p *GoTypePlan) Tag() string { + return p.tag +} + +// IsPointer reports whether an enclosing struct stores this value through a +// pointer under the selected pointer and default rules. +func (p *GoTypePlan) IsPointer() bool { + return p.fieldPointer +} + +// ReferenceIsPointer reports whether this type is written with a leading +// pointer when it is used as a value. Named objects and unions use pointers; +// primitive aliases, arrays, and maps use values. +func (p *GoTypePlan) ReferenceIsPointer() bool { + return p.referencePointer +} + +// ReferenceCanBeNil reports whether the generated reference can represent an +// absent value without another pointer. +func (p *GoTypePlan) ReferenceCanBeNil() bool { + return p.referenceNilable +} + +// Import returns the package written directly in this type name. The second +// result is false for built-in types and generated declarations. +func (p *GoTypePlan) Import() (GoTypeImport, bool) { + return p.directImport, p.hasDirectImport +} + +// ImportPreferences returns each requested imported package name and each +// generated type package found in this plan, in field order. It keeps different +// requested names for the same path so Generation can choose the final name. +func (p *GoTypePlan) ImportPreferences() []GoTypeImport { + return p.importPreferences(p.walkImports) +} + +// CompleteImportPreferences returns every imported package used by the full +// retained type. Unlike ImportPreferences, it follows fields below named types +// and union branches. Generators use it when one file writes code that reads or +// constructs those nested values. +func (p *GoTypePlan) CompleteImportPreferences() []GoTypeImport { + return p.importPreferences(p.walk) +} + +// importPreferences collects each distinct package request in traversal order. +func (p *GoTypePlan) importPreferences(walk func(func(*GoTypePlan))) []GoTypeImport { + seen := make(map[GoTypeImport]struct{}) + var imports []GoTypeImport + walk(func(candidate *GoTypePlan) { + var goImport GoTypeImport + switch { + case candidate.hasDirectImport: + goImport = candidate.directImport + case candidate.declaration != nil: + goImport = GoTypeImport{Path: candidate.owner} + default: + return + } + if _, exists := seen[goImport]; exists { + return + } + seen[goImport] = struct{}{} + imports = append(imports, goImport) + }) + return imports +} + +// Fields returns a copy of the anonymous struct fields in source order. +func (p *GoTypePlan) Fields() []*GoTypePlan { + return append([]*GoTypePlan(nil), p.fields...) +} + +// Branches returns a copy of the union branches in source order. +func (p *GoTypePlan) Branches() []*GoTypePlan { + return append([]*GoTypePlan(nil), p.branches...) +} + +// Elem returns the planned array or map element type. It returns nil for other +// kinds. +func (p *GoTypePlan) Elem() *GoTypePlan { + return p.element +} + +// Key returns the planned map key type. It returns nil for other kinds. +func (p *GoTypePlan) Key() *GoTypePlan { + return p.key +} + +// Equivalent reports whether p and other produce the same Go type. It compares +// declarations, package paths, pointer choices, field names, tags, imports, and +// child types, but does not compare source expression pointers. +func (p *GoTypePlan) Equivalent(other *GoTypePlan) bool { + if p == nil || other == nil { + return p == other + } + if p.kind != other.kind || p.owner != other.owner || p.policy != other.policy || + p.fieldNameUpper != other.fieldNameUpper || p.fieldNameLower != other.fieldNameLower || + p.description != other.description || p.comment != other.comment || p.tag != other.tag || + p.fieldPointer != other.fieldPointer || p.definitionPointer != other.definitionPointer || + p.referencePointer != other.referencePointer || p.referenceNilable != other.referenceNilable || p.primitive != other.primitive || + p.directImport != other.directImport || p.hasDirectImport != other.hasDirectImport || + p.customQualifier != other.customQualifier || p.typeDeclaration != other.typeDeclaration || + p.unionDeclaration != other.unionDeclaration || p.declaration != other.declaration || p.fixedName != other.fixedName || len(p.fields) != len(other.fields) || + len(p.branches) != len(other.branches) { + return false + } + if !p.key.Equivalent(other.key) || !p.element.Equivalent(other.element) { + return false + } + for index := range p.fields { + if !p.fields[index].Equivalent(other.fields[index]) { + return false + } + } + for index := range p.branches { + if !p.branches[index].Equivalent(other.branches[index]) { + return false + } + } + return true +} + +// Link prepares this plan for formatting in outputPath after generated type and +// imported package names are final. The returned value uses only data already +// copied into the plan. +func (p *GoTypePlan) Link(outputPath string, qualifier GoTypeQualifier) LinkedGoType { + return LinkedGoType{plan: p, outputPath: outputPath, qualifier: qualifier} +} + +// Name returns the Go type name selected by the plan. +func (l LinkedGoType) Name() string { + if l.plan.fixedName != "" { + return l.plan.fixedName + } + switch l.plan.kind { + case GoPrimitive: + if !l.plan.hasDirectImport || l.plan.customQualifier == "" { + return l.plan.primitive + } + return rebindMetaTypeQualifier( + l.plan.primitive, + l.plan.customQualifier, + l.qualify(l.plan.directImport.Path), + ) + case GoArray: + element := l.Enter(l.plan.element) + return "[]" + element.RefWithPointer( + element.ReferenceIsPointer() || + l.plan.element.definitionPointer && l.plan.element.kind != GoStruct, + ) + case GoMap: + key := l.Enter(l.plan.key) + element := l.Enter(l.plan.element) + return fmt.Sprintf( + "map[%s]%s", + key.RefWithPointer(key.ReferenceIsPointer() || + l.plan.key.definitionPointer && l.plan.key.kind != GoStruct), + element.RefWithPointer(element.ReferenceIsPointer() || + l.plan.element.definitionPointer && l.plan.element.kind != GoStruct), + ) + case GoStruct: + return l.Def() + case GoNamed: + return l.qualifiedDeclaration(l.plan.declaration) + case GoUnion: + return l.qualifiedDeclaration(l.plan.declaration) + case GoEmpty: + return "struct {}" + case GoServiceError: + return l.qualify(l.plan.directImport.Path) + ".ServiceError" + default: + panic(fmt.Sprintf("format unknown retained Go type kind %d", l.plan.kind)) + } +} + +// Def returns the complete Go type definition selected by the plan. +func (l LinkedGoType) Def() string { + switch l.plan.kind { + case GoArray: + element := l.Enter(l.plan.element).Def() + if l.plan.element.definitionPointer { + element = "*" + element + } + return "[]" + element + case GoMap: + key := l.Enter(l.plan.key).Def() + if l.plan.key.definitionPointer { + key = "*" + key + } + element := l.Enter(l.plan.element).Def() + if l.plan.element.definitionPointer { + element = "*" + element + } + return fmt.Sprintf("map[%s]%s", key, element) + case GoStruct: + lines := []string{"struct {"} + for _, field := range l.plan.fields { + fieldType := l.Enter(field).Def() + if field.fieldPointer { + fieldType = "*" + fieldType + } + var description string + if field.comment != "" { + description = field.comment + "\n\t" + } + lines = append(lines, fmt.Sprintf( + "\t%s%s %s%s", + description, + field.fieldNameUpper, + fieldType, + field.tag, + )) + } + return strings.Join(append(lines, "}"), "\n") + default: + return l.Name() + } +} + +// Ref returns the Go type reference, including any pointer required for a named +// object or union. +func (l LinkedGoType) Ref() string { + return l.RefWithPointer(l.plan.referencePointer) +} + +// RefWithPointer returns this planned type with the requested top-level +// pointer. Child pointer choices remain unchanged. +func (l LinkedGoType) RefWithPointer(pointer bool) string { + if pointer { + return "*" + l.Name() + } + return l.Name() +} + +// Kind returns how this linked value is represented in Go. +func (l LinkedGoType) Kind() GoTypeKind { + return l.plan.Kind() +} + +// ReferenceIsPointer reports whether the ordinary reference uses a pointer. +func (l LinkedGoType) ReferenceIsPointer() bool { + return l.plan.ReferenceIsPointer() +} + +// ReferenceCanBeNil reports whether the ordinary reference can represent an +// absent value. +func (l LinkedGoType) ReferenceCanBeNil() bool { + return l.plan.ReferenceCanBeNil() +} + +// Field returns the copied Go field name for this planned value. +func (l LinkedGoType) Field(firstUpper bool) string { + return l.plan.FieldName(firstUpper) +} + +// Package returns the package name written before this type when referenced +// from the output package. It returns an empty string when both types are in the +// same package. +func (l LinkedGoType) Package() string { + var importPath string + switch l.plan.kind { + case GoPrimitive: + if l.plan.hasDirectImport && l.plan.customQualifier != "" { + importPath = l.plan.directImport.Path + } + case GoNamed, GoUnion: + importPath = l.plan.owner + case GoServiceError: + importPath = l.plan.directImport.Path + } + if importPath == "" || importPath == l.outputPath { + return "" + } + return l.qualify(importPath) +} + +// Enter returns a formatter for child that uses the same output package and +// imported package name lookup. +func (l LinkedGoType) Enter(child *GoTypePlan) LinkedGoType { + if child == nil { + panic("enter nil retained Go type plan") + } + return LinkedGoType{plan: child, outputPath: l.outputPath, qualifier: l.qualifier} +} + +// Imports returns every package used by this type and its children except the +// output package itself. +func (l LinkedGoType) Imports() []GoTypeImport { + preferences := l.plan.ImportPreferences() + seen := make(map[string]struct{}) + imports := make([]GoTypeImport, 0, len(preferences)) + for _, preference := range preferences { + if preference.Path == l.outputPath { + continue + } + if _, exists := seen[preference.Path]; exists { + continue + } + seen[preference.Path] = struct{}{} + imports = append(imports, GoTypeImport{ + Name: l.qualify(preference.Path), + Path: preference.Path, + }) + } + return imports +} + +// plan copies one attribute and recursively plans anonymous child types. Named +// types stop at their generated declaration. +func (p goTypePlanner) plan(attribute *expr.AttributeExpr, owner, fieldName string, parent *expr.AttributeExpr, definitionPointer bool) (*GoTypePlan, error) { + layoutAttribute := attribute + for { + if _, named := layoutAttribute.Type.(expr.UserType); named { + break + } + composite, ok := layoutAttribute.Type.(expr.CompositeExpr) + if !ok { + break + } + layoutAttribute = composite.Attribute() + } + plan := &GoTypePlan{ + owner: owner, + policy: p.policy, + occurrence: attribute, + fieldNameUpper: GoifyAtt(attribute, fieldName, true), + fieldNameLower: GoifyAtt(attribute, fieldName, false), + description: attribute.Description, + tag: AttributeTagsWithName(parent, fieldName, attribute), + definitionPointer: definitionPointer, + } + if attribute.Description != "" { + plan.comment = Comment(attribute.Description) + } + if parent != nil { + field := expr.AsObject(parent.Type).Attribute(fieldName) + switch { + case expr.IsUnion(field.Type): + plan.fieldPointer = p.policy.UnionPointer && (!parent.IsRequired(fieldName) || p.policy.Pointer) + case !p.policy.SumType: + plan.fieldPointer = expr.IsPrimitive(field.Type) && + (p.policy.Pointer || parent.IsPrimitivePointer(fieldName, p.policy.UseDefault)) + default: + plan.fieldPointer = goFieldIsPointer(parent, fieldName, p.policy.Pointer, p.policy.UseDefault) + } + } + + dataType := layoutAttribute.Type + _, rawObject := dataType.(*expr.Object) + plan.referencePointer = !rawObject && (expr.IsObject(dataType) || expr.IsUnion(dataType)) + plan.referenceNilable = plan.referencePointer || !rawObject && IsNilable(dataType) + switch actual := dataType.(type) { + case expr.Primitive: + plan.kind = GoPrimitive + plan.primitive = GoNativeTypeName(actual) + if custom, importSpec := GetMetaType(layoutAttribute); custom != "" { + plan.primitive = custom + if importSpec != nil { + plan.directImport = GoTypeImport{Name: importSpec.Name, Path: importSpec.Path} + plan.hasDirectImport = true + plan.customQualifier = importSpec.preferredName() + } + } + case *expr.Array: + plan.kind = GoArray + elementPointer := expr.IsObject(actual.ElemType.Type) || + arrayElementIsPointer(actual, p.policy.ArrayElementPointer) + element, err := p.plan(actual.ElemType, owner, "", nil, elementPointer) + if err != nil { + return nil, err + } + plan.element = element + case *expr.Map: + plan.kind = GoMap + key, err := p.plan(actual.KeyType, owner, "", nil, expr.IsObject(actual.KeyType.Type)) + if err != nil { + return nil, err + } + element, err := p.plan(actual.ElemType, owner, "", nil, expr.IsObject(actual.ElemType.Type)) + if err != nil { + return nil, err + } + plan.key = key + plan.element = element + case *expr.Object: + plan.kind = GoStruct + plan.fields = make([]*GoTypePlan, len(*actual)) + for index, field := range *actual { + child, err := p.plan(field.Attribute, owner, field.Name, layoutAttribute, false) + if err != nil { + return nil, err + } + plan.fields[index] = child + } + case expr.UserType: + switch actual { + case expr.Empty: + plan.kind = GoEmpty + case expr.ErrorResult: + plan.kind = GoServiceError + goaImport := GoaImport("") + plan.directImport = GoTypeImport{Name: goaImport.Name, Path: goaImport.Path} + plan.hasDirectImport = true + default: + plan.kind = GoNamed + binding, err := p.binding(layoutAttribute, owner, GoNamed) + if err != nil { + return nil, err + } + plan.owner = binding.Owner + plan.typeDeclaration = binding.Type + plan.declaration = binding.declaration() + plan.fixedName = binding.name + if p.retainNamedValue { + value := p.activeNamedValues[actual] + if value == nil { + value = &GoTypePlan{} + p.activeNamedValues[actual] = value + planned, err := p.plan(actual.Attribute(), binding.Owner, "", nil, false) + if err != nil { + delete(p.activeNamedValues, actual) + return nil, err + } + *value = *planned + delete(p.activeNamedValues, actual) + } + plan.value = value + } + } + case *expr.Union: + plan.kind = GoUnion + binding, err := p.binding(layoutAttribute, owner, GoUnion) + if err != nil { + return nil, err + } + plan.owner = binding.Owner + plan.unionDeclaration = binding.Union + plan.declaration = binding.declaration() + plan.fixedName = binding.name + plan.branches = make([]*GoTypePlan, len(actual.Values)) + for index, branch := range actual.Values { + child, err := p.plan(branch.Attribute, binding.Owner, branch.Name, nil, false) + if err != nil { + return nil, err + } + plan.branches[index] = child + } + default: + return nil, fmt.Errorf("plan Go type: unsupported data type %T", actual) + } + return plan, nil +} + +// The planner asks the caller for the generated declaration and checks that its +// package path and type match the request. +func (p goTypePlanner) binding(attribute *expr.AttributeExpr, inheritedOwner string, kind GoTypeKind) (GoTypeBinding, error) { + if p.bind == nil { + return GoTypeBinding{}, fmt.Errorf("plan Go %s: declaration binder must not be nil", kind) + } + binding, err := p.bind(GoTypeBindingRequest{ + Attribute: attribute, + InheritedOwner: inheritedOwner, + Kind: kind, + }) + if err != nil { + return GoTypeBinding{}, fmt.Errorf("plan Go %s %q: %w", kind, attribute.Type.Name(), err) + } + if binding.Owner == "" { + return GoTypeBinding{}, fmt.Errorf("plan Go %s %q: binding owner must not be empty", kind, attribute.Type.Name()) + } + switch kind { + case GoNamed: + if binding.declarationCount() != 1 || binding.Union != nil { + return GoTypeBinding{}, fmt.Errorf("plan Go named type %q: binding requires one type declaration", attribute.Type.Name()) + } + if binding.name == "" && binding.declaration().packagePath() != binding.Owner { + declarationOwner := binding.declaration().packagePath() + return GoTypeBinding{}, fmt.Errorf( + "plan Go named type %q: binding owner %q does not match declaration owner %q", + attribute.Type.Name(), binding.Owner, declarationOwner, + ) + } + case GoUnion: + if binding.declarationCount() != 1 || binding.Type != nil { + return GoTypeBinding{}, fmt.Errorf("plan Go union %q: binding requires one union declaration", attribute.Type.Name()) + } + if binding.name == "" && binding.declaration().packagePath() != binding.Owner { + declarationOwner := binding.declaration().packagePath() + return GoTypeBinding{}, fmt.Errorf( + "plan Go union %q: binding owner %q does not match declaration owner %q", + attribute.Type.Name(), binding.Owner, declarationOwner, + ) + } + } + return binding, nil +} + +// declaration returns the generated name selected for this type binding. +func (b GoTypeBinding) declaration() *NameDeclaration { + if b.Declaration != nil { + return b.Declaration + } + if b.Type != nil { + return b.Type.Declaration() + } + if b.Union != nil { + return b.Union.Declaration() + } + return nil +} + +// declarationCount reports how many mutually exclusive name sources are set. +func (b GoTypeBinding) declarationCount() int { + count := 0 + if b.Type != nil { + count++ + } + if b.Union != nil { + count++ + } + if b.Declaration != nil { + count++ + } + if b.name != "" { + count++ + } + return count +} + +// walk visits this plan and then its children in their stored order. +func (p *GoTypePlan) walk(visit func(*GoTypePlan)) { + p.walkOnce(visit, make(map[*GoTypePlan]struct{})) +} + +// walkOnce visits retained named values once so recursive types cannot cause +// the layout walk to repeat forever. +func (p *GoTypePlan) walkOnce(visit func(*GoTypePlan), seen map[*GoTypePlan]struct{}) { + if _, ok := seen[p]; ok { + return + } + seen[p] = struct{}{} + visit(p) + if p.key != nil { + p.key.walkOnce(visit, seen) + } + if p.element != nil { + p.element.walkOnce(visit, seen) + } + for _, field := range p.fields { + field.walkOnce(visit, seen) + } + for _, branch := range p.branches { + branch.walkOnce(visit, seen) + } + if p.value != nil { + p.value.walkOnce(visit, seen) + } +} + +// walkImports visits the types whose packages must be imported by the current +// file. It stops at a union because the file that declares the union imports +// the packages used by its branches. +func (p *GoTypePlan) walkImports(visit func(*GoTypePlan)) { + visit(p) + if p.kind == GoUnion { + return + } + if p.key != nil { + p.key.walkImports(visit) + } + if p.element != nil { + p.element.walkImports(visit) + } + for _, field := range p.fields { + field.walkImports(visit) + } +} + +// qualify returns the package name written before types from importPath. It +// panics when no usable name was planned. +func (l LinkedGoType) qualify(importPath string) string { + if l.qualifier == nil { + panic(fmt.Sprintf("format retained Go type import %q without qualifier lookup", importPath)) + } + qualifier := l.qualifier(importPath) + if qualifier == "" { + panic(fmt.Sprintf("format retained Go type import %q with empty qualifier", importPath)) + } + return qualifier +} + +// qualifiedDeclaration adds the declaring package name before a generated type +// when that type is outside the output package. +func (l LinkedGoType) qualifiedDeclaration(declaration *NameDeclaration) string { + name := declaration.Name() + if l.plan.owner == l.outputPath { + return name + } + return l.qualify(l.plan.owner) + "." + name +} diff --git a/codegen/go_type_plan_test.go b/codegen/go_type_plan_test.go new file mode 100644 index 0000000000..370a38018d --- /dev/null +++ b/codegen/go_type_plan_test.go @@ -0,0 +1,796 @@ +// This file verifies that Go type planning retains every expression-derived +// layout decision before generated package names freeze. +package codegen + +import ( + "fmt" + "testing" + + "github.com/stretchr/testify/require" + + "goa.design/goa/v3/expr" +) + +// TestGoTypePlanRetainsNestedOwners verifies that a binding changes the owner +// inherited by declarations nested beneath the bound occurrence. +func TestGoTypePlanRetainsNestedOwners(t *testing.T) { + const ( + rootOwner = "generated.local/gen/service" + unionOwner = "generated.local/gen/unions" + ) + generation, err := NewGeneration("generated.local/gen", nil) + require.NoError(t, err) + branch := goTypeTestUserType("ChoiceText", expr.String) + union := &expr.Union{ + TypeName: "Choice", + Values: []*expr.NamedAttributeExpr{ + {Name: "text", Attribute: &expr.AttributeExpr{Type: branch}}, + }, + } + attribute := &expr.AttributeExpr{Type: &expr.Object{ + {Name: "choice", Attribute: &expr.AttributeExpr{Type: union}}, + }} + unionDeclaration := declareGoTypeTestUnion(t, generation, unionOwner, union) + branchDeclaration := declareGoTypeTestUserType(t, generation, unionOwner, branch) + binder := func(request GoTypeBindingRequest) (GoTypeBinding, error) { + switch request.Attribute.Type { + case union: + require.Equal(t, rootOwner, request.InheritedOwner) + return GoTypeBinding{Owner: unionOwner, Union: unionDeclaration}, nil + case branch: + require.Equal(t, unionOwner, request.InheritedOwner) + return GoTypeBinding{Owner: request.InheritedOwner, Type: branchDeclaration}, nil + default: + return GoTypeBinding{}, fmt.Errorf("unexpected binding for %T", request.Attribute.Type) + } + } + + plan, err := PlanGoType(attribute, GoTypePlanOptions{ + Owner: rootOwner, + Policy: GoLayoutPolicy{UseDefault: true, SumType: true}, + Bind: binder, + }) + require.NoError(t, err) + choice := plan.Fields()[0] + require.Equal(t, GoUnion, choice.Kind()) + require.Equal(t, unionOwner, choice.Owner()) + require.Same(t, unionDeclaration, choice.UnionDeclaration()) + require.Len(t, choice.Branches(), 1) + require.Equal(t, unionOwner, choice.Branches()[0].Owner()) + require.Same(t, branchDeclaration, choice.Branches()[0].TypeDeclaration()) + require.Equal(t, []GoTypeImport{{Path: unionOwner}}, plan.ImportPreferences()) + + require.NoError(t, generation.Freeze()) + formatter := plan.Link(rootOwner, goTypeTestQualifier) + require.Equal(t, "struct {\n\tChoice unions.Choice\n}", formatter.Def()) + require.Equal(t, "unions", formatter.Enter(choice).Package()) +} + +// TestGoTypePlanUnionReferenceOwnsOnlyItsDeclarationImport verifies that a +// file referring to a named union does not import packages used only by the +// separate file that defines the union branches. +func TestGoTypePlanUnionReferenceOwnsOnlyItsDeclarationImport(t *testing.T) { + const ( + outputOwner = "generated.local/gen/service" + unionOwner = "generated.local/gen/unions" + branchOwner = "example.com/branch" + ) + generation, err := NewGeneration("generated.local/gen", nil) + require.NoError(t, err) + union := &expr.Union{ + TypeName: "Choice", + Values: []*expr.NamedAttributeExpr{ + {Name: "value", Attribute: &expr.AttributeExpr{ + Type: expr.String, + Meta: expr.MetaExpr{ + "struct:field:type": {"branch.Value", branchOwner, "branch"}, + }, + }}, + }, + } + declaration := declareGoTypeTestUnion(t, generation, unionOwner, union) + plan, err := PlanGoType(&expr.AttributeExpr{Type: union}, GoTypePlanOptions{ + Owner: outputOwner, + Policy: GoLayoutPolicy{UseDefault: true, SumType: true}, + Bind: goTypeTestBinder(map[expr.DataType]GoTypeBinding{ + union: {Owner: unionOwner, Union: declaration}, + }), + }) + require.NoError(t, err) + require.Equal(t, []GoTypeImport{{Path: unionOwner}}, plan.ImportPreferences()) + + require.NoError(t, generation.Freeze()) + linked := plan.Link(outputOwner, func(importPath string) string { + require.Equal(t, unionOwner, importPath) + return "unions" + }) + require.Equal(t, "unions.Choice", linked.Name()) + require.Equal(t, []GoTypeImport{{Name: "unions", Path: unionOwner}}, linked.Imports()) +} + +// TestGoTypePlanRetainsFieldMetadata verifies field names, comments, tags, and +// custom primitive import identity are copied during planning. +func TestGoTypePlanRetainsFieldMetadata(t *testing.T) { + field := &expr.AttributeExpr{ + Type: expr.String, + Description: "stored payload bytes", + Meta: expr.MetaExpr{ + "struct:field:name": {"PayloadID"}, + "struct:field:type": {"json.RawMessage", "encoding/json", "json"}, + "struct:tag:json:name": {"payload_id"}, + "struct:tag:xml": {"payload"}, + }, + } + attribute := &expr.AttributeExpr{Type: &expr.Object{ + {Name: "payload", Attribute: field}, + }} + + plan, err := PlanGoType(attribute, GoTypePlanOptions{ + Owner: "generated.local/gen/service", + Policy: GoLayoutPolicy{UseDefault: true, SumType: true}, + }) + require.NoError(t, err) + plannedField := plan.Fields()[0] + require.Equal(t, "PayloadID", plannedField.FieldName(true)) + require.Equal(t, "payloadID", plannedField.FieldName(false)) + require.Equal(t, "stored payload bytes", plannedField.Description()) + require.Equal(t, " `json:\"payload_id,omitempty\" xml:\"payload\"`", plannedField.Tag()) + goImport, ok := plannedField.Import() + require.True(t, ok) + require.Equal(t, GoTypeImport{Name: "json", Path: "encoding/json"}, goImport) + require.Equal(t, []GoTypeImport{{Name: "json", Path: "encoding/json"}}, plan.ImportPreferences()) + + formatter := plan.Link("generated.local/gen/service", func(importPath string) string { + require.Equal(t, "encoding/json", importPath) + return "json2" + }) + require.Equal(t, "PayloadID", formatter.Enter(plannedField).Field(true)) + require.Equal(t, "struct {\n\t// stored payload bytes\n\tPayloadID *json2.RawMessage `json:\"payload_id,omitempty\" xml:\"payload\"`\n}", formatter.Def()) +} + +// TestGoTypePlanRebindsCustomTypeQualifier verifies that linking changes only +// the imported package identifier and preserves the complete authored Go type. +func TestGoTypePlanRebindsCustomTypeQualifier(t *testing.T) { + const importPath = "example.com/wire" + tests := []struct { + name string + custom string + want string + }{ + {name: "pointer", custom: "*wire.Value", want: "*wire2.Value"}, + {name: "slice", custom: "[]wire.Value", want: "[]wire2.Value"}, + {name: "nested pointer slice", custom: "[]*wire.Value", want: "[]*wire2.Value"}, + {name: "map", custom: "map[wire.Key]wire.Value", want: "map[wire2.Key]wire2.Value"}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + attribute := &expr.AttributeExpr{ + Type: expr.String, + Meta: expr.MetaExpr{ + "struct:field:type": {test.custom, importPath, "wire"}, + }, + } + plan, err := PlanGoType(attribute, GoTypePlanOptions{ + Owner: "generated.local/gen/service", + Policy: GoLayoutPolicy{UseDefault: true, SumType: true}, + }) + require.NoError(t, err) + + linked := plan.Link("generated.local/gen/service", func(path string) string { + require.Equal(t, importPath, path) + return "wire2" + }) + require.Equal(t, test.want, linked.Name()) + require.Equal(t, "wire2", linked.Package()) + }) + } +} + +func TestLinkedGoTypePackageIgnoresInheritedOwnerForBuiltInType(t *testing.T) { + plan, err := PlanGoType(&expr.AttributeExpr{Type: expr.String}, GoTypePlanOptions{ + Owner: "generated.local/gen/http", + Policy: GoLayoutPolicy{UseDefault: true, SumType: true}, + }) + require.NoError(t, err) + linked := plan.Link("generated.local/gen/service", func(string) string { + panic("built-in type must not request a package name") + }) + + require.Empty(t, linked.Package()) +} + +// TestGoTypePlanRetainsServiceErrorImport verifies that the built-in service +// error type uses the frozen alias selected for Goa's runtime package. +func TestGoTypePlanRetainsServiceErrorImport(t *testing.T) { + const ( + owner = "generated.local/gen/service" + goaPath = "goa.design/goa/v3/pkg" + ) + plan, err := PlanGoType(&expr.AttributeExpr{Type: expr.ErrorResult}, GoTypePlanOptions{ + Owner: owner, + Policy: GoLayoutPolicy{UseDefault: true, SumType: true}, + }) + require.NoError(t, err) + require.Equal(t, []GoTypeImport{{Name: "goa", Path: goaPath}}, plan.ImportPreferences()) + + generation, err := NewGeneration("generated.local/gen", nil) + require.NoError(t, err) + pkg, err := generation.ClaimPackage(owner) + require.NoError(t, err) + require.NoError(t, pkg.RequireImport(NewImport("goa", "example.com/fixed/goa"))) + for _, preference := range plan.ImportPreferences() { + require.NoError(t, pkg.DeclareImport(NewImport(preference.Name, preference.Path))) + } + require.NoError(t, generation.Freeze()) + require.Equal(t, "goa2", pkg.ImportName(goaPath)) + + linked := plan.Link(owner, pkg.ImportName) + require.Equal(t, "goa2.ServiceError", linked.Name()) + require.Equal(t, "*goa2.ServiceError", linked.Ref()) + require.Equal(t, "goa2", linked.Package()) + require.Equal(t, []GoTypeImport{{Name: "goa2", Path: goaPath}}, linked.Imports()) +} + +// TestGoTypePlanRetainsPointerAndDefaultPolicy verifies field indirection is a +// planning decision rather than a formatting-time expression query. +func TestGoTypePlanRetainsPointerAndDefaultPolicy(t *testing.T) { + withDefault := &expr.AttributeExpr{Type: expr.String, DefaultValue: "ready"} + attribute := &expr.AttributeExpr{ + Type: &expr.Object{ + {Name: "required", Attribute: &expr.AttributeExpr{Type: expr.String}}, + {Name: "optional", Attribute: &expr.AttributeExpr{Type: expr.Int}}, + {Name: "defaulted", Attribute: withDefault}, + {Name: "bytes", Attribute: &expr.AttributeExpr{Type: expr.Bytes}}, + {Name: "nested", Attribute: &expr.AttributeExpr{Type: &expr.Object{}}}, + }, + Validation: &expr.ValidationExpr{Required: []string{"required"}}, + } + + tests := []struct { + name string + pointer bool + want []bool + def string + }{ + { + name: "Goa service policy", + want: []bool{false, true, false, false, true}, + def: "struct {\n\tRequired string\n\tOptional *int\n\tDefaulted string\n\tBytes []byte\n\tNested *struct {\n}\n}", + }, + { + name: "forced primitive pointers", + pointer: true, + want: []bool{true, true, true, false, true}, + def: "struct {\n\tRequired *string\n\tOptional *int\n\tDefaulted *string\n\tBytes []byte\n\tNested *struct {\n}\n}", + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + policy := GoLayoutPolicy{ + Pointer: test.pointer, + IgnoreRequired: true, + UseDefault: true, + UnionPointer: true, + SumType: true, + } + plan, err := PlanGoType(attribute, GoTypePlanOptions{ + Owner: "generated.local/gen/service", + Policy: policy, + }) + require.NoError(t, err) + require.Equal(t, policy, plan.Policy()) + fields := plan.Fields() + for index, want := range test.want { + require.Equal(t, want, fields[index].IsPointer(), fields[index].FieldName(true)) + } + require.Equal(t, test.def, plan.Link(plan.Owner(), goTypeTestQualifier).Def()) + }) + } +} + +// TestGoTypePlanReportsReferencePointers verifies that callers can use the +// planned pointer choice without parsing a formatted Go type. +func TestGoTypePlanReportsReferencePointers(t *testing.T) { + const owner = "example.com/gen/service" + generation, err := NewGeneration("example.com/gen", nil) + require.NoError(t, err) + object := goTypeTestUserType("Message", &expr.Object{}) + alias := goTypeTestUserType("Label", expr.String) + union := &expr.Union{ + TypeName: "Choice", + Values: []*expr.NamedAttributeExpr{ + {Name: "label", Attribute: &expr.AttributeExpr{Type: expr.String}}, + }, + } + binder := goTypeTestBinder(map[expr.DataType]GoTypeBinding{ + object: {Owner: owner, Type: declareGoTypeTestUserType(t, generation, owner, object)}, + alias: {Owner: owner, Type: declareGoTypeTestUserType(t, generation, owner, alias)}, + union: {Owner: owner, Union: declareGoTypeTestUnion(t, generation, owner, union)}, + }) + tests := []struct { + name string + attribute *expr.AttributeExpr + pointer bool + }{ + { + name: "object", + attribute: &expr.AttributeExpr{Type: object}, + pointer: true, + }, + { + name: "anonymous object", + attribute: &expr.AttributeExpr{Type: &expr.Object{}}, + pointer: false, + }, + { + name: "union", + attribute: &expr.AttributeExpr{Type: union}, + pointer: true, + }, + { + name: "primitive alias", + attribute: &expr.AttributeExpr{Type: alias}, + pointer: false, + }, + { + name: "array", + attribute: &expr.AttributeExpr{Type: &expr.Array{ElemType: &expr.AttributeExpr{Type: expr.String}}}, + pointer: false, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + plan, err := PlanGoType(test.attribute, GoTypePlanOptions{ + Owner: owner, + Bind: binder, + }) + require.NoError(t, err) + require.Equal(t, test.pointer, plan.ReferenceIsPointer()) + }) + } +} + +// TestGoTypePlanRetainsRequiredArrayElementPointers verifies that only JSON +// input layouts add pointers to primitive elements that must reject null. +func TestGoTypePlanRetainsRequiredArrayElementPointers(t *testing.T) { + const owner = "generated.local/gen/types" + generation, err := NewGeneration("generated.local/gen", nil) + require.NoError(t, err) + alias := goTypeTestUserType("Alias", expr.String) + bytesAlias := goTypeTestUserType("BytesAlias", expr.Bytes) + binder := goTypeTestBinder(map[expr.DataType]GoTypeBinding{ + alias: { + Owner: owner, + Type: declareGoTypeTestUserType(t, generation, owner, alias), + }, + bytesAlias: { + Owner: owner, + Type: declareGoTypeTestUserType(t, generation, owner, bytesAlias), + }, + }) + require.NoError(t, generation.Freeze()) + + tests := []struct { + name string + array *expr.Array + jsonBody bool + want string + }{ + { + name: "built-in string in JSON input", + array: &expr.Array{ElemType: &expr.AttributeExpr{Type: expr.String}, NonNullableElems: true}, + jsonBody: true, + want: "[]*string", + }, + { + name: "string alias in JSON input", + array: &expr.Array{ElemType: &expr.AttributeExpr{Type: alias}, NonNullableElems: true}, + jsonBody: true, + want: "[]*Alias", + }, + { + name: "ordinary string alias array", + array: &expr.Array{ElemType: &expr.AttributeExpr{Type: alias}}, + jsonBody: true, + want: "[]Alias", + }, + { + name: "service string alias array", + array: &expr.Array{ElemType: &expr.AttributeExpr{Type: alias}, NonNullableElems: true}, + want: "[]Alias", + }, + { + name: "bytes alias already represents null", + array: &expr.Array{ElemType: &expr.AttributeExpr{Type: bytesAlias}, NonNullableElems: true}, + jsonBody: true, + want: "[]BytesAlias", + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + plan, err := PlanGoType(&expr.AttributeExpr{Type: test.array}, GoTypePlanOptions{ + Owner: owner, + Policy: GoLayoutPolicy{ + UseDefault: true, + SumType: true, + ArrayElementPointer: test.jsonBody, + }, + Bind: binder, + }) + require.NoError(t, err) + linked := plan.Link(owner, goTypeTestQualifier) + require.Equal(t, test.want, linked.Name()) + require.Equal(t, test.want, linked.Def()) + require.Equal(t, test.want, linked.Ref()) + }) + } +} + +// TestGoTypePlanFormatsContainersAndUnions verifies array, map, raw struct, +// named object, and union layouts use their retained child and declaration +// policies after linking. +func TestGoTypePlanFormatsContainersAndUnions(t *testing.T) { + const owner = "generated.local/gen/types" + generation, err := NewGeneration("generated.local/gen", nil) + require.NoError(t, err) + item := goTypeTestUserType("Item", &expr.Object{}) + choice := &expr.Union{TypeName: "Choice"} + itemDeclaration := declareGoTypeTestUserType(t, generation, owner, item) + choiceDeclaration := declareGoTypeTestUnion(t, generation, owner, choice) + binder := goTypeTestBinder(map[expr.DataType]GoTypeBinding{ + item: {Owner: owner, Type: itemDeclaration}, + choice: {Owner: owner, Union: choiceDeclaration}, + }) + tests := []struct { + name string + att *expr.AttributeExpr + kind GoTypeKind + wantName string + wantDef string + wantRef string + }{ + { + name: "array of named objects", + att: &expr.AttributeExpr{Type: &expr.Array{ElemType: &expr.AttributeExpr{Type: item}}}, + kind: GoArray, + wantName: "[]*Item", + wantDef: "[]*Item", + wantRef: "[]*Item", + }, + { + name: "map of unions", + att: &expr.AttributeExpr{Type: &expr.Map{ + KeyType: &expr.AttributeExpr{Type: expr.String}, + ElemType: &expr.AttributeExpr{Type: choice}, + }}, + kind: GoMap, + wantName: "map[string]*Choice", + wantDef: "map[string]Choice", + wantRef: "map[string]*Choice", + }, + { + name: "array of raw structs", + att: &expr.AttributeExpr{Type: &expr.Array{ElemType: &expr.AttributeExpr{Type: &expr.Object{}}}}, + kind: GoArray, + wantName: "[]struct {\n}", + wantDef: "[]*struct {\n}", + wantRef: "[]struct {\n}", + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + plan, err := PlanGoType(test.att, GoTypePlanOptions{ + Owner: owner, + Policy: GoLayoutPolicy{UseDefault: true, SumType: true}, + Bind: binder, + }) + require.NoError(t, err) + require.Equal(t, test.kind, plan.Kind()) + require.NoError(t, generation.Freeze()) + formatter := plan.Link(owner, goTypeTestQualifier) + require.Equal(t, test.wantName, formatter.Name()) + require.Equal(t, test.wantDef, formatter.Def()) + require.Equal(t, test.wantRef, formatter.Ref()) + }) + } +} + +// TestGoTypePlanIgnoresExpressionMutationAfterPlanning verifies formatting +// never revisits type, metadata, descriptions, tags, or required/default state. +func TestGoTypePlanIgnoresExpressionMutationAfterPlanning(t *testing.T) { + const owner = "generated.local/gen/service" + generation, err := NewGeneration("generated.local/gen", nil) + require.NoError(t, err) + record := goTypeTestUserType("Record", &expr.Object{}) + declaration := declareGoTypeTestUserType(t, generation, owner, record) + field := &expr.AttributeExpr{ + Type: record, + Description: "the original record", + Meta: expr.MetaExpr{ + "struct:field:name": {"Original"}, + "struct:tag:json:name": {"original"}, + }, + } + attribute := &expr.AttributeExpr{Type: &expr.Object{ + {Name: "record", Attribute: field}, + }} + plan, err := PlanGoType(attribute, GoTypePlanOptions{ + Owner: owner, + Policy: GoLayoutPolicy{UseDefault: true, SumType: true}, + Bind: goTypeTestBinder(map[expr.DataType]GoTypeBinding{ + record: {Owner: owner, Type: declaration}, + }), + }) + require.NoError(t, err) + + field.Type = expr.Int + field.Description = "mutated" + field.DefaultValue = 42 + field.Meta = expr.MetaExpr{ + "struct:field:name": {"Mutated"}, + "struct:field:type": {"time.Time", "time", "time"}, + "struct:tag:json:name": {"mutated"}, + } + attribute.Type = expr.String + + require.NoError(t, generation.Freeze()) + formatter := plan.Link(owner, goTypeTestQualifier) + require.Equal(t, "struct {\n\t// the original record\n\tOriginal *Record `json:\"original,omitempty\"`\n}", formatter.Def()) + require.Equal(t, "Original", formatter.Enter(plan.Fields()[0]).Field(true)) +} + +// TestGoTypePlanSeparatesImportPreferencesFromLinkedImports verifies planning +// retains every authored alias request while linked files import each path once. +func TestGoTypePlanSeparatesImportPreferencesFromLinkedImports(t *testing.T) { + const importPath = "example.com/shared" + attribute := &expr.AttributeExpr{Type: &expr.Object{ + {Name: "first", Attribute: &expr.AttributeExpr{ + Type: expr.String, + Meta: expr.MetaExpr{"struct:field:type": {"alpha.Value", importPath, "alpha"}}, + }}, + {Name: "second", Attribute: &expr.AttributeExpr{ + Type: expr.String, + Meta: expr.MetaExpr{"struct:field:type": {"beta.Value", importPath, "beta"}}, + }}, + }} + plan, err := PlanGoType(attribute, GoTypePlanOptions{ + Owner: "generated.local/gen/service", + Policy: GoLayoutPolicy{UseDefault: true, SumType: true}, + }) + require.NoError(t, err) + require.Equal(t, []GoTypeImport{ + {Name: "alpha", Path: importPath}, + {Name: "beta", Path: importPath}, + }, plan.ImportPreferences()) + + linked := plan.Link("generated.local/gen/service", func(path string) string { + require.Equal(t, importPath, path) + return "shared2" + }) + require.Equal(t, []GoTypeImport{{Name: "shared2", Path: importPath}}, linked.Imports()) + require.Equal(t, "struct {\n\tFirst *shared2.Value\n\tSecond *shared2.Value\n}", linked.Def()) +} + +// TestGoTypePlanSeparatesNamedReferencesFromCompleteImports verifies ordinary +// files import the named types they mention while conversion files can request +// every package used below those named types. +func TestGoTypePlanSeparatesNamedReferencesFromCompleteImports(t *testing.T) { + const ( + owner = "generated.local/gen/service" + childOwner = "generated.local/gen/child" + grandchildOwner = "generated.local/gen/grandchild" + ) + grandchild := goTypeTestUserType("Grandchild", &expr.Object{ + {Name: "value", Attribute: &expr.AttributeExpr{Type: expr.String}}, + }) + child := goTypeTestUserType("Child", &expr.Object{ + {Name: "grandchild", Attribute: &expr.AttributeExpr{Type: grandchild}}, + }) + generation, err := NewGeneration("generated.local/gen", nil) + require.NoError(t, err) + childDeclaration := declareGoTypeTestUserType(t, generation, childOwner, child) + grandchildDeclaration := declareGoTypeTestUserType(t, generation, grandchildOwner, grandchild) + attribute := &expr.AttributeExpr{Type: &expr.Object{ + {Name: "child", Attribute: &expr.AttributeExpr{Type: child}}, + }} + plan, err := PlanGoType(attribute, GoTypePlanOptions{ + Owner: owner, + RetainNamedValue: true, + Bind: goTypeTestBinder(map[expr.DataType]GoTypeBinding{ + child: {Owner: childOwner, Type: childDeclaration}, + grandchild: {Owner: grandchildOwner, Type: grandchildDeclaration}, + }), + }) + require.NoError(t, err) + + require.Equal(t, []GoTypeImport{{Path: childOwner}}, plan.ImportPreferences()) + require.Equal(t, []GoTypeImport{ + {Path: childOwner}, + {Path: grandchildOwner}, + }, plan.CompleteImportPreferences()) +} + +// TestGoTypePlanEquivalence compares symbolic layouts without relying on the +// expression pointers from which independently retained copies were planned. +func TestGoTypePlanEquivalence(t *testing.T) { + firstAttribute := &expr.AttributeExpr{Type: &expr.Object{ + {Name: "value", Attribute: &expr.AttributeExpr{ + Type: expr.String, + Description: "the retained value", + Meta: expr.MetaExpr{ + "struct:field:name": {"ValueID"}, + "struct:tag:json:name": {"value_id"}, + }, + }}, + }} + secondAttribute := expr.DupAtt(firstAttribute) + options := GoTypePlanOptions{ + Owner: "generated.local/gen/service", + Policy: GoLayoutPolicy{UseDefault: true, SumType: true}, + } + first, err := PlanGoType(firstAttribute, options) + require.NoError(t, err) + second, err := PlanGoType(secondAttribute, options) + require.NoError(t, err) + require.True(t, first.Equivalent(second)) + require.True(t, second.Equivalent(first)) + + secondField := (*expr.AsObject(secondAttribute.Type))[0].Attribute + secondField.Meta["struct:field:name"] = []string{"OtherID"} + different, err := PlanGoType(secondAttribute, options) + require.NoError(t, err) + require.False(t, first.Equivalent(different)) + require.False(t, different.Equivalent(first)) +} + +func TestGoTypePlanFindsOccurrenceInsideNamedValue(t *testing.T) { + field := &expr.AttributeExpr{Type: expr.String} + named := goTypeTestUserType("Record", &expr.Object{ + {Name: "value", Attribute: field}, + }) + plan, err := PlanGoType(&expr.AttributeExpr{Type: named}, GoTypePlanOptions{ + Owner: "generated.local/gen/service", + RetainNamedValue: true, + Bind: func(GoTypeBindingRequest) (GoTypeBinding, error) { + return GoTypeBinding{ + Owner: "generated.local/gen/service", + name: "Record", + }, nil + }, + }) + require.NoError(t, err) + + matches := plan.PlansForOccurrence(field) + require.Len(t, matches, 1) + require.Equal(t, "Value", matches[0].FieldName(true)) +} + +// TestGoTypePlanFindsOccurrencesInsideCopiedNamedValues verifies that separate +// copies of one named type retain the fields that belong to each copy. +func TestGoTypePlanFindsOccurrencesInsideCopiedNamedValues(t *testing.T) { + source := goTypeTestUserType("Record", &expr.Object{ + {Name: "value", Attribute: &expr.AttributeExpr{Type: expr.String}}, + }) + first := expr.DupAtt(&expr.AttributeExpr{Type: source}) + second := expr.DupAtt(&expr.AttributeExpr{Type: source}) + attribute := &expr.AttributeExpr{Type: &expr.Object{ + {Name: "first", Attribute: first}, + {Name: "second", Attribute: second}, + }} + plan, err := PlanGoType(attribute, GoTypePlanOptions{ + Owner: "generated.local/gen/service", + RetainNamedValue: true, + Bind: func(GoTypeBindingRequest) (GoTypeBinding, error) { + return GoTypeBinding{ + Owner: "generated.local/gen/service", + name: "Record", + }, nil + }, + }) + require.NoError(t, err) + + firstField := (*expr.AsObject(first.Type.(expr.UserType).Attribute().Type))[0].Attribute + secondField := (*expr.AsObject(second.Type.(expr.UserType).Attribute().Type))[0].Attribute + require.Len(t, plan.PlansForOccurrence(firstField), 1) + require.Len(t, plan.PlansForOccurrence(secondField), 1) +} + +// TestGoTypePlanSeparatesNestedCopiesWithOneOrigin verifies that a nested copy +// is not mistaken for a recursive reference to the copy that contains it. +func TestGoTypePlanSeparatesNestedCopiesWithOneOrigin(t *testing.T) { + source := goTypeTestUserType("Record", &expr.Object{ + {Name: "value", Attribute: &expr.AttributeExpr{Type: expr.String}}, + }) + outer := expr.DupAtt(&expr.AttributeExpr{Type: source}).Type.(expr.UserType) + nested := expr.DupAtt(&expr.AttributeExpr{Type: source}).Type.(expr.UserType) + nestedField := (*expr.AsObject(nested.Attribute().Type))[0].Attribute + outer.SetAttribute(&expr.AttributeExpr{Type: &expr.Object{ + {Name: "nested", Attribute: &expr.AttributeExpr{Type: nested}}, + }}) + plan, err := PlanGoType(&expr.AttributeExpr{Type: outer}, GoTypePlanOptions{ + Owner: "generated.local/gen/service", + RetainNamedValue: true, + Bind: func(GoTypeBindingRequest) (GoTypeBinding, error) { + return GoTypeBinding{ + Owner: "generated.local/gen/service", + name: "Record", + }, nil + }, + }) + require.NoError(t, err) + require.Len(t, plan.PlansForOccurrence(nestedField), 1) +} + +// TestGoTypePlanRetainsRecursiveNamedValue verifies that retaining a named +// value stops when the type refers back to itself. +func TestGoTypePlanRetainsRecursiveNamedValue(t *testing.T) { + record := &expr.UserTypeExpr{TypeName: "Record"} + record.AttributeExpr = &expr.AttributeExpr{Type: &expr.Object{ + {Name: "next", Attribute: &expr.AttributeExpr{Type: record}}, + }} + plan, err := PlanGoType(&expr.AttributeExpr{Type: record}, GoTypePlanOptions{ + Owner: "generated.local/gen/service", + RetainNamedValue: true, + Bind: func(GoTypeBindingRequest) (GoTypeBinding, error) { + return GoTypeBinding{ + Owner: "generated.local/gen/service", + name: "Record", + }, nil + }, + }) + require.NoError(t, err) + require.Len(t, plan.value.fields, 1) + require.Same(t, plan.value, plan.value.fields[0].value) +} + +// goTypeTestUserType constructs one named type without running the DSL. +func goTypeTestUserType(name string, dataType expr.DataType) expr.UserType { + return &expr.UserTypeExpr{ + TypeName: name, + AttributeExpr: &expr.AttributeExpr{Type: dataType}, + } +} + +// declareGoTypeTestUserType adds one exact user type to a generated package. +func declareGoTypeTestUserType(t *testing.T, generation *Generation, owner string, userType expr.UserType) *TypeDeclaration { + t.Helper() + generatedPackage, err := generation.ClaimPackage(owner) + require.NoError(t, err) + declaration, err := generatedPackage.DeclareUserType(userType) + require.NoError(t, err) + return declaration +} + +// declareGoTypeTestUnion adds one exact union to a generated package. +func declareGoTypeTestUnion(t *testing.T, generation *Generation, owner string, union *expr.Union) *UnionDeclaration { + t.Helper() + generatedPackage, err := generation.ClaimPackage(owner) + require.NoError(t, err) + declaration, err := generatedPackage.DeclareUnion(&expr.AttributeExpr{Type: union}) + require.NoError(t, err) + return declaration +} + +// goTypeTestBinder resolves exact test data types to predeclared package records. +func goTypeTestBinder(bindings map[expr.DataType]GoTypeBinding) GoTypeBinder { + return func(request GoTypeBindingRequest) (GoTypeBinding, error) { + binding, ok := bindings[request.Attribute.Type] + if !ok { + return GoTypeBinding{}, fmt.Errorf("no binding for %T %q", request.Attribute.Type, request.Attribute.Type.Name()) + } + return binding, nil + } +} + +// goTypeTestQualifier supplies stable aliases for focused type-plan tests. +func goTypeTestQualifier(importPath string) string { + switch importPath { + case "generated.local/gen/unions": + return "unions" + case "generated.local/gen/types": + return "types" + default: + return "" + } +} diff --git a/codegen/go_value.go b/codegen/go_value.go new file mode 100644 index 0000000000..5b8ff7a908 --- /dev/null +++ b/codegen/go_value.go @@ -0,0 +1,583 @@ +// This file renders design values as Go expressions while type names and +// pointer choices are still available to the generator. Generated programs +// receive only the declarations and expression needed for the selected value. +package codegen + +import ( + "fmt" + "math" + "reflect" + "sort" + "strconv" + "strings" + + "goa.design/goa/v3/expr" +) + +type ( + // GoValueCode contains the Go source needed to build one design value. + // Declarations must be written before Expression in the same block. + GoValueCode struct { + // Declarations initializes values whose addresses appear in Expression. + Declarations []string + // Expression is the final typed Go value. + Expression string + } + + // UnionConstructorResolver returns the planned constructor for one OneOf + // branch. The returned name includes any package qualifier needed by the + // generated file. + UnionConstructorResolver func(attribute *expr.AttributeExpr, branch string) (string, error) + + // GoTypeLayoutResolver returns the linked Go type selected by one generated + // package for an attribute and its pointer policy. + GoTypeLayoutResolver interface { + GoTypeLayout(attribute *expr.AttributeExpr, policy GoLayoutPolicy) (LinkedGoType, error) + } + + // goValueRenderer holds the target layout and names local declarations made + // while rendering one value. + goValueRenderer struct { + resolveUnion UnionConstructorResolver + prefix string + nextLocal int + declarations []string + } + + // goMapValue keeps one authored map entry with its stable generation order. + goMapValue struct { + order string + key reflect.Value + value reflect.Value + } +) + +// RenderGoValue renders value using the linked Go layout selected for attribute. +// pointer reports whether the complete value is stored through a pointer. +// resolveUnion is required only when attribute contains a OneOf value. +func RenderGoValue(attribute *expr.AttributeExpr, value any, layout LinkedGoType, pointer bool, resolveUnion UnionConstructorResolver, localPrefix string) (GoValueCode, error) { + if attribute == nil { + return GoValueCode{}, fmt.Errorf("render Go value: attribute must not be nil") + } + if layout.plan == nil { + return GoValueCode{}, fmt.Errorf("render Go value for %s: linked Go type must not be empty", attribute.Type.Name()) + } + prefix := Goify(localPrefix, false) + if prefix == "" { + prefix = "defaultValue" + } + renderer := &goValueRenderer{ + resolveUnion: resolveUnion, + prefix: prefix, + } + expression, err := renderer.render(attribute, reflect.ValueOf(value), layout, pointer) + if err != nil { + return GoValueCode{}, fmt.Errorf("render Go value for %s: %w", attribute.Type.Name(), err) + } + return GoValueCode{ + Declarations: renderer.declarations, + Expression: expression, + }, nil +} + +// planGoTypeWithAttributor records the final names selected by an attribute +// scope. Pointer choices still come only from GoTypePlan. +func planGoTypeWithAttributor(attribute *expr.AttributeExpr, policy GoLayoutPolicy, attributor Attributor) (LinkedGoType, error) { + const owner = "goa.local/generated" + plan, err := PlanGoType(attribute, GoTypePlanOptions{ + Owner: owner, + Policy: policy, + RetainNamedValue: true, + Bind: func(request GoTypeBindingRequest) (GoTypeBinding, error) { + return GoTypeBinding{ + Owner: request.InheritedOwner, + name: attributor.Name( + request.Attribute, + attributor.Package(request.Attribute), + policy.Pointer, + policy.UseDefault, + ), + }, nil + }, + }) + if err != nil { + return LinkedGoType{}, err + } + imports := make(map[string]string) + for _, preference := range plan.ImportPreferences() { + imports[preference.Path] = preference.Name + } + return plan.Link(owner, func(importPath string) string { + return imports[importPath] + }), nil +} + +// render writes the expression for one value and records any local values +// whose addresses are required by the target layout. +func (r *goValueRenderer) render(attribute *expr.AttributeExpr, value reflect.Value, layout LinkedGoType, pointer bool) (string, error) { + concrete, err := concreteGoValue(value) + if err != nil { + return "", err + } + var expression string + shape := layout + if layout.plan.kind == GoNamed { + if layout.plan.value == nil { + return "", fmt.Errorf("named type %q has no planned value layout", attribute.Type.Name()) + } + shape = layout.Enter(layout.plan.value) + } + switch { + case expr.IsPrimitive(attribute.Type): + expression, err = r.renderPrimitive(attribute, concrete, layout) + case expr.IsArray(attribute.Type): + expression, err = r.renderArray(attribute, concrete, layout, shape) + case expr.IsMap(attribute.Type): + expression, err = r.renderMap(attribute, concrete, layout, shape) + case expr.IsObject(attribute.Type): + expression, err = r.renderObject(attribute, concrete, layout, shape, pointer) + pointer = false + case expr.IsUnion(attribute.Type): + expression, err = r.renderUnion(attribute, concrete, layout) + default: + err = fmt.Errorf("unsupported design type %T", attribute.Type) + } + if err != nil { + return "", err + } + if !pointer { + return expression, nil + } + local := r.localName() + r.declarations = append(r.declarations, fmt.Sprintf("var %s %s = %s", local, layout.Def(), expression)) + return "&" + local, nil +} + +// renderPrimitive writes a primitive literal and converts it to the planned +// named type when the design or field metadata requires one. +func (r *goValueRenderer) renderPrimitive(attribute *expr.AttributeExpr, value reflect.Value, layout LinkedGoType) (string, error) { + primitive := underlyingPrimitive(attribute.Type) + if custom, spec := GetMetaType(attribute); custom != "" { + if custom == GoNativeTypeName(primitive) { + return renderPrimitiveLiteral(primitive, value) + } + if err := validateCustomDefault(custom, spec, value); err != nil { + return "", err + } + if value.Type().PkgPath() == "" { + return renderPrimitiveLiteral(primitive, value) + } + return renderTypedCustomValue(layout.Def(), value) + } + literal, err := renderPrimitiveLiteral(primitive, value) + if err != nil { + return "", err + } + return literal, nil +} + +// renderArray writes every element using the element layout selected by the +// same generator context. +func (r *goValueRenderer) renderArray(attribute *expr.AttributeExpr, value reflect.Value, layout, shape LinkedGoType) (string, error) { + if value.Kind() != reflect.Array && value.Kind() != reflect.Slice { + return "", fmt.Errorf("array default has Go type %s", value.Type()) + } + array := expr.AsArray(attribute.Type) + items := make([]string, value.Len()) + elementLayout := shape.Enter(shape.plan.element) + for index := range items { + item, err := r.render(array.ElemType, value.Index(index), elementLayout, elementLayout.plan.definitionPointer) + if err != nil { + return "", fmt.Errorf("array element %d: %w", index, err) + } + items[index] = item + } + return fmt.Sprintf("%s{%s}", layout.Def(), strings.Join(items, ", ")), nil +} + +// renderMap writes map entries in source order independent form so generated +// files remain stable across runs. +func (r *goValueRenderer) renderMap(attribute *expr.AttributeExpr, value reflect.Value, layout, shape LinkedGoType) (string, error) { + if value.Kind() != reflect.Map { + return "", fmt.Errorf("map default has Go type %s", value.Type()) + } + mapped := expr.AsMap(attribute.Type) + values, err := orderedMapValues(mapped.KeyType, value) + if err != nil { + return "", err + } + entries := make([]string, 0, len(values)) + keyLayout := shape.Enter(shape.plan.key) + elementLayout := shape.Enter(shape.plan.element) + for _, item := range values { + key, err := r.render(mapped.KeyType, item.key, keyLayout, keyLayout.plan.definitionPointer) + if err != nil { + return "", fmt.Errorf("map key: %w", err) + } + element, err := r.render(mapped.ElemType, item.value, elementLayout, elementLayout.plan.definitionPointer) + if err != nil { + return "", fmt.Errorf("map value for %s: %w", key, err) + } + entries = append(entries, key+":"+element) + } + return fmt.Sprintf("%s{%s}", layout.Def(), strings.Join(entries, ", ")), nil +} + +// renderObject writes only the fields supplied by the authored value. Pointer +// fields use local declarations when Go cannot take a scalar literal address. +func (r *goValueRenderer) renderObject(attribute *expr.AttributeExpr, value reflect.Value, layout, shape LinkedGoType, pointer bool) (string, error) { + object := expr.AsObject(attribute.Type) + fields := make([]string, 0, len(*object)) + for index, field := range *object { + fieldValue, present, err := authoredObjectField(value, field.Name, field.Attribute) + if err != nil { + return "", err + } + if !present { + continue + } + fieldLayout := shape.Enter(shape.plan.fields[index]) + expression, err := r.render(field.Attribute, fieldValue, fieldLayout, fieldLayout.plan.fieldPointer) + if err != nil { + return "", fmt.Errorf("object field %q: %w", field.Name, err) + } + name := fieldLayout.Field(true) + fields = append(fields, name+": "+expression) + } + prefix := "" + if pointer { + prefix = "&" + } + return fmt.Sprintf("%s%s{%s}", prefix, layout.Def(), strings.Join(fields, ", ")), nil +} + +// renderUnion selects the authored branch and calls the constructor retained +// by the generated service package. +func (r *goValueRenderer) renderUnion(attribute *expr.AttributeExpr, value reflect.Value, layout LinkedGoType) (string, error) { + if r.resolveUnion == nil { + return "", fmt.Errorf("OneOf default requires a planned branch constructor") + } + if value.Kind() != reflect.Map { + return "", fmt.Errorf("OneOf default has Go type %s", value.Type()) + } + union := expr.AsUnion(attribute.Type) + tagValue, found := reflectedMapValue(value, union.GetTypeKey()) + if !found { + return "", fmt.Errorf("OneOf default is missing %q", union.GetTypeKey()) + } + tag, ok := tagValue.Interface().(string) + if !ok { + return "", fmt.Errorf("OneOf default field %q has Go type %s", union.GetTypeKey(), tagValue.Type()) + } + branchValue, found := reflectedMapValue(value, union.GetValueKey()) + if !found { + return "", fmt.Errorf("OneOf default is missing %q", union.GetValueKey()) + } + var ( + branch *expr.NamedAttributeExpr + branchIndex int + ) + for index, candidate := range union.Values { + if candidate.Name == tag { + branch = candidate + branchIndex = index + break + } + } + if branch == nil { + return "", fmt.Errorf("OneOf default selects unknown branch %q", tag) + } + constructor, err := r.resolveUnion(attribute, tag) + if err != nil { + return "", fmt.Errorf("resolve OneOf branch %q: %w", tag, err) + } + branchLayout := layout.Enter(layout.plan.branches[branchIndex]) + argument, err := r.render(branch.Attribute, branchValue, branchLayout, branchLayout.plan.referencePointer) + if err != nil { + return "", fmt.Errorf("OneOf branch %q: %w", tag, err) + } + return fmt.Sprintf("%s(%s)", constructor, argument), nil +} + +// localName returns a name that is unique inside the caller-provided block. +func (r *goValueRenderer) localName() string { + r.nextLocal++ + return r.prefix + strconv.Itoa(r.nextLocal) +} + +// orderedMapValues sorts authored map keys before rendering either side of an +// entry. This keeps local variable names stable when map values need pointers. +func orderedMapValues(attribute *expr.AttributeExpr, value reflect.Value) ([]goMapValue, error) { + if !expr.IsPrimitive(attribute.Type) { + return nil, fmt.Errorf("map default key uses unsupported design type %T", attribute.Type) + } + primitive := underlyingPrimitive(attribute.Type) + values := make([]goMapValue, 0, value.Len()) + iterator := value.MapRange() + for iterator.Next() { + key, err := concreteGoValue(iterator.Key()) + if err != nil { + return nil, fmt.Errorf("map key: %w", err) + } + order, err := renderPrimitiveLiteral(primitive, key) + if err != nil { + return nil, fmt.Errorf("map key: %w", err) + } + values = append(values, goMapValue{order: order, key: iterator.Key(), value: iterator.Value()}) + } + sort.Slice(values, func(left, right int) bool { + return values[left].order < values[right].order + }) + return values, nil +} + +// concreteGoValue removes interface and pointer wrappers while rejecting nil, +// which cannot satisfy a non-nil authored default. +func concreteGoValue(value reflect.Value) (reflect.Value, error) { + if !value.IsValid() { + return reflect.Value{}, fmt.Errorf("default must not be nil") + } + for value.Kind() == reflect.Interface || value.Kind() == reflect.Pointer { + if value.IsNil() { + return reflect.Value{}, fmt.Errorf("default must not be nil") + } + value = value.Elem() + } + return value, nil +} + +// renderPrimitiveLiteral writes a literal accepted by the primitive's native +// Go type. +func renderPrimitiveLiteral(primitive expr.Primitive, value reflect.Value) (string, error) { + switch primitive.Kind() { + case expr.BooleanKind: + if value.Kind() != reflect.Bool { + return "", fmt.Errorf("boolean default has Go type %s", value.Type()) + } + return strconv.FormatBool(value.Bool()), nil + case expr.IntKind, expr.Int32Kind, expr.Int64Kind: + switch value.Kind() { + case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: + return strconv.FormatInt(value.Int(), 10), nil + case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64: + return strconv.FormatUint(value.Uint(), 10), nil + default: + return "", fmt.Errorf("integer default has Go type %s", value.Type()) + } + case expr.UIntKind, expr.UInt32Kind, expr.UInt64Kind: + switch value.Kind() { + case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: + if value.Int() < 0 { + return "", fmt.Errorf("unsigned integer default must not be negative") + } + return strconv.FormatInt(value.Int(), 10), nil + case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64: + return strconv.FormatUint(value.Uint(), 10), nil + default: + return "", fmt.Errorf("unsigned integer default has Go type %s", value.Type()) + } + case expr.Float32Kind, expr.Float64Kind: + var number float64 + switch value.Kind() { + case reflect.Float32, reflect.Float64: + number = value.Float() + case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: + number = float64(value.Int()) + case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64: + number = float64(value.Uint()) + default: + return "", fmt.Errorf("number default has Go type %s", value.Type()) + } + if math.IsInf(number, 0) || math.IsNaN(number) { + return "", fmt.Errorf("number default must be finite") + } + bits := 64 + if primitive.Kind() == expr.Float32Kind { + bits = 32 + } + return strconv.FormatFloat(number, 'g', -1, bits), nil + case expr.StringKind: + if value.Kind() != reflect.String { + return "", fmt.Errorf("string default has Go type %s", value.Type()) + } + return strconv.Quote(value.String()), nil + case expr.BytesKind: + switch value.Kind() { + case reflect.String: + return "[]byte(" + strconv.Quote(value.String()) + ")", nil + case reflect.Slice: + if value.Type().Elem().Kind() != reflect.Uint8 { + return "", fmt.Errorf("bytes default has Go type %s", value.Type()) + } + items := make([]string, value.Len()) + for index := range items { + items[index] = fmt.Sprintf("0x%x", value.Index(index).Uint()) + } + return "[]byte{" + strings.Join(items, ", ") + "}", nil + default: + return "", fmt.Errorf("bytes default has Go type %s", value.Type()) + } + case expr.AnyKind: + return renderAnyValue(value) + default: + return "", fmt.Errorf("unsupported primitive %s", primitive.Name()) + } +} + +// renderAnyValue writes JSON-compatible Go values with explicit container +// types so the generated expression does not depend on a design-time Go type. +func renderAnyValue(value reflect.Value) (string, error) { + if goValueIsNil(value) { + return "nil", nil + } + concrete, err := concreteGoValue(value) + if err != nil { + return "", err + } + switch concrete.Kind() { + case reflect.Bool: + return strconv.FormatBool(concrete.Bool()), nil + case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: + return strconv.FormatInt(concrete.Int(), 10), nil + case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64: + return strconv.FormatUint(concrete.Uint(), 10), nil + case reflect.Float32, reflect.Float64: + if math.IsInf(concrete.Float(), 0) || math.IsNaN(concrete.Float()) { + return "", fmt.Errorf("Any number default must be finite") + } + return strconv.FormatFloat(concrete.Float(), 'g', -1, concrete.Type().Bits()), nil + case reflect.String: + return strconv.Quote(concrete.String()), nil + case reflect.Array, reflect.Slice: + items := make([]string, concrete.Len()) + for index := range items { + items[index], err = renderAnyValue(concrete.Index(index)) + if err != nil { + return "", err + } + } + return "[]any{" + strings.Join(items, ", ") + "}", nil + case reflect.Map: + items := make([]string, 0, concrete.Len()) + iterator := concrete.MapRange() + for iterator.Next() { + key, keyErr := concreteGoValue(iterator.Key()) + if keyErr != nil || key.Kind() != reflect.String { + return "", fmt.Errorf("Any object default key must be a string") + } + element, elementErr := renderAnyValue(iterator.Value()) + if elementErr != nil { + return "", elementErr + } + items = append(items, strconv.Quote(key.String())+":"+element) + } + sort.Strings(items) + return "map[string]any{" + strings.Join(items, ", ") + "}", nil + default: + return "", fmt.Errorf("Any default has unsupported Go type %s", concrete.Type()) + } +} + +// goValueIsNil reports whether an authored Any value is nil after removing +// interface and pointer wrappers. +func goValueIsNil(value reflect.Value) bool { + if !value.IsValid() { + return true + } + for value.Kind() == reflect.Interface || value.Kind() == reflect.Pointer { + if value.IsNil() { + return true + } + value = value.Elem() + } + return false +} + +// renderTypedCustomValue writes an authored value that already uses the exact +// custom Go type from the design. +func renderTypedCustomValue(typeName string, value reflect.Value) (string, error) { + switch value.Kind() { + case reflect.Bool: + return typeName + "(" + strconv.FormatBool(value.Bool()) + ")", nil + case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: + return typeName + "(" + strconv.FormatInt(value.Int(), 10) + ")", nil + case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64: + return typeName + "(" + strconv.FormatUint(value.Uint(), 10) + ")", nil + case reflect.Float32, reflect.Float64: + return typeName + "(" + strconv.FormatFloat(value.Float(), 'g', -1, value.Type().Bits()) + ")", nil + case reflect.String: + return typeName + "(" + strconv.Quote(value.String()) + ")", nil + case reflect.Slice: + if value.Type().Elem().Kind() != reflect.Uint8 { + return "", fmt.Errorf("default for custom Go type %s has unsupported underlying type %s", value.Type(), value.Kind()) + } + items := make([]string, value.Len()) + for index := range items { + items[index] = fmt.Sprintf("0x%x", value.Index(index).Uint()) + } + return typeName + "{" + strings.Join(items, ", ") + "}", nil + default: + return "", fmt.Errorf("default for custom Go type %s has unsupported underlying type %s", value.Type(), value.Kind()) + } +} + +// validateCustomDefault checks an already-typed default against the declared +// custom Go type. Native values use the existing struct:field:type contract: +// the declared type must accept conversion from the Goa primitive. +func validateCustomDefault(custom string, spec *ImportSpec, value reflect.Value) error { + if value.Type().PkgPath() == "" { + return nil + } + if spec == nil { + return fmt.Errorf("default for custom Go type %q must use that exact Go type", custom) + } + _, name, qualified := strings.Cut(custom, ".") + if !qualified { + name = custom + } + if value.Type().PkgPath() != spec.Path || value.Type().Name() != name { + return fmt.Errorf("default for custom Go type %q has Go type %s", custom, value.Type()) + } + return nil +} + +// authoredObjectField returns one supplied object field from a map or struct. +func authoredObjectField(value reflect.Value, name string, attribute *expr.AttributeExpr) (reflect.Value, bool, error) { + concrete, err := concreteGoValue(value) + if err != nil { + return reflect.Value{}, false, err + } + switch concrete.Kind() { + case reflect.Map: + field, ok := reflectedMapValue(concrete, name) + return field, ok, nil + case reflect.Struct: + field := concrete.FieldByName(GoifyAtt(attribute, name, true)) + return field, field.IsValid(), nil + default: + return reflect.Value{}, false, fmt.Errorf("object default has Go type %s", concrete.Type()) + } +} + +// reflectedMapValue finds a string key in maps whose key type may be string or +// any, as used by evaluated design defaults. +func reflectedMapValue(value reflect.Value, key string) (reflect.Value, bool) { + iterator := value.MapRange() + for iterator.Next() { + candidate, err := concreteGoValue(iterator.Key()) + if err == nil && candidate.Kind() == reflect.String && candidate.String() == key { + return iterator.Value(), true + } + } + return reflect.Value{}, false +} + +// underlyingPrimitive returns the primitive beneath a named primitive type. +func underlyingPrimitive(dataType expr.DataType) expr.Primitive { + for { + if primitive, ok := dataType.(expr.Primitive); ok { + return primitive + } + dataType = dataType.(expr.UserType).Attribute().Type + } +} diff --git a/codegen/go_value_test.go b/codegen/go_value_test.go new file mode 100644 index 0000000000..009ca3acfd --- /dev/null +++ b/codegen/go_value_test.go @@ -0,0 +1,264 @@ +// This file verifies that authored defaults become specialized Go values with +// the same names and pointer layout as generated service types. +package codegen + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/require" + + "goa.design/goa/v3/expr" +) + +func TestRenderGoValueUsesPlannedFieldLayout(t *testing.T) { + status := &expr.UserTypeExpr{ + TypeName: "Status", + AttributeExpr: &expr.AttributeExpr{ + Type: expr.String, + }, + } + configObject := expr.Object{ + &expr.NamedAttributeExpr{Name: "status", Attribute: &expr.AttributeExpr{Type: status}}, + &expr.NamedAttributeExpr{Name: "data", Attribute: &expr.AttributeExpr{Type: expr.Bytes}}, + } + config := &expr.UserTypeExpr{ + TypeName: "Config", + AttributeExpr: &expr.AttributeExpr{Type: &configObject}, + } + layout := goValueTestLayout(t, &expr.AttributeExpr{Type: config}, GoLayoutPolicy{ + UseDefault: true, + SumType: true, + }, map[expr.DataType]GoTypeBinding{ + status: goValueTestTypeBinding(t, status), + config: goValueTestTypeBinding(t, config), + }) + + code, err := RenderGoValue( + &expr.AttributeExpr{Type: config}, + map[string]any{"status": "ready", "data": "ok"}, + layout, + true, + nil, + "defaultValue", + ) + require.NoError(t, err) + require.Equal(t, []string{`var defaultValue1 Status = "ready"`}, code.Declarations) + require.Equal(t, `&Config{Status: &defaultValue1, Data: []byte("ok")}`, code.Expression) +} + +func TestRenderGoValueUsesPlannedUnionConstructor(t *testing.T) { + choice := &expr.AttributeExpr{Type: &expr.Union{ + TypeName: "Choice", + Values: []*expr.NamedAttributeExpr{ + {Name: "text", Attribute: &expr.AttributeExpr{Type: expr.String}}, + }, + }} + layout := goValueTestLayout(t, choice, GoLayoutPolicy{UseDefault: true, SumType: true}, map[expr.DataType]GoTypeBinding{ + choice.Type: goValueTestUnionBinding(t, choice), + }) + + code, err := RenderGoValue( + choice, + map[string]any{"type": "text", "value": "ready"}, + layout, + false, + func(attribute *expr.AttributeExpr, branch string) (string, error) { + require.Same(t, choice, attribute) + require.Equal(t, "text", branch) + return "NewChoiceText", nil + }, + "defaultValue", + ) + require.NoError(t, err) + require.Empty(t, code.Declarations) + require.Equal(t, `NewChoiceText("ready")`, code.Expression) +} + +func TestRenderGoValueUsesTypedCustomDefault(t *testing.T) { + attribute := &expr.AttributeExpr{ + Type: expr.Bytes, + Meta: expr.MetaExpr{ + "struct:field:type": {"json.RawMessage", "encoding/json", "json"}, + }, + } + layout := goValueTestLayout(t, attribute, GoLayoutPolicy{UseDefault: true, SumType: true}, nil) + + code, err := RenderGoValue(attribute, json.RawMessage("ok"), layout, false, nil, "defaultValue") + require.NoError(t, err) + require.Empty(t, code.Declarations) + require.Equal(t, `json.RawMessage{0x6f, 0x6b}`, code.Expression) + + code, err = RenderGoValue(attribute, "ok", layout, false, nil, "defaultValue") + require.NoError(t, err) + require.Equal(t, `[]byte("ok")`, code.Expression) +} + +func TestRenderGoValueConvertsNativeCustomDefault(t *testing.T) { + attribute := &expr.AttributeExpr{ + Type: expr.Int64, + Meta: expr.MetaExpr{ + "struct:field:type": {"flag.ErrorHandling", "flag"}, + }, + } + layout := goValueTestLayout(t, attribute, GoLayoutPolicy{UseDefault: true, SumType: true}, nil) + + code, err := RenderGoValue(attribute, 1, layout, false, nil, "defaultValue") + require.NoError(t, err) + require.Equal(t, `1`, code.Expression) +} + +func TestRenderGoValueRendersAnyObjectsWithStringKeys(t *testing.T) { + attribute := &expr.AttributeExpr{Type: expr.Any} + layout := goValueTestLayout(t, attribute, GoLayoutPolicy{UseDefault: true, SumType: true}, nil) + + code, err := RenderGoValue(attribute, map[string]any{"count": 1}, layout, false, nil, "defaultValue") + require.NoError(t, err) + require.Equal(t, `map[string]any{"count":1}`, code.Expression) + + _, err = RenderGoValue(attribute, map[int]any{1: "invalid"}, layout, false, nil, "defaultValue") + require.EqualError(t, err, "render Go value for any: Any object default key must be a string") +} + +func TestRenderGoValueUsesContainerElementLayouts(t *testing.T) { + choice := &expr.AttributeExpr{Type: &expr.Union{ + TypeName: "Choice", + Values: []*expr.NamedAttributeExpr{ + {Name: "text", Attribute: &expr.AttributeExpr{Type: expr.String}}, + }, + }} + tests := []struct { + name string + attribute *expr.AttributeExpr + value any + want string + }{ + { + name: "array of OneOf values", + attribute: &expr.AttributeExpr{Type: &expr.Array{ + ElemType: choice, + }}, + value: []any{map[string]any{"type": "text", "value": "ready"}}, + want: `[]Choice{NewChoiceText("ready")}`, + }, + { + name: "map of OneOf values", + attribute: &expr.AttributeExpr{Type: &expr.Map{ + KeyType: &expr.AttributeExpr{Type: expr.String}, + ElemType: choice, + }}, + value: map[string]any{"mode": map[string]any{"type": "text", "value": "ready"}}, + want: `map[string]Choice{"mode":NewChoiceText("ready")}`, + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + layout := goValueTestLayout(t, test.attribute, GoLayoutPolicy{UseDefault: true, SumType: true}, map[expr.DataType]GoTypeBinding{ + choice.Type: goValueTestUnionBinding(t, choice), + }) + code, err := RenderGoValue( + test.attribute, + test.value, + layout, + false, + func(_ *expr.AttributeExpr, _ string) (string, error) { + return "NewChoiceText", nil + }, + "defaultValue", + ) + require.NoError(t, err) + require.Equal(t, test.want, code.Expression) + }) + } +} + +func TestRenderGoValueUsesPointerBackedArrayElements(t *testing.T) { + attribute := &expr.AttributeExpr{Type: &expr.Array{ + ElemType: &expr.AttributeExpr{Type: expr.String}, + NonNullableElems: true, + }} + layout := goValueTestLayout(t, attribute, GoLayoutPolicy{ + UseDefault: true, + SumType: true, + ArrayElementPointer: true, + }, nil) + + code, err := RenderGoValue(attribute, []string{"ready"}, layout, false, nil, "defaultValue") + require.NoError(t, err) + require.Equal(t, []string{`var defaultValue1 string = "ready"`}, code.Declarations) + require.Equal(t, `[]*string{&defaultValue1}`, code.Expression) +} + +func TestRenderGoValueAllocatesMapLocalsInKeyOrder(t *testing.T) { + valueObject := &expr.Object{{Name: "label", Attribute: &expr.AttributeExpr{Type: expr.String}}} + attribute := &expr.AttributeExpr{Type: &expr.Map{ + KeyType: &expr.AttributeExpr{Type: expr.String}, + ElemType: &expr.AttributeExpr{Type: valueObject}, + }} + layout := goValueTestLayout(t, attribute, GoLayoutPolicy{ + Pointer: true, + UseDefault: true, + SumType: true, + }, nil) + value := map[string]any{ + "z": map[string]any{"label": "last"}, + "a": map[string]any{"label": "first"}, + } + + for range 20 { + code, err := RenderGoValue(attribute, value, layout, false, nil, "defaultValue") + require.NoError(t, err) + require.Equal(t, []string{ + `var defaultValue1 string = "first"`, + `var defaultValue2 string = "last"`, + }, code.Declarations) + require.Equal(t, `map[string]*struct { + Label *string +}{"a":&struct { + Label *string +}{Label: &defaultValue1}, "z":&struct { + Label *string +}{Label: &defaultValue2}}`, code.Expression) + } +} + +func goValueTestLayout(t *testing.T, attribute *expr.AttributeExpr, policy GoLayoutPolicy, bindings map[expr.DataType]GoTypeBinding) LinkedGoType { + t.Helper() + plan, err := PlanGoType(attribute, GoTypePlanOptions{ + Owner: "generated.local/gen/service", + Policy: policy, + Bind: goTypeTestBinder(bindings), + RetainNamedValue: true, + }) + require.NoError(t, err) + return plan.Link("generated.local/gen/service", func(importPath string) string { + if importPath == "encoding/json" { + return "json" + } + return goTypeTestQualifier(importPath) + }) +} + +func goValueTestTypeBinding(t *testing.T, userType expr.UserType) GoTypeBinding { + t.Helper() + generation, err := NewGeneration("generated.local/gen", nil) + require.NoError(t, err) + binding := GoTypeBinding{ + Owner: "generated.local/gen/service", + Type: declareGoTypeTestUserType(t, generation, "generated.local/gen/service", userType), + } + require.NoError(t, generation.Freeze()) + return binding +} + +func goValueTestUnionBinding(t *testing.T, attribute *expr.AttributeExpr) GoTypeBinding { + t.Helper() + generation, err := NewGeneration("generated.local/gen", nil) + require.NoError(t, err) + binding := GoTypeBinding{ + Owner: "generated.local/gen/service", + Union: declareGoTypeTestUnion(t, generation, "generated.local/gen/service", attribute.Type.(*expr.Union)), + } + require.NoError(t, generation.Freeze()) + return binding +} diff --git a/codegen/goify.go b/codegen/goify.go index 0345b66178..44501b09f2 100644 --- a/codegen/goify.go +++ b/codegen/goify.go @@ -1,11 +1,8 @@ package codegen import ( - "go/doc" - "go/token" - "strings" - "goa.design/goa/v3/expr" + "goa.design/goa/v3/internal/codegenname" ) // Goify makes a valid Go identifier out of any string. It does that by removing @@ -14,57 +11,24 @@ import ( // firstUpper is true the first character of the identifier is uppercase // otherwise it's lowercase. func Goify(str string, firstUpper bool) string { - // Optimize trivial case - if str == "" { - return "" - } - - // Remove optional suffix that defines corresponding transport specific - // name. - idx := strings.Index(str, ":") - if idx > 0 { - str = str[:idx] - } - - str = CamelCase(str, firstUpper, true) - if str == "" { - // All characters are invalid. Produce a default value. - if firstUpper { - return "Val" - } - return "val" + key := cacheKey{ + input: str, + firstUpper: firstUpper, + operation: "goify", } - return fixReservedGo(str) + return globalStringCache.getCached(key, func() string { + return codegenname.Goify(str, firstUpper) + }) } // GoifyAtt honors any struct:field:name meta set on the attribute and calls // Goify with the tag value if present or the given name otherwise. func GoifyAtt(att *expr.AttributeExpr, name string, upper bool) string { - if tname, ok := att.Meta["struct:field:name"]; ok { - if len(tname) > 0 { - name = tname[0] - } - } + name = codegenname.AttributeName(name, att.Meta["struct:field:name"]) return Goify(name, upper) } // fixReservedGo appends an underscore on to Go reserved keywords. func fixReservedGo(w string) string { - if doc.IsPredeclared(w) || token.IsKeyword(w) || isPackage[w] { - w += "_" - } - return w + return codegenname.FixReserved(w) } - -var ( - isPackage = map[string]bool{ - // stdlib and goa packages used by generated code - "errors": true, - "fmt": true, - "http": true, - "json": true, - "os": true, - "url": true, - "time": true, - } -) diff --git a/codegen/header.go b/codegen/header.go index db93d1c383..95dce37b79 100644 --- a/codegen/header.go +++ b/codegen/header.go @@ -2,12 +2,14 @@ package codegen import ( "encoding/json" + "fmt" "path/filepath" goa "goa.design/goa/v3/pkg" ) -// Header returns a Go source file header section template. +// Header returns a Go source file header section template. It panics when the +// imports give one package path different explicit package names. func Header(title, pack string, imports []*ImportSpec) *SectionTemplate { return &SectionTemplate{ Name: "source-header", @@ -15,7 +17,7 @@ func Header(title, pack string, imports []*ImportSpec) *SectionTemplate { Data: map[string]any{ "Title": title, "Pkg": pack, - "Imports": imports, + "Imports": appendImports(nil, imports...), }, } } @@ -34,8 +36,8 @@ func VersionFile() *File { } } -// AddImport adds imports to a section template that was generated with -// Header. +// AddImport adds imports to a section template that was generated with Header. +// It panics when one package path is given different explicit package names. func AddImport(section *SectionTemplate, imprts ...*ImportSpec) { if len(imprts) == 0 { return @@ -45,16 +47,42 @@ func AddImport(section *SectionTemplate, imprts ...*ImportSpec) { if imports, ok := data["Imports"]; ok { specs = imports.([]*ImportSpec) } - seen := make(map[ImportSpec]struct{}, len(specs)+len(imprts)) - for _, spec := range specs { - seen[*spec] = struct{}{} - } - for _, spec := range imprts { - if _, ok := seen[*spec]; ok { - continue + data["Imports"] = appendImports(specs, imprts...) +} + +// appendImports keeps one import for each package path. An explicit package +// name replaces an unspecified name. Different explicit names are a generator +// error because one Go file cannot use both names for the same package. +func appendImports(existing []*ImportSpec, additions ...*ImportSpec) []*ImportSpec { + positions := make(map[string]int, len(existing)+len(additions)) + result := make([]*ImportSpec, 0, len(existing)+len(additions)) + appendImport := func(spec *ImportSpec) { + position, ok := positions[spec.Path] + if !ok { + positions[spec.Path] = len(result) + result = append(result, spec) + return } - seen[*spec] = struct{}{} - specs = append(specs, spec) + current := result[position] + switch { + case current.Name == spec.Name, spec.Name == "": + return + case current.Name == "": + result[position] = spec + default: + panic(fmt.Sprintf( + "import path %q uses package names %q and %q", + spec.Path, + current.Name, + spec.Name, + )) + } + } + for _, spec := range existing { + appendImport(spec) + } + for _, spec := range additions { + appendImport(spec) } - data["Imports"] = specs + return result } diff --git a/codegen/import.go b/codegen/import.go index 70e23e5cb9..ee0f28c3c5 100644 --- a/codegen/import.go +++ b/codegen/import.go @@ -1,7 +1,15 @@ +// This file models generated Go imports and derives type imports from explicit +// Goa metadata without assigning them to unrelated generated files. package codegen import ( + "bytes" "fmt" + "go/ast" + goformat "go/format" + "go/parser" + "go/token" + "path" "path/filepath" "strconv" @@ -74,6 +82,15 @@ func (s *ImportSpec) Code() string { return fmt.Sprintf(`"%s"`, s.Path) } +// preferredName returns the package name requested by this import. When the +// import has no explicit name, Go uses the last part of its path. +func (s *ImportSpec) preferredName() string { + if s.Name != "" { + return s.Name + } + return path.Base(s.Path) +} + // UserTypeLocation returns the location of the user type if set via the // struct:pkg:path metadata, nil otherwise.. func UserTypeLocation(dt expr.DataType) *Location { @@ -128,28 +145,63 @@ func GetMetaType(att *expr.AttributeExpr) (typeName string, importS *ImportSpec) return } +// rebindMetaTypeQualifier changes every reference to one imported package in +// a custom Go field type. The type is parsed while source is generated, so +// pointers, slices, maps, and repeated package references remain valid Go. +func rebindMetaTypeQualifier(typeName, current, selected string) string { + if current == "" || current == selected { + return typeName + } + typeExpression, err := parser.ParseExpr(typeName) + if err != nil { + panic(fmt.Sprintf("invalid struct:field:type %q: %v", typeName, err)) + } + changed := false + ast.Inspect(typeExpression, func(node ast.Node) bool { + selector, ok := node.(*ast.SelectorExpr) + if !ok { + return true + } + packageName, ok := selector.X.(*ast.Ident) + if ok && packageName.Name == current { + packageName.Name = selected + changed = true + } + return true + }) + if !changed { + return typeName + } + var source bytes.Buffer + if err := goformat.Node(&source, token.NewFileSet(), typeExpression); err != nil { + panic(fmt.Sprintf("format struct:field:type %q: %v", typeName, err)) + } + return source.String() +} + // GetMetaTypeImports parses the attribute for all user defined imports func GetMetaTypeImports(att *expr.AttributeExpr) []*ImportSpec { return safelyGetMetaTypeImports(att, nil) } // safelyGetMetaTypeImports parses attributes while keeping track of previous usertypes to avoid infinite recursion -func safelyGetMetaTypeImports(att *expr.AttributeExpr, seen map[string]struct{}) []*ImportSpec { +func safelyGetMetaTypeImports(att *expr.AttributeExpr, seen map[expr.UserType]struct{}) []*ImportSpec { if att == nil { return nil } if seen == nil { - seen = make(map[string]struct{}) + seen = make(map[expr.UserType]struct{}) } uniqueImports := make(map[ImportSpec]struct{}) imports := make([]*ImportSpec, 0) switch t := att.Type.(type) { case expr.UserType: - if _, wasSeen := seen[t.ID()]; wasSeen { + origin := t.Origin() + if _, wasSeen := seen[origin]; wasSeen { return imports } - seen[t.ID()] = struct{}{} + seen[origin] = struct{}{} for _, im := range safelyGetMetaTypeImports(t.Attribute(), seen) { if im != nil { uniqueImports[*im] = struct{}{} @@ -192,12 +244,3 @@ func safelyGetMetaTypeImports(att *expr.AttributeExpr, seen map[string]struct{}) } return imports } - -// AddServiceMetaTypeImports adds meta type imports for each method of the service expr -func AddServiceMetaTypeImports(header *SectionTemplate, svc *expr.ServiceExpr) { - for _, m := range svc.Methods { - AddImport(header, GetMetaTypeImports(m.Payload)...) - AddImport(header, GetMetaTypeImports(m.StreamingPayload)...) - AddImport(header, GetMetaTypeImports(m.Result)...) - } -} diff --git a/codegen/import_aliases.go b/codegen/import_aliases.go new file mode 100644 index 0000000000..05f9135783 --- /dev/null +++ b/codegen/import_aliases.go @@ -0,0 +1,235 @@ +// This file chooses the Go name written before each imported type or function. +// Generators submit complete import paths before source is written. After +// Generation.Freeze chooses each name, every file in the output package reads +// the same result. +package codegen + +import ( + "fmt" + "path" + "sort" + + "goa.design/goa/v3/eval" +) + +type ( + // importPriority states why a generator requested an import name. A smaller + // value wins when the same import path has several requested names. + importPriority uint8 + + // importAliasPlan records every requested Go name for each complete import + // path before Generation.Freeze chooses the result. + importAliasPlan struct { + candidates map[string]*importAliasCandidate + } + + // importAliasCandidate groups requested Go names by their reason so the + // strongest requirement for one complete import path wins. + importAliasCandidate struct { + spellings [importPriorityCount]map[string]bool + } + + // importAliasBinding records the Go name written before identifiers from one + // imported package and whether the import line must include that name. + importAliasBinding struct { + name string + explicit bool + } +) + +const ( + fixedImportPriority importPriority = iota + generatedImportPriority + metadataImportPriority + importPriorityCount +) + +// RequireImport records an import name that generated source already refers +// to. It returns an error when the same path is required with a different name. +func (p *GeneratedPackage) RequireImport(spec *ImportSpec) error { + return p.declareImport(spec, fixedImportPriority) +} + +// ReserveGeneratedImport requests a Go name for a generated package. A name +// required by source that is already fixed takes priority, so this request may +// receive a number at the end. +func (p *GeneratedPackage) ReserveGeneratedImport(spec *ImportSpec) error { + return p.declareImport(spec, generatedImportPriority) +} + +// DeclareImport requests the Go name supplied by design metadata. Repeated +// requests for the same complete path are combined before Generation.Freeze. +func (p *GeneratedPackage) DeclareImport(spec *ImportSpec) error { + return p.declareImport(spec, metadataImportPriority) +} + +// Import returns the import line data chosen for importPath. It panics before +// Generation.Freeze or when no generator submitted that path. +func (p *GeneratedPackage) Import(importPath string) *ImportSpec { + binding := p.importBinding(importPath) + return &ImportSpec{Name: explicitImportName(importPath, binding), Path: importPath} +} + +// ImportName returns the Go name written before identifiers imported from +// importPath. It panics before Generation.Freeze or when no generator submitted +// that path. +func (p *GeneratedPackage) ImportName(importPath string) string { + return p.importBinding(importPath).name +} + +// HasRoot reports whether root is one of the exact evaluated roots registered +// when the generation was constructed. +func (g *Generation) HasRoot(root eval.Root) bool { + for _, registered := range g.roots { + if registered == root { + return true + } + } + return false +} + +// declareImport records one requested Go name for an import path. +func (p *GeneratedPackage) declareImport(spec *ImportSpec, priority importPriority) error { + if p.frozen { + return fmt.Errorf("generated package %q is frozen", p.path) + } + importPath, preferred := spec.Path, spec.Name + if importPath == "" { + return nil + } + if preferred == "" { + preferred = path.Base(importPath) + } + candidate, ok := p.importPlan.candidates[importPath] + if !ok { + candidate = &importAliasCandidate{} + p.importPlan.candidates[importPath] = candidate + } + spellings := candidate.spellings[priority] + if spellings == nil { + spellings = make(map[string]bool) + candidate.spellings[priority] = spellings + } + if priority == fixedImportPriority && len(spellings) > 0 { + required, _ := firstImportSpelling(spellings) + if required != preferred { + return fmt.Errorf( + "fixed import path %q requires qualifier %q, not %q", + importPath, + required, + preferred, + ) + } + } + spellings[preferred] = spellings[preferred] || spec.Name != "" + return nil +} + +// freezeImports rejects conflicting required names, then chooses one unused Go +// name for every import path. No import name changes afterward. +func (p *GeneratedPackage) freezeImports() error { + paths := make([]string, 0, len(p.importPlan.candidates)) + for importPath := range p.importPlan.candidates { + paths = append(paths, importPath) + } + sort.Slice(paths, func(i, j int) bool { + left := p.importPlan.candidates[paths[i]].priority() + right := p.importPlan.candidates[paths[j]].priority() + if left != right { + return left < right + } + return paths[i] < paths[j] + }) + fixedPaths := make(map[string]string) + for _, importPath := range paths { + candidate := p.importPlan.candidates[importPath] + if candidate.priority() != fixedImportPriority { + continue + } + name, _ := firstImportSpelling(candidate.spellings[fixedImportPriority]) + if existingPath, ok := fixedPaths[name]; ok && existingPath != importPath { + return fmt.Errorf( + "fixed import qualifier %q is required by both %q and %q", + name, + existingPath, + importPath, + ) + } + fixedPaths[name] = importPath + } + bindings := make(map[string]importAliasBinding, len(paths)) + for _, importPath := range paths { + candidate := p.importPlan.candidates[importPath] + spellings := candidate.spellings[candidate.priority()] + preferred, explicit := firstImportSpelling(spellings) + name := p.scope.Unique(preferred) + if candidate.priority() == fixedImportPriority && name != preferred { + return fmt.Errorf( + "generated package %q cannot preserve fixed import qualifier %q for %q", + p.path, + preferred, + importPath, + ) + } + bindings[importPath] = importAliasBinding{ + name: name, + explicit: explicit || name != path.Base(importPath), + } + p.scope.bindImport(importPath, name) + if relative, ok := relativeGeneratedPackagePath(p.genpkg, importPath); ok { + p.scope.bindGeneratedImport(relative, name) + } + } + p.imports = bindings + return nil +} + +// importBinding returns the Go package name chosen for one import path after +// Generation.Freeze chooses all import names. +func (p *GeneratedPackage) importBinding(importPath string) importAliasBinding { + if !p.frozen { + panic(fmt.Sprintf("generated package %q imports requested before freeze", p.path)) + } + binding, ok := p.imports[importPath] + if !ok { + panic(fmt.Sprintf("import path %q has no planned alias", importPath)) + } + return binding +} + +// firstImportSpelling prefers a supplied package name over a name inferred from +// the import path, then sorts equal requests so call order cannot change source. +func firstImportSpelling(spellings map[string]bool) (string, bool) { + names := make([]string, 0, len(spellings)) + for name := range spellings { + names = append(names, name) + } + sort.Slice(names, func(i, j int) bool { + if spellings[names[i]] != spellings[names[j]] { + return spellings[names[i]] + } + return names[i] < names[j] + }) + name := names[0] + return name, spellings[name] +} + +// priority returns the strongest reason for a requested import name. +func (c *importAliasCandidate) priority() importPriority { + for priority := fixedImportPriority; priority < importPriorityCount; priority++ { + if len(c.spellings[priority]) > 0 { + return priority + } + } + panic("import alias candidate has no spellings") +} + +// explicitImportName returns an empty string when the import path already ends +// in the chosen Go name; otherwise it returns the name written on the import +// line. +func explicitImportName(importPath string, binding importAliasBinding) string { + if binding.explicit || binding.name != path.Base(importPath) { + return binding.name + } + return "" +} diff --git a/codegen/import_aliases_test.go b/codegen/import_aliases_test.go new file mode 100644 index 0000000000..93510c1859 --- /dev/null +++ b/codegen/import_aliases_test.go @@ -0,0 +1,255 @@ +// This file verifies that one generation assigns import qualifiers by explicit +// ownership priority rather than declaration order or import-path sorting. +package codegen + +import ( + "testing" + + "github.com/stretchr/testify/require" + + "goa.design/goa/v3/expr" +) + +// TestPackageScopeUsesFinalImportAliases verifies that type rendering and +// import declarations read the same package name after a collision. +func TestPackageScopeUsesFinalImportAliases(t *testing.T) { + generation := mustTestGeneration(t, "generated.local/gen", nil) + pkg := mustClaimTestPackage(t, generation, "generated.local/gen/service") + token := &expr.UserTypeExpr{ + TypeName: "Token", + AttributeExpr: &expr.AttributeExpr{ + Type: expr.String, + Meta: expr.MetaExpr{"struct:pkg:path": {"domain/../strconv"}}, + }, + } + values := &expr.AttributeExpr{Type: &expr.Array{ + ElemType: &expr.AttributeExpr{Type: token}, + }} + _, err := pkg.DeclareUserType(token) + require.NoError(t, err) + require.NoError(t, pkg.DeclareImport(NewImport("strconv", "generated.local/gen/strconv"))) + require.NoError(t, pkg.RequireImport(SimpleImport("strconv"))) + require.NoError(t, generation.Freeze()) + + require.Equal(t, "strconv2", pkg.ImportName("generated.local/gen/strconv")) + require.Equal(t, "strconv2", pkg.Scope().generatedImportName("domain/../strconv", "strconv")) + require.Equal(t, "[]strconv2.Token", pkg.Scope().GoTypeDef(values, false, true)) +} + +// TestPackageScopeUsesFinalImportAliasesFromSlashRoot verifies that a local +// generated package uses its selected name when generation starts at "/". +func TestPackageScopeUsesFinalImportAliasesFromSlashRoot(t *testing.T) { + generation := mustTestGeneration(t, "/", nil) + pkg := mustClaimTestPackage(t, generation, "/service") + token := &expr.UserTypeExpr{ + TypeName: "Token", + AttributeExpr: &expr.AttributeExpr{ + Type: expr.String, + Meta: expr.MetaExpr{"struct:pkg:path": {"strconv"}}, + }, + } + _, err := pkg.DeclareUserType(token) + require.NoError(t, err) + require.NoError(t, pkg.DeclareImport(NewImport("strconv", "/strconv"))) + require.NoError(t, pkg.RequireImport(SimpleImport("strconv"))) + require.NoError(t, generation.Freeze()) + + require.Equal(t, "strconv2", pkg.ImportName("/strconv")) + require.Equal(t, "strconv2.Token", pkg.Scope().GoTypeDef(&expr.AttributeExpr{Type: token}, false, true)) +} + +// TestImportAliasPrioritiesIgnoreRegistrationOrder verifies that static +// template imports keep required qualifiers ahead of generated packages and +// design metadata regardless of planning order. +func TestImportAliasPrioritiesIgnoreRegistrationOrder(t *testing.T) { + freeze := func(reverse bool) map[string]string { + generation := mustTestGeneration(t, "generated.local/gen", nil) + pkg, err := generation.ClaimPackage("generated.local/gen/service") + require.NoError(t, err) + declare := []func() error{ + func() error { + return pkg.RequireImport(NewImport("goa", "goa.design/goa/v3/pkg")) + }, + func() error { + return pkg.ReserveGeneratedImport(NewImport("goa", "generated.local/gen/goa")) + }, + func() error { + return pkg.DeclareImport(NewImport("goa", "example.com/custom/goa")) + }, + } + if reverse { + declare[0], declare[2] = declare[2], declare[0] + } + for _, register := range declare { + require.NoError(t, register()) + } + require.NoError(t, generation.Freeze()) + return map[string]string{ + "fixed": pkg.ImportName("goa.design/goa/v3/pkg"), + "generated": pkg.ImportName("generated.local/gen/goa"), + "metadata": pkg.ImportName("example.com/custom/goa"), + } + } + + want := map[string]string{ + "fixed": "goa", + "generated": "goa2", + "metadata": "goa3", + } + require.Equal(t, want, freeze(false)) + require.Equal(t, want, freeze(true)) +} + +// TestImportAliasHighestPriorityWinsPerPath verifies that one complete import +// path has one identity and uses its highest-priority requested spelling. +func TestImportAliasHighestPriorityWinsPerPath(t *testing.T) { + freeze := func(reverse bool) string { + generation := mustTestGeneration(t, "generated.local/gen", nil) + pkg, err := generation.ClaimPackage("generated.local/gen/service") + require.NoError(t, err) + declare := []func() error{ + func() error { + return pkg.RequireImport(NewImport("json", "encoding/json")) + }, + func() error { + return pkg.ReserveGeneratedImport(NewImport("jason", "encoding/json")) + }, + func() error { + return pkg.DeclareImport(NewImport("jsonp", "encoding/json")) + }, + } + if reverse { + declare[0], declare[2] = declare[2], declare[0] + } + for _, register := range declare { + require.NoError(t, register()) + } + require.NoError(t, generation.Freeze()) + return pkg.ImportName("encoding/json") + } + + require.Equal(t, "json", freeze(false)) + require.Equal(t, "json", freeze(true)) +} + +// TestGeneratedImportPreferenceIsOrderIndependent verifies that repeated +// generated-package preferences for one path use deterministic spelling. +func TestGeneratedImportPreferenceIsOrderIndependent(t *testing.T) { + freeze := func(first, second string) string { + generation := mustTestGeneration(t, "generated.local/gen", nil) + pkg, err := generation.ClaimPackage("generated.local/gen/service") + require.NoError(t, err) + require.NoError(t, pkg.ReserveGeneratedImport(NewImport(first, "generated.local/gen/value"))) + require.NoError(t, pkg.ReserveGeneratedImport(NewImport(second, "generated.local/gen/value"))) + require.NoError(t, generation.Freeze()) + return pkg.ImportName("generated.local/gen/value") + } + + require.Equal(t, freeze("alpha", "zeta"), freeze("zeta", "alpha")) +} + +// TestNamedGeneratedImportBeatsPathGuess verifies that a generator which knows +// a package's Go name wins over another generator which knows only its path. +func TestNamedGeneratedImportBeatsPathGuess(t *testing.T) { + for _, reverse := range []bool{false, true} { + generation := mustTestGeneration(t, "generated.local/gen", nil) + pkg, err := generation.ClaimPackage("generated.local/gen/consumer") + require.NoError(t, err) + imports := []*ImportSpec{ + NewImport("servicepkg", "generated.local/gen/service_pkg"), + SimpleImport("generated.local/gen/service_pkg"), + } + if reverse { + imports[0], imports[1] = imports[1], imports[0] + } + for _, spec := range imports { + require.NoError(t, pkg.ReserveGeneratedImport(spec)) + } + require.NoError(t, generation.Freeze()) + + require.Equal(t, "servicepkg", pkg.ImportName("generated.local/gen/service_pkg")) + require.Equal(t, NewImport("servicepkg", "generated.local/gen/service_pkg"), pkg.Import("generated.local/gen/service_pkg")) + } +} + +// TestImportAliasRejectsIncompatibleFixedRequirements verifies that static +// templates cannot request two different mandatory spellings for one path. +func TestImportAliasRejectsIncompatibleFixedRequirements(t *testing.T) { + generation := mustTestGeneration(t, "generated.local/gen", nil) + pkg, err := generation.ClaimPackage("generated.local/gen/service") + require.NoError(t, err) + require.NoError(t, pkg.RequireImport(NewImport("json", "encoding/json"))) + require.ErrorContains( + t, + pkg.RequireImport(NewImport("jason", "encoding/json")), + "requires qualifier", + ) +} + +// TestImportAliasRejectsFixedQualifierCollision verifies that two static +// packages cannot both require the same qualifier. +func TestImportAliasRejectsFixedQualifierCollision(t *testing.T) { + generation := mustTestGeneration(t, "generated.local/gen", nil) + pkg, err := generation.ClaimPackage("generated.local/gen/service") + require.NoError(t, err) + require.NoError(t, pkg.RequireImport(NewImport("runtime", "example.com/first"))) + require.NoError(t, pkg.RequireImport(NewImport("runtime", "example.com/second"))) + require.ErrorContains(t, generation.Freeze(), "required by both") +} + +// TestImportAliasesAreIndependentAcrossOutputPackages verifies that packages +// which never compile together may use the same natural import name. +func TestImportAliasesAreIndependentAcrossOutputPackages(t *testing.T) { + generation := mustTestGeneration(t, "generated.local/gen", nil) + httpPackage, err := generation.ClaimPackage("generated.local/gen/http/cli/calc") + require.NoError(t, err) + grpcPackage, err := generation.ClaimPackage("generated.local/gen/grpc/cli/calc") + require.NoError(t, err) + require.NoError(t, httpPackage.ReserveGeneratedImport(NewImport( + "calcc", + "generated.local/gen/http/calc/client", + ))) + require.NoError(t, grpcPackage.ReserveGeneratedImport(NewImport( + "calcc", + "generated.local/gen/grpc/calc/client", + ))) + require.NoError(t, generation.Freeze()) + require.Equal(t, "calcc", httpPackage.ImportName("generated.local/gen/http/calc/client")) + require.Equal(t, "calcc", grpcPackage.ImportName("generated.local/gen/grpc/calc/client")) +} + +// TestImportAliasSharesPackageDeclarationScope verifies that an import and a +// package declaration cannot receive the same Go name. +func TestImportAliasSharesPackageDeclarationScope(t *testing.T) { + generation := mustTestGeneration(t, "generated.local/gen", nil) + pkg := mustClaimTestPackage(t, generation, "generated.local/gen/service") + require.NoError(t, pkg.ReserveGeneratedImport(NewImport( + "inspectPayloadFieldDescs", + "example.com/inspect-payload-field-descs", + ))) + declaration := NewPreferredName( + NameVariable, + "inspectPayloadFieldDescs", + UnexportedName, + testNameOrder{value: "inspect-payload-field-descs"}, + ) + require.NoError(t, pkg.DeclareName(declaration)) + + require.NoError(t, generation.Freeze()) + require.Equal(t, "inspectPayloadFieldDescs", pkg.ImportName("example.com/inspect-payload-field-descs")) + require.Equal(t, "inspectPayloadFieldDescs2", declaration.Name()) +} + +// TestGeneratedImportYieldsToExactPackageDeclaration verifies that a package +// declaration which must keep its spelling is chosen before a renamable import. +func TestGeneratedImportYieldsToExactPackageDeclaration(t *testing.T) { + generation := mustTestGeneration(t, "generated.local/gen", nil) + pkg := mustClaimTestPackage(t, generation, "generated.local/gen/service") + require.NoError(t, pkg.ReserveGeneratedImport(NewImport("widget", "example.com/widget"))) + declaration := NewExactName(NameVariable, "widget") + require.NoError(t, pkg.DeclareName(declaration)) + + require.NoError(t, generation.Freeze()) + require.Equal(t, "widget", declaration.Name()) + require.Equal(t, "widget2", pkg.ImportName("example.com/widget")) +} diff --git a/codegen/import_test.go b/codegen/import_test.go index 3aae8c7273..720ad3801d 100644 --- a/codegen/import_test.go +++ b/codegen/import_test.go @@ -1,3 +1,5 @@ +// This file verifies that import discovery follows complete attribute shapes +// while keeping independent user declarations distinct during cycle checks. package codegen import ( @@ -170,3 +172,42 @@ func TestGetMetaTypeImports(t *testing.T) { }) } } + +// TestGetMetaTypeImportsKeepsIndependentDeclarationsWithOneID verifies +// semantic IDs do not collapse imports from exact in-memory declarations. +func TestGetMetaTypeImportsKeepsIndependentDeclarationsWithOneID(t *testing.T) { + first := &expr.UserTypeExpr{ + TypeName: "First", + UID: "shared-semantic-id", + AttributeExpr: &expr.AttributeExpr{ + Type: expr.String, + Meta: expr.MetaExpr{ + "struct:field:type": {"First", "example.com/first"}, + }, + }, + } + second := &expr.UserTypeExpr{ + TypeName: "Second", + UID: "shared-semantic-id", + AttributeExpr: &expr.AttributeExpr{ + Type: expr.String, + Meta: expr.MetaExpr{ + "struct:field:type": {"Second", "example.com/second"}, + }, + }, + } + object := expr.Object{ + &expr.NamedAttributeExpr{Name: "first", Attribute: &expr.AttributeExpr{Type: first}}, + &expr.NamedAttributeExpr{Name: "second", Attribute: &expr.AttributeExpr{Type: second}}, + } + + imports := GetMetaTypeImports(&expr.AttributeExpr{Type: &object}) + paths := make([]string, len(imports)) + for i, spec := range imports { + paths[i] = spec.Path + } + sort.Strings(paths) + if want := []string{"example.com/first", "example.com/second"}; !reflect.DeepEqual(paths, want) { + t.Errorf("want %+v, got %+v", want, paths) + } +} diff --git a/codegen/internal/pluginregistry/registry.go b/codegen/internal/pluginregistry/registry.go new file mode 100644 index 0000000000..6420c4cb34 --- /dev/null +++ b/codegen/internal/pluginregistry/registry.go @@ -0,0 +1,113 @@ +// Package pluginregistry stores callbacks registered through Goa's released +// plugin API until generation starts. Plugin authors may register callbacks, +// but only the generator may stop registration or read the stored list. +package pluginregistry + +import ( + "fmt" + "slices" + "sync" +) + +type ( + // Position identifies when a plugin runs relative to normal plugins. + Position uint8 + + // Registry stores callbacks until the first generation run copies them. + // Callback types stay paired with the package that defines them. + Registry struct { + mu sync.Mutex + registrations []storedRegistration + sealed bool + } + + // Registration is one plugin definition with its original callback types. + Registration[Prepare, Generate any] struct { + Name string + Command string + Position Position + Prepare Prepare + Generate Generate + } + + // storedRegistration keeps callbacks without importing their defining + // package. Snapshot restores the types supplied by that same package. + storedRegistration struct { + name string + command string + position Position + prepare any + generate any + } +) + +const ( + // First places a plugin before normally ordered plugins. + First Position = iota + // Normal places a plugin between first and last plugins. + Normal + // Last places a plugin after normally ordered plugins. + Last +) + +var defaultRegistry = New() + +// New creates an open plugin registry for Goa or a focused test. +func New() *Registry { + return &Registry{} +} + +// Register records one plugin in the process-wide registry used by Goa. +func Register[Prepare, Generate any](name, command string, position Position, prepare Prepare, generate Generate) { + RegisterIn(defaultRegistry, name, command, position, prepare, generate) +} + +// RegisterIn records one plugin in registry before generation starts. +func RegisterIn[Prepare, Generate any](registry *Registry, name, command string, position Position, prepare Prepare, generate Generate) { + registry.mu.Lock() + defer registry.mu.Unlock() + if registry.sealed { + panic("plugin registry is sealed") + } + registry.registrations = append(registry.registrations, storedRegistration{ + name: name, + command: command, + position: position, + prepare: prepare, + generate: generate, + }) +} + +// Snapshot stops further process-wide registrations and returns a copy of the +// registered plugins with their original callback types. +func Snapshot[Prepare, Generate any]() []Registration[Prepare, Generate] { + return SnapshotFrom[Prepare, Generate](defaultRegistry) +} + +// SnapshotFrom stops further registrations in registry and returns a copy that +// callers may sort without changing the stored order. +func SnapshotFrom[Prepare, Generate any](registry *Registry) []Registration[Prepare, Generate] { + registry.mu.Lock() + defer registry.mu.Unlock() + registry.sealed = true + stored := slices.Clone(registry.registrations) + registrations := make([]Registration[Prepare, Generate], len(stored)) + for index, plugin := range stored { + prepare, ok := plugin.prepare.(Prepare) + if !ok { + panic(fmt.Sprintf("plugin %q has an unexpected prepare callback type", plugin.name)) + } + generate, ok := plugin.generate.(Generate) + if !ok { + panic(fmt.Sprintf("plugin %q has an unexpected generate callback type", plugin.name)) + } + registrations[index] = Registration[Prepare, Generate]{ + Name: plugin.name, + Command: plugin.command, + Position: plugin.position, + Prepare: prepare, + Generate: generate, + } + } + return registrations +} diff --git a/codegen/name_declaration.go b/codegen/name_declaration.go new file mode 100644 index 0000000000..f4915cfbee --- /dev/null +++ b/codegen/name_declaration.go @@ -0,0 +1,253 @@ +// This file records package-level Go names before source is written. Names that +// must remain exact are assigned first, then generated names receive numeric +// suffixes when needed. After that, the names cannot change. +package codegen + +import ( + "fmt" + "go/token" + "reflect" + "strings" +) + +type ( + // PackageNameKind states whether a package-level name declares a type, + // function, constant, or variable. All four kinds must have different names + // within one Go package. + PackageNameKind uint8 + + // PackageNameVisibility states whether generated code outside the package can + // use a preferred name. + PackageNameVisibility uint8 + + // PackageNameOrder sorts generated declarations that request the same name. + // Implementations must be named, non-pointer values containing only values + // that cannot change. Values of different concrete types are sorted by their + // package and type names before this method is called. + PackageNameOrder interface { + // ComparePackageName compares two values of the same concrete type. It must + // return a negative value when the receiver comes first, zero when the values + // are equal, and a positive value when the receiver comes last. Reversing the + // arguments must reverse the sign, and comparison must sort consistently. + ComparePackageName(PackageNameOrder) int + } + + // NameDeclaration records one package-level Go name. Name cannot be read + // until Generation.Freeze chooses its final spelling among all declarations + // in the package. + NameDeclaration struct { + kind PackageNameKind + visibility PackageNameVisibility + preferred string + final string + owner *GeneratedPackage + exact bool + order PackageNameOrder + base *NameDeclaration + prefix string + suffix string + hashes []Hasher + frozen bool + } +) + +const ( + // NameType marks a package-level type name. + NameType PackageNameKind = iota + 1 + // NameFunction marks a package-level function name. + NameFunction + // NameConstant marks a package-level constant name. + NameConstant + // NameVariable marks a package-level variable name. + NameVariable +) + +const ( + // ExportedName requests a name that code outside the package can use. + ExportedName PackageNameVisibility = iota + 1 + // UnexportedName requests a name that only code in the package can use. + UnexportedName +) + +// NewExactName records a package-level Go name that must not change. Adding the +// declaration to a generated package fails if name is invalid or already used. +func NewExactName(kind PackageNameKind, name string) *NameDeclaration { + return &NameDeclaration{ + kind: kind, + preferred: name, + exact: true, + } +} + +// NewPreferredName records a generated name that may receive a numeric suffix +// when another declaration requests the same spelling. order decides which +// declaration keeps the unsuffixed name and must be a named, non-pointer value +// containing only values that cannot change. +func NewPreferredName(kind PackageNameKind, preferred string, visibility PackageNameVisibility, order PackageNameOrder) *NameDeclaration { + return &NameDeclaration{ + kind: kind, + visibility: visibility, + preferred: Goify(preferred, visibility == ExportedName), + order: order, + } +} + +// Name returns the final Go name. It panics until Generation.Freeze chooses all +// declaration names because another declaration may still change this one. +func (d *NameDeclaration) Name() string { + if !d.frozen { + panic(fmt.Sprintf("package name %q requested before generation freeze", d.preferredName())) + } + return d.final +} + +// Kind returns whether this name declares a type, function, constant, or +// variable. +func (d *NameDeclaration) Kind() PackageNameKind { + return d.kind +} + +// String returns the declaration kind used in error messages. +func (k PackageNameKind) String() string { + switch k { + case NameType: + return "type" + case NameFunction: + return "function" + case NameConstant: + return "constant" + case NameVariable: + return "variable" + default: + return "unknown" + } +} + +// newDependentName records a name formed by adding prefix and suffix to base's +// final name. +func newDependentName(kind PackageNameKind, base *NameDeclaration, prefix, suffix string, order PackageNameOrder) *NameDeclaration { + if base == nil { + panic("dependent package name requires a base declaration") + } + return &NameDeclaration{ + kind: kind, + order: order, + base: base, + prefix: prefix, + suffix: suffix, + } +} + +// comparePackageNames sorts declarations by their order value rather than the +// order in which generators added them. Two distinct declarations must not +// compare equal. +func comparePackageNames(left, right *NameDeclaration) int { + return comparePackageNameOrders(left.order, right.order) +} + +// comparePackageNameOrders compares two validated order values without reading +// discovery order or pointer identity. +func comparePackageNameOrders(left, right PackageNameOrder) int { + leftType := reflect.TypeOf(left) + rightType := reflect.TypeOf(right) + if compared := strings.Compare(leftType.PkgPath(), rightType.PkgPath()); compared != 0 { + return compared + } + if compared := strings.Compare(leftType.Name(), rightType.Name()); compared != 0 { + return compared + } + return left.ComparePackageName(right) +} + +// validateNameDeclaration checks that declaration has a valid kind, visibility, +// and requested Go name before a generated package records it. +func validateNameDeclaration(declaration *NameDeclaration) error { + if !declaration.kind.valid() { + return fmt.Errorf("invalid package name kind %d", declaration.kind) + } + if declaration.base == nil && !declaration.exact && !declaration.visibility.valid() { + return fmt.Errorf("invalid package name visibility %d", declaration.visibility) + } + if declaration.preferredName() == "" { + return fmt.Errorf("package name must not be empty") + } + if declaration.exact && !token.IsIdentifier(declaration.preferred) { + return fmt.Errorf("package name %q is not a valid Go identifier", declaration.preferred) + } + return nil +} + +// valid reports whether v is one of the supported visibility values. +func (v PackageNameVisibility) valid() bool { + return v == ExportedName || v == UnexportedName +} + +// validatePackageNameOrder checks that order is a named value whose contents +// cannot change after a generated package records it. +func validatePackageNameOrder(order PackageNameOrder) error { + if order == nil { + return fmt.Errorf("package name order must be a stable concrete named value type") + } + typeOf := reflect.TypeOf(order) + if typeOf.Name() == "" || typeOf.PkgPath() == "" || !isStablePackageNameOrderType(typeOf) { + return fmt.Errorf("package name order %T must be a stable concrete named value type", order) + } + return nil +} + +// isStablePackageNameOrderType reports whether typeOf contains only values that +// cannot change after they are copied. +func isStablePackageNameOrderType(typeOf reflect.Type) bool { + switch typeOf.Kind() { + case reflect.Array: + return isStablePackageNameOrderType(typeOf.Elem()) + case reflect.Struct: + for i := range typeOf.NumField() { + if !isStablePackageNameOrderType(typeOf.Field(i).Type) { + return false + } + } + return true + case reflect.Bool, + reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64, + reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64, + reflect.Float32, reflect.Float64, + reflect.Complex64, reflect.Complex128, + reflect.String: + return true + default: + return false + } +} + +// packagePath returns the import path of the package that declares this name. +// It panics if no generated package has recorded the declaration. +func (d *NameDeclaration) packagePath() string { + if d.owner == nil { + panic(fmt.Sprintf("package name %q has no generated package owner", d.preferredName())) + } + return d.owner.path +} + +// preferredName returns the requested name. A dependent name uses base's final +// spelling once available, then adds its prefix and suffix. +func (d *NameDeclaration) preferredName() string { + if d.base == nil { + return d.preferred + } + base := d.base.preferred + if d.base.frozen { + base = d.base.final + } + return d.prefix + base + d.suffix +} + +// valid reports whether k is one of the supported declaration kinds. +func (k PackageNameKind) valid() bool { + switch k { + case NameType, NameFunction, NameConstant, NameVariable: + return true + default: + return false + } +} diff --git a/codegen/normalize.go b/codegen/normalize.go index 0eaa03005e..b17aba6be2 100644 --- a/codegen/normalize.go +++ b/codegen/normalize.go @@ -1,206 +1,86 @@ +// This file wraps unnamed method payload and result objects in user types +// after evaluation. It records each wrapper so later code finds the same +// generated type without trusting a user-provided string. package codegen import ( - "strings" - + "goa.design/goa/v3/eval" "goa.design/goa/v3/expr" ) -// NormalizeRoot applies the only sanctioned design mutation that may happen -// after the DSL has been evaluated and finalized: it wraps the raw object -// payload, result and streaming types of every service method into -// synthesized user types named after the method. Every code generation layer -// (service, transports, OpenAPI, example, CLI and type conversion) relies on -// method payload and result types being named, so the wrapping must happen -// before any generator reads the design. -// -// NormalizeRoot is idempotent: already wrapped methods are left untouched. It -// must run after the prepare plugins so that plugin contributed endpoints are -// normalized too, and before any generator runs. Past this point the design -// expression tree is read-only for all generators; the purity test in -// codegen/generator enforces that contract. -// -// The synthesized type names are resolved against a name scope seeded with -// the exact same registrations the service generator performs when it -// collects the service user types (see codegen/service analyze) so that -// wrapping up front produces the very same type names the service generator -// produced when it owned the wrapping. -func NormalizeRoot(r *expr.RootExpr) { - for _, svc := range r.Services { - normalizeService(svc) - } -} - -// normalizeService wraps the raw object method types of svc into synthesized -// user types. The name scope fed to PeekUnique is seeded by replaying the -// scope side effects of the service generator analysis in the same order: -// reserved identifiers and package name first, then the user types reachable -// from the service errors and from each method payload, streaming payload, -// result, streaming result, projected result types and method errors. -func normalizeService(svc *expr.ServiceExpr) { - scope := NewNameScope() - scope.Unique("Use") // Reserve "Use" for Endpoints struct Use method. - scope.Unique("websocket") // Reserve "websocket" to avoid collision with gorilla/websocket - scope.HashedUnique(svc, strings.ToLower(Goify(svc.Name, false)), "svc") - seen := make(map[string]struct{}) - for _, er := range svc.Errors { - seedTypeNames(er.AttributeExpr, scope, seen) - } - seedMethodAtt := func(att *expr.AttributeExpr) { - if att == nil { - return - } - if ut, ok := att.Type.(expr.UserType); ok { - att = ut.Attribute() - } - seedTypeNames(att, scope, seen) - } - seenProjected := make(map[string]struct{}) - for _, m := range svc.Methods { - seedMethodAtt(m.Payload) - seedMethodAtt(m.StreamingPayload) - seedMethodAtt(m.Result) - if m.HasMixedResults() { - seedMethodAtt(m.StreamingResult) - } - if hasResultTypeExpr(m.Result, make(map[string]struct{})) { - seedProjectedNames(expr.DupAtt(m.Result), m.Result, scope, seenProjected) - } - for _, er := range m.Errors { - seedTypeNames(er.AttributeExpr, scope, seen) - } - } - wrap := func(att *expr.AttributeExpr, name, id string) { - if att == nil { - return - } - if _, ok := att.Type.(*expr.Object); !ok { - return - } - att.Type = &expr.UserTypeExpr{ - AttributeExpr: expr.DupAtt(att), - TypeName: scope.PeekUnique(name), - UID: id, - } - } - for _, m := range svc.Methods { - name := Goify(m.Name, true) - wrap(m.Payload, name+"Payload", svc.Name+"#"+name+"Payload") - wrap(m.StreamingPayload, name+"StreamingPayload", svc.Name+"#"+name+"StreamingPayload") - wrap(m.Result, name+"Result", svc.Name+"#"+name+"Result") - if m.HasMixedResults() { - wrap(m.StreamingResult, name+"StreamingResult", svc.Name+"#"+name+"StreamingResult") +// normalizeRoots wraps raw method objects in every Goa design root and returns +// the exact compiler-created declarations with their closed method roles. +func normalizeRoots(roots []eval.Root) map[expr.UserType]MethodTypeIdentity { + normalized := make(map[expr.UserType]MethodTypeIdentity) + for _, root := range roots { + if design, ok := root.(*expr.RootExpr); ok { + normalizeRoot(design, normalized) } } + return normalized } -// seedTypeNames mirrors the name scope side effects of the service generator -// user type collection (collectTypes in codegen/service): every user type -// reachable from at reserves its Go type name, the names referenced by its -// type definition and its type reference, in the same order. The returned -// strings are discarded, only the scope registrations matter. -func seedTypeNames(at *expr.AttributeExpr, scope *NameScope, seen map[string]struct{}) { - if at == nil || at.Type == expr.Empty { - return +// normalizeRoot records every wrapper created for one design root. +func normalizeRoot(root *expr.RootExpr, normalized map[expr.UserType]MethodTypeIdentity) { + apiName := "" + if root.API != nil { + apiName = root.API.Name } - switch dt := at.Type.(type) { - case expr.UserType: - if _, ok := seen[dt.ID()]; ok { - return - } - scope.GoTypeName(at) - scope.GoTypeDef(dt.Attribute(), false, true) - scope.GoTypeRef(at) - seen[dt.ID()] = struct{}{} - seedTypeNames(dt.Attribute(), scope, seen) - case *expr.Object: - for _, nat := range *dt { - seedTypeNames(nat.Attribute, scope, seen) - } - case *expr.Array: - seedTypeNames(dt.ElemType, scope, seen) - case *expr.Map: - seedTypeNames(dt.KeyType, scope, seen) - seedTypeNames(dt.ElemType, scope, seen) - case *expr.Union: - for _, nat := range dt.Values { - seedTypeNames(nat.Attribute, scope, seen) - } + for _, service := range root.Services { + normalizeService(apiName, service, normalized) } } -// seedProjectedNames mirrors the name scope side effects of the projected -// type collection (collectProjectedTypes and the view conversion builders in -// codegen/service): projected is a detached copy of the method result -// attribute whose user types are renamed with the "View" suffix while -// traversing, and every result type with views reserves the projected and -// original type names in the service scope, children first. -func seedProjectedNames(projected, att *expr.AttributeExpr, scope *NameScope, seen map[string]struct{}) { - switch pt := projected.Type.(type) { - case expr.UserType: - dt := att.Type.(expr.UserType) - if _, ok := seen[dt.ID()]; ok { - return - } - seen[dt.ID()] = struct{}{} - pt.Rename(pt.Name() + "View") - seedProjectedNames(pt.Attribute(), dt.Attribute(), scope, seen) - if rt, ok := pt.(*expr.ResultTypeExpr); ok && len(rt.Views) > 0 { - if parr := expr.AsArray(pt); parr != nil { - scope.GoTypeName(parr.ElemType) - } - scope.GoTypeName(projected) - scope.GoTypeName(att) - } - case *expr.Array: - seedProjectedNames(pt.ElemType, att.Type.(*expr.Array).ElemType, scope, seen) - case *expr.Map: - dt := att.Type.(*expr.Map) - seedProjectedNames(pt.KeyType, dt.KeyType, scope, seen) - seedProjectedNames(pt.ElemType, dt.ElemType, scope, seen) - case *expr.Object: - dt := att.Type.(*expr.Object) - for _, n := range *pt { - seedProjectedNames(n.Attribute, dt.Attribute(n.Name), scope, seen) - } - case *expr.Union: - dt := att.Type.(*expr.Union) - for i, n := range pt.Values { - seedProjectedNames(n.Attribute, dt.Values[i].Attribute, scope, seen) +// normalizeService wraps each unnamed object payload and result in a generated +// user type without reading or changing generated Go names. +func normalizeService(apiName string, service *expr.ServiceExpr, normalized map[expr.UserType]MethodTypeIdentity) { + for _, method := range service.Methods { + normalizeMethodAttribute(method.Payload, newMethodTypeIdentity( + apiName, + method.Name, + methodPayloadTypeKind, + expr.MethodPayloadExampleIdentity(method), + ), normalized) + normalizeMethodAttribute(method.StreamingPayload, newMethodTypeIdentity( + apiName, + method.Name, + methodStreamingPayloadTypeKind, + expr.MethodStreamingPayloadExampleIdentity(method), + ), normalized) + normalizeMethodAttribute(method.Result, newMethodTypeIdentity( + apiName, + method.Name, + methodResultTypeKind, + expr.MethodResultExampleIdentity(method), + ), normalized) + if method.HasMixedResults() { + normalizeMethodAttribute(method.StreamingResult, newMethodTypeIdentity( + apiName, + method.Name, + methodStreamingResultTypeKind, + expr.MethodStreamingResultExampleIdentity(method), + ), normalized) } } } -// hasResultTypeExpr reports whether att transitively references a result type -// expression. It mirrors hasResultType in codegen/service which decides -// whether the service generator collects projected types for a method result. -func hasResultTypeExpr(att *expr.AttributeExpr, seen map[string]struct{}) bool { - if _, ok := att.Type.(*expr.ResultTypeExpr); ok { - return true +// normalizeMethodAttribute records which generated wrapper was created for an +// unnamed object. Existing named and non-object method types remain unchanged. +func normalizeMethodAttribute(attribute *expr.AttributeExpr, identity MethodTypeIdentity, normalized map[expr.UserType]MethodTypeIdentity) { + if attribute == nil { + return } - switch a := att.Type.(type) { - case expr.UserType: - if _, ok := seen[a.ID()]; ok { - return false - } - seen[a.ID()] = struct{}{} - return hasResultTypeExpr(a.Attribute(), seen) - case *expr.Array: - return hasResultTypeExpr(a.ElemType, seen) - case *expr.Map: - return hasResultTypeExpr(a.KeyType, seen) || hasResultTypeExpr(a.ElemType, seen) - case *expr.Object: - for _, nat := range *a { - if hasResultTypeExpr(nat.Attribute, seen) { - return true - } - } - case *expr.Union: - for _, nat := range a.Values { - if hasResultTypeExpr(nat.Attribute, seen) { - return true - } + if userType, ok := attribute.Type.(expr.UserType); ok { + exampleIdentity, generated := expr.GeneratedUserTypeExampleIdentity(userType) + if generated && exampleIdentity == identity.exampleIdentity { + normalized[userType.Origin()] = identity.bind(userType) } + return + } + if _, ok := attribute.Type.(*expr.Object); !ok { + return } - return false + wrapper := expr.NewGeneratedUserType(identity.Name(), expr.DupAtt(attribute), identity.exampleIdentity) + attribute.Type = wrapper + normalized[wrapper.Origin()] = identity.bind(wrapper) } diff --git a/codegen/plugin.go b/codegen/plugin.go index 57ba30d7a1..9e20fe3e34 100644 --- a/codegen/plugin.go +++ b/codegen/plugin.go @@ -1,126 +1,74 @@ +// This file stores plugins registered through the released Goa v3 API. At the +// start of each generation command, the generator copies the registered +// functions. It calls First plugins before normal plugins and Last plugins +// afterward, orders names within each group, and keeps registration order when +// plugins in one group have the same name. This package stores those functions +// but does not call them. package codegen -import "goa.design/goa/v3/eval" +import ( + "fmt" + + "goa.design/goa/v3/codegen/internal/pluginregistry" + "goa.design/goa/v3/eval" +) type ( - // GenerateFunc makes it possible to modify the files generated by the - // goa code generators and other plugins. A GenerateFunc accepts the Go - // import path of the "gen" package, the design roots as well as the - // currently generated files (produced initially by the goa generators - // and potentially modified by previously run plugins) and returns a new - // set of files. + // GenerateFunc may add, remove, or change the files produced by Goa and by + // plugins that ran earlier. It returns the complete file list for the next + // plugin. GenerateFunc func(genpkg string, roots []eval.Root, files []*File) ([]*File, error) - // PrepareFunc makes it possible to modify the design roots before - // the files being generated by the goa code generators or other plugins. + // PrepareFunc may change evaluated designs before Goa chooses generated Go + // names. A nil PrepareFunc means that the plugin does not prepare designs. PrepareFunc func(genpkg string, roots []eval.Root) error - // plugin is a plugin that has been registered with a given command. - plugin struct { - // PrepareFunc is the plugin preparation function. - PrepareFunc - // GenerateFunc is the plugin generator function. - GenerateFunc - // name is the plugin name. - name string - // cmd is the name of cmd to run. - cmd string - // if first is set the plugin cmd must run before all other plugins. - first bool - // if last is set the plugin cmd must run after all other plugins. - last bool - } + // pluginPosition identifies the three ordering groups supported by the + // released registration API. + pluginPosition = pluginregistry.Position ) -// plugins keeps track of the registered plugins sorted by their first/last bools, -// names, or registration order. -var plugins []*plugin +const ( + pluginFirst = pluginregistry.First + pluginNormal = pluginregistry.Normal + pluginLast = pluginregistry.Last +) -// RegisterPlugin adds the plugin to the list of plugins to be invoked with the -// given command. -func RegisterPlugin(name string, cmd string, pre PrepareFunc, p GenerateFunc) { - np := &plugin{name: name, PrepareFunc: pre, GenerateFunc: p, cmd: cmd} - var inserted bool - for i, plgn := range plugins { - if plgn.last || (!plgn.first && np.name < plgn.name) { - plugins = append(plugins[:i], append([]*plugin{np}, plugins[i:]...)...) - inserted = true - break - } - } - if !inserted { - plugins = append(plugins, np) - } +// RegisterPlugin adds a plugin to the normal alphabetically ordered group. It +// panics for an empty name, an unknown command, a nil generation function, or +// registration after generation has started. Repeated names remain allowed for +// compatibility with released Goa plugins. +func RegisterPlugin(name, command string, prepare PrepareFunc, generate GenerateFunc) { + registerPlugin(name, command, pluginNormal, prepare, generate) } -// RegisterPluginFirst adds the plugin to the beginning of the list of plugins -// to be invoked with the given command. If more than one plugins are registered -// using this, the plugins will be sorted alphabetically by their names. If two -// plugins have same names, then they are sorted by registration order. -func RegisterPluginFirst(name string, cmd string, pre PrepareFunc, p GenerateFunc) { - np := &plugin{name: name, PrepareFunc: pre, GenerateFunc: p, cmd: cmd, first: true} - var inserted bool - for i, plgn := range plugins { - if !plgn.first || np.name < plgn.name { - plugins = append(plugins[:i], append([]*plugin{np}, plugins[i:]...)...) - inserted = true - break - } - } - if !inserted { - plugins = append(plugins, np) - } +// RegisterPluginFirst adds a plugin before normal and last plugins. Plugins in +// this group run by name. Plugins with the same name run in registration order. +func RegisterPluginFirst(name, command string, prepare PrepareFunc, generate GenerateFunc) { + registerPlugin(name, command, pluginFirst, prepare, generate) } -// RegisterPluginLast adds the plugin to the end of the list of plugins -// to be invoked with the given command. If more than one plugins are registered -// using this, the plugins will be sorted alphabetically by their names. If two -// plugins have same names, then they are sorted by registration order. -func RegisterPluginLast(name string, cmd string, pre PrepareFunc, p GenerateFunc) { - np := &plugin{name: name, PrepareFunc: pre, GenerateFunc: p, cmd: cmd, last: true} - var inserted bool - for i := len(plugins) - 1; i >= 0; i-- { - plgn := plugins[i] - if !plgn.last || plgn.name < np.name { - plugins = append(plugins[:i+1], append([]*plugin{np}, plugins[i+1:]...)...) - inserted = true - break - } - } - if !inserted { - plugins = append(plugins, np) - } +// RegisterPluginLast adds a plugin after first and normal plugins. Plugins in +// this group run by name. Plugins with the same name run in registration order. +func RegisterPluginLast(name, command string, prepare PrepareFunc, generate GenerateFunc) { + registerPlugin(name, command, pluginLast, prepare, generate) } -// RunPluginsPrepare executes the plugins prepare functions in the order -// they were registered. -func RunPluginsPrepare(cmd, genpkg string, roots []eval.Root) error { - for _, plugin := range plugins { - if plugin.cmd != cmd { - continue - } - if plugin.PrepareFunc != nil { - err := plugin.PrepareFunc(genpkg, roots) - if err != nil { - return err - } - } - } - return nil +// register validates and records one plugin definition before generation. +func registerPlugin(name, command string, position pluginPosition, prepare PrepareFunc, generate GenerateFunc) { + validatePlugin(name, command, generate) + pluginregistry.Register(name, command, position, prepare, generate) } -// RunPlugins executes the plugins registered with the given command in the order -// they were registered. -func RunPlugins(cmd, genpkg string, roots []eval.Root, genfiles []*File) ([]*File, error) { - for _, plugin := range plugins { - if plugin.cmd != cmd { - continue - } - gs, err := plugin.GenerateFunc(genpkg, roots, genfiles) - if err != nil { - return nil, err - } - genfiles = gs +// validatePlugin rejects definitions that the generator cannot execute. +func validatePlugin(name, command string, generate GenerateFunc) { + if name == "" { + panic("plugin name is empty") + } + if command != "gen" && command != "example" { + panic(fmt.Sprintf("unknown generator command %q", command)) + } + if generate == nil { + panic("plugin generate function is nil") } - return genfiles, nil } diff --git a/codegen/plugin_test.go b/codegen/plugin_test.go index 7cec4e091e..9d79ae3e8a 100644 --- a/codegen/plugin_test.go +++ b/codegen/plugin_test.go @@ -1,110 +1,102 @@ +// This file verifies the released plugin registration calls without running a +// second generation pipeline. The generator package consumes the copied +// registrations tested here. package codegen import ( - "reflect" "testing" -) -func TestRegisterPlugin(t *testing.T) { - var ( - p1 = &plugin{name: "abc"} - p2 = &plugin{name: "def"} + "github.com/stretchr/testify/require" - pf1 = &plugin{name: "abc", first: true} + "goa.design/goa/v3/codegen/internal/pluginregistry" + "goa.design/goa/v3/eval" +) - pl1 = &plugin{name: "abc", last: true} +// TestReleasedPluginRegistrationSignatures verifies that existing plugin +// packages still compile against the four-argument Goa v3 API. +func TestReleasedPluginRegistrationSignatures(t *testing.T) { + var register func(string, string, PrepareFunc, GenerateFunc) - pIns = &plugin{name: "cde"} - ) + register = RegisterPlugin + require.NotNil(t, register) + register = RegisterPluginFirst + require.NotNil(t, register) + register = RegisterPluginLast + require.NotNil(t, register) +} + +// TestPluginRegistryRejectsInvalidRegistration verifies that invalid plugin +// definitions fail when they are registered, before generation can start. +func TestPluginRegistryRejectsInvalidRegistration(t *testing.T) { tests := []struct { - name string - existingPs []*plugin - expectedPs []*plugin + name string + plugin string + command string + generate GenerateFunc + error string }{ - {"no-plugins", []*plugin{}, []*plugin{pIns}}, - {"plugins-without-first", []*plugin{p1}, []*plugin{p1, pIns}}, - {"plugins-with-first", []*plugin{pf1, p2}, []*plugin{pf1, pIns, p2}}, - {"plugins-with-same-name", []*plugin{pf1, pIns, p2}, []*plugin{pf1, pIns, pIns, p2}}, - {"plugins-with-last", []*plugin{pf1, pl1}, []*plugin{pf1, pIns, pl1}}, - {"mixed", []*plugin{pf1, p1, p2}, []*plugin{pf1, p1, pIns, p2}}, + {name: "empty name", command: "gen", generate: unchangedFiles, error: "plugin name is empty"}, + {name: "unknown command", plugin: "plugin", command: "other", generate: unchangedFiles, error: `unknown generator command "other"`}, + {name: "missing generate", plugin: "plugin", command: "gen", error: "plugin generate function is nil"}, } - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - plugins = tc.existingPs - RegisterPlugin(pIns.name, "", nil, nil) - if !reflect.DeepEqual(plugins, tc.expectedPs) { - t.Errorf("invalid plugin registration order") - } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + registry := pluginregistry.New() + require.PanicsWithValue(t, test.error, func() { + registerPluginIn(registry, test.plugin, test.command, pluginNormal, test.generate) + }) }) } } -func TestRegisterPluginFirst(t *testing.T) { - var ( - p1 = &plugin{name: "abc"} - p2 = &plugin{name: "def"} +// TestPluginRegistryKeepsDuplicateRegistrationOrder verifies that the released +// API keeps every callback when packages reuse the same command and name. +func TestPluginRegistryKeepsDuplicateRegistrationOrder(t *testing.T) { + registry := pluginregistry.New() + registerPluginIn(registry, "plugin", "gen", pluginFirst, unchangedFiles) + registerPluginIn(registry, "plugin", "gen", pluginLast, changedFiles) - pf1 = &plugin{name: "abc", first: true} - pf2 = &plugin{name: "def", first: true} + registrations := pluginregistry.SnapshotFrom[PrepareFunc, GenerateFunc](registry) + require.Len(t, registrations, 2) + require.Equal(t, pluginFirst, registrations[0].Position) + require.Equal(t, pluginLast, registrations[1].Position) + files, err := registrations[1].Generate("generated.local/gen", nil, nil) + require.NoError(t, err) + require.Equal(t, "changed", files[0].Path) + require.PanicsWithValue(t, "plugin registry is sealed", func() { + registerPluginIn(registry, "late", "gen", pluginNormal, unchangedFiles) + }) +} - pl1 = &plugin{name: "abc", last: true} +// TestPluginRegistrySnapshotIsCopied verifies that a caller cannot change the +// registrations retained for later generation runs. +func TestPluginRegistrySnapshotIsCopied(t *testing.T) { + registry := pluginregistry.New() + registerPluginIn(registry, "plugin", "gen", pluginNormal, unchangedFiles) - pIns = &plugin{name: "cde", first: true} - ) - tests := []struct { - name string - existingPs []*plugin - expectedPs []*plugin - }{ - {"no-plugins", []*plugin{}, []*plugin{pIns}}, - {"plugins-without-first", []*plugin{p1, p2}, []*plugin{pIns, p1, p2}}, - {"plugins-with-first", []*plugin{pf1, pf2}, []*plugin{pf1, pIns, pf2}}, - {"plugins-with-same-name", []*plugin{pf1, pIns}, []*plugin{pf1, pIns, pIns}}, - {"plugins-with-last", []*plugin{pf1, pl1}, []*plugin{pf1, pIns, pl1}}, - {"mixed", []*plugin{pf1, pf2, p1, p2}, []*plugin{pf1, pIns, pf2, p1, p2}}, - } - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - plugins = tc.existingPs - RegisterPluginFirst(pIns.name, "", nil, nil) - if !reflect.DeepEqual(plugins, tc.expectedPs) { - t.Errorf("invalid plugin registration order") - } - }) - } -} + first := pluginregistry.SnapshotFrom[PrepareFunc, GenerateFunc](registry) + first[0].Name = "changed" + second := pluginregistry.SnapshotFrom[PrepareFunc, GenerateFunc](registry) -func TestRegisterPluginLast(t *testing.T) { - var ( - p1 = &plugin{name: "abc"} - p2 = &plugin{name: "def"} + require.Equal(t, "plugin", second[0].Name) + require.Equal(t, "gen", second[0].Command) + require.Equal(t, pluginNormal, second[0].Position) +} - pl1 = &plugin{name: "abc", last: true} - pl2 = &plugin{name: "def", last: true} +// registerPluginIn applies the public registration checks to an isolated +// registry so the test does not stop later process-wide registrations. +func registerPluginIn(registry *pluginregistry.Registry, name, command string, position pluginPosition, generate GenerateFunc) { + validatePlugin(name, command, generate) + pluginregistry.RegisterIn[PrepareFunc, GenerateFunc](registry, name, command, position, nil, generate) +} - pf1 = &plugin{name: "abc", first: true} +// unchangedFiles provides a valid generation callback for registration tests. +func unchangedFiles(_ string, _ []eval.Root, files []*File) ([]*File, error) { + return files, nil +} - pIns = &plugin{name: "cde", last: true} - ) - tests := []struct { - name string - existingPs []*plugin - expectedPs []*plugin - }{ - {"no-plugins", []*plugin{}, []*plugin{pIns}}, - {"plugins-without-last", []*plugin{p1, p2}, []*plugin{p1, p2, pIns}}, - {"plugins-with-last", []*plugin{pl1, pl2}, []*plugin{pl1, pIns, pl2}}, - {"plugins-with-same-name", []*plugin{pl1, pIns}, []*plugin{pl1, pIns, pIns}}, - {"plugins-with-first", []*plugin{pf1, pl2}, []*plugin{pf1, pIns, pl2}}, - {"mixed", []*plugin{pf1, p1, p2, pl1, pl2}, []*plugin{pf1, p1, p2, pl1, pIns, pl2}}, - } - for _, tc := range tests { - t.Run(tc.name, func(t *testing.T) { - plugins = tc.existingPs - RegisterPluginLast(pIns.name, "", nil, nil) - if !reflect.DeepEqual(plugins, tc.expectedPs) { - t.Errorf("invalid plugin registration order") - } - }) - } +// changedFiles gives duplicate registration tests a distinct callback. +func changedFiles(_ string, _ []eval.Root, files []*File) ([]*File, error) { + return append(files, &File{Path: "changed"}), nil } diff --git a/codegen/protobuf.go b/codegen/protobuf.go new file mode 100644 index 0000000000..071d49e07e --- /dev/null +++ b/codegen/protobuf.go @@ -0,0 +1,73 @@ +// This file converts authored names into identifiers that Goa can safely write +// to protobuf files. Transport generators and external plugins use the same +// functions so identical design names produce identical protobuf names. +package codegen + +import ( + "regexp" + "strings" +) + +var ( + protobufDigits = regexp.MustCompile("[0-9]+") + + protobufKeywords = map[string]struct{}{ + "bool": {}, "bytes": {}, "double": {}, "fixed32": {}, "fixed64": {}, + "float": {}, "int32": {}, "int64": {}, "sfixed32": {}, "sfixed64": {}, + "sint32": {}, "sint64": {}, "string": {}, "uint32": {}, "uint64": {}, + "enum": {}, "import": {}, "map": {}, "message": {}, "oneof": {}, + "option": {}, "package": {}, "public": {}, "repeated": {}, "reserved": {}, + "returns": {}, "rpc": {}, "service": {}, "syntax": {}, + } +) + +// ProtobufName returns the identifier written for a protobuf message, service, +// or method. It keeps common acronyms uppercase and makes the first character +// legal for protobuf source. +func ProtobufName(name string) string { + return protobufIdentifier(name, true, true) +} + +// ProtobufFieldName returns the snake-case identifier written for a protobuf +// field or oneof. It makes the first character legal for protobuf source. +func ProtobufFieldName(name string) string { + name = SnakeCase(protobufIdentifier(name, false, false)) + if _, reserved := protobufKeywords[name]; reserved { + name += "_" + } + return name +} + +// protobufIdentifier removes characters protobuf identifiers cannot contain +// and separates digits so the generated Go name matches protoc-gen-go. +func protobufIdentifier(name string, firstUpper, acronym bool) string { + if index := strings.Index(name, ":"); index > 0 { + name = name[:index] + } + name = strings.Map(func(character rune) rune { + if character >= 'a' && character <= 'z' || + character >= 'A' && character <= 'Z' || + character >= '0' && character <= '9' || + character == '_' { + return character + } + return '_' + }, name) + name = string(protobufDigits.ReplaceAllFunc([]byte(name), func(match []byte) []byte { + result := make([]byte, len(match)+1) + copy(result, match) + result[len(result)-1] = '_' + return result + })) + name = CamelCase(name, firstUpper, acronym) + if name == "" { + if firstUpper { + return "Val" + } + return "val" + } + if name[0] >= '0' && name[0] <= '9' { + name = "_" + name + } + return name +} diff --git a/codegen/scope.go b/codegen/scope.go index 1b23d1b02c..f60f1b1c97 100644 --- a/codegen/scope.go +++ b/codegen/scope.go @@ -1,7 +1,13 @@ +// Code generators use this file to turn type declarations and attributes into +// unique Go names and type references. Generated user types keep the identity +// of the declaration they came from. Other lookups use the caller's exact Hash +// value. After Freeze, callers can read existing names but cannot reserve new +// ones. package codegen import ( "fmt" + "path" "path/filepath" "strconv" "strings" @@ -12,8 +18,13 @@ import ( type ( // NameScope defines a naming scope. NameScope struct { - names map[string]string // type hash to unique name - counts map[string]int // raw type name to occurrence count + names map[string]string // type hash to unique name + userTypeNames map[expr.UserType]string // original user type to exact name + unionNames map[UnionDeclarationID]string // authored OneOf to exact name + importNames map[string]string // import path to final package name + generatedImports map[string]string // generated relative path to final package name + counts map[string]int // raw type name to occurrence count + frozen bool // true after this set rejects new names } // Hasher is the interface implemented by the objects that must be @@ -33,20 +44,78 @@ type ( // NewNameScope creates an empty name scope. func NewNameScope() *NameScope { return &NameScope{ - names: make(map[string]string), - counts: make(map[string]int), + names: make(map[string]string), + userTypeNames: make(map[expr.UserType]string), + unionNames: make(map[UnionDeclarationID]string), + importNames: make(map[string]string), + generatedImports: make(map[string]string), + counts: make(map[string]int), } } +// Fork returns a new scope containing every lookup key and name already +// recorded in s. The new scope can add private helper names without changing s +// or colliding with names already chosen there. +func (s *NameScope) Fork() *NameScope { + fork := NewNameScope() + for hash, name := range s.names { + fork.names[hash] = name + } + for origin, name := range s.userTypeNames { + fork.userTypeNames[origin] = name + } + for identity, name := range s.unionNames { + fork.unionNames[identity] = name + } + for importPath, name := range s.importNames { + fork.importNames[importPath] = name + } + for importPath, name := range s.generatedImports { + fork.generatedImports[importPath] = name + } + for name, count := range s.counts { + fork.counts[name] = count + } + return fork +} + +// bindImport makes type rendering use the package name selected for an import +// path. Generated packages call it before the scope is frozen. +func (s *NameScope) bindImport(importPath, name string) { + if s.frozen { + panic("cannot bind an import name in a frozen name scope") + } + if existing, ok := s.importNames[importPath]; ok && existing != name { + panic(fmt.Sprintf("import path %q is already bound to package name %q", importPath, existing)) + } + s.importNames[importPath] = name +} + +// bindGeneratedImport records the final name for a path relative to the +// generated module root. +func (s *NameScope) bindGeneratedImport(importPath, name string) { + if s.frozen { + panic("cannot bind a generated import name in a frozen name scope") + } + if existing, ok := s.generatedImports[importPath]; ok && existing != name { + panic(fmt.Sprintf("generated import path %q is already bound to package name %q", importPath, existing)) + } + s.generatedImports[importPath] = name +} + // HashedUnique builds the unique name for key using name and - if not unique - // appending suffix and - if still not unique - a counter value. It returns // the same value when called multiple times for a key returning the same hash. func (s *NameScope) HashedUnique(key Hasher, name string, suffix ...string) string { - if n, ok := s.names[key.Hash()]; ok { + hash := key.Hash() + if n, ok := s.names[hash]; ok { return n } + if s.frozen { + panic("cannot reserve a new hashed name in a frozen name scope") + } name = s.Unique(name, suffix...) - s.names[key.Hash()] = name + s.names[hash] = name return name } @@ -55,11 +124,67 @@ func (s *NameScope) HashedUnique(key Hasher, name string, suffix ...string) stri // counter value is added to the suffixed name until unique. The returned name // is reserved in the scope. func (s *NameScope) Unique(name string, suffix ...string) string { + if s.frozen { + panic("cannot reserve a name in a frozen name scope") + } ret := s.PeekUnique(name, suffix...) s.counts[ret]++ return ret } +// Freeze prevents the scope from reserving new names. Names already associated +// with hashes remain readable through HashedUnique and type-reference methods. +func (s *NameScope) Freeze() { + s.frozen = true +} + +// bind makes key return an already reserved Go name without reserving another +// name. Generated packages call it while Generation.Freeze assigns final +// declaration names. +func (s *NameScope) bind(key Hasher, name string) { + if s.frozen { + panic("cannot bind a hashed name in a frozen name scope") + } + hash := key.Hash() + if existing, ok := s.names[hash]; ok && existing != name { + panic(fmt.Sprintf("hash %q is already bound to package name %q", hash, existing)) + } + if _, ok := s.counts[name]; !ok { + panic(fmt.Sprintf("package name %q must be reserved before hash binding", name)) + } + s.names[hash] = name +} + +// bindUserType makes a user type and copies made from it return the same exact +// generated name. +func (s *NameScope) bindUserType(userType expr.UserType, name string) { + if s.frozen { + panic("cannot bind a user type name in a frozen name scope") + } + origin := userType.Origin() + if existing, ok := s.userTypeNames[origin]; ok && existing != name { + panic(fmt.Sprintf("user type %q is already bound to package name %q", origin.Name(), existing)) + } + if _, ok := s.counts[name]; !ok { + panic(fmt.Sprintf("package name %q must be reserved before user type binding", name)) + } + s.userTypeNames[origin] = name +} + +// bindUnion makes copies of one authored OneOf return its exact generated name. +func (s *NameScope) bindUnion(identity UnionDeclarationID, name string) { + if s.frozen { + panic("cannot bind a union name in a frozen name scope") + } + if existing, ok := s.unionNames[identity]; ok && existing != name { + panic(fmt.Sprintf("union declaration is already bound to package name %q", existing)) + } + if _, ok := s.counts[name]; !ok { + panic(fmt.Sprintf("package name %q must be reserved before union binding", name)) + } + s.unionNames[identity] = name +} + // PeekUnique returns the name that Unique would return for the same inputs, // without mutating the scope. // @@ -109,9 +234,9 @@ func (s *NameScope) Name(name string) string { func (s *NameScope) GoTypeDef(att *expr.AttributeExpr, ptr, useDefault bool) string { pkg := "" if loc := UserTypeLocation(att.Type); loc != nil { - pkg = loc.PackageName() + pkg = s.generatedImportName(loc.RelImportPath, loc.PackageName()) } else if p, ok := att.Meta.Last("struct:pkg:path"); ok && p != "" { - pkg = Goify(filepath.Base(p), false) + pkg = s.generatedImportName(p, Goify(filepath.Base(p), false)) } return s.goTypeDefWithPkgOverride(att, ptr, useDefault, pkg, "") } @@ -124,8 +249,8 @@ func (s *NameScope) GoTypeDef(att *expr.AttributeExpr, ptr, useDefault bool) str func (s *NameScope) goTypeDefWithPkgOverride(att *expr.AttributeExpr, ptr, useDefault bool, pkg, targetPkg string) string { switch actual := att.Type.(type) { case expr.Primitive: - if t, _ := GetMetaType(att); t != "" { - return t + if typeName := s.metaTypeName(att); typeName != "" { + return typeName } return GoNativeTypeName(actual) case *expr.Array: @@ -183,22 +308,15 @@ func (s *NameScope) goTypeDefWithPkgOverride(att *expr.AttributeExpr, ptr, useDe if actual == expr.Empty { return "struct {}" } - var prefix string + var referencedPkg string if loc := UserTypeLocation(actual); loc != nil { if targetPkg != "" || loc.PackageName() != pkg { - prefix = loc.PackageName() + "." + referencedPkg = s.generatedImportName(loc.RelImportPath, loc.PackageName()) } } else if targetPkg != "" { - prefix = targetPkg + "." - } - // Qualified references (pkg.Type) do not compete in the local identifier - // namespace. Never apply local scoping (suffixing) to the type name portion - // of an external reference, otherwise we can emit identifiers that do not - // exist in the referenced package (e.g., pkg.Foo2). - if prefix == "" { - return s.GoTypeName(att) + referencedPkg = targetPkg } - return prefix + Goify(actual.Name(), true) + return s.scopedTypeName(actual, Goify(actual.Name(), true), referencedPkg) default: panic(fmt.Sprintf("unknown data type %T", actual)) // bug } @@ -260,62 +378,107 @@ func (s *NameScope) GoTypeNameWithDefaults(att *expr.AttributeExpr) string { func (s *NameScope) GoFullTypeName(att *expr.AttributeExpr, pkg string) string { switch actual := att.Type.(type) { case expr.Primitive: - if t, _ := GetMetaType(att); t != "" { - return t + if typeName := s.metaTypeName(att); typeName != "" { + return typeName } return GoNativeTypeName(actual) case *expr.Array: - return "[]" + s.GoFullTypeRef(actual.ElemType, pkgWithDefault(actual.ElemType.Type, pkg)) + return "[]" + s.GoFullTypeRef(actual.ElemType, s.pkgWithDefault(actual.ElemType.Type, pkg)) case *expr.Map: return fmt.Sprintf("map[%s]%s", - s.GoFullTypeRef(actual.KeyType, pkgWithDefault(actual.KeyType.Type, pkg)), - s.GoFullTypeRef(actual.ElemType, pkgWithDefault(actual.ElemType.Type, pkg))) + s.GoFullTypeRef(actual.KeyType, s.pkgWithDefault(actual.KeyType.Type, pkg)), + s.GoFullTypeRef(actual.ElemType, s.pkgWithDefault(actual.ElemType.Type, pkg))) case *expr.Object: return s.GoTypeDef(att, false, false) - case expr.UserType, *expr.Union: - if actual == expr.ErrorResult { + case expr.UserType: + if expr.IsErrorResult(actual) { return "goa.ServiceError" } - // Qualified type references (pkg.Type) do not compete in the local - // identifier namespace. - // - // When generating qualified references, we must not blindly apply local - // scoping (suffixing) to the referenced type name, otherwise we can emit - // identifiers that do not exist in the referenced package (e.g., pkg.Foo2). - // - // However, when the scope already assigned a unique name to the referenced - // type (i.e., the type is defined in this scope and got suffixed due to a - // collision), qualified references must use that assigned name to stay - // consistent across packages. This is critical for transport packages that - // refer to types defined in the service package (e.g., grpc referencing a - // payload type defined as Request2). - base := Goify(actual.Name(), true) - if pkg == "" { - return s.HashedUnique(actual, base, "") - } - if UserTypeLocation(actual) == nil { - if n, ok := s.names[actual.Hash()]; ok { - return pkg + "." + n - } - } - return pkg + "." + base + return s.scopedTypeName(actual, Goify(actual.Name(), true), pkg) + case *expr.Union: + return s.scopedUnionTypeName(NewUnionDeclarationID(att), Goify(actual.Name(), true), pkg) case expr.CompositeExpr: - return s.GoFullTypeName(actual.Attribute(), pkgWithDefault(actual.Attribute().Type, pkg)) + return s.GoFullTypeName(actual.Attribute(), s.pkgWithDefault(actual.Attribute().Type, pkg)) default: panic(fmt.Sprintf("unknown data type %T", actual)) // bug } } +// scopedUnionTypeName returns the declaration recorded for one authored OneOf +// occurrence. A package qualifier is added when the caller names another Go +// package. +func (s *NameScope) scopedUnionTypeName(identity UnionDeclarationID, base, pkg string) string { + name := base + if selected, ok := s.unionNames[identity]; ok { + name = selected + } else if s.frozen { + panic(fmt.Sprintf("union %q was not declared before the package name scope was frozen", base)) + } + if pkg == "" { + return name + } + return pkg + "." + name +} + +// scopedTypeName returns the generated name recorded for userType. A copied +// user type first uses the name of its original declaration. Types without an +// explicit declaration binding keep the existing Hash-based lookup. +func (s *NameScope) scopedTypeName(userType expr.UserType, base, pkg string) string { + if name, ok := s.userTypeNames[userType.Origin()]; ok { + if pkg == "" { + return name + } + return pkg + "." + name + } + if pkg == "" { + return s.HashedUnique(userType, base, "") + } + if name, ok := s.names[userType.Hash()]; ok { + return pkg + "." + name + } + return pkg + "." + base +} + // pkgWithDefault returns the package defining the given type. If the types is a // user type with "struct:pkg:path" metadata then it returns the corresponding // value, otherwise it returns pkg. -func pkgWithDefault(dt expr.DataType, pkg string) string { +func (s *NameScope) pkgWithDefault(dt expr.DataType, pkg string) string { if loc := UserTypeLocation(dt); loc != nil { - return loc.PackageName() + return s.generatedImportName(loc.RelImportPath, loc.PackageName()) } return pkg } +// importName returns the final package name for importPath when the generated +// package planned that import. +func (s *NameScope) importName(importPath, fallback string) string { + if name := s.importNames[importPath]; name != "" { + return name + } + return fallback +} + +// generatedImportName returns the final package name for a path relative to +// the generated module root. +func (s *NameScope) generatedImportName(importPath, fallback string) string { + if name := s.generatedImports[path.Clean(importPath)]; name != "" { + return name + } + return s.importName(importPath, fallback) +} + +// metaTypeName applies the final package name to a type named by +// struct:field:type metadata. +func (s *NameScope) metaTypeName(attribute *expr.AttributeExpr) string { + typeName, spec := GetMetaType(attribute) + if typeName == "" || spec == nil { + return typeName + } + preferred := spec.preferredName() + selected := s.importName(spec.Path, preferred) + return rebindMetaTypeQualifier(typeName, preferred, selected) +} + func goTypeRef(name string, dt expr.DataType) string { // For a raw struct, no need to dereference if isRawStruct(dt) { diff --git a/codegen/scope_test.go b/codegen/scope_test.go index d40f7436a1..da3cd46554 100644 --- a/codegen/scope_test.go +++ b/codegen/scope_test.go @@ -1,13 +1,55 @@ +// This file verifies package-level name allocation and generated type identity. package codegen import ( "testing" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" "goa.design/goa/v3/expr" ) +type exactHasher string + +// Hash returns the exact map identity supplied by the test. +func (h exactHasher) Hash() string { + return string(h) +} + +func TestNameScope_Freeze(t *testing.T) { + scope := NewNameScope() + existing := &expr.UserTypeExpr{ + AttributeExpr: &expr.AttributeExpr{Type: expr.String}, + TypeName: "Existing", + UID: "existing", + } + require.Equal(t, "Existing", scope.GoTypeName(&expr.AttributeExpr{Type: existing})) + + scope.Freeze() + scope.Freeze() + require.Equal(t, "Existing", scope.GoTypeName(&expr.AttributeExpr{Type: existing})) + require.Equal(t, "Next", scope.PeekUnique("Next")) + require.Equal(t, "Next", scope.Name("Next")) + require.Panics(t, func() { + scope.Unique("Next") + }) + require.Panics(t, func() { + scope.HashedUnique(&expr.UserTypeExpr{ + AttributeExpr: &expr.AttributeExpr{Type: expr.String}, + TypeName: "Next", + UID: "next", + }, "Next") + }) + require.Panics(t, func() { + scope.GoTypeName(&expr.AttributeExpr{Type: &expr.UserTypeExpr{ + AttributeExpr: &expr.AttributeExpr{Type: expr.String}, + TypeName: "Indirect", + UID: "indirect", + }}) + }) +} + func TestNameScope_Unique(t *testing.T) { sequence := []struct { Input string @@ -41,6 +83,13 @@ func TestNameScope_Unique(t *testing.T) { } } +func TestNameScope_HashedUniqueUsesExactHash(t *testing.T) { + scope := NewNameScope() + require.Equal(t, "First", scope.HashedUnique(exactHasher("shared"), "First")) + require.Equal(t, "First", scope.HashedUnique(exactHasher("shared"), "Ignored")) + require.Equal(t, "First2", scope.HashedUnique(exactHasher("distinct"), "First")) +} + func TestNameScope_GoFullTypeName_UsesScopedNameWhenQualified(t *testing.T) { scope := NewNameScope() @@ -68,6 +117,389 @@ func TestNameScope_GoFullTypeName_UsesScopedNameWhenQualified(t *testing.T) { } } +func TestNameScope_GoFullTypeNameUsesAuthoredUnionNameBeforePlanning(t *testing.T) { + scope := NewNameScope() + first := &expr.Union{TypeName: "Value"} + second := &expr.Union{TypeName: "Value"} + scope.GoTypeName(&expr.AttributeExpr{Type: first}) + secondAtt := &expr.AttributeExpr{Type: second} + if got, want := scope.GoTypeName(secondAtt), "Value"; got != want { + t.Errorf("GoTypeName() = %q, want %q", got, want) + } + if got, want := scope.GoFullTypeName(secondAtt, "types"), "types.Value"; got != want { + t.Errorf("GoFullTypeName() = %q, want %q", got, want) + } +} + +func TestNameScope_GoFullTypeNameRejectsUnplannedUnionAfterFreeze(t *testing.T) { + scope := NewNameScope() + attribute := &expr.AttributeExpr{Type: &expr.Union{TypeName: "Value"}} + require.Equal(t, "Value", scope.GoFullTypeName(attribute, "")) + + scope.Freeze() + require.PanicsWithValue(t, + `union "Value" was not declared before the package name scope was frozen`, + func() { scope.GoFullTypeName(attribute, "") }, + ) +} + +func TestNameScope_GoTypeNameDoesNotPlanUnionDeclarations(t *testing.T) { + scope := NewNameScope() + first := &expr.Union{TypeName: "Value", TypeKey: "type", ValueKey: "value"} + second := &expr.Union{TypeName: "Value", TypeKey: "kind", ValueKey: "data"} + assert.Equal(t, first.Hash(), second.Hash(), "compatibility hash should remain unchanged") + assert.NotEqual(t, NewUnionTypeID(first), NewUnionTypeID(second)) + assert.Equal(t, "Value", scope.GoTypeName(&expr.AttributeExpr{Type: first})) + assert.Equal(t, "Value", scope.GoTypeName(&expr.AttributeExpr{Type: second})) +} + +func TestNameScope_GoTypeNameDistinguishesUnionBranchPackages(t *testing.T) { + branch := func(path string) expr.UserType { + return &expr.UserTypeExpr{ + TypeName: "Entry", + UID: path, + AttributeExpr: &expr.AttributeExpr{ + Type: expr.String, + Meta: expr.MetaExpr{"struct:pkg:path": {path}}, + }, + } + } + first := &expr.Union{ + TypeName: "Value", + Values: []*expr.NamedAttributeExpr{ + {Name: "entry", Attribute: &expr.AttributeExpr{Type: branch("types/first")}}, + }, + } + second := &expr.Union{ + TypeName: "Value", + Values: []*expr.NamedAttributeExpr{ + {Name: "entry", Attribute: &expr.AttributeExpr{Type: branch("types/second")}}, + }, + } + assert.Equal(t, first.Hash(), second.Hash(), "compatibility hash should remain unchanged") + assert.NotEqual(t, NewUnionTypeID(first), NewUnionTypeID(second)) + scope := NewNameScope() + assert.Equal(t, "Value", scope.GoTypeName(&expr.AttributeExpr{Type: first})) + assert.Equal(t, "Value", scope.GoTypeName(&expr.AttributeExpr{Type: second})) +} + +func TestNameScope_GoTypeNameDistinguishesUnionBranchOrder(t *testing.T) { + branch := func(name string) *expr.NamedAttributeExpr { + return &expr.NamedAttributeExpr{Name: name, Attribute: &expr.AttributeExpr{Type: expr.String}} + } + first := &expr.Union{TypeName: "Value", Values: []*expr.NamedAttributeExpr{branch("left"), branch("right")}} + second := &expr.Union{TypeName: "Value", Values: []*expr.NamedAttributeExpr{branch("right"), branch("left")}} + assert.Equal(t, first.Hash(), second.Hash(), "compatibility hash should remain unchanged") + assert.NotEqual(t, NewUnionTypeID(first), NewUnionTypeID(second)) + scope := NewNameScope() + assert.Equal(t, "Value", scope.GoTypeName(&expr.AttributeExpr{Type: first})) + assert.Equal(t, "Value", scope.GoTypeName(&expr.AttributeExpr{Type: second})) +} + +func TestNameScope_GoTypeNameDistinguishesInlineObjectFieldOrder(t *testing.T) { + object := func(names ...string) *expr.Object { + fields := make(expr.Object, len(names)) + for i, name := range names { + fields[i] = &expr.NamedAttributeExpr{Name: name, Attribute: &expr.AttributeExpr{Type: expr.String}} + } + return &fields + } + union := func(fields *expr.Object) *expr.Union { + return &expr.Union{ + TypeName: "Value", + Values: []*expr.NamedAttributeExpr{ + {Name: "object", Attribute: &expr.AttributeExpr{Type: fields}}, + }, + } + } + first := union(object("left", "right")) + second := union(object("right", "left")) + assert.Equal(t, first.Hash(), second.Hash(), "compatibility hash should remain unchanged") + assert.NotEqual(t, NewUnionTypeID(first), NewUnionTypeID(second)) + scope := NewNameScope() + assert.Equal(t, "Value", scope.GoTypeName(&expr.AttributeExpr{Type: first})) + assert.Equal(t, "Value", scope.GoTypeName(&expr.AttributeExpr{Type: second})) +} + +// TestNameScope_GoTypeNameUsesAuthoredNameForEqualUnionDefinitions verifies +// reference formatting does not decide whether two declarations can share a +// package name. The generated package catalog makes that decision before the +// scope is frozen. +func TestNameScope_GoTypeNameUsesAuthoredNameForEqualUnionDefinitions(t *testing.T) { + branch := func(name, id string) expr.UserType { + return &expr.UserTypeExpr{ + TypeName: name, + UID: id, + AttributeExpr: &expr.AttributeExpr{ + Type: expr.String, + Meta: expr.MetaExpr{"struct:pkg:path": {"types"}}, + }, + } + } + union := func(user expr.UserType) *expr.Union { + return &expr.Union{ + TypeName: "Value", + Values: []*expr.NamedAttributeExpr{ + {Name: "entry", Attribute: &expr.AttributeExpr{Type: user}}, + }, + } + } + first := union(branch("foo-bar", "first")) + second := union(branch("foo_bar", "second")) + assert.Equal(t, NewUnionTypeID(first), NewUnionTypeID(second)) + scope := NewNameScope() + assert.Equal(t, "Value", scope.GoTypeName(&expr.AttributeExpr{Type: first})) + assert.Equal(t, "Value", scope.GoTypeName(&expr.AttributeExpr{Type: second})) +} + +// TestNameScopeForkPreservesBindingsAndAcceptsHelperNames verifies a frozen +// declaration scope can seed a separate mutable helper namespace. +func TestNameScopeForkPreservesBindingsAndAcceptsHelperNames(t *testing.T) { + typeExpr := &expr.UserTypeExpr{ + AttributeExpr: &expr.AttributeExpr{Type: expr.String}, + TypeName: "Value", + } + scope := NewNameScope() + assert.Equal(t, "Value", scope.GoTypeName(&expr.AttributeExpr{Type: typeExpr})) + scope.Freeze() + + fork := scope.Fork() + assert.Equal(t, "Value", fork.GoTypeName(&expr.AttributeExpr{Type: typeExpr})) + assert.Equal(t, "Value2", fork.Unique("Value")) + assert.Equal(t, "helper", fork.Unique("helper")) +} + +func TestUnionTypeID(t *testing.T) { + branch := func(name string, dataType expr.DataType) *expr.NamedAttributeExpr { + return &expr.NamedAttributeExpr{Name: name, Attribute: &expr.AttributeExpr{Type: dataType}} + } + userType := func(path string) expr.UserType { + return &expr.UserTypeExpr{ + AttributeExpr: &expr.AttributeExpr{ + Type: expr.String, + Meta: expr.MetaExpr{"struct:pkg:path": {path}}, + }, + TypeName: "Entry", + UID: "entry", + } + } + tests := []struct { + name string + first *expr.Union + second *expr.Union + }{ + { + name: "wire keys", + first: &expr.Union{TypeName: "Value", TypeKey: "type", ValueKey: "value"}, + second: &expr.Union{TypeName: "Value", TypeKey: "kind", ValueKey: "data"}, + }, + { + name: "branch order", + first: &expr.Union{TypeName: "Value", Values: []*expr.NamedAttributeExpr{ + branch("left", expr.String), + branch("right", expr.Int), + }}, + second: &expr.Union{TypeName: "Value", Values: []*expr.NamedAttributeExpr{ + branch("right", expr.Int), + branch("left", expr.String), + }}, + }, + { + name: "branch Go shape", + first: &expr.Union{TypeName: "Value", Values: []*expr.NamedAttributeExpr{ + branch("entry", expr.String), + }}, + second: &expr.Union{TypeName: "Value", Values: []*expr.NamedAttributeExpr{ + {Name: "entry", Attribute: &expr.AttributeExpr{ + Type: expr.String, + Meta: expr.MetaExpr{"struct:field:type": {"CustomString"}}, + }}, + }}, + }, + { + name: "relocated branch package", + first: &expr.Union{TypeName: "Value", Values: []*expr.NamedAttributeExpr{ + branch("entry", userType("types/first")), + }}, + second: &expr.Union{TypeName: "Value", Values: []*expr.NamedAttributeExpr{ + branch("entry", userType("types/second")), + }}, + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + require.NotEqual(t, NewUnionTypeID(test.first), NewUnionTypeID(test.second)) + }) + } +} + +// TestUnionTypeIDIncludesDefaultDrivenPointerShape verifies that a default +// which changes an inline service field from a pointer to a value also changes +// the union definition identity. +func TestUnionTypeIDIncludesDefaultDrivenPointerShape(t *testing.T) { + union := func(withDefault bool) (*expr.Union, *expr.AttributeExpr) { + field := &expr.AttributeExpr{Type: expr.String} + if withDefault { + field.DefaultValue = "ready" + } + branch := &expr.AttributeExpr{Type: &expr.Object{ + {Name: "label", Attribute: field}, + }} + return &expr.Union{ + TypeName: "Value", + Values: []*expr.NamedAttributeExpr{ + {Name: "entry", Attribute: branch}, + }, + }, branch + } + withoutDefault, pointerBranch := union(false) + withDefault, valueBranch := union(true) + policy := GoLayoutPolicy{UseDefault: true, SumType: true} + pointerLayout, err := PlanGoType(pointerBranch, GoTypePlanOptions{ + Owner: "generated.local/gen/service", + Policy: policy, + }) + require.NoError(t, err) + valueLayout, err := PlanGoType(valueBranch, GoTypePlanOptions{ + Owner: "generated.local/gen/service", + Policy: policy, + }) + require.NoError(t, err) + + require.True(t, pointerLayout.Fields()[0].IsPointer()) + require.False(t, valueLayout.Fields()[0].IsPointer()) + require.NotEqual(t, NewUnionTypeID(withoutDefault), NewUnionTypeID(withDefault)) +} + +func TestUnionTypeIDIgnoresNonEmittedPointerSharing(t *testing.T) { + object := func() *expr.Object { + fields := expr.Object{ + {Name: "text", Attribute: &expr.AttributeExpr{Type: expr.String}}, + } + return &fields + } + innerUnion := func() *expr.Union { + return &expr.Union{ + TypeName: "Inner", + Values: []*expr.NamedAttributeExpr{ + {Name: "text", Attribute: &expr.AttributeExpr{Type: expr.String}}, + }, + } + } + outerUnion := func(left, right expr.DataType) *expr.Union { + return &expr.Union{ + TypeName: "Outer", + Values: []*expr.NamedAttributeExpr{ + {Name: "left", Attribute: &expr.AttributeExpr{Type: left}}, + {Name: "right", Attribute: &expr.AttributeExpr{Type: right}}, + }, + } + } + + t.Run("inline object", func(t *testing.T) { + shared := object() + assert.Equal(t, NewUnionTypeID(outerUnion(shared, shared)), NewUnionTypeID(outerUnion(object(), object()))) + }) + t.Run("nested union", func(t *testing.T) { + shared := innerUnion() + assert.Equal(t, NewUnionTypeID(outerUnion(shared, shared)), NewUnionTypeID(outerUnion(innerUnion(), innerUnion()))) + }) +} + +// TestUnionTypeIDIncludesGeneratedUserTypeShape verifies generated aliases +// with one name and distinct definitions produce distinct union identities. +func TestUnionTypeIDIncludesGeneratedUserTypeShape(t *testing.T) { + generatedBranch := func(dataType expr.DataType) *expr.Union { + alias := &expr.UserTypeExpr{ + AttributeExpr: &expr.AttributeExpr{Type: dataType}, + TypeName: "ValueText", + } + return &expr.Union{ + TypeName: "Value", + Values: []*expr.NamedAttributeExpr{{ + Name: "text", + Attribute: &expr.AttributeExpr{Type: alias}, + }}, + } + } + + require.NotEqual(t, + NewUnionTypeID(generatedBranch(expr.String)), + NewUnionTypeID(generatedBranch(expr.Int)), + ) +} + +// TestUnionTypeIDEncodesRecursiveGeneratedUserTypeShape verifies recursive +// generated aliases terminate and retain their distinct field definitions. +func TestUnionTypeIDEncodesRecursiveGeneratedUserTypeShape(t *testing.T) { + recursiveUnion := func(fieldType expr.DataType) *expr.Union { + alias := &expr.UserTypeExpr{TypeName: "ValueNode"} + alias.AttributeExpr = &expr.AttributeExpr{Type: &expr.Object{ + {Name: "value", Attribute: &expr.AttributeExpr{Type: fieldType}}, + {Name: "next", Attribute: &expr.AttributeExpr{Type: alias}}, + }} + return &expr.Union{ + TypeName: "Value", + Values: []*expr.NamedAttributeExpr{ + {Name: "node", Attribute: &expr.AttributeExpr{Type: alias}}, + }, + } + } + + require.NotEqual(t, + NewUnionTypeID(recursiveUnion(expr.String)), + NewUnionTypeID(recursiveUnion(expr.Int)), + ) +} + +func TestNameScope_GoFullTypeName_UsesScopedRelocatedUserTypeNameWhenQualified(t *testing.T) { + scope := NewNameScope() + first := &expr.UserTypeExpr{ + AttributeExpr: &expr.AttributeExpr{ + Type: expr.String, + Meta: expr.MetaExpr{"struct:pkg:path": {"types"}}, + }, + TypeName: "foo-bar", + UID: "first", + } + second := &expr.UserTypeExpr{ + AttributeExpr: &expr.AttributeExpr{ + Type: expr.String, + Meta: expr.MetaExpr{"struct:pkg:path": {"types"}}, + }, + TypeName: "foo_bar", + UID: "second", + } + scope.GoTypeName(&expr.AttributeExpr{Type: first}) + secondAtt := &expr.AttributeExpr{Type: second} + if got, want := scope.GoTypeName(secondAtt), "FooBar2"; got != want { + t.Fatalf("GoTypeName() = %q, want %q", got, want) + } + if got, want := scope.GoFullTypeName(secondAtt, "types"), "types.FooBar2"; got != want { + t.Errorf("GoFullTypeName() = %q, want %q", got, want) + } +} + +// TestNameScopeForkPreservesUserTypeBindings verifies that a child scope keeps +// the exact generated name chosen for an original user type and all its copies. +func TestNameScopeForkPreservesUserTypeBindings(t *testing.T) { + scope := NewNameScope() + original := &expr.UserTypeExpr{ + AttributeExpr: &expr.AttributeExpr{Type: expr.String}, + TypeName: "Value", + UID: "value", + } + copy := original.Dup(expr.DupAtt(original.Attribute())) + require.Equal(t, "Value", scope.Unique("Value")) + scope.bindUserType(original, "Value") + + fork := scope.Fork() + scope.Freeze() + fork.Freeze() + require.Equal(t, "Value", scope.GoTypeName(&expr.AttributeExpr{Type: copy})) + require.Equal(t, "types.Value", fork.GoFullTypeName(&expr.AttributeExpr{Type: copy}, "types")) +} + func TestNameScope_PeekUnique_MatchesUniqueWithoutMutation(t *testing.T) { seed := func(scope *NameScope) { scope.Unique("a") diff --git a/codegen/sections_test.go b/codegen/sections_test.go index b810de4576..c3f142b042 100644 --- a/codegen/sections_test.go +++ b/codegen/sections_test.go @@ -122,3 +122,45 @@ package testpackage }) } } + +func TestHeaderKeepsOneImportPerPath(t *testing.T) { + section := Header("", "testpackage", []*ImportSpec{ + {Path: "encoding/json"}, + {Name: "json", Path: "encoding/json"}, + }) + var source bytes.Buffer + if err := section.Write(&source); err != nil { + t.Fatal(err) + } + if count := strings.Count(source.String(), `"encoding/json"`); count != 1 { + t.Fatalf("encoding/json import count = %d, want 1\n%s", count, source.String()) + } + if !strings.Contains(source.String(), `json "encoding/json"`) { + t.Fatalf("encoding/json import did not keep its explicit name\n%s", source.String()) + } +} + +func TestAddImportKeepsOneImportPerPath(t *testing.T) { + section := Header("", "testpackage", []*ImportSpec{{Path: "encoding/json"}}) + AddImport(section, &ImportSpec{Name: "json", Path: "encoding/json"}) + var source bytes.Buffer + if err := section.Write(&source); err != nil { + t.Fatal(err) + } + if count := strings.Count(source.String(), `"encoding/json"`); count != 1 { + t.Fatalf("encoding/json import count = %d, want 1\n%s", count, source.String()) + } + if !strings.Contains(source.String(), `json "encoding/json"`) { + t.Fatalf("encoding/json import did not keep its explicit name\n%s", source.String()) + } + + t.Run("different explicit names", func(t *testing.T) { + section := Header("", "testpackage", []*ImportSpec{{Name: "first", Path: "example.com/log"}}) + defer func() { + if recovered := recover(); recovered == nil { + t.Fatal("AddImport did not reject different explicit names for one package") + } + }() + AddImport(section, &ImportSpec{Name: "second", Path: "example.com/log"}) + }) +} diff --git a/codegen/service/client.go b/codegen/service/client.go index cbe861f665..b837fad9e3 100644 --- a/codegen/service/client.go +++ b/codegen/service/client.go @@ -1,32 +1,24 @@ +// This file renders one service's in-process client and includes only the type +// imports used by that generated client file. package service import ( "path/filepath" "goa.design/goa/v3/codegen" - "goa.design/goa/v3/expr" ) -const ( - // clientStructName is the name of the generated client data structure. - clientStructName = "Client" -) - -// ClientFile returns the client file for the given service. -func ClientFile(_ string, service *expr.ServiceExpr, services *ServicesData) *codegen.File { - svc := services.Get(service.Name) +// clientFile renders the client from the service data copied into plan. +func clientFile(plan *Plan, facts *serviceFacts) *codegen.File { + services := plan.Services() + svc := services.Get(facts.name) data := endpointData(svc) path := filepath.Join(codegen.Gendir, svc.PathName, "client.go") var ( sections []*codegen.SectionTemplate ) { - imports := []*codegen.ImportSpec{ - {Path: "context"}, - {Path: "io"}, - codegen.GoaImport(""), - } - header := codegen.Header(service.Name+" client", svc.PkgName, imports) + header := codegen.Header(facts.name+" client", svc.PkgName, facts.imports.client.Imports()) def := &codegen.SectionTemplate{ Name: "client-struct", Source: serviceTemplates.Read(serviceClientT), diff --git a/codegen/service/client_test.go b/codegen/service/client_test.go index 54a7505f5b..dac329e3f3 100644 --- a/codegen/service/client_test.go +++ b/codegen/service/client_test.go @@ -38,9 +38,9 @@ func TestClient(t *testing.T) { for _, c := range cases { t.Run(c.Name, func(t *testing.T) { root := codegen.RunDSL(t, c.DSL) - services := NewServicesData(root) + plan := mustServicePlan(t, root) require.Len(t, root.Services, 1) - fs := ClientFile("test/gen", root.Services[0], services) + fs := clientFile(plan, plan.facts.services[0]) require.NotNil(t, fs) buf := new(bytes.Buffer) for _, s := range fs.SectionTemplates[1:] { diff --git a/codegen/service/codegen_specialization_test.go b/codegen/service/codegen_specialization_test.go new file mode 100644 index 0000000000..731e9c84f6 --- /dev/null +++ b/codegen/service/codegen_specialization_test.go @@ -0,0 +1,280 @@ +// This file verifies that service generation omits runtime work whose answer +// is fixed by the evaluated design. +package service + +import ( + "bytes" + "go/ast" + "go/parser" + "go/token" + "strings" + "testing" + + "github.com/stretchr/testify/require" + + "goa.design/goa/v3/codegen" + "goa.design/goa/v3/dsl" +) + +// TestInterceptorAccessorsDoNotRediscoverPlannedMethods catches generated +// accessors that switch on the method name or the planned payload wrapper. +func TestInterceptorAccessorsDoNotRediscoverPlannedMethods(t *testing.T) { + root := codegen.RunDSL(t, interceptorSpecializationDSL) + plan := retainedServicePlanForPackage(t, root) + files := interceptorsFiles(plan, plan.facts.services[0]) + + var rendered strings.Builder + for _, file := range files { + var source bytes.Buffer + for _, section := range file.SectionTemplates { + require.NoError(t, section.Write(&source)) + } + parsed, err := parser.ParseFile(token.NewFileSet(), file.Path, source.Bytes(), 0) + require.NoError(t, err, source.String()) + ast.Inspect(parsed, func(node ast.Node) bool { + switch node.(type) { + case *ast.SwitchStmt, *ast.TypeSwitchStmt: + t.Errorf("%s contains a runtime switch for a planned interceptor fact", file.Path) + } + return true + }) + rendered.Write(source.Bytes()) + } + + code := rendered.String() + require.Contains(t, code, "InspectInfo interface {\n\t\tgoa.InterceptorInfo") + require.Contains(t, code, "inspectFirstServerUnaryInfo struct {\n\t\tinspectFirstInfo") + require.NotContains(t, code, "inspectFirstServerUnaryInfo struct {\n\t\t*inspectFirstInfo") + require.NotContains(t, code, "inspectFirstInfo: &inspectFirstInfo") + require.NotContains(t, code, "method string") + require.NotContains(t, code, "callType goa.InterceptorCallType") + + generated, err := Files(plan) + require.NoError(t, err) + compileGeneratedServiceFilesWith(t, generated, map[string]string{ + "gen/interceptor_specialization/info_specialization_test.go": interceptorInfoRuntimeTest, + }) +} + +// TestSharedInterceptorSpecializesDifferentClientAndServerMethods catches a +// client method implementation lost when the same interceptor is also used by +// a different server method. +func TestSharedInterceptorSpecializesDifferentClientAndServerMethods(t *testing.T) { + root := codegen.RunDSL(t, func() { + dsl.Interceptor("inspect", func() { + dsl.ReadPayload(func() { + dsl.Attribute("value") + }) + }) + dsl.Service("SplitInterceptorMethods", func() { + dsl.Method("ServerOnly", func() { + dsl.ServerInterceptor("inspect") + dsl.Payload(func() { + dsl.Attribute("value", dsl.String) + }) + }) + dsl.Method("ClientOnly", func() { + dsl.ClientInterceptor("inspect") + dsl.Payload(func() { + dsl.Attribute("value", dsl.String) + }) + }) + }) + }) + plan := retainedServicePlanForPackage(t, root) + files, err := Files(plan) + require.NoError(t, err) + compileGeneratedServiceFiles(t, files) +} + +// TestEmptyProjectedValidatorsAreOmitted catches public validation functions +// and parent calls that cannot report an error for any value. +func TestEmptyProjectedValidatorsAreOmitted(t *testing.T) { + root := codegen.RunDSL(t, func() { + empty := dsl.ResultType("application/vnd.empty", func() { + dsl.TypeName("Empty") + dsl.Attribute("name", dsl.String) + dsl.View("default", func() { + dsl.Attribute("name") + }) + }) + dsl.Service("EmptyViews", func() { + dsl.Method("Read", func() { + dsl.Result(empty) + }) + }) + }) + plan := retainedServicePlanForPackage(t, root) + data := plan.Services().Get("EmptyViews") + + require.Len(t, data.projectedTypes, 1) + require.Empty(t, data.projectedTypes[0].Validations) + require.Len(t, data.viewedResultTypes, 1) + require.Len(t, data.viewedResultTypes[0].Validate.Calls, 1) + require.Nil(t, data.viewedResultTypes[0].Validate.Calls[0].Declaration) + + files, err := Files(plan) + require.NoError(t, err) + compileGeneratedServiceFiles(t, files) +} + +// TestRequiredParentOmitsEmptyChildCall catches removal of the parent's +// missing-field check when its selected child view has no other rules. +func TestRequiredParentOmitsEmptyChildCall(t *testing.T) { + root := codegen.RunDSL(t, func() { + child := dsl.ResultType("application/vnd.empty-child", func() { + dsl.TypeName("EmptyChild") + dsl.Attribute("name", dsl.String) + dsl.View("default", func() { + dsl.Attribute("name") + }) + }) + parent := dsl.ResultType("application/vnd.required-parent", func() { + dsl.TypeName("RequiredParent") + dsl.Attribute("child", child) + dsl.Required("child") + dsl.View("default", func() { + dsl.Attribute("child") + }) + }) + dsl.Service("RequiredParentViews", func() { + dsl.Method("Read", func() { + dsl.Result(parent) + }) + }) + }) + plan := retainedServicePlanForPackage(t, root) + data := plan.Services().Get("RequiredParentViews") + + var parentValidation *ValidateData + for _, projected := range data.projectedTypes { + switch projected.Name { + case "EmptyChildView": + require.Empty(t, projected.Validations) + case "RequiredParentView": + require.Len(t, projected.Validations, 1) + parentValidation = projected.Validations[0] + } + } + require.NotNil(t, parentValidation) + require.Empty(t, parentValidation.Calls) + require.Contains(t, parentValidation.Validate, `MissingFieldError("child", "result")`) + + files, err := Files(plan) + require.NoError(t, err) + compileGeneratedServiceFiles(t, files) +} + +// TestEmptyRecursiveProjectedValidatorsAreOmitted catches cycles that retain +// validators even though no node in the cycle can report an error. +func TestEmptyRecursiveProjectedValidatorsAreOmitted(t *testing.T) { + root := codegen.RunDSL(t, func() { + tree := dsl.ResultType("application/vnd.empty-tree", func() { + dsl.TypeName("EmptyTree") + dsl.Attribute("next", "EmptyTree") + dsl.View("default", func() { + dsl.Attribute("next") + }) + }) + dsl.Service("EmptyRecursiveViews", func() { + dsl.Method("Read", func() { + dsl.Result(tree) + }) + }) + }) + plan := retainedServicePlanForPackage(t, root) + data := plan.Services().Get("EmptyRecursiveViews") + + require.Len(t, data.projectedTypes, 1) + require.Empty(t, data.projectedTypes[0].Validations) + + files, err := Files(plan) + require.NoError(t, err) + compileGeneratedServiceFiles(t, files) +} + +// interceptorSpecializationDSL applies one interceptor to multiple methods so +// generated accessors must select exact method and streaming types in advance. +func interceptorSpecializationDSL() { + dsl.Interceptor("inspect", func() { + dsl.ReadPayload(func() { + dsl.Attribute("initial") + }) + dsl.ReadStreamingPayload(func() { + dsl.Attribute("input") + }) + dsl.ReadStreamingResult(func() { + dsl.Attribute("output") + }) + }) + dsl.Service("InterceptorSpecialization", func() { + dsl.ServerInterceptor("inspect") + dsl.ClientInterceptor("inspect") + for _, name := range []string{"First", "Second"} { + dsl.Method(name, func() { + dsl.Payload(func() { + dsl.Field(1, "initial", dsl.String) + }) + dsl.StreamingPayload(func() { + dsl.Field(1, "input", dsl.String) + }) + dsl.StreamingResult(func() { + dsl.Field(1, "output", dsl.String) + }) + dsl.GRPC(func() {}) + }) + } + }) +} + +const interceptorInfoRuntimeTest = `package interceptorspecialization + +import ( + "testing" + + goa "goa.design/goa/v3/pkg" +) + +func TestSpecializedInterceptorInfo(t *testing.T) { + initial := "start" + input := "in" + output := "out" + payload := &FirstPayload{Initial: &initial} + streamingPayload := &FirstStreamingPayload{Input: &input} + streamingResult := &FirstResult{Output: &output} + + server := &inspectFirstServerUnaryInfo{inspectFirstInfo: inspectFirstInfo{ + rawPayload: &FirstEndpointInput{Payload: payload}, + }} + if server.Service() != "InterceptorSpecialization" || server.Method() != "First" || server.CallType() != goa.InterceptorUnary { + t.Errorf("unexpected server metadata: %s %s %v", server.Service(), server.Method(), server.CallType()) + } + if actual := server.Payload().Initial(); actual != initial { + t.Errorf("server payload = %q, want %q", actual, initial) + } + + client := &inspectFirstClientUnaryInfo{inspectFirstInfo: inspectFirstInfo{rawPayload: payload}} + if client.CallType() != goa.InterceptorUnary || client.Payload().Initial() != initial { + t.Errorf("unexpected client endpoint metadata") + } + + send := &inspectFirstStreamingSendInfo{inspectFirstInfo: inspectFirstInfo{rawPayload: streamingResult}} + if send.CallType() != goa.InterceptorStreamingSend || send.ServerStreamingResult().Output() != output { + t.Errorf("unexpected server send metadata") + } + + recv := &inspectFirstStreamingRecvInfo{inspectFirstInfo: inspectFirstInfo{}} + if recv.CallType() != goa.InterceptorStreamingRecv || recv.ServerStreamingPayload(streamingPayload).Input() != input { + t.Errorf("unexpected server receive metadata") + } + + clientSend := &inspectFirstStreamingSendInfo{inspectFirstInfo: inspectFirstInfo{rawPayload: streamingPayload}} + if clientSend.ClientStreamingPayload().Input() != input { + t.Errorf("unexpected client send metadata") + } + clientRecv := &inspectFirstStreamingRecvInfo{inspectFirstInfo: inspectFirstInfo{}} + if clientRecv.ClientStreamingResult(streamingResult).Output() != output { + t.Errorf("unexpected client receive metadata") + } +} +` diff --git a/codegen/service/conversion_plan.go b/codegen/service/conversion_plan.go new file mode 100644 index 0000000000..cc452c7e7c --- /dev/null +++ b/codegen/service/conversion_plan.go @@ -0,0 +1,560 @@ +// This file records external Go type conversions before generated names are +// chosen. It stores each conversion, its recursive functions, and its imports. +package service + +import ( + "cmp" + "fmt" + "reflect" + "sort" + "strings" + + "goa.design/goa/v3/codegen" + "goa.design/goa/v3/expr" +) + +type ( + // externalConversionDirection identifies whether a generated method converts + // to or from a user-supplied Go type. + externalConversionDirection uint8 + + // externalConversionNameOrder orders child conversion helpers by their + // owning conversion and source and target types instead of discovery order. + externalConversionNameOrder struct { + receiverID string + externalPkg string + external string + direction externalConversionDirection + source string + target string + definition codegen.TransformHelperDefinitionLocation + } + + // externalConversionFacts stores one conversion between a Goa type and a + // user-supplied Go type, including conversions for nested fields. + externalConversionFacts struct { + direction externalConversionDirection + serviceName string + servicePath string + receiverID string + receiverAttribute *expr.AttributeExpr + receiverType *codegen.TypeDeclaration + externalType reflect.Type + externalPath string + externalAlias string + externalAttribute *expr.AttributeExpr + externalPackages map[expr.UserType]*codegen.ImportSpec + externalScope *codegen.NameScope + plan *codegen.TransformPlan + methodName string + data *convertData + helpers []*codegen.TransformFunctionData + } + + // externalConversionFileFacts groups the conversion operations and imports + // emitted by one generated receiver package's convert.go file. + externalConversionFileFacts struct { + owner *codegen.GeneratedPackage + operations []*externalConversionFacts + imports *codegen.GeneratedImportPlan + } + + // externalConversionIdentity selects one generated method by its receiver, + // conversion direction, and user-supplied Go type across all designs in the + // generation command. + externalConversionIdentity struct { + receiver *codegen.TypeDeclaration + direction externalConversionDirection + externalType reflect.Type + externalPath string + } + + // externalConversionResolver writes each user-supplied type with the import + // name chosen for the package that declares it. + externalConversionResolver struct { + scope *codegen.AttributeScope + packages map[expr.UserType]string + } +) + +const ( + externalConvertTo externalConversionDirection = iota + 1 + externalCreateFrom +) + +// collectExternalConversions records every conversion once in the package that +// declares its receiver type. When several designs use the same receiver +// package, Goa writes one set of method names and one convert.go file. +func collectExternalConversions(roots []*rootFacts, generation *codegen.Generation) error { + files := make(map[*codegen.GeneratedPackage]*externalConversionFileFacts) + fileRoots := make(map[*codegen.GeneratedPackage]*rootFacts) + serviceRoots := make(map[*serviceFacts]*rootFacts) + operations := make(map[externalConversionIdentity]struct{}) + for _, root := range roots { + root.externalConversions = nil + for _, service := range root.services { + serviceRoots[service] = root + } + } + collect := func(mappings []*expr.TypeMap, direction externalConversionDirection) error { + for _, mapping := range mappings { + owners := make(map[*codegen.GeneratedPackage]*serviceFacts) + for _, candidate := range roots { + for _, service := range candidate.services { + if !typeMapMatchesFacts(mapping, service) { + continue + } + owner := generation.Package(generatedPackagePath( + generation.GenPkg(), service.packagePath, codegen.UserTypeLocation(mapping.User), + )) + selected := owners[owner] + if selected == nil || service.packagePath < selected.packagePath { + owners[owner] = service + } + } + } + orderedOwners := make([]*codegen.GeneratedPackage, 0, len(owners)) + for owner := range owners { + orderedOwners = append(orderedOwners, owner) + } + sort.Slice(orderedOwners, func(i, j int) bool { + return orderedOwners[i].ImportPath() < orderedOwners[j].ImportPath() + }) + for _, owner := range orderedOwners { + identity, externalAlias, err := identifyExternalConversion(mapping, owner, direction) + if err != nil { + return err + } + if _, exists := operations[identity]; exists { + return fmt.Errorf( + "duplicate external conversion for receiver %q in package %q and external type %q", + mapping.User.ID(), + identity.receiver.PackagePath(), + identity.externalType.String(), + ) + } + operations[identity] = struct{}{} + operation, err := planExternalConversion( + owners[owner], mapping, owner, identity, externalAlias, + ) + if err != nil { + return err + } + file := files[owner] + if file == nil { + file = &externalConversionFileFacts{owner: owner} + files[owner] = file + } + file.operations = append(file.operations, operation) + candidateRoot := serviceRoots[owners[owner]] + selectedRoot := fileRoots[owner] + if selectedRoot == nil || rootFactsOrder(candidateRoot) < rootFactsOrder(selectedRoot) { + fileRoots[owner] = candidateRoot + } + } + } + return nil + } + for _, root := range roots { + if err := collect(root.root.Conversions, externalConvertTo); err != nil { + return err + } + if err := collect(root.root.Creations, externalCreateFrom); err != nil { + return err + } + } + + for _, file := range files { + if err := finishExternalConversionFile(file, generation); err != nil { + return err + } + owner := fileRoots[file.owner] + owner.externalConversions = append(owner.externalConversions, file) + } + for _, root := range roots { + sort.Slice(root.externalConversions, func(i, j int) bool { + return root.externalConversions[i].owner.ImportPath() < root.externalConversions[j].owner.ImportPath() + }) + } + return nil +} + +// identifyExternalConversion identifies the generated receiver method before +// Goa submits its helper names and imports. +func identifyExternalConversion(mapping *expr.TypeMap, owner *codegen.GeneratedPackage, direction externalConversionDirection) (externalConversionIdentity, string, error) { + externalType := reflect.TypeOf(mapping.External) + if externalType == nil { + return externalConversionIdentity{}, "", fmt.Errorf("external conversion type must not be nil") + } + externalPath, externalAlias, err := getExternalReflectTypeInfo(externalType) + if err != nil { + return externalConversionIdentity{}, "", err + } + receiver, err := owner.Type(mapping.User) + if err != nil { + return externalConversionIdentity{}, "", err + } + return externalConversionIdentity{ + receiver: receiver, + direction: direction, + externalType: externalType, + externalPath: externalPath, + }, externalAlias, nil +} + +// rootFactsOrder returns the API and service names used to order definitions +// shared by several designs. +func rootFactsOrder(facts *rootFacts) string { + paths := make([]string, len(facts.services)) + for index, service := range facts.services { + paths[index] = service.packagePath + } + sort.Strings(paths) + return facts.apiName + "\x00" + strings.Join(paths, "\x00") +} + +// externalConversionFiles returns one convert.go description per receiver +// package and rejects two service plans that both try to write that file. +func externalConversionFiles(plans []*Plan) ([]*externalConversionFileFacts, error) { + byOwner := make(map[*codegen.GeneratedPackage]struct{}) + var files []*externalConversionFileFacts + for _, plan := range plans { + for _, retained := range plan.facts.externalConversions { + if _, exists := byOwner[retained.owner]; exists { + return nil, fmt.Errorf( + "external conversion package %q was assigned to more than one service plan", + retained.owner.ImportPath(), + ) + } + byOwner[retained.owner] = struct{}{} + files = append(files, retained) + } + } + sort.Slice(files, func(i, j int) bool { + return files[i].owner.ImportPath() < files[j].owner.ImportPath() + }) + return files, nil +} + +// planExternalConversion reads one user-supplied Go type, records the complete +// field conversion, and submits each child helper to the receiver's package. +func planExternalConversion( + service *serviceFacts, + mapping *expr.TypeMap, + owner *codegen.GeneratedPackage, + identity externalConversionIdentity, + externalAlias string, +) (*externalConversionFacts, error) { + externalType := identity.externalType + externalDataType, reflectedTypes, err := buildExternalDesignType(externalType, mapping.User) + if err != nil { + return nil, err + } + externalPackages := make(map[expr.UserType]*codegen.ImportSpec, len(reflectedTypes)) + for userType, reflected := range reflectedTypes { + importPath, alias, err := getExternalReflectTypeInfo(reflected) + if err != nil { + return nil, err + } + externalPackages[userType.Origin()] = codegen.NewImport(alias, importPath) + } + externalPath := identity.externalPath + externalAttribute := &expr.AttributeExpr{Type: externalDataType} + if identity.direction == externalConvertTo { + externalAttribute.AddMeta("struct:type:name", externalDataType.Name()) + } + receiverAttribute := expr.DupAtt(&expr.AttributeExpr{Type: mapping.User}) + source := receiverAttribute + target := externalAttribute + if identity.direction == externalCreateFrom { + source, target = externalAttribute, source + } + transform, err := codegen.NewTransformPlan(source, target, "", nil) + if err != nil { + return nil, err + } + operation := &externalConversionFacts{ + direction: identity.direction, + serviceName: service.name, + servicePath: service.packagePath, + receiverID: mapping.User.ID(), + receiverAttribute: receiverAttribute, + externalType: externalType, + externalPath: externalPath, + externalAlias: externalAlias, + externalAttribute: externalAttribute, + externalPackages: externalPackages, + externalScope: codegen.NewNameScope(), + plan: transform, + receiverType: identity.receiver, + } + for _, definition := range transform.HelperDefinitions() { + sourceName, sourceID := transformDataTypeName(definition.Source.Type) + targetName, targetID := transformDataTypeName(definition.Target.Type) + if identity.direction == externalConvertTo { + targetName = externalAlias + codegen.Goify(targetName, true) + } else { + sourceName = externalAlias + codegen.Goify(sourceName, true) + } + order := externalConversionNameOrder{ + receiverID: mapping.User.ID(), + externalPkg: externalPath, + external: externalType.Name(), + direction: identity.direction, + source: sourceID, + target: targetID, + definition: definition.Location, + } + declaration := codegen.NewPreferredName( + codegen.NameFunction, + "transform"+codegen.Goify(sourceName, true)+"To"+codegen.Goify(targetName, true), + codegen.UnexportedName, + order, + ) + if err := owner.DeclareName(declaration); err != nil { + return nil, err + } + if err := transform.BindHelperDefinition(definition.ID, declaration); err != nil { + return nil, err + } + } + return operation, nil +} + +// finishExternalConversionFile sorts the generated methods, assigns child +// helper names within each receiver method, and records the imports used by +// convert.go. +func finishExternalConversionFile(file *externalConversionFileFacts, generation *codegen.Generation) error { + sort.Slice(file.operations, func(i, j int) bool { + return externalConversionOperationLess(file.operations[i], file.operations[j]) + }) + takenByReceiver := make(map[*codegen.TypeDeclaration]map[string]struct{}) + for _, operation := range file.operations { + receiver := operation.receiverType + taken := takenByReceiver[receiver] + if taken == nil { + taken = make(map[string]struct{}) + takenByReceiver[receiver] = taken + } + prefix := "ConvertTo" + if operation.direction == externalCreateFrom { + prefix = "CreateFrom" + } + operation.methodName = uniquify(prefix+operation.externalType.Name(), taken) + } + + definitions := make([]*expr.AttributeExpr, 0, len(file.operations)*2) + references := make([]*expr.AttributeExpr, 0, len(file.operations)*2) + for _, operation := range file.operations { + definitions = append(definitions, operation.receiverAttribute, operation.externalAttribute) + references = append(references, operation.receiverAttribute, operation.externalAttribute) + } + imports, err := retainFileImports( + generation, + file.owner.ImportPath(), + nil, + nil, + definitions, + references, + ) + if err != nil { + return err + } + for _, operation := range file.operations { + for _, spec := range operation.externalPackages { + if err := imports.AddDesign(spec); err != nil { + return err + } + } + } + file.imports = imports + return nil +} + +// externalConversionOperationLess orders generated receiver methods by their +// source and target types, service, method, and direction. +func externalConversionOperationLess(left, right *externalConversionFacts) bool { + if left.receiverID != right.receiverID { + return left.receiverID < right.receiverID + } + if left.direction != right.direction { + return left.direction < right.direction + } + if left.externalPath != right.externalPath { + return left.externalPath < right.externalPath + } + return left.externalType.Name() < right.externalType.Name() +} + +// linkExternalConversions adds the chosen import names and formats every +// previously recorded conversion without reading Go types or creating helpers. +func linkExternalConversions( + facts *rootFacts, + generation *codegen.Generation, + aliases *importAliases, +) error { + for _, file := range facts.externalConversions { + if err := file.imports.Link(); err != nil { + return err + } + for _, operation := range file.operations { + serviceResolver := newServiceResolver( + generation, + aliases, + operation.serviceName, + operation.servicePath, + file.owner.ImportPath(), + ) + if err := linkExternalConversion(operation, serviceResolver, aliases); err != nil { + return err + } + } + } + return nil +} + +// linkExternalConversion formats one recorded field conversion with the Go +// type and import names selected for its output file. +func linkExternalConversion( + operation *externalConversionFacts, + serviceResolver *declarationResolver, + aliases *importAliases, +) error { + externalResolver := newExternalConversionResolver( + operation.externalScope, + operation.externalPackages, + aliases, + serviceResolver.outputPath, + ) + externalContext := &codegen.AttributeContext{ + Scope: externalResolver, + } + serviceContext := &codegen.AttributeContext{ + UseDefault: true, + Scope: serviceResolver, + } + sourceContext, targetContext := serviceContext, externalContext + sourceVar, targetVar := "t", "v" + if operation.direction == externalCreateFrom { + sourceContext, targetContext = externalContext, serviceContext + sourceVar, targetVar = "v", "temp" + } + if err := operation.plan.BindContexts(sourceContext, targetContext); err != nil { + return err + } + code, helpers, err := operation.plan.Render(sourceVar, targetVar, true) + if err != nil { + return err + } + receiverLayout, err := serviceResolver.GoTypeLayout(operation.receiverAttribute, serviceContext.LayoutPolicy()) + if err != nil { + return err + } + operation.data = &convertData{ + Name: operation.methodName, + ReceiverTypeRef: receiverLayout.Ref(), + TypeRef: externalResolver.Ref( + operation.externalAttribute, + externalResolver.Package(operation.externalAttribute), + ), + Code: code, + } + if operation.direction == externalConvertTo { + operation.data.TypeName = operation.externalType.Name() + } + operation.helpers = helpers + return nil +} + +// newExternalConversionResolver associates each user-supplied Go type with the +// import name selected for its package. +func newExternalConversionResolver( + scope *codegen.NameScope, + packages map[expr.UserType]*codegen.ImportSpec, + aliases *importAliases, + outputPackage string, +) *externalConversionResolver { + resolved := make(map[expr.UserType]string, len(packages)) + for userType, spec := range packages { + resolved[userType.Origin()] = aliases.name(outputPackage, spec.Path) + } + return &externalConversionResolver{ + scope: codegen.NewAttributeScope(scope), + packages: resolved, + } +} + +// Name renders an external reflected type with the alias for its own package. +func (r *externalConversionResolver) Name(att *expr.AttributeExpr, pkg string, ptr, useDefault bool) string { + if userType, ok := att.Type.(expr.UserType); ok { + pkg = r.packageName(userType) + } + return r.scope.Name(att, pkg, ptr, useDefault) +} + +// Ref renders an external reflected type reference with its own package alias. +func (r *externalConversionResolver) Ref(att *expr.AttributeExpr, pkg string) string { + if userType, ok := att.Type.(expr.UserType); ok { + pkg = r.packageName(userType) + } + return r.scope.Ref(att, pkg) +} + +// Field returns the reflected Go struct field selected for name. +func (r *externalConversionResolver) Field(att *expr.AttributeExpr, name string, firstUpper bool) string { + return r.scope.Field(att, name, firstUpper) +} + +// Package returns the Go name written before a user-supplied named type. +func (r *externalConversionResolver) Package(att *expr.AttributeExpr) string { + if userType, ok := att.Type.(expr.UserType); ok { + return r.packageName(userType) + } + return "" +} + +// Enter returns the same resolver because each child named type already records +// the package that declares it. +func (r *externalConversionResolver) Enter(*expr.AttributeExpr) codegen.Attributor { + return r +} + +// IsSumType reports the standard Goa transform representation. +func (r *externalConversionResolver) IsSumType() bool { + return r.scope.IsSumType() +} + +// ValidatorCall is not part of external conversion rendering. +func (*externalConversionResolver) ValidatorCall(*expr.AttributeExpr, string, string, string) string { + panic("external conversion resolver does not own validators") +} + +// Scope returns the name set used to prevent generated field and local variable +// names from colliding. +func (r *externalConversionResolver) Scope() *codegen.NameScope { + return r.scope.Scope() +} + +// packageName returns the Go import name chosen for one user-supplied type. +func (r *externalConversionResolver) packageName(userType expr.UserType) string { + name, ok := r.packages[userType.Origin()] + if !ok { + panic(fmt.Sprintf("external reflected type %q has no planned package alias", userType.Name())) + } + return name +} + +// ComparePackageName orders conversion helpers by their source and target +// types, service, method, and direction instead of discovery order. +func (o externalConversionNameOrder) ComparePackageName(other codegen.PackageNameOrder) int { + right := other.(externalConversionNameOrder) + return cmp.Or( + strings.Compare(o.receiverID, right.receiverID), + strings.Compare(o.externalPkg, right.externalPkg), + strings.Compare(o.external, right.external), + cmp.Compare(o.direction, right.direction), + strings.Compare(o.source, right.source), + strings.Compare(o.target, right.target), + o.definition.Compare(right.definition), + ) +} diff --git a/codegen/service/conversion_plan_contract_test.go b/codegen/service/conversion_plan_contract_test.go new file mode 100644 index 0000000000..19d4707e2e --- /dev/null +++ b/codegen/service/conversion_plan_contract_test.go @@ -0,0 +1,387 @@ +// This file verifies external conversions are owned once by their generated +// receiver package and retain every reflected package reference before freeze. +package service + +import ( + "bytes" + "path/filepath" + "slices" + "testing" + + "github.com/stretchr/testify/require" + + "goa.design/goa/v3/codegen" + nestedalpha "goa.design/goa/v3/codegen/service/testdata/nested-alpha" + nestedbeta "goa.design/goa/v3/codegen/service/testdata/nested-beta" + nestedouter "goa.design/goa/v3/codegen/service/testdata/nested-outer" + "goa.design/goa/v3/dsl" + "goa.design/goa/v3/eval" + "goa.design/goa/v3/expr" +) + +// TestExternalConversionsBelongToGeneratedReceiverPackage catches conversion +// files duplicated by two services that reference one relocated receiver. It +// also compiles two same-named reflected children from distinct Go packages. +func TestExternalConversionsBelongToGeneratedReceiverPackage(t *testing.T) { + root := externalConversionContractRoot(t) + generation := mustTestGeneration(t, "generated.local/gen", []eval.Root{root}) + plan, err := NewPlan(root, generation, expr.NewExampleGenerator(root.API.RandomizerFactory)) + require.NoError(t, err) + require.NoError(t, generation.Freeze()) + require.NoError(t, plan.Link()) + + files, err := Files(plan) + require.NoError(t, err) + conversionPath := filepath.Join(codegen.Gendir, "shared", "types", "convert.go") + require.Len(t, filesAtPath(files, conversionPath), 1) + conversion := renderSingleFileAtPath(t, files, conversionPath) + require.NotContains(t, conversion, "goa.design/goa/v3/codegen/service/testdata/a-nested-alpha") + require.Contains(t, conversion, "nestedalpha.Child") + require.NotContains(t, conversion, "nestedalpha2.Child") + compileGeneratedServiceFiles(t, files) +} + +// TestExternalConversionPlanIgnoresLaterTypeMapMutation proves linked output +// is byte-for-byte determined by facts retained in NewPlan. +func TestExternalConversionPlanIgnoresLaterTypeMapMutation(t *testing.T) { + baseline := retainedServicePlanForPackage(t, externalConversionContractRoot(t)) + baselineFiles, err := Files(baseline) + require.NoError(t, err) + conversionPath := filepath.Join(codegen.Gendir, "shared", "types", "convert.go") + before := renderSingleFileAtPath(t, baselineFiles, conversionPath) + + root := externalConversionContractRoot(t) + generation := mustTestGeneration(t, "generated.local/gen", []eval.Root{root}) + plan, err := NewPlan(root, generation, expr.NewExampleGenerator(root.API.RandomizerFactory)) + require.NoError(t, err) + originalServiceName := root.Services[0].Name + root.Services[0].Name = "MutatedService" + for _, mapping := range append(root.Conversions, root.Creations...) { + mapping.User.Attribute().AddMeta("struct:pkg:path", "mutated/types") + if object := expr.AsObject(mapping.User); object != nil && len(*object) > 0 { + (*object)[0].Attribute.AddMeta( + "struct:field:type", + "mutated.Value", + "mutated.local/value", + "mutated", + ) + } + mapping.User.Rename("Mutated" + mapping.User.Name()) + mapping.User = nil + mapping.External = struct{}{} + } + root.Conversions = nil + root.Creations = nil + require.NoError(t, generation.Freeze()) + require.NoError(t, plan.Link()) + require.Equal(t, "generated.local/gen/alpha", plan.Services().ServiceImport("generated.local", originalServiceName).Path) + afterFiles, err := Files(plan) + require.NoError(t, err) + after := renderSingleFileAtPath(t, afterFiles, conversionPath) + require.Equal(t, before, after) + compileGeneratedServiceFiles(t, afterFiles) +} + +// TestExternalConversionOperationsHaveCanonicalOrder catches convert.go output +// that follows TypeMap traversal rather than stable receiver identities. +func TestExternalConversionOperationsHaveCanonicalOrder(t *testing.T) { + forward := externalConversionContractRoot(t) + reverse := externalConversionContractRoot(t) + slices.Reverse(reverse.Conversions) + slices.Reverse(reverse.Creations) + forwardPlan := retainedServicePlanForPackage(t, forward) + reversePlan := retainedServicePlanForPackage(t, reverse) + forwardFiles, err := Files(forwardPlan) + require.NoError(t, err) + reverseFiles, err := Files(reversePlan) + require.NoError(t, err) + conversionPath := filepath.Join(codegen.Gendir, "shared", "types", "convert.go") + require.Equal( + t, + renderSingleFileAtPath(t, forwardFiles, conversionPath), + renderSingleFileAtPath(t, reverseFiles, conversionPath), + ) +} + +// TestExternalConversionReachabilityCoversEveryServiceValue catches mappings +// omitted when a type is reachable only through a stream or error contract. +func TestExternalConversionReachabilityCoversEveryServiceValue(t *testing.T) { + tests := map[string]func(expr.UserType){ + "streaming payload": func(mapped expr.UserType) { + dsl.Service("Reach", func() { + dsl.Method("Use", func() { + dsl.StreamingPayload(mapped) + dsl.Result(dsl.String) + }) + }) + }, + "mixed streaming result": func(mapped expr.UserType) { + dsl.Service("Reach", func() { + dsl.Method("Use", func() { + dsl.Result(dsl.String) + dsl.StreamingResult(mapped) + }) + }) + }, + "service error": func(mapped expr.UserType) { + dsl.Service("Reach", func() { + dsl.Error("failed", mapped) + dsl.Method("Use", func() {}) + }) + }, + "method error": func(mapped expr.UserType) { + dsl.Service("Reach", func() { + dsl.Method("Use", func() { + dsl.Error("failed", mapped) + }) + }) + }, + } + for name, use := range tests { + t.Run(name, func(t *testing.T) { + root := codegen.RunDSL(t, func() { + mapped := dsl.Type("Mapped", func() { + dsl.ConvertTo(nestedalpha.Child{}) + dsl.Attribute("value", dsl.String) + dsl.Required("value") + }) + use(mapped) + }) + plan := retainedServicePlanForPackage(t, root) + files, err := Files(plan) + require.NoError(t, err) + conversionPath := filepath.Join(codegen.Gendir, "reach", "convert.go") + require.Len(t, filesAtPath(files, conversionPath), 1) + compileGeneratedServiceFiles(t, files) + }) + } +} + +// TestExternalConversionsAggregateAcrossRoots catches root-local conversion +// files that target one generated package and change with root order. +func TestExternalConversionsAggregateAcrossRoots(t *testing.T) { + forwardPlans := convertedRootPlans(t, false) + forwardFiles, err := Files(forwardPlans...) + require.NoError(t, err) + conversionPath := filepath.Join(codegen.Gendir, "shared", "types", "convert.go") + require.Len(t, filesAtPath(forwardFiles, conversionPath), 1) + forward := renderSingleFileAtPath(t, forwardFiles, conversionPath) + + reversePlans := convertedRootPlans(t, true) + reverseFiles, err := Files(reversePlans...) + require.NoError(t, err) + require.Len(t, filesAtPath(reverseFiles, conversionPath), 1) + require.Equal(t, forward, renderSingleFileAtPath(t, reverseFiles, conversionPath)) + require.Contains(t, forward, "func (t *AlphaMapped) ConvertToChild()") + require.Contains(t, forward, "func (t *BetaMapped) ConvertToChild()") + compileGeneratedServiceFiles(t, forwardFiles) +} + +// TestExternalConversionsShareReceiverMethodNamesAcrossRoots catches method +// names assigned independently by roots that contribute operations for the +// same canonical receiver declaration. +func TestExternalConversionsShareReceiverMethodNamesAcrossRoots(t *testing.T) { + forwardPlans := sharedConvertedReceiverPlans(t, false) + forwardFiles, err := Files(forwardPlans...) + require.NoError(t, err) + conversionPath := filepath.Join(codegen.Gendir, "shared", "types", "convert.go") + require.Len(t, filesAtPath(forwardFiles, conversionPath), 1) + forward := renderSingleFileAtPath(t, forwardFiles, conversionPath) + + reversePlans := sharedConvertedReceiverPlans(t, true) + reverseFiles, err := Files(reversePlans...) + require.NoError(t, err) + require.Len(t, filesAtPath(reverseFiles, conversionPath), 1) + require.Equal(t, forward, renderSingleFileAtPath(t, reverseFiles, conversionPath)) + require.Contains(t, forward, "func (t *SharedMapped) ConvertToChild()") + require.Contains(t, forward, "func (t *SharedMapped) ConvertToChild2()") + compileGeneratedServiceFiles(t, forwardFiles) +} + +// TestNewPlansRejectDuplicateExternalConversionsAcrossRoots proves the batch +// boundary rejects one exact receiver operation instead of inventing X2. +func TestNewPlansRejectDuplicateExternalConversionsAcrossRoots(t *testing.T) { + var shared expr.UserType + first := codegen.RunDSL(t, func() { + shared = dsl.Type("SharedMapped", func() { + dsl.Meta("struct:pkg:path", "shared/types") + dsl.ConvertTo(nestedalpha.Child{}) + dsl.Attribute("value", dsl.String) + dsl.Required("value") + }) + dsl.Service("Alpha", func() { + dsl.Method("Use", func() { dsl.Payload(shared) }) + }) + }) + second := codegen.RunDSL(t, func() { + dsl.Service("Beta", func() { + dsl.Method("Use", func() { dsl.Payload(shared) }) + }) + }) + second.Conversions = append(second.Conversions, &expr.TypeMap{ + User: shared, + External: nestedalpha.Child{}, + }) + generation := mustTestGeneration(t, "generated.local/gen", []eval.Root{first, second}) + _, err := NewPlans( + generation, + PlanInput{Root: first, Examples: expr.NewExampleGenerator(first.API.RandomizerFactory)}, + PlanInput{Root: second, Examples: expr.NewExampleGenerator(second.API.RandomizerFactory)}, + ) + require.ErrorContains(t, err, "duplicate external conversion") +} + +// externalConversionContractRoot builds one relocated receiver referenced by +// two services and mapped in both conversion directions. +func externalConversionContractRoot(t *testing.T) *expr.RootExpr { + t.Helper() + return codegen.RunDSL(t, func() { + alpha := dsl.Type("AlphaChild", func() { + dsl.Meta("struct:pkg:path", "shared/types") + dsl.Attribute("value", dsl.String) + dsl.Required("value") + }) + beta := dsl.Type("BetaChild", func() { + dsl.Meta("struct:pkg:path", "shared/types") + dsl.Attribute("value", dsl.String) + dsl.Required("value") + }) + envelope := dsl.Type("Envelope", func() { + dsl.Meta("struct:pkg:path", "shared/types") + dsl.ConvertTo(nestedouter.Envelope{}) + dsl.CreateFrom(nestedouter.Envelope{}) + dsl.Attribute("alpha", alpha) + dsl.Attribute("beta", beta) + dsl.Required("alpha", "beta") + }) + for _, service := range []string{"Alpha", "Beta"} { + dsl.Service(service, func() { + dsl.Method("Read", func() { + dsl.Payload(envelope) + }) + }) + } + }) +} + +// convertedRootPlans creates two roots whose distinct converted receivers are +// relocated into one generated package, in either discovery order. +func convertedRootPlans(t *testing.T, reverse bool) []*Plan { + t.Helper() + roots := []*expr.RootExpr{ + convertedReceiverRoot(t, "Alpha", nestedalpha.Child{}), + convertedReceiverRoot(t, "Beta", nestedbeta.Child{}), + } + if reverse { + slices.Reverse(roots) + } + evaluated := make([]eval.Root, len(roots)) + for index, root := range roots { + evaluated[index] = root + } + generation := mustTestGeneration(t, "generated.local/gen", evaluated) + inputs := make([]PlanInput, len(roots)) + for index, root := range roots { + inputs[index] = PlanInput{Root: root, Examples: expr.NewExampleGenerator(root.API.RandomizerFactory)} + } + plans, err := NewPlans(generation, inputs...) + require.NoError(t, err) + require.NoError(t, generation.Freeze()) + for _, plan := range plans { + require.NoError(t, plan.Link()) + } + return plans +} + +// sharedConvertedReceiverPlans creates two roots that contribute distinct +// same-named external mappings for one exact relocated receiver declaration. +func sharedConvertedReceiverPlans(t *testing.T, reverse bool) []*Plan { + t.Helper() + var shared expr.UserType + first := codegen.RunDSL(t, func() { + shared = dsl.Type("SharedMapped", func() { + dsl.Meta("struct:pkg:path", "shared/types") + dsl.ConvertTo(nestedalpha.Child{}) + dsl.Attribute("value", dsl.String) + dsl.Required("value") + }) + dsl.Service("Alpha", func() { + dsl.Method("Use", func() { + dsl.Payload(shared) + }) + }) + }) + second := codegen.RunDSL(t, func() { + dsl.Service("Beta", func() { + dsl.Method("Use", func() { + dsl.Payload(shared) + }) + }) + }) + second.Conversions = append(second.Conversions, &expr.TypeMap{ + User: shared, + External: nestedbeta.Child{}, + }) + roots := []*expr.RootExpr{first, second} + if reverse { + slices.Reverse(roots) + } + evaluated := make([]eval.Root, len(roots)) + for index, root := range roots { + evaluated[index] = root + } + generation := mustTestGeneration(t, "generated.local/gen", evaluated) + inputs := make([]PlanInput, len(roots)) + for index, root := range roots { + inputs[index] = PlanInput{Root: root, Examples: expr.NewExampleGenerator(root.API.RandomizerFactory)} + } + plans, err := NewPlans(generation, inputs...) + require.NoError(t, err) + require.NoError(t, generation.Freeze()) + for _, plan := range plans { + require.NoError(t, plan.Link()) + } + return plans +} + +// convertedReceiverRoot builds one relocated receiver mapping for the +// multi-root conversion aggregation contract. +func convertedReceiverRoot(t *testing.T, service string, external any) *expr.RootExpr { + t.Helper() + return codegen.RunDSL(t, func() { + mapped := dsl.Type(service+"Mapped", func() { + dsl.Meta("struct:pkg:path", "shared/types") + dsl.ConvertTo(external) + dsl.Attribute("value", dsl.String) + dsl.Required("value") + }) + dsl.Service(service, func() { + dsl.Method("Use", func() { + dsl.Payload(mapped) + }) + }) + }) +} + +// filesAtPath returns every generated file that targets path. +func filesAtPath(files []*codegen.File, path string) []*codegen.File { + var matches []*codegen.File + for _, file := range files { + if file.Path == path { + matches = append(matches, file) + } + } + return matches +} + +// renderSingleFileAtPath renders the unique file targeting path. +func renderSingleFileAtPath(t *testing.T, files []*codegen.File, path string) string { + t.Helper() + matches := filesAtPath(files, path) + require.Len(t, matches, 1) + var rendered bytes.Buffer + for _, section := range matches[0].SectionTemplates { + require.NoError(t, section.Write(&rendered)) + } + return rendered.String() +} diff --git a/codegen/service/convert.go b/codegen/service/convert.go index b9f3305079..dc4ab22fac 100644 --- a/codegen/service/convert.go +++ b/codegen/service/convert.go @@ -1,3 +1,7 @@ +// This file generates ConvertTo and CreateFrom functions for service types +// mapped to external Go structs. Service-side names come from the completed +// package records, including nested types placed in packages by design +// metadata. package service import ( @@ -28,278 +32,45 @@ type convertData struct { Code string } -// ConvertFiles returns multiple files containing conversion and creation functions, -// grouped by target package as specified by struct:pkg:path metadata. -func ConvertFiles(root *expr.RootExpr, service *expr.ServiceExpr, services *ServicesData) ([]*codegen.File, error) { - // Filter conversion and creation functions that are relevant for this service - svc := services.Get(service.Name) - conversions := relevantTypeMaps(root.Conversions, service, svc) - creations := relevantTypeMaps(root.Creations, service, svc) - - if len(conversions) == 0 && len(creations) == 0 { - return nil, nil - } - - // Group conversions and creations by target package path - allPaths := make(map[string]struct{}) - conversionsByPath := groupByConvertPath(conversions, service, allPaths) - creationsByPath := groupByConvertPath(creations, service, allPaths) - - // Generate a file for each path - var files []*codegen.File - for path := range allPaths { - file, err := generateConvertFileForPath( - path, - conversionsByPath[path], - creationsByPath[path], - service, - svc, - ) - if err != nil { - return nil, err - } - if file != nil { - files = append(files, file) - } - } - - return files, nil -} - -// relevantTypeMaps filters the type maps whose user type is a method payload, -// a method result, or a user type of the given service. The returned slice -// drives which ConvertTo/CreateFrom functions ConvertFiles generates. -func relevantTypeMaps(maps []*expr.TypeMap, service *expr.ServiceExpr, svc *Data) []*expr.TypeMap { - var relevant []*expr.TypeMap - for _, c := range maps { - if typeMapMatchesService(c, service, svc) { - relevant = append(relevant, c) - } - } - return relevant -} - -// typeMapMatchesService reports whether the type map's user type is used by -// the service as a method payload, a method result, or a service user type. -func typeMapMatchesService(c *expr.TypeMap, service *expr.ServiceExpr, svc *Data) bool { - for _, m := range service.Methods { - if ut, ok := m.Payload.Type.(expr.UserType); ok && ut.Name() == c.User.Name() { - return true - } - if ut, ok := m.Result.Type.(expr.UserType); ok && ut.Name() == c.User.Name() { - return true - } - } - for _, t := range svc.userTypes { - if c.User.Name() == t.Name { - return true - } - } - return false -} - -// groupByConvertPath groups the type maps by the convert.go file path derived -// from their user type location, defaulting to the service package. It -// records every path in paths so the caller can iterate the union of -// conversion and creation paths. -func groupByConvertPath(maps []*expr.TypeMap, service *expr.ServiceExpr, paths map[string]struct{}) map[string][]*expr.TypeMap { - byPath := make(map[string][]*expr.TypeMap) - for _, c := range maps { - var path string - if loc := codegen.UserTypeLocation(c.User); loc != nil { - path = filepath.Join(codegen.Gendir, filepath.Dir(loc.FilePath), "convert.go") - } else { - path = filepath.Join(codegen.Gendir, codegen.SnakeCase(service.Name), "convert.go") - } - byPath[path] = append(byPath[path], c) - paths[path] = struct{}{} - } - return byPath -} - -// generateConvertFileForPath generates a single convert.go file for the given path -// containing the specified conversions and creations -func generateConvertFileForPath( - convertPath string, - conversions []*expr.TypeMap, - creations []*expr.TypeMap, - service *expr.ServiceExpr, - svc *Data, -) (*codegen.File, error) { - if len(conversions) == 0 && len(creations) == 0 { - return nil, nil - } - - // Determine package name from path - var convertPkgName string - if len(conversions) > 0 { - if loc := codegen.UserTypeLocation(conversions[0].User); loc != nil { - convertPkgName = loc.PackageName() - } else { - convertPkgName = svc.PkgName - } - } else if len(creations) > 0 { - if loc := codegen.UserTypeLocation(creations[0].User); loc != nil { - convertPkgName = loc.PackageName() - } else { - convertPkgName = svc.PkgName - } - } - - // Retrieve external packages info - ppm := make(map[string]string) - for _, c := range conversions { - pkgImport, alias, err := getExternalTypeInfo(c.External) - if err != nil { - return nil, err +// convertFiles formats the package-owned external conversion files aggregated +// from every linked root plan. It does not inspect design mappings or allocate +// generated names. +func convertFiles(conversions []*externalConversionFileFacts) []*codegen.File { + files := make([]*codegen.File, len(conversions)) + for index, retained := range conversions { + sections := []*codegen.SectionTemplate{ + codegen.Header( + "External type conversion functions", + codegen.Goify(path.Base(retained.owner.ImportPath()), false), + retained.imports.Imports(), + ), + } + for _, operation := range retained.operations { + name, source := "convert-to", serviceTemplates.Read(convertT) + if operation.direction == externalCreateFrom { + name, source = "create-from", serviceTemplates.Read(createT) + } + sections = append(sections, &codegen.SectionTemplate{ + Name: name, + Source: source, + Data: operation.data, + }) + } + for _, operation := range retained.operations { + for _, helper := range operation.helpers { + sections = append(sections, &codegen.SectionTemplate{ + Name: "convert-create-helper", + Source: serviceTemplates.Read(transformHelperT), + Data: helper, + }) + } } - ppm[pkgImport] = alias - } - for _, c := range creations { - pkgImport, alias, err := getExternalTypeInfo(c.External) - if err != nil { - return nil, err + files[index] = &codegen.File{ + Path: filepath.Join(retained.owner.OutputDirectory(), "convert.go"), + SectionTemplates: sections, } - ppm[pkgImport] = alias - } - pkgs := make([]*codegen.ImportSpec, 0, len(ppm)+2) - for pp, alias := range ppm { - pkgs = append(pkgs, &codegen.ImportSpec{Name: alias, Path: pp}) } - - // Build header section - pkgs = append(pkgs, &codegen.ImportSpec{Path: "context"}, codegen.GoaImport("")) - sections := []*codegen.SectionTemplate{ - codegen.Header(service.Name+" service type conversion functions", convertPkgName, pkgs), - } - - var ( - names = map[string]struct{}{} - transFuncs []*codegen.TransformFunctionData - ) - - // Build conversion sections if any - for _, c := range conversions { - var dt expr.DataType - if err := buildDesignType(&dt, reflect.TypeOf(c.External), c.User); err != nil { - return nil, err - } - t := reflect.TypeOf(c.External) - tgtPkg := t.String() - if idx := strings.Index(tgtPkg, "."); idx != -1 { - tgtPkg = tgtPkg[:idx] - } - - // Use the correct source context based on where the conversion file will be generated - var srcCtx *codegen.AttributeContext - if loc := codegen.UserTypeLocation(c.User); loc != nil { - // Create a context for the custom package with empty default package to avoid qualification - srcScope := codegen.NewNameScope() - // Register the user type in this scope - this will ensure proper type references - srcScope.GoTypeName(&expr.AttributeExpr{Type: c.User}) - // Use conversion context so types in the same package are not qualified - srcCtx = codegen.NewAttributeContextForConversion(false, false, true, convertPkgName, srcScope) - } else { - srcCtx = typeContext(svc.Scope) - } - tgtCtx := codegen.NewAttributeContext(false, false, false, tgtPkg, codegen.NewNameScope()) - srcAtt := &expr.AttributeExpr{Type: c.User} - tgtAtt := &expr.AttributeExpr{Type: dt} - tgtAtt.AddMeta("struct:type:name", dt.Name()) // Used by transformer to generate the correct type name. - code, tf, err := codegen.GoTransform( - srcAtt, tgtAtt, - "t", "v", srcCtx, tgtCtx, "transform", true) - if err != nil { - return nil, err - } - transFuncs = codegen.AppendHelpers(transFuncs, tf) - base := "ConvertTo" + t.Name() - name := uniquify(base, names) - ref := t.String() - if expr.IsObject(c.User) { - ref = "*" + ref - } - data := convertData{ - Name: name, - ReceiverTypeRef: srcCtx.Scope.Ref(srcAtt, ""), - TypeName: t.Name(), - TypeRef: ref, - Code: code, - } - sections = append(sections, &codegen.SectionTemplate{ - Name: "convert-to", - Source: serviceTemplates.Read(convertT), - Data: data, - }) - } - - // Build creation sections if any - for _, c := range creations { - var dt expr.DataType - if err := buildDesignType(&dt, reflect.TypeOf(c.External), c.User); err != nil { - return nil, err - } - t := reflect.TypeOf(c.External) - srcPkg := t.String() - if idx := strings.Index(srcPkg, "."); idx != -1 { - srcPkg = srcPkg[:idx] - } - srcCtx := codegen.NewAttributeContext(false, false, false, srcPkg, codegen.NewNameScope()) - - // Use the correct target context based on where the conversion file will be generated - var tgtCtx *codegen.AttributeContext - if loc := codegen.UserTypeLocation(c.User); loc != nil { - // Create a context for the custom package with empty default package to avoid qualification - tgtScope := codegen.NewNameScope() - // Register the user type in this scope - this will ensure proper type references - tgtScope.GoTypeName(&expr.AttributeExpr{Type: c.User}) - // Use conversion context so types in the same package are not qualified - tgtCtx = codegen.NewAttributeContextForConversion(false, false, true, convertPkgName, tgtScope) - } else { - tgtCtx = typeContext(svc.Scope) - } - tgtAtt := &expr.AttributeExpr{Type: c.User} - code, tf, err := codegen.GoTransform( - &expr.AttributeExpr{Type: dt}, tgtAtt, - "v", "temp", srcCtx, tgtCtx, "transform", true) - if err != nil { - return nil, err - } - transFuncs = codegen.AppendHelpers(transFuncs, tf) - base := "CreateFrom" + t.Name() - name := uniquify(base, names) - ref := t.String() - if expr.IsObject(c.User) { - ref = "*" + ref - } - data := convertData{ - Name: name, - ReceiverTypeRef: tgtCtx.Scope.Ref(tgtAtt, ""), - TypeRef: ref, - Code: code, - } - sections = append(sections, &codegen.SectionTemplate{ - Name: "create-from", - Source: serviceTemplates.Read(createT), - Data: data, - }) - } - - // Build transformation helper functions section if any. - seen := make(map[string]struct{}) - for _, tf := range transFuncs { - if _, ok := seen[tf.Name]; ok { - continue - } - seen[tf.Name] = struct{}{} - sections = append(sections, &codegen.SectionTemplate{ - Name: "convert-create-helper", - Source: serviceTemplates.Read(transformHelperT), - Data: tf, - }) - } - - return &codegen.File{Path: convertPath, SectionTemplates: sections}, nil + return files } func commonPath(sep byte, paths ...string) string { @@ -393,12 +164,13 @@ func getPkgImport(pkg, cwd string) string { return pkg } -func getExternalTypeInfo(external any) (string, string, error) { +// getExternalReflectTypeInfo returns the source import path and authored +// package qualifier for one named reflected type. +func getExternalReflectTypeInfo(pkg reflect.Type) (string, string, error) { cwd, err := os.Getwd() if err != nil { return "", "", err } - pkg := reflect.TypeOf(external) pkgImport := getPkgImport(pkg.PkgPath(), cwd) alias := strings.Split(pkg.String(), ".")[0] return pkgImport, alias, nil @@ -422,8 +194,9 @@ func uniquify(base string, taken map[string]struct{}) string { } type dtRec struct { - path string - seen map[string]expr.DataType + path string + seen map[reflect.Type]expr.DataType + named map[expr.UserType]reflect.Type } func appendPath(r dtRec, p string) dtRec { @@ -431,6 +204,22 @@ func appendPath(r dtRec, p string) dtRec { return r } +// buildExternalDesignType returns the reflected design graph and the exact Go +// type behind every named node that may require a package-qualified reference. +func buildExternalDesignType(t reflect.Type, ref expr.DataType) (expr.DataType, map[expr.UserType]reflect.Type, error) { + named := make(map[expr.UserType]reflect.Type) + rec := dtRec{ + path: "", + seen: make(map[reflect.Type]expr.DataType), + named: named, + } + var dataType expr.DataType + if err := buildDesignType(&dataType, t, ref, rec); err != nil { + return nil, nil, err + } + return dataType, named, nil +} + // buildDesignType builds a user type that represents the given external type. // ref is the user type the data type being built is converted to or created // from. It's used to compute the non-generated type field names and can be nil @@ -447,13 +236,14 @@ func buildDesignType(dt *expr.DataType, t reflect.Type, ref expr.DataType, recs var rec dtRec if recs != nil { rec = recs[0] - if s, ok := rec.seen[t.Name()]; ok { + if s, ok := rec.seen[t]; ok { *dt = s return nil } } else { rec.path = "" - rec.seen = make(map[string]expr.DataType) + rec.seen = make(map[reflect.Type]expr.DataType) + rec.named = make(map[expr.UserType]reflect.Type) } switch t.Kind() { @@ -526,22 +316,26 @@ func buildDesignType(dt *expr.DataType, t reflect.Type, ref expr.DataType, recs oref = expr.AsObject(ref) } - // Build list of fields that should not be ignored. + // Keep only fields represented by the matching design object. External + // structs may contain additional fields, but generated transforms neither + // read nor write them and therefore must not reserve their package imports. var fields []reflect.StructField for i := 0; i < t.NumField(); i++ { f := t.FieldByIndex([]int{i}) atn, _ := attributeName(oref, f.Name) if oref != nil { - if at := oref.Attribute(atn); at != nil { - if m := at.Meta["struct:field:external"]; len(m) > 0 { - if m[0] == "-" { - continue - } + at := oref.Attribute(atn) + if at == nil { + continue + } + if m := at.Meta["struct:field:external"]; len(m) > 0 { + if m[0] == "-" { + continue } - if m := at.Meta["struct.field.external"]; len(m) > 0 { // Deprecated syntax. Only present for backward compatibility. - if m[0] == "-" { - continue - } + } + if m := at.Meta["struct.field.external"]; len(m) > 0 { // Deprecated syntax. Only present for backward compatibility. + if m[0] == "-" { + continue } } } @@ -556,7 +350,8 @@ func buildDesignType(dt *expr.DataType, t reflect.Type, ref expr.DataType, recs UID: t.PkgPath() + "#" + t.Name(), } *dt = ut - rec.seen[t.Name()] = ut + rec.seen[t] = ut + rec.named[ut] = t var required []string for i, f := range fields { recf := appendPath(rec, "."+f.Name) diff --git a/codegen/service/convert_test.go b/codegen/service/convert_test.go index 0dcf39c0db..6148a017e4 100644 --- a/codegen/service/convert_test.go +++ b/codegen/service/convert_test.go @@ -1,3 +1,5 @@ +// This file verifies service conversion paths, generated helper declarations, +// and the exact package names used by both definitions and references. package service import ( @@ -13,6 +15,8 @@ import ( "github.com/stretchr/testify/require" "goa.design/goa/v3/codegen/service/testdata" + aliasd "goa.design/goa/v3/codegen/service/testdata/alias-external" + "goa.design/goa/v3/codegen/service/testdata/external" "goa.design/goa/v3/dsl" "goa.design/goa/v3/expr" ) @@ -25,6 +29,12 @@ type ( hasNonPtrFields struct { inner inner } + + externalSibling struct { + Label string + Left *externalSibling + Right *externalSibling + } ) func TestCommonPath(t *testing.T) { @@ -318,37 +328,172 @@ func TestConvertFiles(t *testing.T) { "gen/models/convert.go": 3, // header + convert-to + create-from sections }, }, + { + "noncanonical-location-uses-owned-package", + func() { + filter := dsl.Type("FilterConfig", func() { + dsl.Meta("struct:pkg:path", "domain/../types") + dsl.CreateFrom(testdata.TestFilterConfig{}) + dsl.ConvertTo(testdata.TestFilterConfig{}) + dsl.Attribute("name", dsl.String) + dsl.Attribute("enabled", dsl.Boolean) + dsl.Attribute("value", dsl.Int) + dsl.Required("name", "enabled", "value") + }) + dsl.Service("Values", func() { + dsl.Method("Read", func() { dsl.Payload(filter) }) + }) + }, + map[string]int{ + "gen/types/convert.go": 3, + }, + }, } for _, c := range cases { t.Run(c.Name, func(t *testing.T) { root := runDSL(t, c.DSL) - services := NewServicesData(root) - - for _, svc := range root.Services { - files, err := ConvertFiles(root, svc, services) - require.NoError(t, err) - - // Check expected number of files - require.Equal(t, len(c.ExpectedFiles), len(files)) - - // Verify each expected file - for expectedPath, expectedSections := range c.ExpectedFiles { - found := false - // Normalize expected path for cross-platform compatibility - normalizedExpected := filepath.FromSlash(expectedPath) - for _, file := range files { - if strings.HasSuffix(file.Path, normalizedExpected) { - found = true - require.Equal(t, expectedSections, len(file.SectionTemplates)) - // First section should be header - require.Equal(t, "source-header", file.SectionTemplates[0].Name) - break - } + plan := mustServicePlan(t, root) + retained, err := externalConversionFiles([]*Plan{plan}) + require.NoError(t, err) + files := convertFiles(retained) + + // Check expected number of files + require.Equal(t, len(c.ExpectedFiles), len(files)) + + // Verify each expected file + for expectedPath, expectedSections := range c.ExpectedFiles { + found := false + // Normalize expected path for cross-platform compatibility + normalizedExpected := filepath.FromSlash(expectedPath) + for _, file := range files { + if strings.HasSuffix(file.Path, normalizedExpected) { + found = true + require.Equal(t, expectedSections, len(file.SectionTemplates)) + // First section should be header + require.Equal(t, "source-header", file.SectionTemplates[0].Name) + break } - require.True(t, found, "Expected file %s not found", expectedPath) } + require.True(t, found, "Expected file %s not found", expectedPath) } }) } } + +// TestConversionPlanSharesHelperDeclarations proves recursive call edges and +// emitted helper definitions use the same package declaration retained by the +// external conversion operation. +func TestConversionPlanSharesHelperDeclarations(t *testing.T) { + root := runDSL(t, func() { + recursive := dsl.Type("Recursive", func() { + dsl.ConvertTo(objRecursiveT{}) + dsl.CreateFrom(objRecursiveT{}) + dsl.Attribute("Foo", dsl.String) + dsl.Attribute("Bar", dsl.Int) + dsl.Attribute("Goo", dsl.Float32) + dsl.Attribute("Goo2", dsl.UInt) + dsl.Attribute("Rec", "Recursive") + dsl.Required("Foo", "Bar", "Goo", "Goo2") + }) + dsl.Service("RecursiveService", func() { + dsl.Method("Read", func() { + dsl.Payload(recursive) + }) + }) + }) + plan := mustServicePlan(t, root) + files := plan.facts.externalConversions + require.Len(t, files, 1) + require.Len(t, files[0].operations, 2) + for _, operation := range files[0].operations { + planned := operation.plan.Helpers() + require.NotEmpty(t, planned) + require.Len(t, operation.helpers, len(planned)) + for index := range planned { + require.Equal(t, planned[index].ID, operation.helpers[index].ID) + require.Same(t, planned[index].Declaration, operation.helpers[index].Declaration) + } + } +} + +// TestExternalConversionSharesSiblingHelper checks that one external type +// conversion uses the same strict function for required and optional fields. +func TestExternalConversionSharesSiblingHelper(t *testing.T) { + root := runDSL(t, func() { + sibling := dsl.Type("ExternalSibling", func() { + dsl.ConvertTo(externalSibling{}) + dsl.Attribute("Label", dsl.String) + dsl.Attribute("Left", "ExternalSibling") + dsl.Attribute("Right", "ExternalSibling") + dsl.Required("Label", "Left") + }) + dsl.Service("SiblingService", func() { + dsl.Method("Read", func() { + dsl.Payload(sibling) + }) + }) + }) + plan := mustServicePlan(t, root) + files := plan.facts.externalConversions + require.Len(t, files, 1) + require.Len(t, files[0].operations, 1) + operation := files[0].operations[0] + planned := operation.plan.Helpers() + require.Len(t, planned, 2) + require.True(t, planned[0].Required) + require.False(t, planned[1].Required) + require.Same(t, planned[0].Declaration, planned[1].Declaration) + require.Len(t, operation.helpers, 1) + require.NotContains(t, operation.helpers[0].Code, "if v == nil") + require.Contains(t, operation.data.Code, "v.Left = "+planned[0].Declaration.Name()+"(t.Left)") + require.Contains(t, operation.data.Code, "if t.Right != nil {") + require.Contains(t, operation.data.Code, "v.Right = "+planned[0].Declaration.Name()+"(t.Right)") +} + +// TestConversionMethodNamesUseReceiverNamespaces verifies different receiver +// types may use the same method spelling while collisions on one receiver are +// resolved in stable external-package order. +func TestConversionMethodNamesUseReceiverNamespaces(t *testing.T) { + root := runDSL(t, func() { + foo := dsl.Type("Foo", func() { + dsl.ConvertTo(external.ConvertModel{}) + dsl.Attribute("Foo", dsl.String) + }) + bar := dsl.Type("Bar", func() { + dsl.ConvertTo(aliasd.ConvertModel{}) + dsl.Attribute("Bar", dsl.String) + }) + empty := dsl.Type("Empty", func() { + dsl.ConvertTo(external.ConvertModel{}) + dsl.ConvertTo(aliasd.ConvertModel{}) + }) + dsl.Service("Values", func() { + for _, method := range []struct { + name string + payload expr.UserType + }{ + {"Foo", foo}, + {"Bar", bar}, + {"Empty", empty}, + } { + dsl.Method(method.name, func() { + dsl.Payload(method.payload) + }) + } + }) + }) + plan := mustServicePlan(t, root) + var operations []*externalConversionFacts + for _, file := range plan.facts.externalConversions { + operations = append(operations, file.operations...) + } + names := make(map[string]string) + for _, operation := range operations { + names[operation.receiverType.Name()+":"+operation.externalPath] = operation.methodName + } + require.Equal(t, "ConvertToConvertModel", names["Foo:goa.design/goa/v3/codegen/service/testdata/external"]) + require.Equal(t, "ConvertToConvertModel", names["Bar:goa.design/goa/v3/codegen/service/testdata/alias-external"]) + require.Equal(t, "ConvertToConvertModel", names["Empty:goa.design/goa/v3/codegen/service/testdata/alias-external"]) + require.Equal(t, "ConvertToConvertModel2", names["Empty:goa.design/goa/v3/codegen/service/testdata/external"]) +} diff --git a/codegen/service/declaration_resolver.go b/codegen/service/declaration_resolver.go new file mode 100644 index 0000000000..9316a68e88 --- /dev/null +++ b/codegen/service/declaration_resolver.go @@ -0,0 +1,350 @@ +// This file writes service type definitions and references using the Go names +// chosen for each generated package. A type with an explicit package location +// uses that import path; a child type without one stays in its enclosing type's +// package. +package service + +import ( + "fmt" + "path" + "strings" + + "goa.design/goa/v3/codegen" + "goa.design/goa/v3/expr" +) + +type ( + // declarationResolver writes service fields and type references from the + // package declarations recorded by Plan. + declarationResolver struct { + generation *codegen.Generation + aliases *importAliases + serviceName string + currentPath string + outputPath string + derived map[expr.UserType]codegen.DerivedTypeID + validators map[validatorKey]*codegen.NameDeclaration + view bool + } +) + +// newServiceResolver starts in the assigned service package and qualifies type +// references for the package that will contain the generated file. +func newServiceResolver(generation *codegen.Generation, aliases *importAliases, serviceName, servicePath, outputPath string) *declarationResolver { + return &declarationResolver{ + generation: generation, + aliases: aliases, + serviceName: serviceName, + currentPath: servicePath, + outputPath: outputPath, + } +} + +// newViewResolver starts in the assigned views package and associates each +// generated view type with the service result type from which it was built. +func newViewResolver(generation *codegen.Generation, aliases *importAliases, serviceName, viewsPath string, derived map[expr.UserType]codegen.DerivedTypeID) *declarationResolver { + return &declarationResolver{ + generation: generation, + aliases: aliases, + serviceName: serviceName, + currentPath: viewsPath, + outputPath: viewsPath, + derived: derived, + view: true, + } +} + +// Name returns the generated Go type name for att. The resolver's current +// import path chooses the package containing the declaration; the textual pkg +// argument does not. +func (r *declarationResolver) Name(att *expr.AttributeExpr, _ string, ptr, useDefault bool) string { + switch actual := att.Type.(type) { + case expr.Primitive: + return r.generation.Package(r.outputPath).Scope().GoTypeName(att) + case *expr.Array: + return "[]" + r.Ref(actual.ElemType, "") + case *expr.Map: + return fmt.Sprintf("map[%s]%s", r.Ref(actual.KeyType, ""), r.Ref(actual.ElemType, "")) + case *expr.Object: + return r.Def(att, ptr, useDefault) + case expr.UserType: + if actual == expr.Empty { + return "struct {}" + } + if expr.IsErrorResult(actual) { + return "goa.ServiceError" + } + owner := r.owner(att) + declaration := r.userType(owner, actual) + return r.qualify(owner, declaration.Name()) + case *expr.Union: + owner := r.owner(att) + declaration, err := r.generation.Package(owner).Union(att) + if err != nil { + panic(fmt.Sprintf("resolve union %q for service %q in package %q: %v", actual.Name(), r.serviceName, owner, err)) + } + return r.qualify(owner, declaration.Name()) + case expr.CompositeExpr: + return r.Name(actual.Attribute(), "", ptr, useDefault) + default: + panic(fmt.Sprintf("resolve service type %T for service %q", actual, r.serviceName)) + } +} + +// Def returns the Go definition for att while resolving every nested named +// declaration through its actual generated package. +func (r *declarationResolver) Def(att *expr.AttributeExpr, ptr, useDefault bool) string { + switch actual := att.Type.(type) { + case expr.Primitive: + return r.Name(att, "", ptr, useDefault) + case *expr.Array: + definition := r.Enter(actual.ElemType).(*declarationResolver).Def(actual.ElemType, ptr, useDefault) + if expr.IsObject(actual.ElemType.Type) { + definition = "*" + definition + } + return "[]" + definition + case *expr.Map: + key := r.Enter(actual.KeyType).(*declarationResolver).Def(actual.KeyType, ptr, useDefault) + if expr.IsObject(actual.KeyType.Type) { + key = "*" + key + } + value := r.Enter(actual.ElemType).(*declarationResolver).Def(actual.ElemType, ptr, useDefault) + if expr.IsObject(actual.ElemType.Type) { + value = "*" + value + } + return fmt.Sprintf("map[%s]%s", key, value) + case *expr.Object: + lines := []string{"struct {"} + for _, field := range *actual { + fieldResolver := r.Enter(field.Attribute).(*declarationResolver) + definition := fieldResolver.Def(field.Attribute, ptr, useDefault) + if serviceFieldIsPointer(att, field.Name, ptr, useDefault) { + definition = "*" + definition + } + var description string + if field.Attribute.Description != "" { + description = codegen.Comment(field.Attribute.Description) + "\n\t" + } + lines = append(lines, fmt.Sprintf( + "\t%s%s %s%s", + description, + codegen.GoifyAtt(field.Attribute, field.Name, true), + definition, + codegen.AttributeTagsWithName(att, field.Name, field.Attribute), + )) + } + return strings.Join(append(lines, "}"), "\n") + case expr.UserType, *expr.Union: + return r.Name(att, "", ptr, useDefault) + case expr.CompositeExpr: + return r.Def(actual.Attribute(), ptr, useDefault) + default: + panic(fmt.Sprintf("define service type %T for service %q", actual, r.serviceName)) + } +} + +// Ref returns the generated Go reference for att. +func (r *declarationResolver) Ref(att *expr.AttributeExpr, pkg string) string { + name := r.Name(att, pkg, false, false) + if _, ok := att.Type.(*expr.Object); ok { + return name + } + if expr.IsObject(att.Type) || expr.IsUnion(att.Type) { + return "*" + name + } + return name +} + +// UnionConstructor returns the planned constructor for one service OneOf +// branch, qualified for the generated file that will call it. +func (r *declarationResolver) UnionConstructor(attribute *expr.AttributeExpr, branch string) (string, error) { + owner := r.owner(attribute) + declaration, err := r.generation.Package(owner).UnionBranch(attribute, branch) + if err != nil { + return "", err + } + return r.qualify(owner, declaration.Constructor()), nil +} + +// GoTypeLayout returns the retained Go fields and references selected by the +// service package for attribute. +func (r *declarationResolver) GoTypeLayout(attribute *expr.AttributeExpr, policy codegen.GoLayoutPolicy) (codegen.LinkedGoType, error) { + owner := r.owner(attribute) + layout, err := codegen.PlanGoType(attribute, codegen.GoTypePlanOptions{ + Owner: owner, + Policy: policy, + RetainNamedValue: true, + Bind: func(request codegen.GoTypeBindingRequest) (codegen.GoTypeBinding, error) { + bindingOwner := request.InheritedOwner + if location := codegen.UserTypeLocation(request.Attribute.Type); location != nil { + bindingOwner = path.Join(r.generation.GenPkg(), location.RelImportPath) + } + switch request.Kind { + case codegen.GoNamed: + userType := request.Attribute.Type.(expr.UserType) + return codegen.GoTypeBinding{ + Owner: bindingOwner, + Type: r.userType(bindingOwner, userType), + }, nil + case codegen.GoUnion: + declaration, lookupErr := r.generation.Package(bindingOwner).Union(request.Attribute) + if lookupErr != nil { + return codegen.GoTypeBinding{}, lookupErr + } + return codegen.GoTypeBinding{Owner: bindingOwner, Union: declaration}, nil + default: + return codegen.GoTypeBinding{}, fmt.Errorf("resolve unsupported service Go type kind %s", request.Kind) + } + }, + }) + if err != nil { + return codegen.LinkedGoType{}, err + } + return layout.Link(r.outputPath, func(importPath string) string { + return r.aliases.name(r.outputPath, importPath) + }), nil +} + +// Field returns the generated Go field name for one service attribute. +func (*declarationResolver) Field(att *expr.AttributeExpr, name string, firstUpper bool) string { + return codegen.GoifyAtt(att, name, firstUpper) +} + +// Package returns the qualifier for att relative to the file being rendered. +func (r *declarationResolver) Package(att *expr.AttributeExpr) string { + owner := r.currentPath + if att != nil { + owner = r.owner(att) + } + if owner == r.outputPath { + return "" + } + return r.aliases.name(r.outputPath, owner) +} + +// Enter returns a resolver whose current package owns att and its unlocated +// nested declarations. +func (r *declarationResolver) Enter(att *expr.AttributeExpr) codegen.Attributor { + owner := r.owner(att) + if owner == r.currentPath { + return r + } + entered := *r + entered.currentPath = owner + return &entered +} + +// withOutputPackage returns a resolver that keeps declarations in the current +// package but qualifies references for a file emitted in packagePath. +func (r *declarationResolver) withOutputPackage(packagePath string) *declarationResolver { + if packagePath == r.outputPath { + return r + } + output := *r + output.outputPath = packagePath + return &output +} + +// bindDerived returns a resolver that associates a render-only expression +// origin with one declaration planned from its exact source type. +func (r *declarationResolver) bindDerived(origin expr.UserType, identity codegen.DerivedTypeID) *declarationResolver { + bound := *r + bound.derived = make(map[expr.UserType]codegen.DerivedTypeID, len(r.derived)+1) + for existing, existingIdentity := range r.derived { + bound.derived[existing] = existingIdentity + } + bound.derived[origin.Origin()] = identity + return &bound +} + +// withValidators returns a resolver that maps each child validation call to the +// Go function declaration submitted during service planning. +func (r *declarationResolver) withValidators(validators map[validatorKey]*codegen.NameDeclaration) *declarationResolver { + bound := *r + bound.validators = validators + return &bound +} + +// IsSumType reports that service unions use generated values that hold one branch. +func (*declarationResolver) IsSumType() bool { + return true +} + +// ValidatorCall returns a call to the validation function submitted for att +// and view before Generation.Freeze chose the function's Go name. +func (r *declarationResolver) ValidatorCall(att *expr.AttributeExpr, view, target, _ string) string { + declaration := r.validatorDeclaration(att, view) + return fmt.Sprintf("%s(%s)", r.qualify(r.owner(att), declaration.Name()), target) +} + +// validatorDeclaration returns the NameDeclaration recorded for att and the +// selected view. The default view uses the same empty key as its call sites. +func (r *declarationResolver) validatorDeclaration(att *expr.AttributeExpr, view string) *codegen.NameDeclaration { + userType, ok := att.Type.(expr.UserType) + if !ok { + panic(fmt.Sprintf("resolve validator for non-user type %T", att.Type)) + } + owner := r.owner(att) + declaration := r.userType(owner, userType) + validator := r.validators[validatorKey{declaration: declaration, view: canonicalValidatorView(view)}] + if validator == nil { + panic(fmt.Sprintf( + "validator for type %q view %q was not retained in generated package %q", + userType.Name(), view, owner, + )) + } + return validator +} + +// Scope returns the name set for the resolver's current generated package. +func (r *declarationResolver) Scope() *codegen.NameScope { + return r.generation.Package(r.currentPath).Scope() +} + +// owner returns the import path of the package containing att. View-specific +// result copies stay in the views package after Goa removes their original +// struct:pkg:path metadata. +func (r *declarationResolver) owner(att *expr.AttributeExpr) string { + if r.view { + return r.currentPath + } + if location := codegen.UserTypeLocation(att.Type); location != nil { + return path.Join(r.generation.GenPkg(), location.RelImportPath) + } + return r.currentPath +} + +// userType selects an exact, generated union branch, or rebuilt view record. +func (r *declarationResolver) userType(owner string, userType expr.UserType) *codegen.TypeDeclaration { + generatedPackage := r.generation.Package(owner) + if identity, ok := r.derived[userType.Origin()]; ok { + declaration, err := generatedPackage.DerivedType(identity) + if err != nil { + panic(fmt.Sprintf("resolve derived type %q for service %q in package %q: %v", userType.Name(), r.serviceName, owner, err)) + } + return declaration + } + declaration, err := generatedPackage.Type(userType) + if err != nil { + panic(fmt.Sprintf("resolve user type %q for service %q in package %q: %v", userType.Name(), r.serviceName, owner, err)) + } + return declaration +} + +// qualify adds the owning package name when the current output file is in a +// different generated package. +func (r *declarationResolver) qualify(owner, name string) string { + if owner == r.outputPath { + return name + } + return r.aliases.name(r.outputPath, owner) + "." + name +} + +// serviceFieldIsPointer applies Goa's service-struct pointer rules to one field +// definition. +func serviceFieldIsPointer(parent *expr.AttributeExpr, name string, pointer, useDefault bool) bool { + field := expr.AsObject(parent.Type).Attribute(name) + return expr.IsObject(field.Type) || + parent.IsPrimitivePointer(name, useDefault) || + pointer && expr.IsPrimitive(field.Type) && field.Type.Kind() != expr.AnyKind && field.Type.Kind() != expr.BytesKind +} diff --git a/codegen/service/declaration_resolver_test.go b/codegen/service/declaration_resolver_test.go new file mode 100644 index 0000000000..509bd95d80 --- /dev/null +++ b/codegen/service/declaration_resolver_test.go @@ -0,0 +1,283 @@ +// This file verifies that service transformations resolve every named type +// through the frozen package catalog as recursion crosses explicit package +// locations. +package service + +import ( + "path" + "strings" + "testing" + + "github.com/stretchr/testify/require" + + "goa.design/goa/v3/codegen" + "goa.design/goa/v3/dsl" + "goa.design/goa/v3/eval" + "goa.design/goa/v3/expr" +) + +// TestDeclarationResolverTransformsRelocatedUnionBranches verifies both +// conversion directions use the frozen generated alias in the owning package. +func TestDeclarationResolverTransformsRelocatedUnionBranches(t *testing.T) { + service := &expr.ServiceExpr{Name: "Convert"} + generatedBranch := resolverUserType("ValueText", expr.String) + union := &expr.Union{ + TypeName: "Value", + Values: []*expr.NamedAttributeExpr{ + {Name: "text", Attribute: &expr.AttributeExpr{Type: generatedBranch}}, + }, + } + relocated := resolverUserType("Record", &expr.Object{ + {Name: "value", Attribute: &expr.AttributeExpr{Type: union}}, + }) + unionAttribute := expr.AsObject(relocated.Attribute().Type).Attribute("value") + relocated.Attribute().AddMeta("struct:pkg:path", "types") + + generation := mustTestGeneration(t, "generated.local/gen", nil) + types := mustClaimTestPackage(t, generation, "generated.local/gen/types") + _, err := types.DeclareUserType(relocated) + require.NoError(t, err) + _, err = types.DeclareUnion(unionAttribute) + require.NoError(t, err) + branchDeclaration, err := types.DeclareUnionBranchType(unionAttribute, "text", generatedBranch) + require.NoError(t, err) + require.NoError(t, generation.Freeze()) + require.Equal(t, "ValueBranchText", branchDeclaration.Name()) + + externalBranch := resolverUserType("ExternalValueText", expr.String) + externalUnion := &expr.Union{ + TypeName: "ExternalValue", + Values: []*expr.NamedAttributeExpr{ + {Name: "text", Attribute: &expr.AttributeExpr{Type: externalBranch}}, + }, + } + external := resolverUserType("ExternalRecord", &expr.Object{ + {Name: "value", Attribute: &expr.AttributeExpr{Type: externalUnion}}, + }) + + relocatedAttribute := &expr.AttributeExpr{Type: relocated} + externalAttribute := &expr.AttributeExpr{Type: external} + resolver := newServiceResolver( + generation, + aliasesForTest(t, "generated.local/gen/types"), + service.Name, + servicePackagePath(generation.GenPkg(), service), + "generated.local/gen/types", + ) + relocatedContext := declarationContext(resolver.Enter(relocatedAttribute), false) + externalContext := codegen.NewAttributeContext(false, false, true, "external", codegen.NewNameScope()) + + _, _, err = codegen.GoTransform( + relocatedAttribute, + externalAttribute, + "record", + "externalRecord", + relocatedContext, + externalContext, + "convert", + true, + ) + require.NoError(t, err) + + toRelocated, toRelocatedHelpers, err := codegen.GoTransform( + externalAttribute, + relocatedAttribute, + "externalRecord", + "record", + externalContext, + relocatedContext, + "create", + true, + ) + require.NoError(t, err) + require.Contains(t, transformSource(toRelocated, toRelocatedHelpers), "ValueBranchText") +} + +// TestDeclarationResolverQualifiesRelocatedConsumersWithoutRenamingLocalType +// verifies errors and interceptor fields use their actual package owner. +func TestDeclarationResolverQualifiesRelocatedConsumersWithoutRenamingLocalType(t *testing.T) { + service := &expr.ServiceExpr{Name: "Collisions"} + local := resolverUserType("Fault", expr.String) + relocated := resolverUserType("fault", expr.String) + relocated.Attribute().AddMeta("struct:pkg:path", "errors") + container := resolverUserType("Container", &expr.Object{ + {Name: "fault", Attribute: &expr.AttributeExpr{Type: relocated}}, + }) + container.Attribute().AddMeta("struct:pkg:path", "types") + + generation := mustTestGeneration(t, "generated.local/gen", nil) + servicePackage := mustClaimTestPackage(t, generation, servicePackagePath(generation.GenPkg(), service)) + localDeclaration, err := servicePackage.DeclareUserType(local) + require.NoError(t, err) + errorConstructor := codegen.NewPreferredName( + codegen.NameFunction, + "MakeFault", + codegen.ExportedName, + serviceNameOrder{role: serviceErrorConstructorNameRole, subject: "fault"}, + ) + require.NoError(t, servicePackage.DeclareName(errorConstructor)) + errorsPackage := mustClaimTestPackage(t, generation, "generated.local/gen/errors") + _, err = errorsPackage.DeclareUserType(relocated) + require.NoError(t, err) + typesPackage := mustClaimTestPackage(t, generation, "generated.local/gen/types") + _, err = typesPackage.DeclareUserType(container) + require.NoError(t, err) + require.NoError(t, generation.Freeze()) + + resolver := newServiceResolver( + generation, + aliasesForTest( + t, + servicePackagePath(generation.GenPkg(), service), + "generated.local/gen/errors", + "generated.local/gen/types", + ), + service.Name, + servicePackagePath(generation.GenPkg(), service), + servicePackagePath(generation.GenPkg(), service), + ) + require.Equal(t, "Fault", localDeclaration.Name()) + require.Equal(t, "Fault", resolver.Ref(&expr.AttributeExpr{Type: local}, "")) +} + +// TestDeclarationResolverUsesFinalCustomTypeImportAlias verifies that service +// fields keep their complete custom Go type when an import name changes. +func TestDeclarationResolverUsesFinalCustomTypeImportAlias(t *testing.T) { + const ( + servicePath = "generated.local/gen/values" + customPath = "example.com/custom/wire" + ) + attribute := &expr.AttributeExpr{ + Type: expr.String, + Meta: expr.MetaExpr{ + "struct:field:type": {"map[wire.Key]wire.Value", customPath, "wire"}, + }, + } + generation := mustTestGeneration(t, "generated.local/gen", nil) + pkg := mustClaimTestPackage(t, generation, servicePath) + require.NoError(t, pkg.DeclareImport(codegen.NewImport("wire", customPath))) + require.NoError(t, pkg.RequireImport(codegen.NewImport("wire", "example.com/fixed/wire"))) + require.NoError(t, generation.Freeze()) + require.Equal(t, "wire2", pkg.ImportName(customPath)) + + resolver := newServiceResolver( + generation, + &importAliases{generation: generation}, + "Values", + servicePath, + servicePath, + ) + require.Equal(t, "map[wire2.Key]wire2.Value", resolver.Name(attribute, "", false, true)) +} + +// TestDeclarationResolverPanicsWhenPlanOmittedType verifies render analysis +// fails immediately instead of allocating a missing declaration. +func TestDeclarationResolverPanicsWhenPlanOmittedType(t *testing.T) { + service := &expr.ServiceExpr{Name: "Missing"} + generation := mustTestGeneration(t, "generated.local/gen", nil) + mustClaimTestPackage(t, generation, servicePackagePath(generation.GenPkg(), service)) + require.NoError(t, generation.Freeze()) + resolver := newServiceResolver( + generation, + aliasesForTest(t, servicePackagePath(generation.GenPkg(), service)), + service.Name, + servicePackagePath(generation.GenPkg(), service), + servicePackagePath(generation.GenPkg(), service), + ) + missing := resolverUserType("Missing", expr.String) + require.PanicsWithValue( + t, + "resolve user type \"Missing\" for service \"Missing\" in package \"generated.local/gen/missing\": user type \"Missing\" has no declaration in generated package \"generated.local/gen/missing\"", + func() { + resolver.Name(&expr.AttributeExpr{Type: missing}, "", false, true) + }, + ) +} + +// TestServicesDataServiceAttributorUsesFrozenPackageDeclarations verifies +// transport generators can consume the same local, relocated, and nested +// declaration records used by service rendering without accessing resolver +// state. +func TestServicesDataServiceAttributorUsesFrozenPackageDeclarations(t *testing.T) { + var record expr.UserType + root := codegen.RunDSL(t, func() { + record = dsl.Type("Record", func() { + dsl.Meta("struct:pkg:path", "types") + dsl.OneOf("Value", func() { + dsl.Attribute("text", dsl.String) + }) + dsl.Attribute("external", dsl.String, func() { + dsl.Meta("struct:field:type", "custom.Value", "example.com/custom", "custom") + }) + }) + dsl.Service("Values", func() { + dsl.Method("Read", func() { + dsl.Payload(record) + }) + }) + }) + generation := mustTestGeneration(t, "goa.design/goa/example", []eval.Root{root}) + plan, err := NewPlan(root, generation, expr.NewExampleGenerator(root.API.RandomizerFactory)) + require.NoError(t, err) + consumer, err := generation.ClaimOutputPackage("example.com/consumer", "consumer") + require.NoError(t, err) + require.NoError(t, consumer.ReserveGeneratedImport(codegen.NewImport("types", "goa.design/goa/example/types"))) + require.NoError(t, consumer.DeclareImport(codegen.NewImport("custom", "example.com/custom"))) + require.NoError(t, generation.Freeze()) + require.NoError(t, plan.Link()) + services := plan.Services() + external := services.ServiceAttributor("Values", "example.com/consumer") + recordAttribute := &expr.AttributeExpr{Type: record} + recordResolver := external.Enter(recordAttribute) + value := expr.AsObject(record.Attribute().Type).Attribute("Value") + externalValue := expr.AsObject(record.Attribute().Type).Attribute("external") + + require.Equal(t, "*types.Record", external.Ref(recordAttribute, "")) + require.Equal(t, "*types.Value", recordResolver.Ref(value, "")) + require.Equal(t, "custom.Value", recordResolver.Ref(externalValue, "")) + + typesPackage := "goa.design/goa/example/types" + local := services.ServiceAttributor("Values", typesPackage).Enter(recordAttribute) + require.Equal(t, "*Record", local.Ref(recordAttribute, "")) + require.Equal(t, "*Value", local.Ref(value, "")) +} + +// aliasesForTest builds the same frozen full-path qualifier table used by +// service analysis for the package paths exercised by a focused resolver test. +func aliasesForTest(t *testing.T, paths ...string) *importAliases { + t.Helper() + generation := mustTestGeneration(t, "generated.local/gen", nil) + packages := make([]*codegen.GeneratedPackage, len(paths)) + for index, importPath := range paths { + packages[index] = mustClaimTestPackage(t, generation, importPath) + } + for _, pkg := range packages { + for _, importPath := range paths { + if importPath != pkg.ImportPath() { + require.NoError(t, pkg.DeclareImport(codegen.NewImport(codegen.Goify(path.Base(importPath), false), importPath))) + } + } + } + require.NoError(t, generation.Freeze()) + return &importAliases{generation: generation} +} + +// resolverUserType constructs one exact declaration for resolver tests. +func resolverUserType(name string, dataType expr.DataType) *expr.UserTypeExpr { + return &expr.UserTypeExpr{ + AttributeExpr: &expr.AttributeExpr{Type: dataType}, + TypeName: name, + UID: "resolver-test#" + name, + } +} + +// transformSource combines an inline transformation with every recursive +// helper so tests can assert the complete code emitted for one conversion. +func transformSource(code string, helpers []*codegen.TransformFunctionData) string { + var source strings.Builder + source.WriteString(code) + for _, helper := range helpers { + source.WriteString(helper.Code) + } + return source.String() +} diff --git a/codegen/service/endpoint.go b/codegen/service/endpoint.go index ca26a860f5..378ca1c708 100644 --- a/codegen/service/endpoint.go +++ b/codegen/service/endpoint.go @@ -1,3 +1,5 @@ +// This file renders one service's endpoint API and derives type imports only +// from the methods emitted into that endpoint file. package service import ( @@ -6,23 +8,42 @@ import ( "strings" "goa.design/goa/v3/codegen" - "goa.design/goa/v3/expr" ) type ( // EndpointsData contains the data necessary to render the // service endpoints struct template. EndpointsData struct { + // EndpointsDeclaration is the exact package-level endpoint collection. + EndpointsDeclaration *codegen.NameDeclaration + // NewEndpointsDeclaration is the exact endpoint constructor. + NewEndpointsDeclaration *codegen.NameDeclaration + // ClientDeclaration is the exact package-level client. + ClientDeclaration *codegen.NameDeclaration + // NewClientDeclaration is the exact client constructor. + NewClientDeclaration *codegen.NameDeclaration + // ServiceDeclaration is the exact service interface. + ServiceDeclaration *codegen.NameDeclaration + // ServerInterceptorsDeclaration is the exact server interceptor interface. + ServerInterceptorsDeclaration *codegen.NameDeclaration + // ClientInterceptorsDeclaration is the exact client interceptor interface. + ClientInterceptorsDeclaration *codegen.NameDeclaration + // VarName is the generated endpoint collection name kept for existing plugins. + // + // Deprecated: Use EndpointsDeclaration.Name() after planning. + VarName string + // ClientVarName is the generated client name kept for existing plugins. + // + // Deprecated: Use ClientDeclaration.Name() after planning. + ClientVarName string + // ServiceVarName is the generated service interface name kept for existing plugins. + // + // Deprecated: Use ServiceDeclaration.Name() after planning. + ServiceVarName string // Name is the service name. Name string // Description is the service description. Description string - // VarName is the endpoint struct name. - VarName string - // ClientVarName is the client struct name. - ClientVarName string - // ServiceVarName is the service interface name. - ServiceVarName string // Methods lists the endpoint struct methods. Methods []*EndpointMethodData // ClientInitArgs lists the arguments needed to instantiate the client. @@ -41,6 +62,18 @@ type ( // EndpointMethodData describes a single endpoint method. EndpointMethodData struct { *MethodData + // ClientDeclaration is the exact package-level client used as the method receiver. + ClientDeclaration *codegen.NameDeclaration + // ServiceDeclaration is the exact service interface accepted by the endpoint constructor. + ServiceDeclaration *codegen.NameDeclaration + // ClientVarName is the generated client name kept for existing plugins. + // + // Deprecated: Use ClientDeclaration.Name() after planning. + ClientVarName string + // ServiceVarName is the generated service interface name kept for existing plugins. + // + // Deprecated: Use ServiceDeclaration.Name() after planning. + ServiceVarName string // ArgName is the name of the argument used to initialize the client // struct method field. ArgName string @@ -49,27 +82,15 @@ type ( // // It is only set when HasMixedResults is true. StreamArgName string - // ClientVarName is the corresponding client struct field name. - ClientVarName string - // ServiceName is the name of the owner service. + // ServiceName is the name of the service that declares this method. ServiceName string - // ServiceVarName is the name of the owner service Go interface. - ServiceVarName string } ) -const ( - // endpointsStructName is the name of the generated endpoints data - // structure. - endpointsStructName = "Endpoints" - - // serviceInterfaceName is the name of the generated service interface. - serviceInterfaceName = "Service" -) - -// EndpointFile returns the endpoint file for the given service. -func EndpointFile(genpkg string, service *expr.ServiceExpr, services *ServicesData) *codegen.File { - svc := services.Get(service.Name) +// endpointFile renders endpoints from the service data copied into plan. +func endpointFile(plan *Plan, facts *serviceFacts) *codegen.File { + services := plan.Services() + svc := services.Get(facts.name) svcName := svc.PathName path := filepath.Join(codegen.Gendir, svcName, "endpoints.go") data := endpointData(svc) @@ -77,15 +98,7 @@ func EndpointFile(genpkg string, service *expr.ServiceExpr, services *ServicesDa sections []*codegen.SectionTemplate ) { - imports := []*codegen.ImportSpec{ - {Path: "context"}, - {Path: "io"}, - {Path: "fmt"}, - codegen.GoaImport(""), - codegen.GoaImport("security"), - {Path: genpkg + "/" + svcName + "/" + "views", Name: svc.ViewsPkg}, - } - header := codegen.Header(service.Name+" endpoints", svc.PkgName, imports) + header := codegen.Header(facts.name+" endpoints", svc.PkgName, facts.imports.endpoint.Imports()) def := &codegen.SectionTemplate{ Name: "endpoints-struct", Source: serviceTemplates.Read(serviceEndpointsT), @@ -94,18 +107,11 @@ func EndpointFile(genpkg string, service *expr.ServiceExpr, services *ServicesDa sections = []*codegen.SectionTemplate{header, def} for _, m := range data.Methods { if m.ServerStream != nil { - // Generate endpoint input struct for streaming methods - // For JSON-RPC WebSocket with StreamingResult: generate struct (needed for stream handle) - // For JSON-RPC WebSocket without StreamingResult (client streaming only): no struct needed - // For JSON-RPC SSE: always generate struct (methods have stream params) - // For HTTP/gRPC: always generate endpoint input struct - if !m.IsJSONRPCWebSocket || m.ServerStream.EndpointStruct != "" { - sections = append(sections, &codegen.SectionTemplate{ - Name: "endpoint-input-struct", - Source: serviceTemplates.Read(serviceEndpointStreamStructT), - Data: m, - }) - } + sections = append(sections, &codegen.SectionTemplate{ + Name: "endpoint-input-struct", + Source: serviceTemplates.Read(serviceEndpointStreamStructT), + Data: m, + }) } if m.SkipRequestBodyEncodeDecode { sections = append(sections, &codegen.SectionTemplate{ @@ -157,36 +163,41 @@ func endpointData(svc *Data) *EndpointsData { names = append(names, streamArgName) } methods[i] = &EndpointMethodData{ - MethodData: m, - ArgName: argName, - StreamArgName: streamArgName, - ServiceName: svc.Name, - ServiceVarName: serviceInterfaceName, - ClientVarName: clientStructName, + MethodData: m, + ClientDeclaration: svc.ClientDeclaration, + ServiceDeclaration: svc.ServiceDeclaration, + ClientVarName: svc.ClientDeclaration.Name(), + ServiceVarName: svc.ServiceDeclaration.Name(), + ArgName: argName, + StreamArgName: streamArgName, + ServiceName: svc.Name, } } - desc := fmt.Sprintf("%s wraps the %q service endpoints.", endpointsStructName, svc.Name) + desc := fmt.Sprintf("%s wraps the %q service endpoints.", svc.EndpointsDeclaration.Name(), svc.Name) return &EndpointsData{ - Name: svc.Name, - Description: desc, - VarName: endpointsStructName, - ClientVarName: clientStructName, - ServiceVarName: serviceInterfaceName, - ClientInitArgs: strings.Join(names, ", "), - Methods: methods, - Schemes: svc.Schemes, - HasServerInterceptors: len(svc.ServerInterceptors) > 0, - HasClientInterceptors: len(svc.ClientInterceptors) > 0, + EndpointsDeclaration: svc.EndpointsDeclaration, + NewEndpointsDeclaration: svc.NewEndpointsDeclaration, + ClientDeclaration: svc.ClientDeclaration, + NewClientDeclaration: svc.NewClientDeclaration, + ServiceDeclaration: svc.ServiceDeclaration, + ServerInterceptorsDeclaration: svc.ServerInterceptorsDeclaration, + ClientInterceptorsDeclaration: svc.ClientInterceptorsDeclaration, + VarName: svc.EndpointsDeclaration.Name(), + ClientVarName: svc.ClientDeclaration.Name(), + ServiceVarName: svc.ServiceDeclaration.Name(), + Name: svc.Name, + Description: desc, + ClientInitArgs: strings.Join(names, ", "), + Methods: methods, + Schemes: svc.Schemes, + HasServerInterceptors: len(svc.ServerInterceptors) > 0, + HasClientInterceptors: len(svc.ClientInterceptors) > 0, } } func payloadVar(e *EndpointMethodData) string { if e.ServerStream != nil { - if e.ServerStream.EndpointStruct != "" { - return "ep.Payload" - } - // JSON-RPC WebSocket has no payload for server streaming - return "" + return "ep.Payload" } if e.SkipRequestBodyEncodeDecode { return "ep.Payload" diff --git a/codegen/service/endpoint_test.go b/codegen/service/endpoint_test.go index b42c12b235..0bc3e05d1d 100644 --- a/codegen/service/endpoint_test.go +++ b/codegen/service/endpoint_test.go @@ -40,9 +40,9 @@ func TestEndpoint(t *testing.T) { for _, c := range cases { t.Run(c.Name, func(t *testing.T) { root := codegen.RunDSL(t, c.DSL) - services := NewServicesData(root) + plan := mustServicePlan(t, root) require.Len(t, root.Services, 1) - fs := EndpointFile("goa.design/goa/example", root.Services[0], services) + fs := endpointFile(plan, plan.facts.services[0]) require.NotNil(t, fs) buf := new(bytes.Buffer) for _, s := range fs.SectionTemplates[1:] { diff --git a/codegen/service/example_generator_test.go b/codegen/service/example_generator_test.go new file mode 100644 index 0000000000..e9a8e61162 --- /dev/null +++ b/codegen/service/example_generator_test.go @@ -0,0 +1,74 @@ +// This file verifies that service analysis retains the exact mutable example +// generator owned by its generation run. +package service + +import ( + "testing" + + "github.com/stretchr/testify/require" + + "goa.design/goa/v3/codegen" + "goa.design/goa/v3/dsl" + "goa.design/goa/v3/eval" + "goa.design/goa/v3/expr" +) + +func TestServicesDataRetainsRunExampleGenerator(t *testing.T) { + root := codegen.RunDSL(t, func() { + dsl.Service("Values", func() { + dsl.Method("Read", func() { + dsl.Result(dsl.String) + }) + }) + }) + root.API.RandomizerFactory = expr.NewDeterministicRandomizerFactory() + generation, err := codegen.NewGeneration("generated.local/gen", []eval.Root{root}) + require.NoError(t, err) + examples := expr.NewExampleGenerator(root.API.RandomizerFactory) + plan, err := NewPlan(root, generation, examples) + require.NoError(t, err) + require.NoError(t, generation.Freeze()) + require.NoError(t, plan.Link()) + services := plan.Services() + require.NoError(t, err) + attribute := &expr.AttributeExpr{Type: expr.String} + method := root.Services[0].Methods[0] + owner := expr.MethodResultExampleIdentity(method) + require.Equal(t, "abc123", services.Example(attribute, owner)) + require.Equal(t, "abc123", services.FieldExample(attribute, attribute, "value", owner)) +} + +func TestRepeatedServiceReadsKeepAnonymousExamplesStable(t *testing.T) { + root := codegen.RunDSL(t, func() { + dsl.Service("Values", func() { + dsl.Method("Primitive", func() { + dsl.Payload(dsl.String) + dsl.Result(dsl.String) + }) + dsl.Method("Array", func() { + dsl.Payload(dsl.ArrayOf(dsl.String)) + dsl.Result(dsl.ArrayOf(dsl.Int)) + }) + dsl.Method("Map", func() { + dsl.Payload(dsl.MapOf(dsl.String, dsl.Int)) + dsl.Result(dsl.MapOf(dsl.Int, dsl.String)) + }) + }) + }) + generation, err := codegen.NewGeneration("generated.local/gen", []eval.Root{root}) + require.NoError(t, err) + examples := expr.NewExampleGenerator(root.API.RandomizerFactory) + plan, err := NewPlan(root, generation, examples) + require.NoError(t, err) + require.NoError(t, generation.Freeze()) + require.NoError(t, plan.Link()) + first := plan.Services() + second := plan.Services() + require.Len(t, first.Get("Values").Methods, 3) + require.Len(t, second.Get("Values").Methods, 3) + for index, firstMethod := range first.Get("Values").Methods { + secondMethod := second.Get("Values").Methods[index] + require.Equal(t, firstMethod.PayloadEx, secondMethod.PayloadEx, firstMethod.Name+" payload") + require.Equal(t, firstMethod.ResultEx, secondMethod.ResultEx, firstMethod.Name+" result") + } +} diff --git a/codegen/service/example_interceptors.go b/codegen/service/example_interceptors.go index 4580cfdc90..2221e97d1c 100644 --- a/codegen/service/example_interceptors.go +++ b/codegen/service/example_interceptors.go @@ -1,85 +1,104 @@ +// This file renders starter interceptor implementations that depend only on +// the service package and interceptor metadata, not service type packages. package service import ( "fmt" - "os" "path" "path/filepath" "goa.design/goa/v3/codegen" - "goa.design/goa/v3/expr" ) -// ExampleInterceptorsFiles returns the files for the example server and client interceptors. -func ExampleInterceptorsFiles(genpkg string, r *expr.RootExpr, services *ServicesData) []*codegen.File { +type ( + // exampleInterceptorData contains the generated type and constructor names + // plus service metadata rendered by one starter interceptor implementation. + exampleInterceptorData struct { + // ServiceName is the design service name described by the comments. + ServiceName string + // ServicePkg is the generated service package qualifier. + ServicePkg string + // StructDeclaration is the starter interceptor implementation type. + StructDeclaration *codegen.NameDeclaration + // ConstructorDeclaration creates StructDeclaration. + ConstructorDeclaration *codegen.NameDeclaration + // Interceptors contains the interceptor methods implemented by the type. + Interceptors []*InterceptorData + } +) + +// ExampleInterceptorsFiles returns starter server and client interceptor files +// for every service copied into plan. +func ExampleInterceptorsFiles(plan *Plan) []*codegen.File { var fw []*codegen.File - for _, svc := range r.Services { - if f := exampleInterceptorsFile(genpkg, svc, services); f != nil { + for _, facts := range plan.facts.services { + if f := exampleInterceptorsFile(plan, facts); f != nil { fw = append(fw, f...) } } return fw } -// exampleInterceptorsFile returns the example interceptors for the given service. -func exampleInterceptorsFile(genpkg string, svc *expr.ServiceExpr, services *ServicesData) []*codegen.File { - sdata := services.Get(svc.Name) - data := map[string]any{ - "ServiceName": sdata.Name, - "StructName": sdata.StructName, - "PkgName": "interceptors", - "ServerInterceptors": sdata.ServerInterceptors, - "ClientInterceptors": sdata.ClientInterceptors, +// exampleInterceptorsFile renders starter interceptors from one service copied +// into plan. +func exampleInterceptorsFile(plan *Plan, facts *serviceFacts) []*codegen.File { + if len(facts.serverInterceptors) == 0 && len(facts.clientInterceptors) == 0 { + return nil } + genpkg := plan.generation.GenPkg() + services := plan.Services() + sdata := services.Get(facts.name) + servicePath := path.Join(genpkg, sdata.PathName) + servicePkg := services.aliases.name(path.Join(path.Dir(genpkg), "interceptors"), servicePath) var files []*codegen.File - // Generate server interceptor if needed and file doesn't exist + // Generate the server interceptor starter when the service uses one. if len(sdata.ServerInterceptors) > 0 { + data := &exampleInterceptorData{ + ServiceName: sdata.Name, + ServicePkg: servicePkg, + StructDeclaration: facts.exampleServerStruct, + ConstructorDeclaration: facts.exampleServerConstructor, + Interceptors: sdata.ServerInterceptors, + } serverPath := filepath.Join("interceptors", sdata.PathName+"_server.go") - if _, err := os.Stat(serverPath); os.IsNotExist(err) { - files = append(files, &codegen.File{ - Path: serverPath, - SectionTemplates: []*codegen.SectionTemplate{ - codegen.Header(fmt.Sprintf("%s example server interceptors", sdata.Name), "interceptors", []*codegen.ImportSpec{ - {Path: "context"}, - {Path: "fmt"}, - {Path: "goa.design/clue/log"}, - codegen.GoaImport(""), - {Path: path.Join(genpkg, sdata.PathName), Name: sdata.PkgName}, - }), - { - Name: "example-server-interceptor", - Source: serviceTemplates.Read(exampleServerInterceptorT), - Data: data, - }, + files = append(files, &codegen.File{ + Path: serverPath, + SectionTemplates: []*codegen.SectionTemplate{ + codegen.Header(fmt.Sprintf("%s example server interceptors", sdata.Name), "interceptors", facts.imports.exampleServerInterceptors.Imports()), + { + Name: "example-server-interceptor", + Source: serviceTemplates.Read(exampleServerInterceptorT), + Data: data, }, - }) - } + }, + SkipExist: true, + }) } - // Generate client interceptor if needed and file doesn't exist + // Generate the client interceptor starter when the service uses one. if len(sdata.ClientInterceptors) > 0 { + data := &exampleInterceptorData{ + ServiceName: sdata.Name, + ServicePkg: servicePkg, + StructDeclaration: facts.exampleClientStruct, + ConstructorDeclaration: facts.exampleClientConstructor, + Interceptors: sdata.ClientInterceptors, + } clientPath := filepath.Join("interceptors", sdata.PathName+"_client.go") - if _, err := os.Stat(clientPath); os.IsNotExist(err) { - files = append(files, &codegen.File{ - Path: clientPath, - SectionTemplates: []*codegen.SectionTemplate{ - codegen.Header(fmt.Sprintf("%s example client interceptors", sdata.Name), "interceptors", []*codegen.ImportSpec{ - {Path: "context"}, - {Path: "fmt"}, - {Path: "goa.design/clue/log"}, - codegen.GoaImport(""), - {Path: path.Join(genpkg, sdata.PathName), Name: sdata.PkgName}, - }), - { - Name: "example-client-interceptor", - Source: serviceTemplates.Read(exampleClientInterceptorT), - Data: data, - }, + files = append(files, &codegen.File{ + Path: clientPath, + SectionTemplates: []*codegen.SectionTemplate{ + codegen.Header(fmt.Sprintf("%s example client interceptors", sdata.Name), "interceptors", facts.imports.exampleClientInterceptors.Imports()), + { + Name: "example-client-interceptor", + Source: serviceTemplates.Read(exampleClientInterceptorT), + Data: data, }, - }) - } + }, + SkipExist: true, + }) } return files diff --git a/codegen/service/example_interceptors_test.go b/codegen/service/example_interceptors_test.go index c4e69999f5..f534a5d0a6 100644 --- a/codegen/service/example_interceptors_test.go +++ b/codegen/service/example_interceptors_test.go @@ -1,3 +1,5 @@ +// This file verifies the starter server and client interceptor files generated +// from API, service, and method interceptor declarations. package service import ( @@ -10,6 +12,7 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "goa.design/goa/v3/codegen" "goa.design/goa/v3/codegen/service/testdata" ) @@ -84,12 +87,13 @@ func TestExampleInterceptorsFiles(t *testing.T) { t.Run(c.Name, func(t *testing.T) { // Run DSL root := runDSL(t, c.DSL) - services := NewServicesData(root) + plan := mustServicePlan(t, root) require.NotNil(t, root) // Generate files - fs := ExampleInterceptorsFiles("", root, services) + fs := ExampleInterceptorsFiles(plan) require.Len(t, fs, len(c.ExpectedFiles)) + assertExampleInterceptorDeclarations(t, plan, fs) // Verify file paths paths := make([]string, len(fs)) @@ -121,3 +125,37 @@ func TestExampleInterceptorsFiles(t *testing.T) { }) } } + +func TestServerInterceptorConstructorIsAvailableToExampleMain(t *testing.T) { + root := runDSL(t, testdata.ServerInterceptorExampleDSL) + plan := mustServicePlan(t, root) + facts := plan.facts.services[0] + + require.Same( + t, + facts.exampleServerConstructor, + plan.Services().Get(facts.name).ExampleServerInterceptorsConstructorDeclaration, + ) +} + +// assertExampleInterceptorDeclarations verifies that starter definitions and +// constructor bodies use the exact declarations retained by the service plan. +func assertExampleInterceptorDeclarations(t *testing.T, plan *Plan, files []*codegen.File) { + t.Helper() + retained := make(map[*codegen.NameDeclaration]*codegen.NameDeclaration) + for _, facts := range plan.facts.services { + if facts.exampleServerStruct != nil { + retained[facts.exampleServerStruct] = facts.exampleServerConstructor + } + if facts.exampleClientStruct != nil { + retained[facts.exampleClientStruct] = facts.exampleClientConstructor + } + } + for _, file := range files { + data, ok := file.SectionTemplates[1].Data.(*exampleInterceptorData) + require.True(t, ok) + constructor, ok := retained[data.StructDeclaration] + require.True(t, ok, "starter interceptor struct was not retained by the plan") + require.Same(t, constructor, data.ConstructorDeclaration) + } +} diff --git a/codegen/service/example_svc.go b/codegen/service/example_svc.go index 1253fc5c0b..aabf6d12d4 100644 --- a/codegen/service/example_svc.go +++ b/codegen/service/example_svc.go @@ -1,9 +1,9 @@ +// This file renders starter service implementations and imports only the +// generated types referenced by each implementation's service methods. package service import ( - "os" "path" - "strings" "goa.design/goa/v3/codegen" "goa.design/goa/v3/expr" @@ -30,79 +30,65 @@ type ( // StreamInterface is the stream interface in the service package used // by the endpoint implementation. StreamInterface string + // ExampleStructDeclaration is the starter implementation receiver. + ExampleStructDeclaration *codegen.NameDeclaration } -) -// ExampleServiceFiles returns a basic service implementation for every -// service expression. -func ExampleServiceFiles(genpkg string, root *expr.RootExpr, services *ServicesData) []*codegen.File { - // determine the unique API package name different from the service names - scope := codegen.NewNameScope() - for _, svc := range root.Services { - s := services.Get(svc.Name) - if s == nil { - panic("unknown service, " + svc.Name) // bug - } - scope.Unique(s.PkgName) + // exampleServiceData separates the generated service package declaration + // name from the qualifier used by this example file. + exampleServiceData struct { + *Data + // ServicePkg is the import name used for the generated service package in + // this example file. + ServicePkg string } - apipkg := scope.Unique(strings.ToLower(codegen.Goify(root.API.Name, false)), "api") +) +// ExampleServiceFiles returns a basic implementation for every service +// copied into plan. +func ExampleServiceFiles(plan *Plan) []*codegen.File { var fw []*codegen.File - for _, svc := range root.Services { - if f := exampleServiceFile(genpkg, root, svc, services, apipkg); f != nil { + for _, facts := range plan.facts.services { + if f := exampleServiceFile(plan, facts, plan.facts.examplePackageName); f != nil { fw = append(fw, f) } } return fw } -// exampleServiceFile returns a basic implementation of the given service. -func exampleServiceFile(genpkg string, _ *expr.RootExpr, svc *expr.ServiceExpr, services *ServicesData, apipkg string) *codegen.File { - data := services.Get(svc.Name) +// exampleServiceFile renders a basic implementation from one service copied +// into plan. +func exampleServiceFile(plan *Plan, facts *serviceFacts, apipkg string) *codegen.File { + genpkg := plan.generation.GenPkg() + services := plan.Services() + data := services.Get(facts.name) svcName := data.PathName + servicePath := path.Join(genpkg, svcName) + servicePkg := services.aliases.name(path.Dir(genpkg), servicePath) + renderData := &exampleServiceData{Data: data, ServicePkg: servicePkg} fpath := svcName + ".go" - if _, err := os.Stat(fpath); !os.IsNotExist(err) { - return nil // file already exists, skip it. - } - specs := []*codegen.ImportSpec{ - {Path: "io"}, - {Path: "context"}, - {Path: "fmt"}, - {Path: "strings"}, - {Path: path.Join(genpkg, svcName), Name: data.PkgName}, - {Path: "goa.design/clue/log"}, - {Path: "goa.design/goa/v3/security"}, - } sections := []*codegen.SectionTemplate{ - codegen.Header("", apipkg, specs), + codegen.Header("", apipkg, facts.imports.exampleService.Imports()), { Name: "basic-service-struct", Source: serviceTemplates.Read(exampleServiceStructT), - Data: data, + Data: renderData, }, { Name: "basic-service-init", Source: serviceTemplates.Read(exampleServiceInitT), - Data: data, + Data: renderData, }, } if len(data.Schemes) > 0 { sections = append(sections, &codegen.SectionTemplate{ Name: "security-authfuncs", Source: serviceTemplates.Read(exampleSecurityAuthfuncsT), - Data: data, + Data: renderData, }) } - for _, m := range svc.Methods { - sections = append(sections, basicEndpointSection(m, data)) - } - - // Add HandleStream method for JSON-RPC WebSocket services (not SSE) - if hasJSONRPCWebSocket(data) { - sections = append(sections, &codegen.SectionTemplate{ - Name: "jsonrpc-handle-stream", - Source: serviceTemplates.Read(jsonrpcHandleStreamT), - Data: data, - }) + outputPath := path.Dir(genpkg) + for _, method := range facts.orderedMethods { + sections = append(sections, basicEndpointSection(method, data, outputPath, services.aliases, servicePkg)) } return &codegen.File{ @@ -112,31 +98,33 @@ func exampleServiceFile(genpkg string, _ *expr.RootExpr, svc *expr.ServiceExpr, } } -// basicEndpointSection returns a section with a basic implementation for the -// given method. -func basicEndpointSection(m *expr.MethodExpr, svcData *Data) *codegen.SectionTemplate { - md := svcData.Method(m.Name) +// basicEndpointSection returns a starter implementation whose payload and +// result references come from the method records after all generated package +// and declaration names have been chosen. +func basicEndpointSection(facts *methodFacts, svcData *Data, outputPath string, aliases *importAliases, servicePkg string) *codegen.SectionTemplate { + md := svcData.Method(facts.name) ed := &basicEndpointData{ - MethodData: md, - ServiceVarName: svcData.VarName, + MethodData: md, + ServiceVarName: svcData.VarName, + ExampleStructDeclaration: svcData.ExampleStructDeclaration, } - if m.Payload.Type != expr.Empty { - ed.PayloadFullRef = svcData.Scope.GoFullTypeRef(m.Payload, svcData.PkgName) + if facts.payload != nil && facts.payload.layout.Kind() != codegen.GoEmpty { + ed.PayloadFullRef = facts.payload.layout.Link(outputPath, retainedTypeQualifier(aliases, outputPath)).Ref() } - if m.Result.Type != expr.Empty { - ed.ResultFullName = svcData.Scope.GoFullTypeName(m.Result, svcData.PkgName) - ed.ResultFullRef = svcData.Scope.GoFullTypeRef(m.Result, svcData.PkgName) - ed.ResultIsStruct = expr.IsObject(m.Result.Type) + if facts.result != nil && facts.result.layout.Kind() != codegen.GoEmpty { + linked := facts.result.layout.Link(outputPath, retainedTypeQualifier(aliases, outputPath)) + ed.ResultFullName = linked.Name() + ed.ResultFullRef = linked.Ref() + ed.ResultIsStruct = facts.result.isObject if md.ViewedResult != nil { - view := expr.DefaultView - if v, ok := m.Result.Meta.Last(expr.ViewMetaKey); ok { - view = v + ed.ResultView = facts.viewedResult.viewName + if ed.ResultView == "" { + ed.ResultView = expr.DefaultView } - ed.ResultView = view } } if md.ServerStream != nil { - ed.StreamInterface = svcData.PkgName + "." + md.ServerStream.Interface + ed.StreamInterface = servicePkg + "." + md.ServerStreamDeclaration.Name() } return &codegen.SectionTemplate{ Name: "basic-endpoint", diff --git a/codegen/service/example_svc_test.go b/codegen/service/example_svc_test.go index 0de9c87dad..b52f13469e 100644 --- a/codegen/service/example_svc_test.go +++ b/codegen/service/example_svc_test.go @@ -1,3 +1,5 @@ +// This file verifies the starter service implementations generated from +// normalized service methods and their frozen package references. package service import ( @@ -9,6 +11,7 @@ import ( "goa.design/goa/v3/codegen" "goa.design/goa/v3/codegen/service/testdata" + "goa.design/goa/v3/codegen/testutil" ) func TestExampleServiceFiles(t *testing.T) { @@ -32,9 +35,9 @@ func TestExampleServiceFiles(t *testing.T) { for _, c := range cases { t.Run(c.Name, func(t *testing.T) { root := codegen.RunDSL(t, c.DSL) - services := NewServicesData(root) + plan := mustServicePlan(t, root) require.Len(t, root.Services, 3) - fs := ExampleServiceFiles("", root, services) + fs := ExampleServiceFiles(plan) require.Len(t, fs, 3) for _, f := range fs { require.Greater(t, len(f.SectionTemplates), 0) @@ -48,4 +51,24 @@ func TestExampleServiceFiles(t *testing.T) { }) } }) + + t.Run("mixed result methods", func(t *testing.T) { + cases := []struct { + Name string + DSL func() + Golden string + }{ + {"result and stream", testdata.MixedResultsEndpointDSL, "testdata/golden/example_service-mixed-results.go.golden"}, + {"result view and stream", testdata.MixedResultsWithViewsEndpointDSL, "testdata/golden/example_service-mixed-results-with-views.go.golden"}, + } + for _, c := range cases { + t.Run(c.Name, func(t *testing.T) { + root := codegen.RunDSL(t, c.DSL) + plan := mustServicePlan(t, root) + files := ExampleServiceFiles(plan) + require.Len(t, files, 1) + testutil.AssertGo(t, c.Golden, renderSections(t, files[0].SectionTemplates)) + }) + } + }) } diff --git a/codegen/service/generated_emission.go b/codegen/service/generated_emission.go new file mode 100644 index 0000000000..0df4d57aaa --- /dev/null +++ b/codegen/service/generated_emission.go @@ -0,0 +1,183 @@ +// This file attaches service template data to types and Goa OneOf unions that +// are written outside their service package. The generation command selects +// each output package and declaration before source is written. +package service + +import ( + "fmt" + "strings" + + "goa.design/goa/v3/codegen" +) + +// generatedPackage returns the template data collected for one generated Go +// package. It creates an empty container when this is the package's first type +// or union. +func (d *ServicesData) generatedPackage(importPath string) *generatedPackageData { + owner := d.generation.Package(importPath) + if generatedPackage, ok := d.packages[owner]; ok { + return generatedPackage + } + generatedPackage := &generatedPackageData{ + types: make(map[*codegen.TypeDeclaration]*generatedTypeData), + unions: make(map[*codegen.UnionDeclaration]*UnionTypeData), + } + d.packages[owner] = generatedPackage + return generatedPackage +} + +// registerPackageData associates each selected type and union declaration with +// the template section and imports written to its generated package. +func (d *ServicesData) registerPackageData() { + for _, emission := range d.facts.generatedTypes { + section, errorSection := generatedTypeSections(emission) + generatedPackage := d.generatedPackage(emission.declaration.PackagePath()) + generatedPackage.types[emission.declaration] = &generatedTypeData{ + declaration: emission.declaration, + location: emission.location, + imports: emission.service.generatedTypeImports[emission.declaration].Imports(), + section: section, + error: errorSection, + } + } + for _, emission := range d.facts.generatedUnions { + generatedPackage := d.generatedPackage(emission.union.declaration.PackagePath()) + generatedPackage.unions[emission.union.declaration] = emission.union.data + generatedPackage.unionImports = appendImportSpecs( + generatedPackage.unionImports, + emission.union.imports.Imports(), + ) + } +} + +// generatedTypeSections returns the template sections for one selected payload, +// result, error, or user type. +func generatedTypeSections(emission *generatedTypeEmissionFacts) (*codegen.SectionTemplate, *codegen.SectionTemplate) { + if emission.method != nil { + var methodData *MethodData + for index, method := range emission.service.orderedMethods { + if method == emission.method { + methodData = emission.service.data.Methods[index] + break + } + } + switch emission.kind { + case generatedPayloadEmission: + return &codegen.SectionTemplate{Name: "service-payload", Source: serviceTemplates.Read(payloadT), Data: methodData}, nil + case generatedStreamingPayloadEmission: + return &codegen.SectionTemplate{Name: "service-streaming-payload", Source: serviceTemplates.Read(streamingPayloadT), Data: methodData}, nil + case generatedResultEmission: + return &codegen.SectionTemplate{Name: "service-result", Source: serviceTemplates.Read(resultT), Data: methodData}, nil + case generatedStreamingResultEmission: + return &codegen.SectionTemplate{ + Name: "service-streaming-result", + Source: serviceTemplates.Read(resultT), + Data: map[string]any{ + "Result": methodData.StreamingResult, + "ResultDef": methodData.StreamingResultDef, + "ResultDesc": methodData.StreamingResultDesc, + }, + }, nil + } + } + data := generatedUserTypeData(emission) + name := "service-user-type" + if emission.error { + name = "error-user-type" + } + section := &codegen.SectionTemplate{Name: name, Source: serviceTemplates.Read(userTypeT), Data: data} + if !emission.error { + return section, nil + } + return section, &codegen.SectionTemplate{Name: "service-error", Source: serviceTemplates.Read(errorT), Data: data} +} + +// generatedUserTypeData returns the template data for one authored type +// declaration selected for a generated package. +func generatedUserTypeData(emission *generatedTypeEmissionFacts) *UserTypeData { + for _, candidate := range emission.service.data.userTypes { + if candidate.Declaration == emission.declaration { + data := *candidate + if data.Description == "" { + data.Description = generatedUserTypeDescription(emission) + } + return &data + } + } + panic(fmt.Sprintf("generated type %q has no linked render data", emission.declaration.Name())) +} + +// generatedUserTypeDescription explains where an authored type is used. A +// nested type has no method role of its own. +func generatedUserTypeDescription(emission *generatedTypeEmissionFacts) string { + name := emission.declaration.Name() + if len(emission.uses) == 0 { + return fmt.Sprintf("%s is a named type defined in the service design.", name) + } + if len(emission.uses) == 1 { + use := emission.uses[0] + return fmt.Sprintf( + "%s is the %s type of the %s service %s method.", + name, + generatedTypeRoleNames(use.roles), + use.service, + use.method, + ) + } + var description strings.Builder + fmt.Fprintf(&description, "%s is used by these service methods:", name) + for _, use := range emission.uses { + fmt.Fprintf( + &description, + "\n- %s %s: %s", + use.service, + use.method, + generatedTypeRoleNames(use.roles), + ) + } + return description.String() +} + +// generatedTypeRoleNames joins the method fields that use one authored type. +func generatedTypeRoleNames(roles generatedTypeMethodRoles) string { + names := make([]string, 0, 4) + for _, role := range []struct { + value generatedTypeMethodRoles + name string + }{ + {generatedPayloadRole, "payload"}, + {generatedStreamingPayloadRole, "streaming payload"}, + {generatedResultRole, "result"}, + {generatedStreamingResultRole, "streaming result"}, + } { + if roles&role.value != 0 { + names = append(names, role.name) + } + } + if len(names) == 1 { + return names[0] + } + if len(names) == 2 { + return names[0] + " and " + names[1] + } + return strings.Join(names[:len(names)-1], ", ") + ", and " + names[len(names)-1] +} + +// copyGeneratedLocation copies the requested package and file so later changes +// to the design expression cannot change where the type is written. +func copyGeneratedLocation(location *codegen.Location) *codegen.Location { + if location == nil { + return nil + } + copy := *location + return © +} + +// sameGeneratedLocation compares the explicit package and file selected for a +// generated declaration. +func sameGeneratedLocation(left, right *codegen.Location) bool { + if left == nil || right == nil { + return left == right + } + return left.RelImportPath == right.RelImportPath && left.FilePath == right.FilePath +} diff --git a/codegen/service/generated_package.go b/codegen/service/generated_package.go new file mode 100644 index 0000000000..fef35bc069 --- /dev/null +++ b/codegen/service/generated_package.go @@ -0,0 +1,1128 @@ +// This file assigns service types and unions to the generated packages that +// write them. Each generated package writes each declaration once. +package service + +import ( + "fmt" + "path" + "slices" + "sort" + "strings" + + "goa.design/goa/v3/codegen" + "goa.design/goa/v3/expr" +) + +type ( + // generatedTypeEmissionKind identifies the template that writes one type + // outside its service package. + generatedTypeEmissionKind uint8 + + // generatedTypeEmissionFacts records one type declaration written outside its + // service package and the service data used to render it. + generatedTypeEmissionFacts struct { + kind generatedTypeEmissionKind + declaration *codegen.TypeDeclaration + location *codegen.Location + root *rootFacts + service *serviceFacts + method *methodFacts + attribute *methodAttributeFacts + userType *userTypeFacts + uses []generatedTypeMethodUse + error bool + } + + // generatedTypeMethodUse records how one service method directly uses an + // authored type written outside the service package. + generatedTypeMethodUse struct { + service string + method string + roles generatedTypeMethodRoles + } + + // generatedTypeMethodRoles records the method fields whose declared type is + // the authored type being written. + generatedTypeMethodRoles uint8 + + // generatedUnionEmissionFacts records one Goa OneOf declaration and the data + // used to write it outside its service package. + generatedUnionEmissionFacts struct { + root *rootFacts + service *serviceFacts + union *unionFacts + } + + // plannedAttribute records one service field and the Go package used by child + // types that do not declare their own struct:pkg:path location. + plannedAttribute struct { + attribute *expr.AttributeExpr + service *serviceFacts + location *codegen.Location + } + + // plannedUserType identifies one user type written to one generated package. + // Extend may copy the same expression into more than one package. + plannedUserType struct { + userType expr.UserType + owner *codegen.GeneratedPackage + } + + // rootTypeSet maps compiler-created copies back to the user type declared in + // the same design. Generated Goa OneOf branch aliases are not included. + rootTypeSet struct { + byOrigin map[expr.UserType]expr.UserType + } + + // generatedPackageData stores the render data emitted into one Go package. + generatedPackageData struct { + types map[*codegen.TypeDeclaration]*generatedTypeData + unions map[*codegen.UnionDeclaration]*UnionTypeData + unionImports []*codegen.ImportSpec + } + + // generatedTypeData stores one user-type declaration placed in the file + // selected by its metadata, plus optional error behavior. + generatedTypeData struct { + declaration *codegen.TypeDeclaration + location *codegen.Location + imports []*codegen.ImportSpec + section *codegen.SectionTemplate + error *codegen.SectionTemplate + } +) + +const ( + generatedPayloadEmission generatedTypeEmissionKind = iota + 1 + generatedStreamingPayloadEmission + generatedResultEmission + generatedStreamingResultEmission + generatedUserTypeEmission +) + +const ( + generatedPayloadRole generatedTypeMethodRoles = 1 << iota + generatedStreamingPayloadRole + generatedResultRole + generatedStreamingResultRole +) + +// collectServiceDeclarations submits every user type and Goa OneOf declaration +// reachable from root. It submits authored type names first, so generated union +// names receive a number when both request the same Go name. +func collectServiceDeclarations(facts *rootFacts, generation *codegen.Generation) error { + if !generation.HasRoot(facts.root) { + return rootMembershipError(facts.root) + } + inputs := planningInputs(facts) + rootTypes := facts.rootTypes + for _, serviceFacts := range facts.services { + // Record the service package now. File building reads its Go names after all + // generators finish submitting declarations. + if _, err := generation.ClaimPackage(serviceFacts.packagePath); err != nil { + return err + } + } + methodTypes, err := planMethodTypes(facts, generation) + if err != nil { + return err + } + + seenTypes := make(map[plannedUserType]struct{}) + for _, input := range inputs { + if err := planUserTypes(input.attribute, input.service, input.location, generation, rootTypes, methodTypes, seenTypes); err != nil { + return err + } + } + + seenTypes = make(map[plannedUserType]struct{}) + for _, input := range inputs { + if err := planUnions(input.attribute, input.service, input.location, generation, rootTypes, seenTypes); err != nil { + return err + } + } + if err := planViews(facts, generation); err != nil { + return err + } + return nil +} + +// collectGeneratedPackageEmissions selects the one service that supplies the +// definition for each type and Goa OneOf declaration shared across the designs +// in this generation command. +func collectGeneratedPackageEmissions(roots []*rootFacts) error { + types := make(map[*codegen.TypeDeclaration]*generatedTypeEmissionFacts) + unions := make(map[*codegen.UnionDeclaration]*generatedUnionEmissionFacts) + for _, root := range roots { + root.generatedTypes = nil + root.generatedUnions = nil + for _, service := range root.services { + errorTypes := make(map[*codegen.TypeDeclaration]struct{}, len(service.errorTypes)) + for _, errorType := range service.errorTypes { + errorTypes[errorType.declaration] = struct{}{} + } + for _, union := range service.unions { + emission := &generatedUnionEmissionFacts{root: root, service: service, union: union} + if existing := unions[union.declaration]; existing != nil { + if err := validateGeneratedUnionEmission(existing, emission); err != nil { + return err + } + if generatedUnionEmissionLess(emission, existing) { + unions[union.declaration] = emission + } + } else { + unions[union.declaration] = emission + } + } + for _, method := range service.orderedMethods { + candidates := []struct { + kind generatedTypeEmissionKind + attribute *methodAttributeFacts + }{ + {generatedPayloadEmission, method.payload}, + {generatedStreamingPayloadEmission, method.streamingPayload}, + {generatedResultEmission, method.result}, + } + if method.hasMixedResults { + candidates = append(candidates, struct { + kind generatedTypeEmissionKind + attribute *methodAttributeFacts + }{generatedStreamingResultEmission, method.streamingResult}) + } + for _, candidate := range candidates { + attribute := candidate.attribute + if attribute == nil || attribute.location == nil || !attribute.normalized || attribute.definition == nil { + continue + } + emission := &generatedTypeEmissionFacts{ + kind: candidate.kind, + declaration: attribute.layout.TypeDeclaration(), + location: copyGeneratedLocation(attribute.location), + root: root, + service: service, + method: method, + attribute: attribute, + } + if err := selectGeneratedTypeEmission(types, emission); err != nil { + return err + } + } + } + for _, userType := range service.userTypes { + if userType.location == nil { + continue + } + emission := &generatedTypeEmissionFacts{ + kind: generatedUserTypeEmission, + declaration: userType.declaration, + location: copyGeneratedLocation(userType.location), + root: root, + service: service, + userType: userType, + uses: generatedTypeMethodUses(service, userType.declaration), + } + _, emission.error = errorTypes[userType.declaration] + if err := selectGeneratedTypeEmission(types, emission); err != nil { + return err + } + } + } + } + for _, emission := range types { + emission.root.generatedTypes = append(emission.root.generatedTypes, emission) + } + for _, emission := range unions { + emission.root.generatedUnions = append(emission.root.generatedUnions, emission) + } + for _, root := range roots { + sort.Slice(root.generatedTypes, func(i, j int) bool { + return generatedTypeEmissionLess(root.generatedTypes[i], root.generatedTypes[j]) + }) + sort.Slice(root.generatedUnions, func(i, j int) bool { + return generatedUnionEmissionLess(root.generatedUnions[i], root.generatedUnions[j]) + }) + } + return nil +} + +// selectGeneratedTypeEmission keeps one copy when two services would write the +// same type declaration and rejects them when their definitions differ. +func selectGeneratedTypeEmission(selected map[*codegen.TypeDeclaration]*generatedTypeEmissionFacts, candidate *generatedTypeEmissionFacts) error { + existing := selected[candidate.declaration] + if existing == nil { + selected[candidate.declaration] = candidate + return nil + } + if err := validateGeneratedTypeEmission(existing, candidate); err != nil { + return err + } + uses := mergeGeneratedTypeMethodUses(existing.uses, candidate.uses) + errorBehavior := existing.error || candidate.error + if generatedTypeEmissionLess(candidate, existing) { + candidate.uses = uses + candidate.error = errorBehavior + selected[candidate.declaration] = candidate + } else { + existing.uses = uses + existing.error = errorBehavior + } + return nil +} + +// generatedTypeMethodUses records each method that declares the authored type +// as its payload, result, or streamed value. Types used only inside another +// type do not match these declarations. +func generatedTypeMethodUses(service *serviceFacts, declaration *codegen.TypeDeclaration) []generatedTypeMethodUse { + uses := make([]generatedTypeMethodUse, 0, len(service.orderedMethods)) + for _, method := range service.orderedMethods { + var roles generatedTypeMethodRoles + roles = addGeneratedTypeMethodRole(roles, method.payload, declaration, generatedPayloadRole) + roles = addGeneratedTypeMethodRole(roles, method.streamingPayload, declaration, generatedStreamingPayloadRole) + roles = addGeneratedTypeMethodRole(roles, method.result, declaration, generatedResultRole) + roles = addGeneratedTypeMethodRole(roles, method.streamingResult, declaration, generatedStreamingResultRole) + if roles == 0 { + continue + } + uses = append(uses, generatedTypeMethodUse{ + service: service.name, + method: method.name, + roles: roles, + }) + } + return mergeGeneratedTypeMethodUses(nil, uses) +} + +// addGeneratedTypeMethodRole records role when the method field directly uses +// declaration. +func addGeneratedTypeMethodRole(roles generatedTypeMethodRoles, attribute *methodAttributeFacts, declaration *codegen.TypeDeclaration, role generatedTypeMethodRoles) generatedTypeMethodRoles { + if attribute != nil && attribute.layout != nil && attribute.layout.TypeDeclaration() == declaration { + return roles | role + } + return roles +} + +// mergeGeneratedTypeMethodUses combines the methods found through different +// services or design roots and returns them in a stable order. +func mergeGeneratedTypeMethodUses(left, right []generatedTypeMethodUse) []generatedTypeMethodUse { + uses := append(append(make([]generatedTypeMethodUse, 0, len(left)+len(right)), left...), right...) + sort.Slice(uses, func(i, j int) bool { + if uses[i].service != uses[j].service { + return uses[i].service < uses[j].service + } + return uses[i].method < uses[j].method + }) + merged := uses[:0] + for _, use := range uses { + last := len(merged) - 1 + if last >= 0 && merged[last].service == use.service && merged[last].method == use.method { + merged[last].roles |= use.roles + continue + } + merged = append(merged, use) + } + return merged +} + +// validateGeneratedTypeEmission returns an error when two services would write +// different definitions for the same generated Go type declaration. +func validateGeneratedTypeEmission(left, right *generatedTypeEmissionFacts) error { + if left.kind != right.kind || !sameGeneratedLocation(left.location, right.location) || + !sameGeneratedTypeEmissionSource(left, right) || + !sameGeneratedTypeEmissionContent(left, right) || + !generatedTypeEmissionLayout(left).Equivalent(generatedTypeEmissionLayout(right)) || + !slices.Equal( + left.service.generatedTypeImports[left.declaration].Paths(), + right.service.generatedTypeImports[right.declaration].Paths(), + ) { + return fmt.Errorf( + "conflicting generated type emission in package %q: roles %d and %d, sources %q and %q", + left.declaration.PackagePath(), + left.kind, + right.kind, + generatedTypeEmissionName(left), + generatedTypeEmissionName(right), + ) + } + return nil +} + +// generatedTypeEmissionLayout returns the Go type definition supplied by one +// service. References from other service files do not affect which definition +// is written. +func generatedTypeEmissionLayout(emission *generatedTypeEmissionFacts) *codegen.GoTypePlan { + if emission.userType != nil { + return emission.userType.layout + } + return emission.attribute.definition +} + +// sameGeneratedTypeEmissionContent reports whether two services would write +// the same declaration text. Error methods may be added by either service. +func sameGeneratedTypeEmissionContent(left, right *generatedTypeEmissionFacts) bool { + if left.userType != nil || right.userType != nil { + return left.userType != nil && right.userType != nil && + left.userType.name == right.userType.name && + left.userType.description == right.userType.description && + left.userType.errorName == right.userType.errorName && + left.userType.serviceError == right.userType.serviceError + } + return left.service.packagePath == right.service.packagePath && + left.method.name == right.method.name && + left.attribute.description == right.attribute.description +} + +// sameGeneratedTypeEmissionSource reports whether two candidates came from the +// same authored type. Generated aliases for the same Goa OneOf branch also +// match because the package already owns one declaration for that branch. +func sameGeneratedTypeEmissionSource(left, right *generatedTypeEmissionFacts) bool { + if generatedTypeEmissionOrigin(left) == generatedTypeEmissionOrigin(right) { + return true + } + return left.userType != nil && right.userType != nil && + !left.root.rootTypes.contains(left.userType.userType) && + !right.root.rootTypes.contains(right.userType.userType) +} + +// generatedTypeEmissionName describes the design type that caused a conflict +// while selecting one generated definition. +func generatedTypeEmissionName(emission *generatedTypeEmissionFacts) string { + origin := generatedTypeEmissionOrigin(emission) + if origin == nil { + return "" + } + return origin.Name() +} + +// validateGeneratedUnionEmission returns an error when two services would +// write the same Goa OneOf declaration in different files or with different +// fields. +func validateGeneratedUnionEmission(left, right *generatedUnionEmissionFacts) error { + if left.union.declaration != right.union.declaration || + left.union.identity != right.union.identity || + left.union.typeKey != right.union.typeKey || + left.union.valueKey != right.union.valueKey || + generatedLocationPath(left.union.location) != generatedLocationPath(right.union.location) || + !sameGeneratedUnionBranches(left.union.branches, right.union.branches) || + !slices.Equal(left.union.imports.Paths(), right.union.imports.Paths()) { + return fmt.Errorf( + "conflicting generated union emission in package %q: declarations equal=%t, keys %q/%q and %q/%q", + left.union.declaration.PackagePath(), + left.union.declaration == right.union.declaration, + left.union.typeKey, + left.union.valueKey, + right.union.typeKey, + right.union.valueKey, + ) + } + return nil +} + +// sameGeneratedUnionBranches reports whether two services would write the same +// branch fields, constructors, validation, and JSON functions for one Goa OneOf +// declaration. +func sameGeneratedUnionBranches(left, right []*unionBranchFacts) bool { + if len(left) != len(right) { + return false + } + for index := range left { + leftBranch, rightBranch := left[index], right[index] + if leftBranch.name != rightBranch.name || + leftBranch.fieldName != rightBranch.fieldName || + leftBranch.declaration != rightBranch.declaration || + !leftBranch.layout.Equivalent(rightBranch.layout) || + leftBranch.nilable != rightBranch.nilable || + leftBranch.emitPrimitiveAlias != rightBranch.emitPrimitiveAlias || + leftBranch.primitiveAliasType != rightBranch.primitiveAliasType { + return false + } + } + return true +} + +// generatedLocationPath returns the generated package selected by location. +// Union declarations always emit in unions.go, so their enclosing type's file +// name does not affect which generated package contains the union. +func generatedLocationPath(location *codegen.Location) string { + if location == nil { + return "" + } + return location.RelImportPath +} + +// generatedTypeEmissionOrigin returns the exact authored or normalized source +// whose layout defines an emitted declaration. +func generatedTypeEmissionOrigin(emission *generatedTypeEmissionFacts) expr.UserType { + if emission.userType != nil { + return emission.userType.userType.Origin() + } + if userType, ok := emission.attribute.attribute.Type.(expr.UserType); ok { + return userType.Origin() + } + return nil +} + +// generatedTypeEmissionLess orders equal definitions by service and method +// names, so walking designs in another order does not change the selected copy. +func generatedTypeEmissionLess(left, right *generatedTypeEmissionFacts) bool { + if left.declaration.PackagePath() != right.declaration.PackagePath() { + return left.declaration.PackagePath() < right.declaration.PackagePath() + } + if left.location.FilePath != right.location.FilePath { + return left.location.FilePath < right.location.FilePath + } + if left.service.packagePath != right.service.packagePath { + return left.service.packagePath < right.service.packagePath + } + leftMethod, rightMethod := "", "" + if left.method != nil { + leftMethod = left.method.name + } + if right.method != nil { + rightMethod = right.method.name + } + return leftMethod < rightMethod +} + +// generatedUnionEmissionLess orders equal Goa OneOf definitions by package and +// service names. +func generatedUnionEmissionLess(left, right *generatedUnionEmissionFacts) bool { + if left.union.declaration.PackagePath() != right.union.declaration.PackagePath() { + return left.union.declaration.PackagePath() < right.union.declaration.PackagePath() + } + return left.service.packagePath < right.service.packagePath +} + +// rootMembershipError reports an attempt to generate files from a design that +// was not supplied to this generation command. +func rootMembershipError(root *expr.RootExpr) error { + return fmt.Errorf("service root %p does not belong to the generation", root) +} + +// planMethodTypes submits the payload and result wrapper types created for raw +// object definitions. Authored user types are submitted separately and keep +// their requested names when no declaration conflicts. +func planMethodTypes(facts *rootFacts, generation *codegen.Generation) (map[expr.UserType]codegen.DerivedTypeID, error) { + planned := make(map[expr.UserType]codegen.DerivedTypeID) + for _, serviceFacts := range facts.services { + generatedPackage := generation.Package(serviceFacts.packagePath) + for _, method := range serviceFacts.methods { + attributes := []*expr.AttributeExpr{ + method.Payload, + method.StreamingPayload, + method.Result, + } + if method.HasMixedResults() { + attributes = append(attributes, method.StreamingResult) + } + for _, attribute := range attributes { + userType, ok := attribute.Type.(expr.UserType) + if !ok { + continue + } + identity, ok := generation.NormalizedMethodType(userType) + if !ok { + continue + } + _, derived, err := generatedPackage.DeclareMethodType(identity, userType) + if err != nil { + return nil, err + } + planned[userType.Origin()] = derived + } + } + } + return planned, nil +} + +// planningInputs returns the payloads, results, errors, and stream values that +// can write service types. It excludes design types that no service uses. +func planningInputs(facts *rootFacts) []plannedAttribute { + var inputs []plannedAttribute + for _, serviceFacts := range facts.services { + for _, serviceError := range serviceFacts.errors { + inputs = append(inputs, plannedAttribute{attribute: serviceError.AttributeExpr, service: serviceFacts}) + } + for _, method := range serviceFacts.methods { + inputs = append(inputs, + plannedAttribute{attribute: method.Payload, service: serviceFacts}, + plannedAttribute{attribute: method.StreamingPayload, service: serviceFacts}, + plannedAttribute{attribute: method.Result, service: serviceFacts}, + ) + if method.HasMixedResults() { + inputs = append(inputs, plannedAttribute{attribute: method.StreamingResult, service: serviceFacts}) + } + for _, methodError := range method.Errors { + inputs = append(inputs, plannedAttribute{attribute: methodError.AttributeExpr, service: serviceFacts}) + } + } + for _, userType := range facts.types { + services, ok := userType.Attribute().Meta["type:generate:force"] + if !ok || len(services) > 0 && !slices.Contains(services, serviceFacts.name) { + continue + } + inputs = append(inputs, plannedAttribute{ + attribute: &expr.AttributeExpr{Type: userType}, + service: serviceFacts, + }) + } + } + return inputs +} + +// planUserTypes walks attribute and submits each user type to the Go package +// selected by its own metadata or by its enclosing type. +func planUserTypes(attribute *expr.AttributeExpr, service *serviceFacts, location *codegen.Location, generation *codegen.Generation, rootTypes *rootTypeSet, methodTypes map[expr.UserType]codegen.DerivedTypeID, seen map[plannedUserType]struct{}) error { + if attribute == nil || attribute.Type == expr.Empty { + return nil + } + recurse := func(attribute *expr.AttributeExpr, location *codegen.Location) error { + return planUserTypes(attribute, service, location, generation, rootTypes, methodTypes, seen) + } + switch actual := attribute.Type.(type) { + case expr.UserType: + if _, normalized := methodTypes[actual.Origin()]; normalized { + return recurse(actual.Attribute(), location) + } + declaredType := rootTypes.canonical(actual) + typeLocation := codegen.UserTypeLocation(actual) + if typeLocation == nil { + typeLocation = location + } + owner, err := claimGeneratedPackage(generation, service.packagePath, typeLocation) + if err != nil { + return err + } + key := plannedUserType{userType: declaredType, owner: owner} + if _, ok := seen[key]; ok { + return nil + } + seen[key] = struct{}{} + if _, err := owner.DeclareUserType(declaredType); err != nil { + return err + } + return recurse(actual.Attribute(), typeLocation) + case *expr.Object: + for _, named := range *actual { + if err := recurse(named.Attribute, location); err != nil { + return err + } + } + case *expr.Array: + return recurse(actual.ElemType, location) + case *expr.Map: + if err := recurse(actual.KeyType, location); err != nil { + return err + } + return recurse(actual.ElemType, location) + case *expr.Union: + for _, named := range actual.Values { + if userType, ok := generatedUnionBranch(named, rootTypes); ok { + if err := recurse(userType.Attribute(), location); err != nil { + return err + } + continue + } + if err := recurse(named.Attribute, location); err != nil { + return err + } + } + } + return nil +} + +// planUnions walks attribute after authored user type names have been submitted +// and records each Goa OneOf declaration in the package that writes it. +func planUnions(attribute *expr.AttributeExpr, service *serviceFacts, location *codegen.Location, generation *codegen.Generation, rootTypes *rootTypeSet, seen map[plannedUserType]struct{}) error { + if attribute == nil || attribute.Type == expr.Empty { + return nil + } + recurse := func(attribute *expr.AttributeExpr, location *codegen.Location) error { + return planUnions(attribute, service, location, generation, rootTypes, seen) + } + switch actual := attribute.Type.(type) { + case expr.UserType: + declaredType := rootTypes.canonical(actual) + typeLocation := codegen.UserTypeLocation(actual) + if typeLocation == nil { + typeLocation = location + } + owner, err := claimGeneratedPackage(generation, service.packagePath, typeLocation) + if err != nil { + return err + } + key := plannedUserType{userType: declaredType, owner: owner} + if _, ok := seen[key]; ok { + return nil + } + seen[key] = struct{}{} + return recurse(actual.Attribute(), typeLocation) + case *expr.Object: + for _, named := range sortedNamedAttributes(*actual) { + if err := recurse(named.Attribute, location); err != nil { + return err + } + } + case *expr.Array: + return recurse(actual.ElemType, location) + case *expr.Map: + if err := recurse(actual.KeyType, location); err != nil { + return err + } + return recurse(actual.ElemType, location) + case *expr.Union: + generatedPackage, err := claimGeneratedPackage(generation, service.packagePath, location) + if err != nil { + return err + } + if _, err := generatedPackage.DeclareUnion(attribute); err != nil { + return err + } + for _, named := range actual.Values { + if userType, ok := generatedUnionBranch(named, rootTypes); ok { + if _, err := generatedPackage.DeclareUnionBranchType(attribute, named.Name, userType); err != nil { + return err + } + if err := recurse(userType.Attribute(), location); err != nil { + return err + } + continue + } + if err := recurse(named.Attribute, location); err != nil { + return err + } + } + } + return nil +} + +// planViews builds the result types for each declared view, submits their Go +// type names, and then submits Goa OneOf types written in the views package. +func planViews(facts *rootFacts, generation *codegen.Generation) error { + for _, serviceFacts := range facts.services { + views, err := generation.ClaimPackage(serviceFacts.viewsPath) + if err != nil { + return err + } + seenProjected := make(map[expr.UserType]expr.UserType) + projectedFactsByOrigin := make(map[expr.UserType]*projectedTypeFacts) + derived := make(map[expr.UserType]codegen.DerivedTypeID) + var projectedRoots []*expr.AttributeExpr + for _, method := range serviceFacts.methods { + if !hasResultType(method.Result) { + continue + } + projected, source := projectedResultRoot(generation, method) + pairs := projectTypePairs(projected, source, seenProjected) + projection := &projectionFacts{pairs: pairs} + serviceFacts.projections[method] = projection + serviceFacts.methodByExpr[method].projection = projection + for _, pair := range pairs { + identity := codegen.NewProjectedTypeID(pair.source) + if _, err := views.DeclareDerivedType(identity, codegen.Goify(pair.projected.Name(), true)); err != nil { + return err + } + derived[pair.projected.Origin()] = identity + projectedFacts, err := collectProjectedTypeFacts(pair) + if err != nil { + return err + } + projection.types = append(projection.types, projectedFacts) + projectedFactsByOrigin[pair.source.Origin()] = projectedFacts + } + if resultType, ok := method.Result.Type.(*expr.ResultTypeExpr); ok { + projectedType := seenProjected[resultType.Origin()] + if len(pairs) > 0 { + projectedType = pairs[0].projected + } + if projectedType != nil { + viewName := "" + if !resultType.HasMultipleViews() { + viewName = expr.DefaultView + } + if selected, ok := method.Result.Meta.Last(expr.ViewMetaKey); ok { + viewName = selected + } + serviceFacts.methodByExpr[method].viewedResult = &viewedResultFacts{ + wrapped: wrapProjected(projectedType), + origin: resultType.Origin(), + source: serviceFacts.methodByExpr[method].result, + viewName: viewName, + views: projectedFactsByOrigin[resultType.Origin()].views, + conversions: projectedFactsByOrigin[resultType.Origin()].conversions, + projected: projectedFactsByOrigin[resultType.Origin()], + isCollection: expr.IsArray(method.Result.Type), + } + } + } + removeMeta(projected) + projectedRoots = append(projectedRoots, projected) + + if resultType, ok := method.Result.Type.(*expr.ResultTypeExpr); ok { + if _, err := views.DeclareDerivedType( + codegen.NewViewedResultTypeID(resultType), + codegen.Goify(resultType.Name(), true), + ); err != nil { + return err + } + } + } + seenUnions := make(map[expr.UserType]struct{}) + retainedUnions := make(map[codegen.UnionDeclarationID]struct{}) + for _, projected := range projectedRoots { + if err := planViewUnions(projected, views, derived, seenUnions, retainedUnions, &serviceFacts.viewUnions); err != nil { + return err + } + } + } + return nil +} + +// collectProjectedTypeFacts selects validation and conversion code for one set +// of result types containing only the fields in their declared views. +func collectProjectedTypeFacts(pair *projectedTypePair) (*projectedTypeFacts, error) { + facts := &projectedTypeFacts{ + pair: pair, + projectedType: pair.projected, + validations: collectValidationFacts(pair.projectedAttribute), + } + resultType, viewed := pair.projected.(*expr.ResultTypeExpr) + if !viewed { + return facts, nil + } + for _, view := range resultType.Views { + object := expr.AsObject(view.Type) + attributes := make([]string, len(*object)) + for index, field := range *object { + attributes[index] = field.Name + } + facts.views = append(facts.views, &viewRenderFacts{ + name: view.Name, + description: view.Description, + attributes: attributes, + }) + } + for _, toResult := range []bool{true, false} { + conversions, err := collectViewConversionFacts(pair.projectedAttribute, pair.sourceAttribute, toResult) + if err != nil { + return nil, err + } + facts.conversions = append(facts.conversions, conversions...) + } + return facts, nil +} + +// collectValidationFacts stores the field checks and child validation calls +// selected for each result type and view. +func collectValidationFacts(projected *expr.AttributeExpr) []*validationFacts { + userType := projected.Type.(expr.UserType) + resultType, viewed := userType.(*expr.ResultTypeExpr) + if !viewed { + return []*validationFacts{{ + attribute: userType.Attribute(), + alias: expr.IsAlias(userType), + pointer: !expr.IsPrimitive(projected.Type), + }} + } + facts := make([]*validationFacts, 0, len(resultType.Views)) + array := expr.AsArray(projected.Type) + for _, view := range resultType.Views { + validation := &validationFacts{viewName: view.Name, pointer: true} + if array != nil { + validation.collectionElem = array.ElemType + facts = append(facts, validation) + continue + } + object := &expr.Object{} + walkViewAttrs(expr.AsObject(projected.Type), view, func(name string, attribute, viewAttribute *expr.AttributeExpr) { + if _, ok := attribute.Type.(*expr.ResultTypeExpr); ok { + selectedView := "" + if explicit, ok := viewAttribute.Meta.Last(expr.ViewMetaKey); ok && explicit != expr.DefaultView { + selectedView = explicit + } + validation.fields = append(validation.fields, &validationFieldFacts{ + name: name, + attribute: attribute, + view: selectedView, + required: resultType.Attribute().IsRequired(name), + }) + return + } + object.Set(name, attribute) + }) + validation.attribute = &expr.AttributeExpr{Type: object, Validation: resultType.Validation} + facts = append(facts, validation) + } + return facts +} + +// markNeededViewValidators keeps only functions that can return an error. A +// parent is needed when it checks one of its own fields or calls a needed child. +func markNeededViewValidators(facts *serviceFacts) { + validations := make(map[viewValidationKey]*validationFacts) + for _, projection := range facts.projections { + for _, projected := range projection.types { + for _, validation := range projected.validations { + key := viewValidationKey{ + origin: projected.pair.projected.Origin(), + view: canonicalValidatorView(validation.viewName), + } + validations[key] = validation + if validation.collectionElem == nil && codegen.NeedsValidation(validation.attribute, viewValidationPolicy()) { + validation.needed = true + } + for _, field := range validation.fields { + validation.needed = validation.needed || field.required + } + } + } + } + + for changed := true; changed; { + changed = false + for _, validation := range validations { + if validation.needed { + continue + } + if validation.collectionElem != nil && neededValidation(validations, validation.collectionElem, validation.viewName) { + validation.needed = true + changed = true + continue + } + for _, field := range validation.fields { + if neededValidation(validations, field.attribute, field.view) { + validation.needed = true + changed = true + break + } + } + } + } +} + +// neededValidation reports whether the selected view-specific result type can +// return a validation error. +func neededValidation(validations map[viewValidationKey]*validationFacts, attribute *expr.AttributeExpr, view string) bool { + userType, ok := attribute.Type.(expr.UserType) + if !ok { + return false + } + validation := validations[viewValidationKey{ + origin: userType.Origin(), + view: canonicalValidatorView(view), + }] + return validation != nil && validation.needed +} + +// collectViewConversionFacts selects the fields in each declared result view +// and stores the conversion used in each direction. +func collectViewConversionFacts(projected, service *expr.AttributeExpr, toResult bool) ([]*viewConversionFacts, error) { + views := service.Type.(*expr.ResultTypeExpr).Views + projectedObject := expr.AsObject(projected.Type) + projectedArray := expr.AsArray(projected.Type) + if projectedArray != nil { + projectedObject = expr.AsObject(projectedArray.ElemType.Type) + } + result := make([]*viewConversionFacts, 0, len(views)) + for _, view := range views { + object := &expr.Object{} + walkViewAttrs(projectedObject, view, func(name string, attribute, _ *expr.AttributeExpr) { + object.Set(name, attribute) + }) + var narrowedType expr.DataType = object + if projectedArray != nil { + narrowedType = &expr.Array{ElemType: &expr.AttributeExpr{Type: &expr.ResultTypeExpr{ + UserTypeExpr: &expr.UserTypeExpr{ + AttributeExpr: &expr.AttributeExpr{Type: object}, + TypeName: projectedArray.ElemType.Type.Name(), + }, + }}} + } + narrowed := &expr.AttributeExpr{Type: &expr.ResultTypeExpr{ + UserTypeExpr: &expr.UserTypeExpr{ + AttributeExpr: &expr.AttributeExpr{Type: narrowedType}, + TypeName: projected.Type.Name(), + }, + Views: views, + Identifier: service.Type.(*expr.ResultTypeExpr).Identifier, + }} + source, target := service, narrowed + if toResult { + source, target = narrowed, service + } + conversion := &viewConversionFacts{ + toResult: toResult, + viewName: view.Name, + source: source, + target: target, + } + if projectedArray == nil { + conversion.transformTarget = expr.DupAtt(target) + targetObject := expr.AsObject(conversion.transformTarget.Type) + for _, field := range *targetObject { + if _, nested := field.Attribute.Type.(*expr.ResultTypeExpr); !nested { + continue + } + nestedView := "" + if selected := source.Type.(*expr.ResultTypeExpr).View(view.Name).Find(field.Name); selected != nil { + if explicit, ok := selected.Meta.Last(expr.ViewMetaKey); ok && explicit != expr.DefaultView { + nestedView = explicit + } + } + conversion.fields = append(conversion.fields, &viewConversionFieldFacts{ + name: field.Name, + attribute: field.Attribute, + view: nestedView, + }) + targetObject.Delete(field.Name) + } + plan, err := codegen.NewTransformPlan(source, conversion.transformTarget, "", nil) + if err != nil { + return nil, err + } + conversion.plan = plan + } + result = append(result, conversion) + } + return result, nil +} + +// planViewUnions submits every Goa OneOf type reachable from view-specific +// result types. Existing view types keep their declarations; a branch without +// one receives a generated alias declaration. +func planViewUnions(attribute *expr.AttributeExpr, generatedPackage *codegen.GeneratedPackage, derived map[expr.UserType]codegen.DerivedTypeID, seen map[expr.UserType]struct{}, retained map[codegen.UnionDeclarationID]struct{}, unions *[]*unionFacts) error { + if attribute == nil || attribute.Type == expr.Empty { + return nil + } + recurse := func(attribute *expr.AttributeExpr) error { + return planViewUnions(attribute, generatedPackage, derived, seen, retained, unions) + } + switch actual := attribute.Type.(type) { + case expr.UserType: + origin := actual.Origin() + if _, ok := seen[origin]; ok { + return nil + } + seen[origin] = struct{}{} + return recurse(actual.Attribute()) + case *expr.Object: + for _, field := range *actual { + if err := recurse(field.Attribute); err != nil { + return err + } + } + case *expr.Array: + return recurse(actual.ElemType) + case *expr.Map: + if err := recurse(actual.KeyType); err != nil { + return err + } + return recurse(actual.ElemType) + case *expr.Union: + if _, err := generatedPackage.DeclareUnion(attribute); err != nil { + return err + } + identity := codegen.NewUnionDeclarationID(attribute) + if _, exists := retained[identity]; !exists { + retained[identity] = struct{}{} + declaration, err := generatedPackage.Union(attribute) + if err != nil { + return err + } + *unions = append(*unions, &unionFacts{ + attribute: attribute, + union: actual, + identity: identity, + typeKey: actual.GetTypeKey(), + valueKey: actual.GetValueKey(), + location: &codegen.Location{RelImportPath: "views"}, + declaration: declaration, + }) + } + for _, branch := range actual.Values { + if userType, ok := branch.Attribute.Type.(expr.UserType); ok { + if _, projected := derived[userType.Origin()]; !projected { + if _, err := generatedPackage.DeclareUnionBranchType(attribute, branch.Name, userType); err != nil { + return err + } + } + } + if err := recurse(branch.Attribute); err != nil { + return err + } + } + } + return nil +} + +// newRootTypeSet records the authored types in one design so compiler-created +// copies can use the same generated Go declarations. Generated Goa OneOf branch +// aliases are not added. +func newRootTypeSet(root *expr.RootExpr) *rootTypeSet { + userTypes := &rootTypeSet{ + byOrigin: make(map[expr.UserType]expr.UserType, len(root.Types)+len(root.ResultTypes)+1), + } + for _, userType := range root.Types { + userTypes.add(userType) + } + for _, resultType := range root.ResultTypes { + userTypes.add(resultType) + } + userTypes.add(expr.ErrorResult) + return userTypes +} + +// generatedUnionBranch reports whether OneOf created a user type around a +// branch that was not declared as a user type in the design. +func generatedUnionBranch(branch *expr.NamedAttributeExpr, rootTypes *rootTypeSet) (expr.UserType, bool) { + userType, ok := branch.Attribute.Type.(expr.UserType) + if !ok { + return nil, false + } + return userType, !rootTypes.contains(userType) +} + +// add records one user type declared in this design under its original type. +func (s *rootTypeSet) add(userType expr.UserType) { + s.byOrigin[userType.Origin()] = userType +} + +// canonical returns the original authored declaration for a compiler-created +// copy from this design. Types originating in another design are returned +// unchanged. +func (s *rootTypeSet) canonical(userType expr.UserType) expr.UserType { + if canonical, ok := s.byOrigin[userType.Origin()]; ok { + return canonical + } + return userType +} + +// contains reports whether userType was declared in this design or copied from +// one of its declarations. +func (s *rootTypeSet) contains(userType expr.UserType) bool { + _, ok := s.byOrigin[userType.Origin()] + return ok +} + +// claimGeneratedPackage passes the relative path from design metadata to +// Generation unchanged. Generation can then reject two different path strings +// that resolve to the same output package. An absolute path is invalid metadata +// and does not select a package under the generated module. +func claimGeneratedPackage(generation *codegen.Generation, servicePath string, location *codegen.Location) (*codegen.GeneratedPackage, error) { + if location == nil { + return generation.ClaimPackage(servicePath) + } + if path.IsAbs(location.RelImportPath) { + return nil, fmt.Errorf("generated package location %q must be relative", location.RelImportPath) + } + claim := strings.TrimSuffix(generation.GenPkg(), "/") + "/" + location.RelImportPath + return generation.ClaimPackage(claim) +} + +// generatedPackagePath returns the cleaned import path selected by location, +// or the service package when location is nil. +func generatedPackagePath(genpkg, servicePath string, location *codegen.Location) string { + if location != nil { + return path.Join(genpkg, location.RelImportPath) + } + return servicePath +} diff --git a/codegen/service/imports.go b/codegen/service/imports.go new file mode 100644 index 0000000000..95180a1045 --- /dev/null +++ b/codegen/service/imports.go @@ -0,0 +1,583 @@ +// This file chooses one Go package name for each import path and records which +// imports each generated service file uses. +package service + +import ( + "path" + + "goa.design/goa/v3/codegen" + "goa.design/goa/v3/expr" +) + +type ( + // importAliases returns the Go package name chosen for an import path in one + // output package before files are rendered. + importAliases struct { + generation *codegen.Generation + } + + // serviceFileImports keeps imports separate for files that emit different + // subsets of one service's types and runtime helpers. + serviceFileImports struct { + service *codegen.GeneratedImportPlan + endpoint *codegen.GeneratedImportPlan + client *codegen.GeneratedImportPlan + views *codegen.GeneratedImportPlan + serverInterceptors *codegen.GeneratedImportPlan + clientInterceptors *codegen.GeneratedImportPlan + interceptorWrappers *codegen.GeneratedImportPlan + exampleService *codegen.GeneratedImportPlan + exampleServerInterceptors *codegen.GeneratedImportPlan + exampleClientInterceptors *codegen.GeneratedImportPlan + } +) + +// newImportAliases returns package-name lookups for the supplied generation. +// Service analysis and rendering use these lookups after all import names are +// chosen. +func newImportAliases(root *expr.RootExpr, generation *codegen.Generation) (*importAliases, error) { + if !generation.HasRoot(root) { + return nil, rootMembershipError(root) + } + return &importAliases{generation: generation}, nil +} + +// name returns the chosen Go package name for importPath. It panics when a +// renderer asks for a package that was not recorded during planning. +func (a *importAliases) name(outputPackage, importPath string) string { + return a.generation.Package(outputPackage).ImportName(importPath) +} + +// spec returns the completed import declaration for importPath. +func (a *importAliases) spec(outputPackage, importPath string) *codegen.ImportSpec { + return a.generation.Package(outputPackage).Import(importPath) +} + +// retainFileImports collects the fixed, generated, type-definition, and +// recursive-reference package paths used by one emitted file before +// Generation.Freeze chooses their Go package names. +func retainFileImports( + generation *codegen.Generation, + outputPackage string, + fixed, generated []*codegen.ImportSpec, + definitions, references []*expr.AttributeExpr, +) (*codegen.GeneratedImportPlan, error) { + imports := codegen.NewGeneratedImportPlan(generation.Package(outputPackage)) + if err := imports.Require(fixed...); err != nil { + return nil, err + } + if err := imports.AddGenerated(generated...); err != nil { + return nil, err + } + if err := imports.AddTypeExpressions(definitions...); err != nil { + return nil, err + } + if err := imports.AddRecursiveTypeReferences(references...); err != nil { + return nil, err + } + return imports, nil +} + +// planServiceFileImports selects the package paths used by each concrete file +// emitted for one service copied into the plan. It requests their preferred Go +// package names before Generation.Freeze chooses the final names. +func planServiceFileImports(facts *serviceFacts, rootTypes *rootTypeSet, generation *codegen.Generation) error { + servicePath := facts.packagePath + serviceImport := facts.packageImport + viewsImport := facts.viewsImport + facts.generatedTypeImports = make(map[*codegen.TypeDeclaration]*codegen.GeneratedImportPlan) + + definitions := serviceDefinitionAttributes(facts) + serviceDefinitions := append([]*expr.AttributeExpr(nil), facts.referenceAttributes...) + serviceDefinitions = append(serviceDefinitions, definitions...) + viewDefinitions := viewDefinitionAttributes(facts) + + contextImport := codegen.SimpleImport("context") + ioImport := codegen.SimpleImport("io") + goaImport := codegen.GoaImport("") + securityImport := codegen.GoaImport("security") + logImport := codegen.SimpleImport("goa.design/clue/log") + + serviceFixed := []*codegen.ImportSpec{contextImport} + if serviceUsesIO(facts) { + serviceFixed = append(serviceFixed, ioImport) + } + if serviceUsesGoaErrors(facts) { + serviceFixed = append(serviceFixed, goaImport) + } + if serviceHasSchemes(facts) { + serviceFixed = append(serviceFixed, securityImport) + } + var serviceGenerated []*codegen.ImportSpec + if len(facts.projections) > 0 { + serviceGenerated = append(serviceGenerated, viewsImport) + } + var err error + facts.imports.service, err = retainFileImports( + generation, servicePath, serviceFixed, serviceGenerated, serviceDefinitions, nil, + ) + if err != nil { + return err + } + + endpointFixed := []*codegen.ImportSpec{contextImport, goaImport} + if serviceUsesIO(facts) { + endpointFixed = append(endpointFixed, ioImport) + } + if serviceHasSchemes(facts) { + endpointFixed = append(endpointFixed, securityImport) + } + var endpointGenerated []*codegen.ImportSpec + for _, method := range facts.methods { + if facts.methodByExpr[method].viewedResult != nil { + endpointGenerated = append(endpointGenerated, viewsImport) + break + } + } + facts.imports.endpoint, err = retainFileImports( + generation, servicePath, endpointFixed, endpointGenerated, facts.referenceAttributes, nil, + ) + if err != nil { + return err + } + + clientFixed := []*codegen.ImportSpec{contextImport, goaImport} + if serviceUsesIO(facts) { + clientFixed = append(clientFixed, ioImport) + } + facts.imports.client, err = retainFileImports( + generation, servicePath, clientFixed, nil, facts.referenceAttributes, nil, + ) + if err != nil { + return err + } + + if len(facts.projections) > 0 { + viewsFixed := []*codegen.ImportSpec{goaImport} + validationFixed, validationGenerated := viewValidationImports(facts) + viewsFixed = append(viewsFixed, validationFixed...) + if len(facts.viewUnions) > 0 { + viewsFixed = append(viewsFixed, + codegen.SimpleImport("bytes"), + codegen.SimpleImport("encoding/json"), + codegen.SimpleImport("fmt"), + ) + } + facts.imports.views, err = retainFileImports( + generation, servicePath+"/views", viewsFixed, validationGenerated, viewDefinitions, nil, + ) + if err != nil { + return err + } + } + + interceptorFixed := []*codegen.ImportSpec{contextImport, goaImport} + if len(facts.serverInterceptors) > 0 { + serverInterceptorNames := interceptorNames(facts.serverInterceptorFacts) + serverInterceptorTypes := interceptorTypes(facts.serverInterceptorFacts) + serverInterceptorTypes = append( + serverInterceptorTypes, + interceptorTypesOnly(facts.clientInterceptorFacts, serverInterceptorNames)..., + ) + facts.imports.serverInterceptors, err = retainFileImports( + generation, servicePath, interceptorFixed, nil, serverInterceptorTypes, nil, + ) + if err != nil { + return err + } + } + if len(facts.clientInterceptors) > 0 { + clientInterceptorTypes := interceptorTypesWithout( + facts.clientInterceptorFacts, + interceptorNames(facts.serverInterceptorFacts), + ) + facts.imports.clientInterceptors, err = retainFileImports( + generation, servicePath, interceptorFixed, nil, clientInterceptorTypes, nil, + ) + if err != nil { + return err + } + } + if len(facts.serverInterceptors) > 0 || len(facts.clientInterceptors) > 0 { + streamTypes := interceptorStreamTypes(facts.serverInterceptorFacts) + streamTypes = append(streamTypes, interceptorStreamTypes(facts.clientInterceptorFacts)...) + facts.imports.interceptorWrappers, err = retainFileImports( + generation, servicePath, interceptorFixed, nil, streamTypes, nil, + ) + if err != nil { + return err + } + } + + exampleFixed := []*codegen.ImportSpec{contextImport, logImport} + if serviceUsesIO(facts) { + exampleFixed = append(exampleFixed, ioImport) + } + if serviceUsesResponseBody(facts) { + exampleFixed = append(exampleFixed, codegen.SimpleImport("strings")) + } + if serviceHasSchemes(facts) { + exampleFixed = append(exampleFixed, codegen.SimpleImport("fmt"), securityImport) + } + facts.imports.exampleService, err = retainFileImports( + generation, path.Dir(generation.GenPkg()), exampleFixed, + []*codegen.ImportSpec{serviceImport}, facts.referenceAttributes, nil, + ) + if err != nil { + return err + } + + exampleInterceptorFixed := []*codegen.ImportSpec{contextImport, logImport, goaImport} + if len(facts.serverInterceptors) > 0 { + facts.imports.exampleServerInterceptors, err = retainFileImports( + generation, path.Join(path.Dir(generation.GenPkg()), "interceptors"), + exampleInterceptorFixed, []*codegen.ImportSpec{serviceImport}, nil, nil, + ) + if err != nil { + return err + } + } + if len(facts.clientInterceptors) > 0 { + facts.imports.exampleClientInterceptors, err = retainFileImports( + generation, path.Join(path.Dir(generation.GenPkg()), "interceptors"), + exampleInterceptorFixed, []*codegen.ImportSpec{serviceImport}, nil, nil, + ) + if err != nil { + return err + } + } + for _, userType := range facts.userTypes { + if userType.location == nil { + continue + } + userType.imports, err = retainFileImports( + generation, + userType.declaration.PackagePath(), + nil, + nil, + []*expr.AttributeExpr{userType.userType.Attribute()}, + nil, + ) + if err != nil { + return err + } + facts.generatedTypeImports[userType.declaration] = userType.imports + } + for _, method := range facts.methods { + attributes := []*expr.AttributeExpr{method.Payload, method.StreamingPayload, method.Result} + if method.HasMixedResults() { + attributes = append(attributes, method.StreamingResult) + } + for _, attribute := range attributes { + if attribute == nil || codegen.UserTypeLocation(attribute.Type) == nil { + continue + } + userType, ok := attribute.Type.(expr.UserType) + if !ok { + continue + } + if _, normalized := generation.NormalizedMethodType(userType); !normalized { + continue + } + owner := generation.Package(generatedPackagePath( + generation.GenPkg(), facts.packagePath, codegen.UserTypeLocation(userType), + )) + declaration, err := owner.UserType(rootTypes.canonical(userType)) + if err != nil { + return err + } + if _, exists := facts.generatedTypeImports[declaration]; exists { + continue + } + retained, err := retainFileImports( + generation, + declaration.PackagePath(), + nil, + nil, + []*expr.AttributeExpr{userType.Attribute()}, + nil, + ) + if err != nil { + return err + } + facts.generatedTypeImports[declaration] = retained + } + } + unionFixed := []*codegen.ImportSpec{ + codegen.SimpleImport("bytes"), + codegen.SimpleImport("encoding/json"), + codegen.SimpleImport("fmt"), + goaImport, + } + for _, union := range facts.unions { + definitions := make([]*expr.AttributeExpr, 0, len(union.union.Values)) + for _, branch := range union.union.Values { + definitions = append(definitions, branch.Attribute) + } + union.imports, err = retainFileImports( + generation, + union.declaration.PackagePath(), + unionFixed, + nil, + definitions, + nil, + ) + if err != nil { + return err + } + } + return nil +} + +// interceptorTypes returns the selected fields whose Go types are written +// in an interceptor interface or accessor method. +func interceptorTypes(interceptors []*interceptorFacts) []*expr.AttributeExpr { + return interceptorTypesWithout(interceptors, nil) +} + +// interceptorTypesWithout returns selected interceptor fields except for +// interceptors whose names are emitted by another file. +func interceptorTypesWithout(interceptors []*interceptorFacts, excluded map[string]struct{}) []*expr.AttributeExpr { + var attributes []*expr.AttributeExpr + for _, interceptor := range interceptors { + if _, skip := excluded[interceptor.name]; skip { + continue + } + attributes = append(attributes, interceptorValueTypes(interceptor)...) + } + return attributes +} + +// interceptorTypesOnly returns types for interceptor definitions +// written in another file with the same name. +func interceptorTypesOnly(interceptors []*interceptorFacts, included map[string]struct{}) []*expr.AttributeExpr { + var attributes []*expr.AttributeExpr + for _, interceptor := range interceptors { + if _, keep := included[interceptor.name]; !keep { + continue + } + attributes = append(attributes, interceptorValueTypes(interceptor)...) + } + return attributes +} + +// interceptorValueTypes returns the selected fields and the complete +// method values stored behind their generated accessors. +func interceptorValueTypes(interceptor *interceptorFacts) []*expr.AttributeExpr { + accesses := [][]*interceptorAccessFacts{ + interceptor.readPayloadFields, + interceptor.writePayloadFields, + interceptor.readResultFields, + interceptor.writeResultFields, + interceptor.readStreamingPayloadFields, + interceptor.writeStreamingPayloadFields, + interceptor.readStreamingResultFields, + interceptor.writeStreamingResultFields, + } + var attributes []*expr.AttributeExpr + for _, fields := range accesses { + for _, field := range fields { + attributes = append(attributes, field.attribute) + } + } + hasPayload := len(interceptor.readPayloadFields) > 0 || len(interceptor.writePayloadFields) > 0 + hasResult := len(interceptor.readResultFields) > 0 || len(interceptor.writeResultFields) > 0 + hasStreamingPayload := len(interceptor.readStreamingPayloadFields) > 0 || len(interceptor.writeStreamingPayloadFields) > 0 + hasStreamingResult := len(interceptor.readStreamingResultFields) > 0 || len(interceptor.writeStreamingResultFields) > 0 + for _, method := range interceptor.methods { + if hasPayload { + attributes = append(attributes, method.payload.attribute) + } + if hasResult { + attributes = append(attributes, method.result.attribute) + } + if hasStreamingPayload { + attributes = append(attributes, method.streamingPayload.attribute) + } + if hasStreamingResult { + attributes = append(attributes, method.streamingResult.attribute) + } + } + return attributes +} + +// interceptorStreamTypes returns the streaming values written by wrapper +// methods for interceptors that inspect those values. +func interceptorStreamTypes(interceptors []*interceptorFacts) []*expr.AttributeExpr { + var attributes []*expr.AttributeExpr + for _, interceptor := range interceptors { + payload := len(interceptor.readStreamingPayloadFields) > 0 || len(interceptor.writeStreamingPayloadFields) > 0 + result := len(interceptor.readStreamingResultFields) > 0 || len(interceptor.writeStreamingResultFields) > 0 + for _, method := range interceptor.methods { + if payload { + attributes = append(attributes, method.streamingPayload.attribute) + } + if result { + attributes = append(attributes, method.streamingResult.attribute) + } + } + } + return attributes +} + +// interceptorNames returns the names of interceptors emitted in a file. +func interceptorNames(interceptors []*interceptorFacts) map[string]struct{} { + names := make(map[string]struct{}, len(interceptors)) + for _, interceptor := range interceptors { + names[interceptor.name] = struct{}{} + } + return names +} + +// viewValidationImports separates packages named directly by templates from +// generated packages whose import names may change to avoid a collision. +func viewValidationImports(facts *serviceFacts) (fixed, generated []*codegen.ImportSpec) { + for _, method := range facts.methods { + projection := facts.projections[method] + if projection == nil { + continue + } + for _, projected := range projection.types { + for _, validation := range projected.validations { + if validation.plan == nil { + continue + } + for _, preference := range validation.plan.ImportPreferences() { + spec := codegen.NewImport(preference.Name, preference.Path) + switch preference.Path { + case codegen.GoaImport("").Path, "unicode/utf8": + fixed = append(fixed, spec) + default: + generated = append(generated, spec) + } + } + } + } + } + return +} + +// linkServiceFileImports resolves every file's imports after +// Generation.Freeze chooses all package names. +func linkServiceFileImports(facts *serviceFacts) error { + imports := []*codegen.GeneratedImportPlan{ + facts.imports.service, + facts.imports.endpoint, + facts.imports.client, + facts.imports.views, + facts.imports.serverInterceptors, + facts.imports.clientInterceptors, + facts.imports.interceptorWrappers, + facts.imports.exampleService, + facts.imports.exampleServerInterceptors, + facts.imports.exampleClientInterceptors, + } + for _, retained := range imports { + if retained != nil { + if err := retained.Link(); err != nil { + return err + } + } + } + for _, union := range facts.unions { + if union.imports != nil { + if err := union.imports.Link(); err != nil { + return err + } + } + } + for _, imports := range facts.generatedTypeImports { + if err := imports.Link(); err != nil { + return err + } + } + return nil +} + +// serviceDefinitionAttributes returns the exact named definitions written to +// service.go in addition to method references. +func serviceDefinitionAttributes(facts *serviceFacts) []*expr.AttributeExpr { + var definitions []*expr.AttributeExpr + for _, method := range facts.methods { + attributes := []*expr.AttributeExpr{method.Payload, method.StreamingPayload, method.Result} + if method.HasMixedResults() { + attributes = append(attributes, method.StreamingResult) + } + for _, attribute := range attributes { + if attribute == nil || codegen.UserTypeLocation(attribute.Type) != nil { + continue + } + if userType, ok := attribute.Type.(expr.UserType); ok { + definitions = append(definitions, userType.Attribute()) + } + } + } + for _, userType := range facts.userTypes { + if userType.location == nil { + definitions = append(definitions, userType.userType.Attribute()) + } + } + return definitions +} + +// viewDefinitionAttributes returns each view-specific definition emitted in +// the service views file exactly once, even when several entries point to the +// same attribute value. +func viewDefinitionAttributes(facts *serviceFacts) []*expr.AttributeExpr { + seen := make(map[*expr.AttributeExpr]struct{}) + var definitions []*expr.AttributeExpr + for _, projection := range facts.projections { + for _, projected := range projection.types { + attribute := projected.pair.projectedAttribute + if userType, ok := attribute.Type.(expr.UserType); ok { + attribute = userType.Attribute() + } + if _, ok := seen[attribute]; ok { + continue + } + seen[attribute] = struct{}{} + definitions = append(definitions, attribute) + } + } + return definitions +} + +// serviceUsesIO reports whether generated method signatures expose a raw +// request or response body stream. +func serviceUsesIO(facts *serviceFacts) bool { + for _, method := range facts.methodByExpr { + if method.skipRequestBodyEncodeDecode || method.skipResponseBodyEncodeDecode { + return true + } + } + return false +} + +// serviceUsesResponseBody reports whether the starter implementation creates +// a raw response body from a string reader. +func serviceUsesResponseBody(facts *serviceFacts) bool { + for _, method := range facts.methodByExpr { + if method.skipResponseBodyEncodeDecode { + return true + } + } + return false +} + +// serviceUsesGoaErrors reports whether service.go emits a constructor that +// calls the Goa service-error runtime. +func serviceUsesGoaErrors(facts *serviceFacts) bool { + for _, serviceError := range facts.errors { + if expr.IsErrorResult(serviceError.Type) { + return true + } + } + for _, method := range facts.methods { + for _, methodError := range method.Errors { + if expr.IsErrorResult(methodError.Type) { + return true + } + } + } + return false +} diff --git a/codegen/service/imports_test.go b/codegen/service/imports_test.go new file mode 100644 index 0000000000..a38124ab68 --- /dev/null +++ b/codegen/service/imports_test.go @@ -0,0 +1,463 @@ +// This file verifies that service import subsets and qualified references use +// one deterministic full-path alias binding across every render analysis. +package service + +import ( + "go/format" + "path" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/require" + + "goa.design/goa/v3/codegen" + "goa.design/goa/v3/dsl" + "goa.design/goa/v3/eval" + "goa.design/goa/v3/expr" +) + +// TestPlanRejectsUnregisteredRoot verifies that service planning cannot create +// render state outside the roots owned by its generation. +func TestPlanRejectsUnregisteredRoot(t *testing.T) { + root := codegen.RunDSL(t, func() { + payload := dsl.Type("Payload", func() { + dsl.Attribute("value", dsl.String, func() { + dsl.Meta("struct:field:type", "shared.Value", "example.com/local/shared", "shared") + }) + }) + dsl.Service("Values", func() { + dsl.Method("Read", func() { + dsl.Payload(payload) + }) + }) + }) + generation := mustTestGeneration(t, "generated.local/gen", nil) + require.ErrorContains(t, planTestServices(root, generation), "does not belong") + require.NoError(t, generation.Freeze()) +} + +// TestPlanUsesCopiedGenerationRoots verifies that mutating root slices outside +// the generation cannot change which service designs planning and rendering +// accept. +func TestPlanUsesCopiedGenerationRoots(t *testing.T) { + first := codegen.RunDSL(t, func() { + dsl.Service("First", func() { + dsl.Method("Read", func() {}) + }) + }) + second := codegen.RunDSL(t, func() { + dsl.Service("Second", func() { + dsl.Method("Read", func() {}) + }) + }) + roots := []eval.Root{first} + generation := mustTestGeneration(t, "generated.local/gen", roots) + roots[0] = second + returnedRoots := generation.Roots() + returnedRoots[0] = second + + firstPlan, err := NewPlan(first, generation, expr.NewExampleGenerator(first.API.RandomizerFactory)) + require.NoError(t, err) + require.ErrorContains(t, planTestServices(second, generation), "does not belong") + require.NoError(t, generation.Freeze()) + roots[0] = nil + returnedRoots = generation.Roots() + returnedRoots[0] = second + require.NoError(t, firstPlan.Link()) + require.NotNil(t, firstPlan.Services().Get("First")) +} + +// TestFileImportsAreRetainedBeforeFreeze verifies that rendering uses the +// exact package paths selected with the file contribution, not a later walk +// over mutable service-analysis slices. +func TestFileImportsAreRetainedBeforeFreeze(t *testing.T) { + root := codegen.RunDSL(t, func() { + payload := dsl.Type("Payload", func() { + dsl.Meta("struct:pkg:path", "types") + dsl.Attribute("value", dsl.String) + }) + dsl.Service("Values", func() { + dsl.Method("Read", func() { + dsl.Payload(payload) + }) + }) + }) + generation := mustTestGeneration(t, "generated.local/gen", []eval.Root{root}) + plan, err := NewPlan(root, generation, expr.NewExampleGenerator(root.API.RandomizerFactory)) + require.NoError(t, err) + plan.facts.services[0].referenceAttributes = nil + require.NoError(t, generation.Freeze()) + require.NoError(t, plan.Link()) + + file := endpointFile(plan, plan.facts.services[0]) + code := renderSections(t, file.SectionTemplates) + require.Contains(t, code, `"generated.local/gen/types"`) +} + +// TestImportAliasesUsePathAsIdentity verifies that generator-owned imports +// retain their canonical qualifier when metadata prefers another spelling for +// the same complete package path. +func TestImportAliasesUsePathAsIdentity(t *testing.T) { + generation := mustTestGeneration(t, "generated.local/gen", nil) + pkg := mustClaimTestPackage(t, generation, "generated.local/gen/values") + require.NoError(t, pkg.RequireImport(codegen.SimpleImport("encoding/json"))) + require.NoError(t, pkg.DeclareImport(codegen.NewImport("jason", "encoding/json"))) + require.NoError(t, generation.Freeze()) + + aliases := &importAliases{generation: generation} + require.Equal(t, "json", aliases.name(pkg.ImportPath(), "encoding/json")) + require.Equal(t, "encoding/json", aliases.spec(pkg.ImportPath(), "encoding/json").Path) +} + +// TestImportAliasPreferenceIsOrderIndependent verifies that two metadata +// spellings for one path produce the same frozen qualifier in either order. +func TestImportAliasPreferenceIsOrderIndependent(t *testing.T) { + freeze := func(first, second string) string { + generation := mustTestGeneration(t, "generated.local/gen", nil) + pkg := mustClaimTestPackage(t, generation, "generated.local/gen/values") + require.NoError(t, pkg.DeclareImport(codegen.NewImport(first, "example.com/value"))) + require.NoError(t, pkg.DeclareImport(codegen.NewImport(second, "example.com/value"))) + require.NoError(t, generation.Freeze()) + return pkg.ImportName("example.com/value") + } + + require.Equal(t, freeze("alpha", "zeta"), freeze("zeta", "alpha")) +} + +// TestRegisteredRootsShareImportAliases verifies that every root analysis and +// relocated declaration consumes the one mapping frozen for the generation. +func TestRegisteredRootsShareImportAliases(t *testing.T) { + rootWithPreference := func(serviceName, typeName, preferred string) *expr.RootExpr { + return codegen.RunDSL(t, func() { + payload := dsl.Type(typeName, func() { + dsl.Meta("struct:pkg:path", "types") + dsl.Attribute("value", dsl.String, func() { + dsl.Meta("struct:field:type", preferred+".Value", "example.com/shared/value", preferred) + }) + }) + dsl.Service(serviceName, func() { + dsl.Method("Read", func() { + dsl.Payload(payload) + }) + }) + }) + } + firstRoot := rootWithPreference("First", "FirstPayload", "zeta") + secondRoot := rootWithPreference("Second", "SecondPayload", "alpha") + generation := mustTestGeneration(t, "generated.local/gen", []eval.Root{firstRoot, secondRoot}) + plans, err := NewPlans( + generation, + PlanInput{Root: firstRoot, Examples: expr.NewExampleGenerator(firstRoot.API.RandomizerFactory)}, + PlanInput{Root: secondRoot, Examples: expr.NewExampleGenerator(secondRoot.API.RandomizerFactory)}, + ) + require.NoError(t, err) + firstPlan, secondPlan := plans[0], plans[1] + require.NoError(t, generation.Freeze()) + require.NoError(t, firstPlan.Link()) + require.NoError(t, secondPlan.Link()) + first := firstPlan.Services() + second := secondPlan.Services() + const outputPackage = "generated.local/gen/types" + require.Equal(t, "alpha", first.aliases.name(outputPackage, "example.com/shared/value")) + require.Equal(t, first.aliases.name(outputPackage, "example.com/shared/value"), second.aliases.name(outputPackage, "example.com/shared/value")) + + files := mustServiceFiles(t, firstPlan, secondPlan) + for _, name := range []string{"first_payload.go", "second_payload.go"} { + file := findFile(files, filepath.Join("gen", "types", name)) + require.NotNil(t, file) + code := renderSections(t, file.SectionTemplates) + require.Contains(t, code, `alpha "example.com/shared/value"`) + require.Contains(t, code, "alpha.Value") + } +} + +// TestEmittedUnionReservesFixedJSON verifies that an emitted union codec's +// encoding/json qualifier wins before a metadata package requests the same +// preferred name. Files without a union do not reserve this runtime import. +func TestEmittedUnionReservesFixedJSON(t *testing.T) { + root := codegen.RunDSL(t, func() { + payload := dsl.Type("Payload", func() { + dsl.OneOf("choice", func() { + dsl.Attribute("text", dsl.String) + }) + dsl.Attribute("value", dsl.String, func() { + dsl.Meta("struct:field:type", "json.Value", "example.com/custom/json", "json") + }) + }) + dsl.Service("Values", func() { + dsl.Method("Read", func() { + dsl.Payload(payload) + }) + }) + }) + generation := mustTestGeneration(t, "generated.local/gen", []eval.Root{root}) + require.NoError(t, planTestServices(root, generation)) + require.NoError(t, generation.Freeze()) + aliases, err := newImportAliases(root, generation) + require.NoError(t, err) + const outputPackage = "generated.local/gen/values" + require.Equal(t, "json", aliases.name(outputPackage, "encoding/json")) + require.Equal(t, "json2", aliases.name(outputPackage, "example.com/custom/json")) +} + +// TestUnionImportsStopAtNamedBranches verifies that a union imports the named +// branch package but not packages used only inside that branch's own file. +func TestUnionImportsStopAtNamedBranches(t *testing.T) { + root := codegen.RunDSL(t, func() { + model := dsl.Type("Model", func() { + dsl.Meta("struct:pkg:path", "models") + dsl.Attribute("token", dsl.String, func() { + dsl.Meta("struct:field:type", "wire.Token", "example.com/wire", "wire") + }) + }) + payload := dsl.Type("Payload", func() { + dsl.OneOf("value", func() { + dsl.Attribute("model", model) + }) + }) + dsl.Service("Values", func() { + dsl.Method("Read", func() { + dsl.Payload(payload) + }) + }) + }) + generation := mustTestGeneration(t, "generated.local/gen", []eval.Root{root}) + plan, err := NewPlan(root, generation, expr.NewExampleGenerator(root.API.RandomizerFactory)) + require.NoError(t, err) + require.Len(t, plan.facts.services[0].unions, 1) + paths := plan.facts.services[0].unions[0].imports.Paths() + require.Contains(t, paths, "generated.local/gen/models") + require.NotContains(t, paths, "example.com/wire") +} + +// TestFixedTemplateAliasesBeatGeneratedPackages verifies that generated +// service paths cannot take qualifiers required by static Goa and log calls. +func TestFixedTemplateAliasesBeatGeneratedPackages(t *testing.T) { + root := codegen.RunDSL(t, func() { + interceptor := dsl.Interceptor("Trace") + for _, name := range []string{"Goa", "Log"} { + dsl.Service(name, func() { + dsl.ServerInterceptor(interceptor) + dsl.Method("Read", func() {}) + }) + } + }) + plan := mustServicePlan(t, root) + services := plan.Services() + outputPackage := path.Join(path.Dir(services.generation.GenPkg()), "interceptors") + require.Equal(t, "goa", services.aliases.name(outputPackage, codegen.GoaImport("").Path)) + require.Equal(t, "goa2", services.aliases.name(outputPackage, servicePackagePath(services.generation.GenPkg(), root.Service("Goa")))) + require.Equal(t, "log", services.aliases.name(outputPackage, "goa.design/clue/log")) + require.Equal(t, "log2", services.aliases.name(outputPackage, servicePackagePath(services.generation.GenPkg(), root.Service("Log")))) +} + +// TestMetadataImportKeepsItsPreferredAlias verifies that an import used only +// by design metadata is not renamed by an unused runtime package. +func TestDocumentedJSONMetadataUsesCanonicalAlias(t *testing.T) { + root := codegen.RunDSL(t, func() { + payload := dsl.Type("Payload", func() { + dsl.Attribute("raw", dsl.String, func() { + dsl.Meta("struct:field:type", "jason.RawMessage", "encoding/json", "jason") + }) + }) + dsl.Service("Values", func() { + dsl.Method("Read", func() { + dsl.Payload(payload) + }) + }) + }) + plan := mustServicePlan(t, root) + file := findFile(mustServiceFiles(t, plan), filepath.Join("gen", "values", "service.go")) + require.NotNil(t, file) + code := renderSections(t, file.SectionTemplates) + require.Contains(t, code, "jason.RawMessage") + require.Equal(t, 1, strings.Count(code, `"encoding/json"`), code) + require.NotContains(t, code, "json.RawMessage") +} + +// TestExampleServiceUsesCanonicalGeneratedPackageQualifier verifies that a +// metadata package cannot steal the qualifier reserved for a generated +// service package. +func TestExampleServiceUsesCanonicalGeneratedPackageQualifier(t *testing.T) { + root := codegen.RunDSL(t, func() { + dsl.Service("Values", func() { + dsl.Method("Read", func() { + dsl.Payload(dsl.String, func() { + dsl.Meta("struct:field:type", "values.Value", "example.com/custom/values", "values") + }) + }) + }) + }) + plan := mustServicePlan(t, root) + services := plan.Services() + servicePath := servicePackagePath(services.generation.GenPkg(), root.Service("Values")) + outputPackage := path.Dir(services.generation.GenPkg()) + require.Equal(t, "values", services.aliases.name(outputPackage, servicePath)) + require.Equal(t, "values2", services.aliases.name(outputPackage, "example.com/custom/values")) + + files := ExampleServiceFiles(plan) + require.Len(t, files, 1) + code := renderSections(t, files[0].SectionTemplates) + _, err := format.Source([]byte(code)) + require.NoError(t, err, code) + require.Contains(t, code, "values.Service") + require.Contains(t, code, "p values2.Value") +} + +// TestExampleServiceReservesFixedQualifiers verifies that standard library +// and generated service imports retain their template qualifiers when service +// metadata or names request the same spelling. +func TestExampleServiceReservesFixedQualifiers(t *testing.T) { + root := codegen.RunDSL(t, func() { + result := dsl.Type("Result", func() { + dsl.Attribute("length", dsl.Int) + }) + dsl.Service("Fmt", func() { + dsl.Method("Read", func() { + dsl.Payload(dsl.String, func() { + dsl.Meta("struct:field:type", "strings.Value", "example.com/custom/strings", "strings") + }) + dsl.Result(result) + dsl.HTTP(func() { + dsl.GET("/") + dsl.SkipResponseBodyEncodeDecode() + dsl.Response(dsl.StatusOK, func() { + dsl.Header("length:Content-Length") + }) + }) + }) + }) + }) + plan := mustServicePlan(t, root) + services := plan.Services() + servicePath := servicePackagePath(services.generation.GenPkg(), root.Service("Fmt")) + outputPackage := path.Dir(services.generation.GenPkg()) + servicePkg := services.aliases.name(outputPackage, servicePath) + require.NotEqual(t, "fmt", servicePkg) + require.Equal(t, "strings2", services.aliases.name(outputPackage, "example.com/custom/strings")) + + files := ExampleServiceFiles(plan) + require.Len(t, files, 1) + code := renderSections(t, files[0].SectionTemplates) + _, err := format.Source([]byte(code)) + require.NoError(t, err, code) + require.Contains(t, code, servicePkg+".Service") + require.Contains(t, code, "p strings2.Value") +} + +// TestServiceUsesCanonicalViewsQualifier verifies that design metadata cannot +// steal the qualifier reserved for the generated views package. +func TestServiceUsesCanonicalViewsQualifier(t *testing.T) { + root := codegen.RunDSL(t, func() { + result := dsl.ResultType("application/vnd.value", func() { + dsl.TypeName("Value") + dsl.Attribute("custom", dsl.String, func() { + dsl.Meta("struct:field:type", "valuesviews.Value", "example.com/custom/views", "valuesviews") + }) + dsl.View("default", func() { + dsl.Attribute("custom") + }) + }) + dsl.Service("Values", func() { + dsl.Method("Read", func() { + dsl.Result(result) + }) + }) + }) + plan := mustServicePlan(t, root) + services := plan.Services() + servicePath := servicePackagePath(services.generation.GenPkg(), root.Service("Values")) + viewsPath := servicePath + "/views" + require.Equal(t, "valuesviews", services.aliases.name(servicePath, viewsPath)) + require.Equal(t, "valuesviews2", services.aliases.name(servicePath, "example.com/custom/views")) + + file := findFile(mustServiceFiles(t, plan), filepath.Join("gen", "values", "service.go")) + require.NotNil(t, file) + code := renderSections(t, file.SectionTemplates) + _, err := format.Source([]byte(code)) + require.NoError(t, err, code) + require.Contains(t, code, `valuesviews "`+viewsPath+`"`) + require.Contains(t, code, `valuesviews2 "example.com/custom/views"`) +} + +// TestViewValidationReservesOnlyUsedImports verifies that validation +// without string-length checks leaves the utf8 package name available to a +// field type supplied by the design. +func TestViewValidationReservesOnlyUsedImports(t *testing.T) { + const customUTF8 = "example.com/custom/utf8" + root := codegen.RunDSL(t, func() { + result := dsl.ResultType("application/vnd.value", func() { + dsl.Attribute("value", dsl.String, func() { + dsl.Meta("struct:field:type", "utf8.Value", customUTF8, "utf8") + }) + dsl.Attribute("name", dsl.String, func() { + dsl.Pattern("^[a-z]+$") + }) + dsl.View("default", func() { + dsl.Attribute("value") + dsl.Attribute("name") + }) + }) + dsl.Service("Values", func() { + dsl.Method("Read", func() { + dsl.Result(result) + }) + }) + }) + plan := mustServicePlan(t, root) + outputPackage := servicePackagePath(plan.Services().generation.GenPkg(), root.Service("Values")) + "/views" + require.Equal(t, "utf8", plan.Services().aliases.name(outputPackage, customUTF8)) + + file := findFile(mustServiceFiles(t, plan), filepath.Join("gen", "values", "views", "view.go")) + require.NotNil(t, file) + code := renderSections(t, file.SectionTemplates) + require.Contains(t, code, `"`+customUTF8+`"`) + require.NotContains(t, code, `"unicode/utf8"`) +} + +// TestUnionFieldReferencesUseFixedImportAliases verifies that the qualifier in +// a union field type and the import declaration come from the same frozen path +// binding when encoding/json already owns the preferred json name. +func TestUnionFieldReferencesUseFixedImportAliases(t *testing.T) { + generation := mustTestGeneration(t, "generated.local/gen", nil) + generatedPackage := mustClaimTestPackage(t, generation, "generated.local/gen/values") + require.NoError(t, generatedPackage.RequireImport(codegen.SimpleImport("encoding/json"))) + require.NoError(t, generatedPackage.DeclareImport(codegen.NewImport("values", "generated.local/gen/values"))) + require.NoError(t, generatedPackage.DeclareImport(codegen.NewImport("json", "example.com/custom/json"))) + branch := &expr.AttributeExpr{Type: expr.String, Meta: expr.MetaExpr{ + "struct:field:type": {"json.Value", "example.com/custom/json", "json"}, + }} + union := &expr.Union{ + TypeName: "Choice", + Values: []*expr.NamedAttributeExpr{{ + Name: "external", + Attribute: branch, + }}, + } + unionAttribute := &expr.AttributeExpr{Type: union} + declaration, err := generatedPackage.DeclareUnion(unionAttribute) + require.NoError(t, err) + facts := &unionFacts{ + attribute: unionAttribute, + union: union, + identity: codegen.NewUnionDeclarationID(unionAttribute), + typeKey: union.GetTypeKey(), + valueKey: union.GetValueKey(), + declaration: declaration, + } + require.NoError(t, planUnionRenderFacts(facts, nil, generatedPackage)) + require.NoError(t, generation.Freeze()) + aliases := &importAliases{generation: generation} + data := buildRetainedUnionTypeData(facts, aliases) + require.Equal(t, "json2.Value", data.Fields[0].FieldType) + + header := codegen.Header("Union types", "values", []*codegen.ImportSpec{ + codegen.SimpleImport("encoding/json"), + generatedPackage.Import("example.com/custom/json"), + }) + var rendered strings.Builder + require.NoError(t, header.Write(&rendered)) + require.Contains(t, rendered.String(), `"encoding/json"`) + require.Contains(t, rendered.String(), `json2 "example.com/custom/json"`) +} diff --git a/codegen/service/interceptor_data.go b/codegen/service/interceptor_data.go new file mode 100644 index 0000000000..431703e9a9 --- /dev/null +++ b/codegen/service/interceptor_data.go @@ -0,0 +1,154 @@ +// This file builds the values used to generate interceptor interfaces and +// wrappers. +package service + +import ( + "goa.design/goa/v3/codegen" +) + +// buildInterceptorData creates the data needed to generate interceptor code. +func buildInterceptorData(service *serviceFacts, facts *interceptorFacts, methods map[*methodFacts]*MethodData, resolver *declarationResolver, server bool) *InterceptorData { + lookup := func(role serviceNameRole, subject string) *codegen.NameDeclaration { + return service.names[serviceSymbolID{ + role: role, service: service.name, subject: subject, + }].declaration + } + data := &InterceptorData{ + InfoDeclaration: lookup(serviceInterceptorInfoNameRole, facts.name), + PayloadDeclaration: lookup(serviceInterceptorPayloadNameRole, facts.name), + ResultDeclaration: lookup(serviceInterceptorResultNameRole, facts.name), + StreamingPayloadDeclaration: lookup(serviceInterceptorStreamingPayloadNameRole, facts.name), + StreamingResultDeclaration: lookup(serviceInterceptorStreamingResultNameRole, facts.name), + Name: codegen.Goify(facts.name, true), + DesignName: facts.name, + Description: facts.description, + Service: service.name, + } + if len(facts.methods) == 0 { + return data + } + data.ReadPayload = formatInterceptorAccess(facts.readPayloadFields, resolver) + data.WritePayload = formatInterceptorAccess(facts.writePayloadFields, resolver) + data.ReadResult = formatInterceptorAccess(facts.readResultFields, resolver) + data.WriteResult = formatInterceptorAccess(facts.writeResultFields, resolver) + data.ReadStreamingPayload = formatInterceptorAccess(facts.readStreamingPayloadFields, resolver) + data.WriteStreamingPayload = formatInterceptorAccess(facts.writeStreamingPayloadFields, resolver) + data.ReadStreamingResult = formatInterceptorAccess(facts.readStreamingResultFields, resolver) + data.WriteStreamingResult = formatInterceptorAccess(facts.writeStreamingResultFields, resolver) + data.HasPayloadAccess = len(data.ReadPayload) > 0 || len(data.WritePayload) > 0 + data.HasResultAccess = len(data.ReadResult) > 0 || len(data.WriteResult) > 0 + data.HasStreamingPayloadAccess = len(data.ReadStreamingPayload) > 0 || len(data.WriteStreamingPayload) > 0 + data.HasStreamingResultAccess = len(data.ReadStreamingResult) > 0 || len(data.WriteStreamingResult) > 0 + for _, method := range facts.methods { + md := methods[method] + data.Methods = append(data.Methods, buildInterceptorMethodData(service, facts.name, md)) + if server { + md.ServerInterceptors = append(md.ServerInterceptors, facts.name) + } else { + md.ClientInterceptors = append(md.ClientInterceptors, facts.name) + } + } + return data +} + +// formatInterceptorAccess returns the generated name and type for each field +// that an interceptor may read or write. +func formatInterceptorAccess(facts []*interceptorAccessFacts, resolver *declarationResolver) []*AttributeData { + if len(facts) == 0 { + return nil + } + data := make([]*AttributeData, len(facts)) + for index, field := range facts { + data[index] = &AttributeData{ + Name: field.name, + TypeRef: field.layout.Link(resolver.outputPath, retainedTypeQualifier(resolver.aliases, resolver.outputPath)).Ref(), + Pointer: field.pointer, + } + } + return data +} + +// buildInterceptorMethodData creates the data needed to generate interceptor +// method code. +func buildInterceptorMethodData(service *serviceFacts, interceptorName string, md *MethodData) *MethodInterceptorData { + declaration := func(role serviceNameRole) *codegen.NameDeclaration { + return service.names[serviceSymbolID{ + role: role, service: service.name, method: md.VarName, subject: interceptorName, + }].declaration + } + var serverStream, clientStream *StreamInterceptorData + if md.ServerStream != nil { + serverStream = &StreamInterceptorData{ + InterfaceDeclaration: md.ServerStreamDeclaration, + WrapperDeclaration: service.names[serviceSymbolID{ + role: serviceServerStreamWrapperNameRole, service: service.name, method: md.VarName, + }].declaration, + Interface: md.ServerStream.Interface, + SendName: md.ServerStream.SendName, + SendWithContextName: md.ServerStream.SendWithContextName, + SendTypeRef: md.ServerStream.SendTypeRef, + RecvName: md.ServerStream.RecvName, + RecvWithContextName: md.ServerStream.RecvWithContextName, + RecvTypeRef: md.ServerStream.RecvTypeRef, + MustClose: md.ServerStream.MustClose, + EndpointStruct: md.ServerStream.EndpointStruct, + } + } + if md.ClientStream != nil { + clientStream = &StreamInterceptorData{ + InterfaceDeclaration: md.ClientStreamDeclaration, + WrapperDeclaration: service.names[serviceSymbolID{ + role: serviceClientStreamWrapperNameRole, service: service.name, method: md.VarName, + }].declaration, + Interface: md.ClientStream.Interface, + SendName: md.ClientStream.SendName, + SendWithContextName: md.ClientStream.SendWithContextName, + SendTypeRef: md.ClientStream.SendTypeRef, + RecvName: md.ClientStream.RecvName, + RecvWithContextName: md.ClientStream.RecvWithContextName, + RecvTypeRef: md.ClientStream.RecvTypeRef, + MustClose: md.ClientStream.MustClose, + } + } + payloadAccessDeclaration := declaration(serviceInterceptorPayloadAccessNameRole) + resultAccessDeclaration := declaration(serviceInterceptorResultAccessNameRole) + streamingPayloadAccessDeclaration := declaration(serviceInterceptorStreamingPayloadAccessNameRole) + streamingResultAccessDeclaration := declaration(serviceInterceptorStreamingResultAccessNameRole) + var payloadAccess, resultAccess, streamingPayloadAccess, streamingResultAccess string + if payloadAccessDeclaration != nil { + payloadAccess = payloadAccessDeclaration.Name() + } + if resultAccessDeclaration != nil { + resultAccess = resultAccessDeclaration.Name() + } + if streamingPayloadAccessDeclaration != nil { + streamingPayloadAccess = streamingPayloadAccessDeclaration.Name() + } + if streamingResultAccessDeclaration != nil { + streamingResultAccess = streamingResultAccessDeclaration.Name() + } + return &MethodInterceptorData{ + InfoDeclaration: declaration(serviceInterceptorMethodInfoNameRole), + ServerUnaryInfoDeclaration: declaration(serviceInterceptorServerUnaryInfoNameRole), + ClientUnaryInfoDeclaration: declaration(serviceInterceptorClientUnaryInfoNameRole), + StreamingSendInfoDeclaration: declaration(serviceInterceptorStreamingSendInfoNameRole), + StreamingRecvInfoDeclaration: declaration(serviceInterceptorStreamingRecvInfoNameRole), + PayloadAccessDeclaration: payloadAccessDeclaration, + ResultAccessDeclaration: resultAccessDeclaration, + StreamingPayloadAccessDeclaration: streamingPayloadAccessDeclaration, + StreamingResultAccessDeclaration: streamingResultAccessDeclaration, + ServerWrapperDeclaration: declaration(serviceServerInterceptorWrapperNameRole), + ClientWrapperDeclaration: declaration(serviceClientInterceptorWrapperNameRole), + MethodName: md.VarName, + PayloadAccess: payloadAccess, + ResultAccess: resultAccess, + PayloadRef: md.PayloadRef, + ResultRef: md.ResultRef, + StreamingPayloadAccess: streamingPayloadAccess, + StreamingPayloadRef: md.StreamingPayloadRef, + StreamingResultAccess: streamingResultAccess, + StreamingResultRef: md.StreamingResultRef, + ClientStream: clientStream, + ServerStream: serverStream, + } +} diff --git a/codegen/service/interceptors.go b/codegen/service/interceptors.go index e34ca006d4..e40199a316 100644 --- a/codegen/service/interceptors.go +++ b/codegen/service/interceptors.go @@ -1,28 +1,50 @@ +// This file generates service interceptor interfaces, call information, and +// endpoint wrappers. package service import ( "path/filepath" "goa.design/goa/v3/codegen" - "goa.design/goa/v3/expr" ) -// InterceptorsFiles returns the interceptors files for the given service. -func InterceptorsFiles(_ string, service *expr.ServiceExpr, services *ServicesData) []*codegen.File { +type ( + // endpointInterceptorWrapperData lists the interceptor wrappers called by + // one generated endpoint wrapper, in call order. + endpointInterceptorWrapperData struct { + Declaration *codegen.NameDeclaration + InterceptorsDeclaration *codegen.NameDeclaration + Method string + Service string + Wrappers []*codegen.NameDeclaration + } + + // interceptorWrappersData identifies the server or client interceptor + // interface and the interceptors called through it. + interceptorWrappersData struct { + Service string + InterceptorsDeclaration *codegen.NameDeclaration + Interceptors []*InterceptorData + } +) + +// interceptorsFiles generates interceptor files for one service. +func interceptorsFiles(plan *Plan, facts *serviceFacts) []*codegen.File { var files []*codegen.File - svc := services.Get(service.Name) + services := plan.Services() + svc := services.Get(facts.name) // Generate service-specific interceptor files if len(svc.ServerInterceptors) > 0 { - files = append(files, interceptorFile(svc, true)) + files = append(files, interceptorFile(svc, facts.imports.serverInterceptors.Imports(), true)) } if len(svc.ClientInterceptors) > 0 { - files = append(files, interceptorFile(svc, false)) + files = append(files, interceptorFile(svc, facts.imports.clientInterceptors.Imports(), false)) } // Generate wrapper file if this service has any interceptors if len(svc.ServerInterceptors) > 0 || len(svc.ClientInterceptors) > 0 { - files = append(files, wrapperFile(svc)) + files = append(files, wrapperFile(svc, facts.imports.interceptorWrappers.Imports())) } return files @@ -30,7 +52,7 @@ func InterceptorsFiles(_ string, service *expr.ServiceExpr, services *ServicesDa // interceptorFile returns the file defining the interceptors. // This method is called twice, once for the server and once for the client. -func interceptorFile(svc *Data, server bool) *codegen.File { +func interceptorFile(svc *Data, imports []*codegen.ImportSpec, server bool) *codegen.File { filename := "client_interceptors.go" template := clientInterceptorsT section := "client-interceptors-type" @@ -44,10 +66,11 @@ func interceptorFile(svc *Data, server bool) *codegen.File { desc = svc.Name + desc path := filepath.Join(codegen.Gendir, svc.PathName, filename) - interceptors := svc.ServerInterceptors - if !server { - interceptors = svc.ClientInterceptors + interceptors := svc.ClientInterceptors + if server { + interceptors = mergeInterceptorDefinitions(svc.ServerInterceptors, svc.ClientInterceptors) } + appliedInterceptors := interceptors // We don't want to generate duplicate interceptor info data structures for // interceptors that are both server and client side so remove interceptors @@ -67,10 +90,7 @@ func interceptorFile(svc *Data, server bool) *codegen.File { } sections := []*codegen.SectionTemplate{ - codegen.Header(desc, svc.PkgName, []*codegen.ImportSpec{ - {Path: "context"}, - codegen.GoaImport(""), - }), + codegen.Header(desc, svc.PkgName, imports), { Name: section, Source: serviceTemplates.Read(template), @@ -96,20 +116,33 @@ func interceptorFile(svc *Data, server bool) *codegen.File { } for _, m := range svc.Methods { ints := m.ServerInterceptors + declaration := m.ServerEndpointWrapperDeclaration + interceptorsDeclaration := svc.ServerInterceptorsDeclaration if !server { ints = m.ClientInterceptors + declaration = m.ClientEndpointWrapperDeclaration + interceptorsDeclaration = svc.ClientInterceptorsDeclaration } if len(ints) == 0 { continue } + wrappers := make([]*codegen.NameDeclaration, len(ints)) + for index, name := range ints { + interceptor := interceptorMethod(appliedInterceptors, name, m.VarName) + wrappers[index] = interceptor.ServerWrapperDeclaration + if !server { + wrappers[index] = interceptor.ClientWrapperDeclaration + } + } sections = append(sections, &codegen.SectionTemplate{ Name: section, Source: serviceTemplates.Read(template), - Data: map[string]any{ - "MethodVarName": m.VarName, - "Method": m.Name, - "Service": svc.Name, - "Interceptors": ints, + Data: &endpointInterceptorWrapperData{ + Declaration: declaration, + InterceptorsDeclaration: interceptorsDeclaration, + Method: m.Name, + Service: svc.Name, + Wrappers: wrappers, }, }) } @@ -120,8 +153,8 @@ func interceptorFile(svc *Data, server bool) *codegen.File { Source: serviceTemplates.Read(interceptorsT), Data: interceptors, FuncMap: map[string]any{ - "hasPrivateImplementationTypes": hasPrivateImplementationTypes, - "hasEndpointStruct": hasEndpointStruct(server), + "hasPrivateAccessorMethods": hasPrivateAccessorMethods, + "hasEndpointStruct": hasEndpointStruct(server), }, }) } @@ -129,16 +162,41 @@ func interceptorFile(svc *Data, server bool) *codegen.File { return &codegen.File{Path: path, SectionTemplates: sections} } +// mergeInterceptorDefinitions adds client-only methods when the shared +// interceptor interface is written in the server file. This keeps every method +// that uses that interface in the same generated file. +func mergeInterceptorDefinitions(server, client []*InterceptorData) []*InterceptorData { + merged := make([]*InterceptorData, len(server)) + for index, interceptor := range server { + copy := *interceptor + copy.Methods = append([]*MethodInterceptorData(nil), interceptor.Methods...) + seen := make(map[string]struct{}, len(copy.Methods)) + for _, method := range copy.Methods { + seen[method.MethodName] = struct{}{} + } + for _, candidate := range client { + if candidate.DesignName != interceptor.DesignName { + continue + } + for _, method := range candidate.Methods { + if _, exists := seen[method.MethodName]; exists { + continue + } + copy.Methods = append(copy.Methods, method) + seen[method.MethodName] = struct{}{} + } + } + merged[index] = © + } + return merged +} + // wrapperFile returns the file containing the interceptor wrappers. -func wrapperFile(svc *Data) *codegen.File { +func wrapperFile(svc *Data, imports []*codegen.ImportSpec) *codegen.File { path := filepath.Join(codegen.Gendir, svc.PathName, "interceptor_wrappers.go") var sections []*codegen.SectionTemplate - sections = append(sections, codegen.Header("Interceptor wrappers", svc.PkgName, []*codegen.ImportSpec{ - {Path: "context"}, - {Path: "fmt"}, - codegen.GoaImport(""), - })) + sections = append(sections, codegen.Header("Interceptor wrappers", svc.PkgName, imports)) // Generate any interceptor stream wrapper struct types first var wrappedServerStreams, wrappedClientStreams []*StreamInterceptorData @@ -172,9 +230,10 @@ func wrapperFile(svc *Data) *codegen.File { sections = append(sections, &codegen.SectionTemplate{ Name: "server-interceptor-wrappers", Source: serviceTemplates.Read(serverInterceptorWrappersT), - Data: map[string]any{ - "Service": svc.Name, - "ServerInterceptors": svc.ServerInterceptors, + Data: &interceptorWrappersData{ + Service: svc.Name, + InterceptorsDeclaration: svc.ServerInterceptorsDeclaration, + Interceptors: svc.ServerInterceptors, }, }) } @@ -182,9 +241,10 @@ func wrapperFile(svc *Data) *codegen.File { sections = append(sections, &codegen.SectionTemplate{ Name: "client-interceptor-wrappers", Source: serviceTemplates.Read(clientInterceptorWrappersT), - Data: map[string]any{ - "Service": svc.Name, - "ClientInterceptors": svc.ClientInterceptors, + Data: &interceptorWrappersData{ + Service: svc.Name, + InterceptorsDeclaration: svc.ClientInterceptorsDeclaration, + Interceptors: svc.ClientInterceptors, }, }) } @@ -215,11 +275,40 @@ func wrapperFile(svc *Data) *codegen.File { } } -// hasPrivateImplementationTypes returns true if any of the interceptors have -// private implementation types. +// interceptorMethod returns the generated call information for one interceptor +// and service method. The design has already linked the interceptor to the +// method, so a missing entry is a generator bug. +func interceptorMethod(interceptors []*InterceptorData, name, method string) *MethodInterceptorData { + for _, interceptor := range interceptors { + if interceptor.DesignName != name { + continue + } + for _, candidate := range interceptor.Methods { + if candidate.MethodName == method { + return candidate + } + } + } + panic("retained interceptor method is missing") +} + +// hasPrivateImplementationTypes reports whether the file needs private structs +// that hold call information for a service method. func hasPrivateImplementationTypes(interceptors []*InterceptorData) bool { for _, intr := range interceptors { - if intr.ReadPayload != nil || intr.WritePayload != nil || intr.ReadResult != nil || intr.WriteResult != nil || intr.ReadStreamingPayload != nil || intr.WriteStreamingPayload != nil || intr.ReadStreamingResult != nil || intr.WriteStreamingResult != nil { + if len(intr.Methods) > 0 { + return true + } + } + return false +} + +// hasPrivateAccessorMethods reports whether an interceptor exposes selected +// payload or result fields through private accessor methods. +func hasPrivateAccessorMethods(interceptors []*InterceptorData) bool { + for _, interceptor := range interceptors { + if interceptor.HasPayloadAccess || interceptor.HasResultAccess || + interceptor.HasStreamingPayloadAccess || interceptor.HasStreamingResultAccess { return true } } @@ -247,14 +336,14 @@ func collectWrappedStreams(interceptors []*InterceptorData, server bool) []*Stre if intr.HasStreamingPayloadAccess || intr.HasStreamingResultAccess { for _, method := range intr.Methods { if server { - if _, ok := streamNames[method.ServerStream.Interface]; !ok { + if _, ok := streamNames[method.ServerStream.InterfaceDeclaration.Name()]; !ok { streams = append(streams, method.ServerStream) - streamNames[method.ServerStream.Interface] = struct{}{} + streamNames[method.ServerStream.InterfaceDeclaration.Name()] = struct{}{} } } else { - if _, ok := streamNames[method.ClientStream.Interface]; !ok { + if _, ok := streamNames[method.ClientStream.InterfaceDeclaration.Name()]; !ok { streams = append(streams, method.ClientStream) - streamNames[method.ClientStream.Interface] = struct{}{} + streamNames[method.ClientStream.InterfaceDeclaration.Name()] = struct{}{} } } } diff --git a/codegen/service/interceptors_test.go b/codegen/service/interceptors_test.go index 3dd2977b14..bdc992efb0 100644 --- a/codegen/service/interceptors_test.go +++ b/codegen/service/interceptors_test.go @@ -1,3 +1,5 @@ +// This file verifies generated server and client interceptor data, including +// selected payload and result attribute references. package service import ( @@ -13,9 +15,7 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - "goa.design/goa/v3/codegen" "goa.design/goa/v3/codegen/service/testdata" - "goa.design/goa/v3/expr" ) var updateGolden = flag.Bool("update-interceptors", false, "update golden files for interceptor tests") @@ -31,8 +31,13 @@ func TestInterceptors(t *testing.T) { {"single-service-server-interceptor", testdata.SingleServiceServerInterceptorDSL, 2}, {"single-method-server-interceptor", testdata.SingleMethodServerInterceptorDSL, 2}, {"single-client-interceptor", testdata.SingleClientInterceptorDSL, 2}, + {"leading-initialism-interceptor", testdata.LeadingInitialismInterceptorDSL, 3}, {"multiple-interceptors", testdata.MultipleInterceptorsExampleDSL, 3}, {"interceptor-with-read-payload", testdata.InterceptorWithReadPayloadDSL, 3}, + {"interceptor-with-external-read-payload", testdata.InterceptorWithExternalReadPayloadDSL, 2}, + {"interceptor-with-external-payload", testdata.InterceptorWithExternalPayloadDSL, 2}, + {"mixed-interceptors-with-external-client-payload", testdata.MixedInterceptorsWithExternalClientPayloadDSL, 3}, + {"merged-interceptors-with-external-client-payload", testdata.MergedInterceptorsWithExternalClientPayloadDSL, 3}, {"interceptor-with-write-payload", testdata.InterceptorWithWritePayloadDSL, 3}, {"interceptor-with-read-write-payload", testdata.InterceptorWithReadWritePayloadDSL, 3}, {"interceptor-with-read-result", testdata.InterceptorWithReadResultDSL, 3}, @@ -41,19 +46,59 @@ func TestInterceptors(t *testing.T) { {"streaming-interceptors", testdata.StreamingInterceptorsDSL, 3}, {"streaming-interceptors-with-read-payload-and-read-streaming-payload", testdata.StreamingInterceptorsWithReadPayloadAndReadStreamingPayloadDSL, 3}, {"streaming-interceptors-with-read-streaming-result", testdata.StreamingInterceptorsWithReadStreamingResultDSL, 3}, + {"mixed-result-streaming-interceptors", testdata.MixedResultStreamingInterceptorsDSL, 3}, {"streaming-interceptors-with-read-payload", testdata.StreamingInterceptorsWithReadPayloadDSL, 2}, {"streaming-interceptors-with-read-result", testdata.StreamingInterceptorsWithReadResultDSL, 2}, } for _, c := range cases { t.Run(c.Name, func(t *testing.T) { root := runDSL(t, c.DSL) - services := NewServicesData(root) + plan := mustServicePlan(t, root) require.Len(t, root.Services, 1) - fs := InterceptorsFiles("goa.design/goa/example", root.Services[0], services) + fs := interceptorsFiles(plan, plan.facts.services[0]) require.Len(t, fs, c.expectedFileCount) for _, f := range fs { + base := filepath.Base(f.Path) + if c.Name == "mixed-result-streaming-interceptors" { + header := new(bytes.Buffer) + require.NoError(t, f.SectionTemplates[0].Write(header)) + if base == "client_interceptors.go" { + require.NotContains(t, header.String(), `"goa.design/goa/example/events"`) + } else { + require.Contains(t, header.String(), `events "goa.design/goa/example/events"`) + } + require.NotContains(t, header.String(), `"goa.design/goa/example/summary"`) + } + if c.Name == "interceptor-with-external-read-payload" && base == "service_interceptors.go" { + header := new(bytes.Buffer) + require.NoError(t, f.SectionTemplates[0].Write(header)) + require.Contains(t, header.String(), `types "goa.design/goa/example/types"`) + } + if c.Name == "interceptor-with-external-payload" && base == "service_interceptors.go" { + header := new(bytes.Buffer) + require.NoError(t, f.SectionTemplates[0].Write(header)) + require.Contains(t, header.String(), `types "goa.design/goa/example/types"`) + } + if c.Name == "mixed-interceptors-with-external-client-payload" { + header := new(bytes.Buffer) + require.NoError(t, f.SectionTemplates[0].Write(header)) + if base == "client_interceptors.go" { + require.Contains(t, header.String(), `types "goa.design/goa/example/types"`) + } else { + require.NotContains(t, header.String(), `"goa.design/goa/example/types"`) + } + } + if c.Name == "merged-interceptors-with-external-client-payload" { + header := new(bytes.Buffer) + require.NoError(t, f.SectionTemplates[0].Write(header)) + if base == "service_interceptors.go" { + require.Contains(t, header.String(), `types "goa.design/goa/example/types"`) + } else { + require.NotContains(t, header.String(), `"goa.design/goa/example/types"`) + } + } buf := new(bytes.Buffer) for _, s := range f.SectionTemplates[1:] { require.NoError(t, s.Write(buf)) @@ -91,137 +136,6 @@ func TestInvalidInterceptors(t *testing.T) { } } -func TestCollectAttributes(t *testing.T) { - cases := []struct { - name string - attrNames *expr.AttributeExpr - parent *expr.AttributeExpr - want []*AttributeData - panics bool - }{ - { - name: "nil-attributes", - attrNames: nil, - parent: &expr.AttributeExpr{Type: &expr.Object{}}, - want: nil, - }, - { - name: "non-object-attributes", - attrNames: &expr.AttributeExpr{Type: expr.Primitive(expr.StringKind)}, - parent: &expr.AttributeExpr{Type: &expr.Object{}}, - want: nil, - }, - { - name: "simple-string-attribute", - attrNames: &expr.AttributeExpr{ - Type: &expr.Object{ - {Name: "name", Attribute: &expr.AttributeExpr{Type: expr.Primitive(expr.StringKind)}}, - }, - }, - parent: &expr.AttributeExpr{ - Type: &expr.Object{ - {Name: "name", Attribute: &expr.AttributeExpr{Type: expr.Primitive(expr.StringKind)}}, - }, - Validation: &expr.ValidationExpr{Required: []string{"name"}}, - }, - want: []*AttributeData{ - {Name: "Name", TypeRef: "string", Pointer: false}, - }, - }, - { - name: "pointer-primitive", - attrNames: &expr.AttributeExpr{ - Type: &expr.Object{ - {Name: "age", Attribute: &expr.AttributeExpr{Type: expr.Primitive(expr.IntKind)}}, - }, - }, - parent: &expr.AttributeExpr{ - Type: &expr.Object{ - {Name: "age", Attribute: &expr.AttributeExpr{Type: expr.Primitive(expr.IntKind), Meta: map[string][]string{"struct:field:pointer": {"true"}}}}, - }, - }, - want: []*AttributeData{ - {Name: "Age", TypeRef: "int", Pointer: true}, - }, - }, - { - name: "multiple-attributes", - attrNames: &expr.AttributeExpr{ - Type: &expr.Object{ - {Name: "name", Attribute: &expr.AttributeExpr{Type: expr.Primitive(expr.StringKind)}}, - {Name: "age", Attribute: &expr.AttributeExpr{Type: expr.Primitive(expr.IntKind)}}, - }, - }, - parent: &expr.AttributeExpr{ - Type: &expr.Object{ - {Name: "name", Attribute: &expr.AttributeExpr{Type: expr.Primitive(expr.StringKind)}}, - {Name: "age", Attribute: &expr.AttributeExpr{Type: expr.Primitive(expr.IntKind), Meta: map[string][]string{"struct:field:pointer": {"true"}}}}, - }, - Validation: &expr.ValidationExpr{Required: []string{"name"}}, - }, - want: []*AttributeData{ - {Name: "Name", TypeRef: "string", Pointer: false}, - {Name: "Age", TypeRef: "int", Pointer: true}, - }, - }, - { - name: "attribute-not-in-parent", - attrNames: &expr.AttributeExpr{ - Type: &expr.Object{ - {Name: "missing", Attribute: &expr.AttributeExpr{Type: expr.Primitive(expr.StringKind)}}, - }, - }, - parent: &expr.AttributeExpr{ - Type: &expr.Object{ - {Name: "name", Attribute: &expr.AttributeExpr{Type: expr.Primitive(expr.StringKind)}}, - }, - Validation: &expr.ValidationExpr{Required: []string{"name"}}, - }, - panics: true, - }, - { - name: "user-type-with-package", - attrNames: &expr.AttributeExpr{ - Type: &expr.Object{ - {Name: "user", Attribute: &expr.AttributeExpr{Type: expr.String}}, - }, - }, - parent: &expr.AttributeExpr{ - Type: &expr.Object{ - {Name: "user", Attribute: &expr.AttributeExpr{ - Type: &expr.UserTypeExpr{ - AttributeExpr: &expr.AttributeExpr{ - Type: &expr.Object{ - {Name: "name", Attribute: &expr.AttributeExpr{Type: expr.Primitive(expr.StringKind)}}, - }, - Meta: map[string][]string{ - "struct:pkg:path": {"goa.design/goa/example/user"}, - }, - }, - TypeName: "User", - }, - }}, - }, - }, - want: []*AttributeData{ - {Name: "User", TypeRef: "*user.User", Pointer: false}, - }, - }, - } - - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - scope := codegen.NewNameScope() - if tc.panics { - assert.Panics(t, func() { collectAttributes(tc.attrNames, tc.parent, scope) }) - return - } - got := collectAttributes(tc.attrNames, tc.parent, scope) - assert.Equal(t, tc.want, got) - }) - } -} - func compareOrUpdateGolden(t *testing.T, code, golden string) { t.Helper() if *updateGolden { diff --git a/codegen/service/method_data.go b/codegen/service/method_data.go new file mode 100644 index 0000000000..0ad274d791 --- /dev/null +++ b/codegen/service/method_data.go @@ -0,0 +1,260 @@ +// This file builds the template data for service methods and their streams from +// the values and Go declarations recorded during planning. +package service + +import ( + "fmt" + + "goa.design/goa/v3/codegen" + "goa.design/goa/v3/expr" +) + +// buildMethodData formats one method using the names and types chosen during +// service planning. +func buildMethodData(facts *methodFacts, resolver *declarationResolver, serviceFacts *serviceFacts) *MethodData { + var ( + vname string + desc string + payloadName string + payloadLoc *codegen.Location + payloadDef string + payloadRef string + payloadDesc string + payloadEx any + rname string + resultLoc *codegen.Location + resultDef string + resultRef string + resultDesc string + resultEx any + errors []*ErrorInitData + errorLocs map[string]*codegen.Location + reqs = facts.requirements + schemes = facts.schemes + ) + vname = facts.varName + desc = facts.description + if desc == "" { + desc = codegen.Goify(facts.name, true) + " implements " + facts.name + "." + } + if facts.payload != nil && facts.payload.present { + payloadLoc = facts.payload.location + payloadName, payloadDef, payloadRef = retainedMethodTypeData(facts.payload, resolver) + payloadDesc = facts.payload.description + if payloadDesc == "" { + payloadDesc = fmt.Sprintf("%s is the payload type of the %s service %s method.", + payloadName, serviceFacts.name, facts.name) + } + payloadEx = facts.payload.example + } + if facts.result != nil && facts.result.present { + resultLoc = facts.result.location + rname, resultDef, resultRef = retainedMethodTypeData(facts.result, resolver) + resultDesc = facts.result.description + if resultDesc == "" { + resultDesc = fmt.Sprintf("%s is the result type of the %s service %s method.", + rname, serviceFacts.name, facts.name) + } + resultEx = facts.result.example + } + if len(facts.errors) > 0 { + errors = make([]*ErrorInitData, len(facts.errors)) + errorLocs = make(map[string]*codegen.Location, len(facts.errors)) + for i, errorFacts := range facts.errors { + errors[i] = buildRetainedErrorInitData(errorFacts, resolver, serviceFacts.errorConstructors[errorFacts.name]) + errorLocs[errorFacts.name] = errorFacts.location + } + } + data := &MethodData{ + EndpointDeclaration: serviceFacts.names.declaration(serviceSymbolID{ + role: serviceMethodEndpointNameRole, service: serviceFacts.name, method: facts.varName, + }), + EndpointInputDeclaration: serviceFacts.names[serviceSymbolID{ + role: serviceEndpointInputNameRole, service: serviceFacts.name, method: facts.varName, + }].declaration, + ServerStreamDeclaration: serviceFacts.names[serviceSymbolID{ + role: serviceServerStreamNameRole, service: serviceFacts.name, method: facts.varName, + }].declaration, + ClientStreamDeclaration: serviceFacts.names[serviceSymbolID{ + role: serviceClientStreamNameRole, service: serviceFacts.name, method: facts.varName, + }].declaration, + RequestDeclaration: serviceFacts.names[serviceSymbolID{ + role: serviceRequestNameRole, service: serviceFacts.name, method: facts.varName, + }].declaration, + ResponseDeclaration: serviceFacts.names[serviceSymbolID{ + role: serviceResponseNameRole, service: serviceFacts.name, method: facts.varName, + }].declaration, + ServerEndpointWrapperDeclaration: serviceFacts.names[serviceSymbolID{ + role: serviceServerEndpointWrapperNameRole, service: serviceFacts.name, method: facts.varName, + }].declaration, + ClientEndpointWrapperDeclaration: serviceFacts.names[serviceSymbolID{ + role: serviceClientEndpointWrapperNameRole, service: serviceFacts.name, method: facts.varName, + }].declaration, + Name: facts.name, + VarName: vname, + Description: desc, + Idempotent: facts.idempotent, + Payload: payloadName, + PayloadLoc: payloadLoc, + PayloadDef: payloadDef, + PayloadRef: payloadRef, + PayloadDeclaration: facts.payload.layout.TypeDeclaration(), + PayloadDesc: payloadDesc, + PayloadEx: payloadEx, + PayloadDefault: facts.payload.defaultValue, + Result: rname, + ResultLoc: resultLoc, + ResultDef: resultDef, + ResultRef: resultRef, + ResultDeclaration: facts.result.layout.TypeDeclaration(), + ResultDesc: resultDesc, + ResultEx: resultEx, + Errors: errors, + ErrorLocs: errorLocs, + Requirements: reqs, + Schemes: schemes, + StreamKind: facts.streamKind, + HasMixedResults: facts.hasMixedResults, + SkipRequestBodyEncodeDecode: facts.skipRequestBodyEncodeDecode, + SkipResponseBodyEncodeDecode: facts.skipResponseBodyEncodeDecode, + RequestStruct: vname + "RequestData", + ResponseStruct: vname + "ResponseData", + EndpointField: facts.endpointField, + StreamEndpointField: facts.streamEndpointField, + } + + initStreamData(data, facts, resolver) + return data +} + +// initStreamData initializes the streaming payload data structures and methods. +func initStreamData(data *MethodData, facts *methodFacts, resolver *declarationResolver) { + if !facts.isStreaming && !facts.hasMixedResults { + return + } + var ( + spayloadName string + spayloadRef string + spayloadDef string + spayloadDesc string + spayloadEx any + srname string + srref string + srdef string + ) + if facts.streamingResult != nil && facts.streamingResult.present { + srname, srdef, srref = retainedMethodTypeData(facts.streamingResult, resolver) + } + data.StreamingResultRef = srref + + // Mixed-result methods return StreamingResult from their streaming endpoint + // and Result from their ordinary endpoint. + if facts.hasMixedResults && facts.streamingResult != nil && facts.streamingResult.present { + data.StreamingResult = srname + data.StreamingResultDef = srdef + data.StreamingResultDeclaration = facts.streamingResult.layout.TypeDeclaration() + data.StreamingResultDesc = facts.streamingResult.description + if data.StreamingResultDesc == "" { + data.StreamingResultDesc = fmt.Sprintf("%s is the streaming result type of the %s service %s method.", + srname, facts.serviceName, facts.name) + } + data.StreamingResultEx = facts.streamingResult.example + } + + if facts.streamingPayload != nil && facts.streamingPayload.present { + spayloadName, spayloadDef, spayloadRef = retainedMethodTypeData(facts.streamingPayload, resolver) + data.StreamingPayloadDeclaration = facts.streamingPayload.layout.TypeDeclaration() + spayloadDesc = facts.streamingPayload.description + if spayloadDesc == "" { + spayloadDesc = fmt.Sprintf("%s is the streaming payload type of the %s service %s method.", + spayloadName, facts.serviceName, facts.name) + } + spayloadEx = facts.streamingPayload.example + } + // Streaming endpoint calls carry the request value and stream together. + var endpointStruct string + if data.EndpointInputDeclaration != nil { + endpointStruct = data.EndpointInputDeclaration.Name() + } + // A mixed-result SSE method sends results from the server even though its + // service method is not otherwise marked as streaming. + streamKind := facts.streamKind + if facts.hasMixedResults && !facts.isStreaming { + streamKind = expr.ServerStreamKind + } + svrStream := &StreamData{ + Interface: data.ServerStreamDeclaration.Name(), + VarName: facts.serverStreamVarName, + EndpointStruct: endpointStruct, + Kind: streamKind, + SendName: "Send", + SendDesc: fmt.Sprintf("Send streams instances of %q.", srname), + SendWithContextName: "SendWithContext", + SendWithContextDesc: fmt.Sprintf("SendWithContext streams instances of %q with context.", srname), + SendTypeName: srname, + SendTypeRef: srref, + MustClose: true, + } + cliStream := &StreamData{ + Interface: data.ClientStreamDeclaration.Name(), + VarName: facts.clientStreamVarName, + Kind: streamKind, + RecvName: "Recv", + RecvDesc: fmt.Sprintf("Recv reads instances of %q from the stream.", srname), + RecvWithContextName: "RecvWithContext", + RecvWithContextDesc: fmt.Sprintf("RecvWithContext reads instances of %q from the stream with context.", srname), + RecvTypeName: srname, + RecvTypeRef: srref, + } + if streamKind == expr.ClientStreamKind || streamKind == expr.BidirectionalStreamKind { + switch streamKind { + case expr.ClientStreamKind: + if srref != "" { + svrStream.SendName = "SendAndClose" + svrStream.SendDesc = fmt.Sprintf("SendAndClose streams instances of %q and closes the stream.", srname) + svrStream.SendWithContextName = "SendAndCloseWithContext" + svrStream.SendWithContextDesc = fmt.Sprintf("SendAndCloseWithContext streams instances of %q and closes the stream with context.", srname) + svrStream.MustClose = false + cliStream.RecvName = "CloseAndRecv" + cliStream.RecvDesc = fmt.Sprintf("CloseAndRecv stops sending messages to the stream and reads instances of %q from the stream.", srname) + cliStream.RecvWithContextName = "CloseAndRecvWithContext" + cliStream.RecvWithContextDesc = fmt.Sprintf("CloseAndRecvWithContext stops sending messages to the stream and reads instances of %q from the stream with context.", srname) + } else { + cliStream.MustClose = true + } + case expr.BidirectionalStreamKind: + cliStream.MustClose = true + } + svrStream.RecvName = "Recv" + svrStream.RecvDesc = fmt.Sprintf("Recv reads instances of %q from the stream.", spayloadName) + svrStream.RecvWithContextName = "RecvWithContext" + svrStream.RecvWithContextDesc = fmt.Sprintf("RecvWithContext reads instances of %q from the stream with context.", spayloadName) + svrStream.RecvTypeName = spayloadName + svrStream.RecvTypeRef = spayloadRef + cliStream.SendName = "Send" + cliStream.SendDesc = fmt.Sprintf("Send streams instances of %q.", spayloadName) + cliStream.SendWithContextName = "SendWithContext" + cliStream.SendWithContextDesc = fmt.Sprintf("SendWithContext streams instances of %q with context.", spayloadName) + cliStream.SendTypeName = spayloadName + cliStream.SendTypeRef = spayloadRef + } + data.ClientStream = cliStream + data.ServerStream = svrStream + data.StreamingPayload = spayloadName + data.StreamingPayloadDef = spayloadDef + data.StreamingPayloadRef = spayloadRef + data.StreamingPayloadDesc = spayloadDesc + data.StreamingPayloadEx = spayloadEx +} + +// This helper returns the Go name, definition, and reference for one payload or +// result relative to the service output package. It reads the type layout +// copied during planning instead of rereading the design expression. +func retainedMethodTypeData(facts *methodAttributeFacts, resolver *declarationResolver) (string, string, string) { + linked := facts.layout.Link(resolver.outputPath, retainedTypeQualifier(resolver.aliases, resolver.outputPath)) + definition := "" + if facts.definition != nil { + definition = facts.definition.Link(facts.layout.Owner(), retainedTypeQualifier(resolver.aliases, facts.layout.Owner())).Def() + } + return linked.Name(), definition, linked.Ref() +} diff --git a/codegen/service/method_nested_layout_test.go b/codegen/service/method_nested_layout_test.go new file mode 100644 index 0000000000..d26a720062 --- /dev/null +++ b/codegen/service/method_nested_layout_test.go @@ -0,0 +1,146 @@ +// This file verifies method layouts retain fields below consecutive named +// types for transports and plugins that plan complete service conversions. +package service + +import ( + "testing" + + "github.com/stretchr/testify/require" + + "goa.design/goa/v3/codegen" + "goa.design/goa/v3/dsl" + "goa.design/goa/v3/expr" +) + +func TestMethodLayoutsRetainNestedNamedValues(t *testing.T) { + var payloadChild, payloadGrandchild, resultGrandchild, streamingPayloadGrandchild, streamingResultGrandchild expr.UserType + root := codegen.RunDSL(t, func() { + payloadGrandchild = dsl.Type("PayloadGrandchild", func() { + dsl.Attribute("value", dsl.String) + }) + payloadChild = dsl.Type("PayloadChild", func() { + dsl.Attribute("grandchild", payloadGrandchild) + }) + payload := dsl.Type("ReadPayload", func() { + dsl.Attribute("child", payloadChild) + }) + resultGrandchild = dsl.Type("ResultGrandchild", func() { + dsl.Attribute("value", dsl.String) + }) + resultChild := dsl.Type("ResultChild", func() { + dsl.Attribute("grandchild", resultGrandchild) + }) + result := dsl.ResultType("ReadResult", func() { + dsl.Attribute("child", resultChild) + }) + streamingPayloadGrandchild = dsl.Type("StreamingPayloadGrandchild", func() { + dsl.Attribute("value", dsl.String) + }) + streamingPayloadChild := dsl.Type("StreamingPayloadChild", func() { + dsl.Attribute("grandchild", streamingPayloadGrandchild) + }) + streamingPayload := dsl.Type("ReadStreamingPayload", func() { + dsl.Attribute("child", streamingPayloadChild) + }) + streamingResultGrandchild = dsl.Type("StreamingResultGrandchild", func() { + dsl.Attribute("value", dsl.String) + }) + streamingResultChild := dsl.Type("StreamingResultChild", func() { + dsl.Attribute("grandchild", streamingResultGrandchild) + }) + streamingResult := dsl.Type("ReadStreamingResult", func() { + dsl.Attribute("child", streamingResultChild) + }) + dsl.Service("Documents", func() { + dsl.Method("Read", func() { + dsl.Payload(payload) + dsl.StreamingPayload(streamingPayload) + dsl.StreamingResult(streamingResult) + dsl.Result(result) + }) + }) + }) + plan := mustServicePlan(t, root) + method := root.Service("Documents").Method("Read") + + for _, test := range []struct { + name string + grandchild expr.UserType + layout func(*expr.MethodExpr) (*codegen.GoTypePlan, error) + }{ + {name: "payload", grandchild: payloadGrandchild, layout: plan.MethodPayloadLayout}, + {name: "result", grandchild: resultGrandchild, layout: plan.MethodResultLayout}, + {name: "streaming result", grandchild: streamingResultGrandchild, layout: plan.StreamingResultLayout}, + } { + t.Run(test.name, func(t *testing.T) { + layout, err := test.layout(method) + require.NoError(t, err) + matches := layout.PlansForOccurrence(test.grandchild.Attribute().Find("value")) + require.Len(t, matches, 1) + require.Equal(t, codegen.GoPrimitive, matches[0].Kind()) + }) + } + + methodFacts := plan.facts.services[0].methodByExpr[method] + streamingPayloadMatches := methodFacts.streamingPayload.layout.PlansForOccurrence( + streamingPayloadGrandchild.Attribute().Find("value"), + ) + require.Len(t, streamingPayloadMatches, 1) + + var payloadFacts *userTypeFacts + for _, candidate := range plan.facts.services[0].userTypes { + if candidate.userType == payloadChild { + payloadFacts = candidate + break + } + } + require.NotNil(t, payloadFacts) + require.Empty(t, payloadFacts.layout.PlansForOccurrence(payloadGrandchild.Attribute().Find("value"))) +} + +// TestMethodTypeLayoutKeepsSourceAndProjectedDeclarations checks that creating +// a result view does not move the original service type into the views package. +func TestMethodTypeLayoutKeepsSourceAndProjectedDeclarations(t *testing.T) { + var viewed, plain *expr.MethodExpr + root := codegen.RunDSL(t, func() { + child := dsl.Type("SharedDeclarationChild", func() { + dsl.Attribute("value", dsl.String) + dsl.Required("value") + }) + result := dsl.ResultType("application/vnd.view-bindings", func() { + dsl.TypeName("ViewedContainer") + dsl.Attribute("child", child) + dsl.Required("child") + dsl.View("default", func() { + dsl.Attribute("child") + }) + }) + dsl.Service("ViewBindings", func() { + viewed = dsl.Method("Viewed", func() { + dsl.Result(result) + }) + plain = dsl.Method("Plain", func() { + dsl.Payload(func() { + dsl.Attribute("child", child) + }) + }) + }) + }) + plan := mustServicePlan(t, root) + + plainLayout, err := plan.MethodTypeLayout(plain, plain.Payload) + require.NoError(t, err) + plainChild := plainLayout.PlansForOccurrence(plain.Payload.Find("child")) + require.Len(t, plainChild, 1) + require.Equal(t, "goa.design/goa/example/view_bindings", plainChild[0].Owner()) + require.Equal(t, "SharedDeclarationChild", plainChild[0].TypeDeclaration().Name()) + + projected, err := plan.ProjectedResult(viewed) + require.NoError(t, err) + projectedLayout, err := plan.MethodTypeLayout(viewed, projected) + require.NoError(t, err) + projectedChild := projectedLayout.PlansForOccurrence(projected.Find("child")) + require.Len(t, projectedChild, 1) + require.Equal(t, "goa.design/goa/example/view_bindings/views", projectedChild[0].Owner()) + require.Equal(t, "SharedDeclarationChildView", projectedChild[0].TypeDeclaration().Name()) +} diff --git a/codegen/service/method_package_imports_test.go b/codegen/service/method_package_imports_test.go new file mode 100644 index 0000000000..221127595b --- /dev/null +++ b/codegen/service/method_package_imports_test.go @@ -0,0 +1,47 @@ +// This file verifies transport generators can retain the exact generated +// service and views package preferences chosen by a service plan. +package service + +import ( + "testing" + + "github.com/stretchr/testify/require" + + "goa.design/goa/v3/codegen" + "goa.design/goa/v3/dsl" + "goa.design/goa/v3/eval" + "goa.design/goa/v3/expr" +) + +// TestMethodPackageImportsReturnsPlannedServiceAndViewsPackages catches HTTP +// generators guessing a service or views package from their own output path. +func TestMethodPackageImportsReturnsPlannedServiceAndViewsPackages(t *testing.T) { + root := expr.RunDSL(t, func() { + result := dsl.ResultType("application/vnd.storage.item", func() { + dsl.TypeName("Item") + dsl.Attribute("name", dsl.String) + dsl.View("default", func() { + dsl.Attribute("name") + }) + }) + dsl.Service("Storage", func() { + dsl.Method("Show", func() { + dsl.Result(result) + }) + }) + }) + generation, err := codegen.NewGeneration("generated.local/gen", []eval.Root{root}) + require.NoError(t, err) + plan, err := NewPlan(root, generation, expr.NewExampleGenerator(root.API.RandomizerFactory)) + require.NoError(t, err) + + servicePackage, viewsPackage, err := plan.MethodPackageImports(root.Service("Storage").Method("Show")) + require.NoError(t, err) + require.Equal(t, &codegen.ImportSpec{Name: "storage", Path: "generated.local/gen/storage"}, servicePackage) + require.Equal(t, &codegen.ImportSpec{Name: "storageviews", Path: "generated.local/gen/storage/views"}, viewsPackage) + + servicePackage, viewsPackage, err = plan.ServicePackageImports(root.Service("Storage")) + require.NoError(t, err) + require.Equal(t, &codegen.ImportSpec{Name: "storage", Path: "generated.local/gen/storage"}, servicePackage) + require.Equal(t, &codegen.ImportSpec{Name: "storageviews", Path: "generated.local/gen/storage/views"}, viewsPackage) +} diff --git a/codegen/service/method_payload_layout_test.go b/codegen/service/method_payload_layout_test.go new file mode 100644 index 0000000000..40885c210e --- /dev/null +++ b/codegen/service/method_payload_layout_test.go @@ -0,0 +1,37 @@ +// This file verifies other generators can read the exact Go fields already +// chosen for a service method payload. +package service + +import ( + "testing" + + "github.com/stretchr/testify/require" + + "goa.design/goa/v3/codegen" + "goa.design/goa/v3/dsl" +) + +// TestMethodPayloadLayoutReturnsAssignedFieldNames verifies field metadata is +// applied once by the service planner and exposed without rebuilding the name. +func TestMethodPayloadLayoutReturnsAssignedFieldNames(t *testing.T) { + root := codegen.RunDSL(t, func() { + query := dsl.Type("ReadQuery", func() { + dsl.Attribute("cursor", dsl.String, func() { + dsl.Meta("struct:field:name", "OriginalCursor") + }) + }) + dsl.Service("Documents", func() { + dsl.Method("Read", func() { + dsl.Payload(query) + }) + }) + }) + plan := mustServicePlan(t, root) + + layout, err := plan.MethodPayloadLayout(root.Service("Documents").Method("Read")) + require.NoError(t, err) + require.Equal(t, codegen.GoStruct, layout.Kind()) + require.Len(t, layout.Fields(), 1) + require.Equal(t, "OriginalCursor", layout.Fields()[0].FieldName(true)) + require.True(t, layout.Fields()[0].IsPointer()) +} diff --git a/codegen/service/method_result_layout_test.go b/codegen/service/method_result_layout_test.go new file mode 100644 index 0000000000..b69e71b1ec --- /dev/null +++ b/codegen/service/method_result_layout_test.go @@ -0,0 +1,37 @@ +// This file verifies other generators can read the exact Go fields already +// chosen for a service method result. +package service + +import ( + "testing" + + "github.com/stretchr/testify/require" + + "goa.design/goa/v3/codegen" + "goa.design/goa/v3/dsl" +) + +// TestMethodResultLayoutReturnsAssignedFieldNames verifies field metadata is +// applied once by the service planner and exposed without rebuilding the name. +func TestMethodResultLayoutReturnsAssignedFieldNames(t *testing.T) { + root := codegen.RunDSL(t, func() { + result := dsl.ResultType("ReadResult", func() { + dsl.Attribute("cursor", dsl.String, func() { + dsl.Meta("struct:field:name", "OriginalCursor") + }) + }) + dsl.Service("Documents", func() { + dsl.Method("Read", func() { + dsl.Result(result) + }) + }) + }) + plan := mustServicePlan(t, root) + + layout, err := plan.MethodResultLayout(root.Service("Documents").Method("Read")) + require.NoError(t, err) + require.Equal(t, codegen.GoStruct, layout.Kind()) + require.Len(t, layout.Fields(), 1) + require.Equal(t, "OriginalCursor", layout.Fields()[0].FieldName(true)) + require.True(t, layout.Fields()[0].IsPointer()) +} diff --git a/codegen/service/plan.go b/codegen/service/plan.go new file mode 100644 index 0000000000..cdf0202bec --- /dev/null +++ b/codegen/service/plan.go @@ -0,0 +1,915 @@ +// This file stores the service declarations and file data selected for one +// generation run. File writers use this stored data without rereading the +// design. +package service + +import ( + "path" + "strings" + + "goa.design/goa/v3/codegen" + "goa.design/goa/v3/expr" +) + +type ( + // PlanInput supplies one evaluated Goa design and the generator used to make + // examples for types in that design. + PlanInput struct { + // Root is one evaluated Goa design included in this generation command. + Root *expr.RootExpr + // Examples produces the values written as examples for Root. + Examples *expr.ExampleGenerator + } + + // Plan stores one design's services, generated Go declarations, and template + // data from name collection through file rendering. + Plan struct { + generation *codegen.Generation + facts *rootFacts + services *ServicesData + } + + // rootFacts stores the services and API values selected from one design. + // Copying its slices prevents later steps from walking the design again and + // finding a different set of services. + rootFacts struct { + root *expr.RootExpr + apiName string + apiVersion string + examplePackageName string + exampleImports []*codegen.ImportSpec + services []*serviceFacts + serviceByID map[string]*serviceFacts + types []expr.UserType + rootTypes *rootTypeSet + examples *expr.ExampleGenerator + externalConversions []*externalConversionFileFacts + generatedTypes []*generatedTypeEmissionFacts + generatedUnions []*generatedUnionEmissionFacts + } + + // serviceFacts stores the service values needed to name and write its files. + serviceFacts struct { + service *expr.ServiceExpr + apiName string + name string + description string + packagePath string + viewsPath string + packageImport *codegen.ImportSpec + viewsImport *codegen.ImportSpec + methods []*expr.MethodExpr + orderedMethods []*methodFacts + methodByExpr map[*expr.MethodExpr]*methodFacts + errors []*expr.ErrorExpr + errorFacts []*errorRenderFacts + serverInterceptors []*expr.InterceptorExpr + clientInterceptors []*expr.InterceptorExpr + serverInterceptorFacts []*interceptorFacts + clientInterceptorFacts []*interceptorFacts + referenceAttributes []*expr.AttributeExpr + reachableTypes map[expr.UserType]struct{} + projections map[*expr.MethodExpr]*projectionFacts + userTypes []*userTypeFacts + errorTypes []*userTypeFacts + unions []*unionFacts + viewUnions []*unionFacts + names serviceNames + validators map[validatorKey]*codegen.NameDeclaration + errorConstructors map[string]*codegen.NameDeclaration + generatedTypeImports map[*codegen.TypeDeclaration]*codegen.GeneratedImportPlan + exampleStruct *codegen.NameDeclaration + exampleConstructor *codegen.NameDeclaration + exampleServerStruct *codegen.NameDeclaration + exampleServerConstructor *codegen.NameDeclaration + exampleClientStruct *codegen.NameDeclaration + exampleClientConstructor *codegen.NameDeclaration + imports serviceFileImports + data *Data + } + + // methodFacts stores the method values used by service and transport files. + methodFacts struct { + method *expr.MethodExpr + serviceName string + name string + description string + idempotent bool + payload *methodAttributeFacts + result *methodAttributeFacts + streamingPayload *methodAttributeFacts + streamingResult *methodAttributeFacts + errors []*errorRenderFacts + requirements RequirementsData + schemes SchemesData + streamKind expr.StreamKind + isStreaming bool + hasMixedResults bool + varName string + serverStreamVarName string + clientStreamVarName string + endpointField string + streamEndpointField string + viewedResult *viewedResultFacts + projection *projectionFacts + skipRequestBodyEncodeDecode bool + skipResponseBodyEncodeDecode bool + } + + // methodAttributeFacts stores one payload or result description, default, and + // example. GoTypePlan separately stores the Go fields nested inside it. + methodAttributeFacts struct { + attribute *expr.AttributeExpr + layout *codegen.GoTypePlan + definition *codegen.GoTypePlan + normalized bool + present bool + isObject bool + location *codegen.Location + description string + defaultValue any + example any + } + + // errorRenderFacts stores the error type, description, and marker fields + // written to service, client, and endpoint files. + errorRenderFacts struct { + attribute *expr.AttributeExpr + layout *codegen.GoTypePlan + name string + description string + location *codegen.Location + temporary bool + timeout bool + fault bool + serviceType bool + } + + // interceptorFacts stores the methods that call one interceptor on either the + // client or server side. + interceptorFacts struct { + name string + description string + readPayload *expr.AttributeExpr + writePayload *expr.AttributeExpr + readResult *expr.AttributeExpr + writeResult *expr.AttributeExpr + readStreamingPayload *expr.AttributeExpr + writeStreamingPayload *expr.AttributeExpr + readStreamingResult *expr.AttributeExpr + writeStreamingResult *expr.AttributeExpr + readPayloadFields []*interceptorAccessFacts + writePayloadFields []*interceptorAccessFacts + readResultFields []*interceptorAccessFacts + writeResultFields []*interceptorAccessFacts + readStreamingPayloadFields []*interceptorAccessFacts + writeStreamingPayloadFields []*interceptorAccessFacts + readStreamingResultFields []*interceptorAccessFacts + writeStreamingResultFields []*interceptorAccessFacts + methods []*methodFacts + } + + // interceptorAccessFacts stores one field exposed to an interceptor and the + // Go type written for that field. + interceptorAccessFacts struct { + attribute *expr.AttributeExpr + name string + pointer bool + layout *codegen.GoTypePlan + } + + // projectionFacts stores copies of one method's result types containing only + // the fields in each selected view. Name collection and template data both + // read these same copies. + projectionFacts struct { + pairs []*projectedTypePair + types []*projectedTypeFacts + } + + // projectedTypeFacts stores one result type containing the fields selected by + // a view, together with the validation and conversion code generated for it. + projectedTypeFacts struct { + pair *projectedTypePair + projectedType expr.UserType + projected *codegen.GoTypePlan + definition *codegen.GoTypePlan + source *codegen.GoTypePlan + resultType bool + views []*viewRenderFacts + validations []*validationFacts + conversions []*viewConversionFacts + mapDeclaration *codegen.NameDeclaration + declaration *codegen.TypeDeclaration + } + + // viewRenderFacts stores the description and ordered field names from one + // declared result view for the service and views templates. + viewRenderFacts struct { + name string + description string + attributes []string + } + + // validationFacts stores the field checks and child validation calls emitted + // by one view-specific validation function. Function names are added later. + validationFacts struct { + viewName string + attribute *expr.AttributeExpr + layout *codegen.GoTypePlan + plan *codegen.ValidationPlan + declaration *codegen.NameDeclaration + needed bool + alias bool + pointer bool + collectionElem *expr.AttributeExpr + collectionCall *codegen.NameDeclaration + fields []*validationFieldFacts + } + + // validationFieldFacts stores one child result field and the validation call + // emitted for it. + validationFieldFacts struct { + name string + attribute *expr.AttributeExpr + view string + required bool + call *codegen.NameDeclaration + } + + // viewValidationKey identifies one result type and view while Goa decides + // whether its generated validation function can return an error. + viewValidationKey struct { + origin expr.UserType + view string + } + + // viewConversionFacts stores one conversion between a service result and a + // selected result view, including conversions for nested fields. + viewConversionFacts struct { + toResult bool + viewName string + source *expr.AttributeExpr + target *expr.AttributeExpr + transformTarget *expr.AttributeExpr + fields []*viewConversionFieldFacts + plan *codegen.TransformPlan + targetLayout *codegen.GoTypePlan + collection bool + contextType expr.UserType + contextIdentity codegen.DerivedTypeID + elementType expr.UserType + elementIdentity codegen.DerivedTypeID + constructor *codegen.NameDeclaration + elementCall *codegen.NameDeclaration + } + + // viewConversionFieldFacts stores one child result constructor call emitted + // separately from the general type conversion. + viewConversionFieldFacts struct { + name string + attribute *expr.AttributeExpr + view string + call *codegen.NameDeclaration + } + + // viewedResultFacts stores the wrapper type and selected view written for one + // method result. + viewedResultFacts struct { + wrapped expr.UserType + wrappedLayout *codegen.GoTypePlan + wrappedDef *codegen.GoTypePlan + projected *projectedTypeFacts + origin expr.UserType + source *methodAttributeFacts + viewName string + views []*viewRenderFacts + conversions []*viewConversionFacts + toViewed *codegen.NameDeclaration + toResult *codegen.NameDeclaration + mapDeclaration *codegen.NameDeclaration + declaration *codegen.TypeDeclaration + validator *codegen.NameDeclaration + validationCalls []*codegen.NameDeclaration + isCollection bool + } + + // userTypeFacts records one selected design type, its generated Go type, and + // the file location inherited from an enclosing type when needed. + userTypeFacts struct { + userType expr.UserType + name string + description string + errorName string + serviceError bool + location *codegen.Location + declaration *codegen.TypeDeclaration + layout *codegen.GoTypePlan + imports *codegen.GeneratedImportPlan + } + + // unionFacts records one Goa OneOf type and its generated Go declaration. + unionFacts struct { + attribute *expr.AttributeExpr + union *expr.Union + identity codegen.UnionDeclarationID + typeKey string + valueKey string + branches []*unionBranchFacts + location *codegen.Location + declaration *codegen.UnionDeclaration + imports *codegen.GeneratedImportPlan + data *UnionTypeData + } + + // unionBranchFacts stores one Goa OneOf branch, its generated names, and the + // Go type written for its value. + unionBranchFacts struct { + name string + fieldName string + storageName string + declaration *codegen.UnionBranchDeclaration + layout *codegen.GoTypePlan + nilable bool + emitPrimitiveAlias bool + primitiveAliasType string + } + + // validatorKey selects the validation function for one generated result type + // and view. Validation code for view-specific result copies uses that exact + // function. + validatorKey struct { + declaration *codegen.TypeDeclaration + view string + } + + // viewConversionCallKey identifies one private constructor by the source + // result declaration, selected view, and conversion direction. + viewConversionCallKey struct { + origin expr.UserType + view string + toResult bool + } + + // streamWrapperKey identifies the client or server wrapper for one method's + // stream. + streamWrapperKey struct { + method *expr.MethodExpr + server bool + } +) + +// collectServiceNames submits every package-level Go declaration written for +// one service and its views before Generation.Freeze chooses the final Go +// names. +func collectServiceNames(facts *serviceFacts, rootTypes *rootTypeSet, generation *codegen.Generation) error { + service := facts.service + serviceName := service.Name + servicePackage := generation.Package(facts.packagePath) + viewsPackage := generation.Package(facts.viewsPath) + examplePackage, err := generation.ClaimOutputPackage(path.Dir(generation.GenPkg()), ".") + if err != nil { + return err + } + exampleInterceptorsPackage, err := generation.ClaimOutputPackage( + path.Join(path.Dir(generation.GenPkg()), "interceptors"), + "interceptors", + ) + if err != nil { + return err + } + facts.names = make(serviceNames) + facts.validators = make(map[validatorKey]*codegen.NameDeclaration) + facts.errorConstructors = make(map[string]*codegen.NameDeclaration) + declare := func(pkg *codegen.GeneratedPackage, role serviceNameRole, preferred string, id serviceSymbolID) error { + id.role = role + id.service = serviceName + _, err := facts.names.declareForAPI(pkg, id, preferred, facts.apiName) + return err + } + static := []struct { + role serviceNameRole + preferred string + }{ + {serviceInterfaceNameRole, "Service"}, + {serviceAPINameRole, "APIName"}, + {serviceAPIVersionNameRole, "APIVersion"}, + {serviceNameConstantRole, "ServiceName"}, + {serviceMethodNamesRole, "MethodNames"}, + {serviceEndpointsNameRole, "Endpoints"}, + {serviceNewEndpointsNameRole, "NewEndpoints"}, + {serviceClientNameRole, "Client"}, + {serviceNewClientNameRole, "NewClient"}, + } + if serviceHasSchemes(facts) { + static = append(static, struct { + role serviceNameRole + preferred string + }{serviceAutherNameRole, "Auther"}) //nolint:misspell // Keep Goa's existing generated interface name. + } + for _, symbol := range static { + if err := declare(servicePackage, symbol.role, symbol.preferred, serviceSymbolID{}); err != nil { + return err + } + } + facts.exampleStruct, err = facts.names.declareForAPI(examplePackage, serviceSymbolID{ + role: serviceExampleStructNameRole, service: serviceName, + }, codegen.Goify(serviceName, false)+"srvc", facts.apiName) + if err != nil { + return err + } + facts.exampleConstructor, err = facts.names.declareForAPI(examplePackage, serviceSymbolID{ + role: serviceExampleConstructorNameRole, service: serviceName, + }, "New"+codegen.Goify(serviceName, true), facts.apiName) + if err != nil { + return err + } + structName := codegen.Goify(serviceName, true) + if len(facts.serverInterceptors) > 0 { + facts.exampleServerStruct, err = facts.names.declareForAPI(exampleInterceptorsPackage, serviceSymbolID{ + role: serviceExampleServerInterceptorsStructNameRole, service: serviceName, + }, structName+"ServerInterceptors", facts.apiName) + if err != nil { + return err + } + facts.exampleServerConstructor, err = facts.names.declareForAPI(exampleInterceptorsPackage, serviceSymbolID{ + role: serviceExampleServerInterceptorsConstructorNameRole, service: serviceName, + }, "New"+structName+"ServerInterceptors", facts.apiName) + if err != nil { + return err + } + } + if len(facts.clientInterceptors) > 0 { + facts.exampleClientStruct, err = facts.names.declareForAPI(exampleInterceptorsPackage, serviceSymbolID{ + role: serviceExampleClientInterceptorsStructNameRole, service: serviceName, + }, structName+"ClientInterceptors", facts.apiName) + if err != nil { + return err + } + facts.exampleClientConstructor, err = facts.names.declareForAPI(exampleInterceptorsPackage, serviceSymbolID{ + role: serviceExampleClientInterceptorsConstructorNameRole, service: serviceName, + }, "New"+structName+"ClientInterceptors", facts.apiName) + if err != nil { + return err + } + } + for _, method := range facts.methods { + methodFacts := facts.methodByExpr[method] + methodID := serviceSymbolID{method: methodFacts.varName} + if method.IsStreaming() || method.HasMixedResults() { + if err := declare(servicePackage, serviceServerStreamNameRole, methodFacts.varName+"ServerStream", methodID); err != nil { + return err + } + if err := declare(servicePackage, serviceClientStreamNameRole, methodFacts.varName+"ClientStream", methodID); err != nil { + return err + } + if err := declare(servicePackage, serviceEndpointInputNameRole, methodFacts.varName+"EndpointInput", methodID); err != nil { + return err + } + } + if err := declare(servicePackage, serviceMethodEndpointNameRole, "New"+methodFacts.varName+"Endpoint", methodID); err != nil { + return err + } + if methodFacts.skipRequestBodyEncodeDecode { + if err := declare(servicePackage, serviceRequestNameRole, methodFacts.varName+"RequestData", methodID); err != nil { + return err + } + } + if methodFacts.skipResponseBodyEncodeDecode { + if err := declare(servicePackage, serviceResponseNameRole, methodFacts.varName+"ResponseData", methodID); err != nil { + return err + } + } + if len(method.ServerInterceptors) > 0 { + if err := declare(servicePackage, serviceServerEndpointWrapperNameRole, "Wrap"+methodFacts.varName+"Endpoint", methodID); err != nil { + return err + } + } + if len(method.ClientInterceptors) > 0 { + if err := declare(servicePackage, serviceClientEndpointWrapperNameRole, "Wrap"+methodFacts.varName+"ClientEndpoint", methodID); err != nil { + return err + } + } + } + if err := collectErrorNames(facts, servicePackage); err != nil { + return err + } + if err := collectInterceptorNames(facts, servicePackage); err != nil { + return err + } + return collectViewNames(facts, servicePackage, viewsPackage, rootTypes, generation) +} + +// serviceHasSchemes reports whether any selected method needs generated +// authorization functions. +func serviceHasSchemes(facts *serviceFacts) bool { + for _, method := range facts.methods { + if len(method.Requirements) > 0 { + return true + } + } + return false +} + +// collectErrorNames declares the constructors emitted for distinct Goa +// service errors shared by service-level and method-level declarations. +func collectErrorNames(facts *serviceFacts, servicePackage *codegen.GeneratedPackage) error { + seen := make(map[string]struct{}) + errors := append([]*expr.ErrorExpr(nil), facts.errors...) + for _, method := range facts.methods { + errors = append(errors, method.Errors...) + } + for _, serviceError := range errors { + if !expr.IsErrorResult(serviceError.Type) { + continue + } + if _, exists := seen[serviceError.Name]; exists { + continue + } + seen[serviceError.Name] = struct{}{} + declaration, err := facts.names.declareForAPI(servicePackage, serviceSymbolID{ + role: serviceErrorConstructorNameRole, + service: facts.service.Name, + subject: serviceError.Name, + }, "Make"+codegen.Goify(serviceError.Name, true), facts.apiName) + if err != nil { + return err + } + facts.errorConstructors[serviceError.Name] = declaration + } + return nil +} + +// collectInterceptorNames declares interceptor interfaces, typed accessors, +// wrappers, and stream wrapper structs in the service package. +func collectInterceptorNames(facts *serviceFacts, servicePackage *codegen.GeneratedPackage) error { + declare := func(role serviceNameRole, preferred string, id serviceSymbolID) error { + id.role = role + id.service = facts.service.Name + _, err := facts.names.declareForAPI(servicePackage, id, preferred, facts.apiName) + return err + } + if len(facts.serverInterceptors) > 0 { + if err := declare(serviceServerInterceptorsNameRole, "ServerInterceptors", serviceSymbolID{}); err != nil { + return err + } + } + if len(facts.clientInterceptors) > 0 { + if err := declare(serviceClientInterceptorsNameRole, "ClientInterceptors", serviceSymbolID{}); err != nil { + return err + } + } + interceptors := append(append([]*expr.InterceptorExpr(nil), facts.serverInterceptors...), facts.clientInterceptors...) + seenInterceptors := make(map[string]struct{}) + seenStreams := make(map[streamWrapperKey]struct{}) + for _, interceptor := range interceptors { + if _, exists := seenInterceptors[interceptor.Name]; !exists { + seenInterceptors[interceptor.Name] = struct{}{} + base := codegen.Goify(interceptor.Name, true) + for _, symbol := range []struct { + role serviceNameRole + suffix string + emit bool + }{ + {serviceInterceptorInfoNameRole, "Info", true}, + {serviceInterceptorPayloadNameRole, "Payload", interceptor.ReadPayload != nil || interceptor.WritePayload != nil}, + {serviceInterceptorResultNameRole, "Result", interceptor.ReadResult != nil || interceptor.WriteResult != nil}, + {serviceInterceptorStreamingPayloadNameRole, "StreamingPayload", interceptor.ReadStreamingPayload != nil || interceptor.WriteStreamingPayload != nil}, + {serviceInterceptorStreamingResultNameRole, "StreamingResult", interceptor.ReadStreamingResult != nil || interceptor.WriteStreamingResult != nil}, + } { + if !symbol.emit { + continue + } + if err := declare(symbol.role, base+symbol.suffix, serviceSymbolID{subject: interceptor.Name}); err != nil { + return err + } + } + } + for _, method := range facts.methods { + server := interceptorNamed(method.ServerInterceptors, interceptor.Name) + client := interceptorNamed(method.ClientInterceptors, interceptor.Name) + if !server && !client { + continue + } + methodName := facts.methodByExpr[method].varName + base := codegen.Goify(codegen.SnakeCase(interceptor.Name), false) + methodName + methodID := serviceSymbolID{method: facts.methodByExpr[method].varName, subject: interceptor.Name} + streamingAccess := interceptorHasStreamingAccess(interceptor) && (method.IsStreaming() || method.HasMixedResults()) + hasPayloadAccess := interceptor.ReadPayload != nil || interceptor.WritePayload != nil + hasStreamingPayloadAccess := interceptor.ReadStreamingPayload != nil || interceptor.WriteStreamingPayload != nil + hasStreamingResultAccess := interceptor.ReadStreamingResult != nil || interceptor.WriteStreamingResult != nil + for _, symbol := range []struct { + role serviceNameRole + suffix string + emit bool + }{ + {serviceInterceptorPayloadAccessNameRole, "Payload", hasPayloadAccess}, + {serviceInterceptorResultAccessNameRole, "Result", interceptor.ReadResult != nil || interceptor.WriteResult != nil}, + {serviceInterceptorStreamingPayloadAccessNameRole, "StreamingPayload", hasStreamingPayloadAccess}, + {serviceInterceptorStreamingResultAccessNameRole, "StreamingResult", hasStreamingResultAccess}, + {serviceInterceptorMethodInfoNameRole, "Info", true}, + {serviceInterceptorServerUnaryInfoNameRole, "ServerUnaryInfo", server && (!streamingAccess || hasPayloadAccess)}, + {serviceInterceptorClientUnaryInfoNameRole, "ClientUnaryInfo", client && (!streamingAccess || hasPayloadAccess)}, + {serviceInterceptorStreamingSendInfoNameRole, "StreamingSendInfo", streamingAccess && (server && hasStreamingResultAccess || client && hasStreamingPayloadAccess)}, + {serviceInterceptorStreamingRecvInfoNameRole, "StreamingRecvInfo", streamingAccess && (server && hasStreamingPayloadAccess || client && hasStreamingResultAccess)}, + } { + if !symbol.emit { + continue + } + if err := declare(symbol.role, base+symbol.suffix, methodID); err != nil { + return err + } + } + if server { + if err := declare(serviceServerInterceptorWrapperNameRole, "wrap"+methodName+codegen.Goify(interceptor.Name, true), methodID); err != nil { + return err + } + } + if client { + if err := declare(serviceClientInterceptorWrapperNameRole, "wrapClient"+methodName+codegen.Goify(interceptor.Name, true), methodID); err != nil { + return err + } + } + if (!method.IsStreaming() && !method.HasMixedResults()) || !interceptorHasStreamingAccess(interceptor) { + continue + } + for _, side := range []struct { + server bool + role serviceNameRole + name string + }{ + {true, serviceServerStreamWrapperNameRole, "wrapped" + methodName + "ServerStream"}, + {false, serviceClientStreamWrapperNameRole, "wrapped" + methodName + "ClientStream"}, + } { + key := streamWrapperKey{method: method, server: side.server} + if _, exists := seenStreams[key]; exists || side.server && !server || !side.server && !client { + continue + } + seenStreams[key] = struct{}{} + if err := declare(side.role, side.name, serviceSymbolID{method: methodName}); err != nil { + return err + } + } + } + } + return nil +} + +// collectViewNames declares validators and constructor/map companions from +// the exact service and view type declarations allocated during view planning. +func collectViewNames(facts *serviceFacts, servicePackage, viewsPackage *codegen.GeneratedPackage, rootTypes *rootTypeSet, generation *codegen.Generation) error { + markNeededViewValidators(facts) + for _, method := range facts.methods { + projection := facts.projections[method] + if projection == nil { + continue + } + for _, projectedFacts := range projection.types { + pair := projectedFacts.pair + declaration, err := viewsPackage.DerivedType(codegen.NewProjectedTypeID(pair.source)) + if err != nil { + return err + } + projectedFacts.declaration = declaration + for _, validation := range projectedFacts.validations { + if !validation.needed { + continue + } + view := canonicalValidatorView(validation.viewName) + suffix := "" + if view != "" { + suffix = codegen.Goify(view, true) + } + key := validatorKey{declaration: declaration, view: view} + if facts.validators[key] != nil { + continue + } + id := serviceSymbolID{ + role: serviceValidatorNameRole, + service: facts.service.Name, + subject: pair.source.ID(), + source: pair.source.Name(), + view: view, + side: "projected", + } + validator, err := facts.names.declareDependentForAPI(viewsPackage, id, declaration.Declaration(), "Validate", suffix, facts.apiName) + if err != nil { + return err + } + facts.validators[key] = validator + validation.declaration = validator + } + if _, ok := pair.projected.(*expr.ResultTypeExpr); ok { + projectedFacts.mapDeclaration, err = facts.names.declareForAPI(viewsPackage, serviceSymbolID{ + role: serviceViewMapNameRole, + service: facts.service.Name, + subject: pair.source.ID(), + source: pair.source.Name(), + }, codegen.Goify(pair.source.Name(), true)+"Map", facts.apiName) + if err != nil { + return err + } + } + for _, conversion := range projectedFacts.conversions { + side := "to-projected" + preferredBase := codegen.Goify(pair.projected.Name(), true) + if conversion.toResult { + side = "to-result" + preferredBase = codegen.Goify(pair.source.Name(), true) + } + suffix := "" + if conversion.viewName != expr.DefaultView { + suffix = codegen.Goify(conversion.viewName, true) + } + conversion.constructor, err = facts.names.declareForAPI(servicePackage, serviceSymbolID{ + role: servicePrivateProjectionConstructorNameRole, + service: facts.service.Name, + subject: pair.source.ID(), + source: pair.source.Name(), + view: canonicalValidatorView(conversion.viewName), + side: side, + }, "new"+preferredBase+suffix, facts.apiName) + if err != nil { + return err + } + if conversion.plan == nil { + continue + } + for _, definition := range conversion.plan.HelperDefinitions() { + sourceName, sourceID := transformDataTypeName(definition.Source.Type) + targetName, targetID := transformDataTypeName(definition.Target.Type) + sourcePreferred := sourceName + targetPreferred := targetName + viewsPackageName := strings.ToLower(codegen.Goify(facts.service.Name, false)) + "views" + if conversion.toResult { + sourcePreferred = viewsPackageName + codegen.Goify(sourceName, true) + } else { + targetPreferred = viewsPackageName + codegen.Goify(targetName, true) + } + declaration, err := facts.names.declareForAPI(servicePackage, serviceSymbolID{ + role: serviceTransformHelperNameRole, + service: facts.service.Name, + subject: pair.source.ID(), + view: canonicalValidatorView(conversion.viewName), + source: sourceID, + target: targetID, + side: side, + definition: definition.Location, + }, "transform"+codegen.Goify(sourcePreferred, true)+"To"+codegen.Goify(targetPreferred, true), facts.apiName) + if err != nil { + return err + } + if err := conversion.plan.BindHelperDefinition(definition.ID, declaration); err != nil { + return err + } + } + } + } + resultType, hasViews := method.Result.Type.(*expr.ResultTypeExpr) + if !hasViews { + continue + } + for _, projected := range projection.types { + if projected.pair.source.Origin() == resultType.Origin() { + facts.methodByExpr[method].viewedResult.conversions = projected.conversions + break + } + } + viewedDeclaration, err := viewsPackage.DerivedType(codegen.NewViewedResultTypeID(resultType)) + if err != nil { + return err + } + facts.methodByExpr[method].viewedResult.declaration = viewedDeclaration + viewedValidatorKey := validatorKey{declaration: viewedDeclaration} + if facts.validators[viewedValidatorKey] == nil { + validatorID := serviceSymbolID{ + role: serviceValidatorNameRole, + service: facts.service.Name, + method: facts.methodByExpr[method].varName, + subject: resultType.ID(), + source: resultType.Name(), + side: "viewed", + } + validator, err := facts.names.declareDependentForAPI(viewsPackage, validatorID, viewedDeclaration.Declaration(), "Validate", "", facts.apiName) + if err != nil { + return err + } + facts.validators[viewedValidatorKey] = validator + } + facts.methodByExpr[method].viewedResult.validator = facts.validators[viewedValidatorKey] + for _, symbol := range []struct { + role serviceNameRole + prefix string + side string + }{ + {serviceViewConstructorNameRole, "NewViewed", "to-viewed"}, + {serviceViewConstructorNameRole, "New", "to-result"}, + } { + constructor, err := facts.names.declareForAPI(servicePackage, serviceSymbolID{ + role: symbol.role, + service: facts.service.Name, + subject: resultType.ID(), + source: resultType.Name(), + side: symbol.side, + }, symbol.prefix+codegen.Goify(resultType.Name(), true), facts.apiName) + if err != nil { + return err + } + if symbol.side == "to-viewed" { + facts.methodByExpr[method].viewedResult.toViewed = constructor + } else { + facts.methodByExpr[method].viewedResult.toResult = constructor + } + } + facts.methodByExpr[method].viewedResult.mapDeclaration, err = facts.names.declareForAPI(viewsPackage, serviceSymbolID{ + role: serviceViewMapNameRole, + service: facts.service.Name, + subject: resultType.ID(), + source: resultType.Name(), + }, codegen.Goify(resultType.Name(), true)+"Map", facts.apiName) + if err != nil { + return err + } + viewedFacts := facts.methodByExpr[method].viewedResult + for _, view := range viewedFacts.views { + declaration := facts.validators[validatorKey{ + declaration: viewedFacts.projected.declaration, + view: canonicalValidatorView(view.name), + }] + viewedFacts.validationCalls = append(viewedFacts.validationCalls, declaration) + } + } + linkViewConversionCalls(facts) + return planServiceValidations(facts, rootTypes, generation) +} + +// linkViewConversionCalls gives collection and child constructor calls the Go +// function names chosen for their result type and view. +func linkViewConversionCalls(facts *serviceFacts) { + lookup := make(map[viewConversionCallKey]*codegen.NameDeclaration) + for _, method := range facts.methods { + projection := facts.projections[method] + if projection == nil { + continue + } + for _, projected := range projection.types { + for _, conversion := range projected.conversions { + origin := projected.pair.projected.Origin() + if conversion.toResult { + origin = projected.pair.source.Origin() + } + lookup[viewConversionCallKey{ + origin: origin, + view: canonicalValidatorView(conversion.viewName), + toResult: conversion.toResult, + }] = conversion.constructor + } + } + } + for _, method := range facts.methods { + projection := facts.projections[method] + if projection == nil { + continue + } + for _, projected := range projection.types { + for _, conversion := range projected.conversions { + collection := projected.pair.projected + if conversion.toResult { + collection = projected.pair.source + } + if array := expr.AsArray(collection); array != nil { + userType := array.ElemType.Type.(expr.UserType) + conversion.elementCall = lookup[viewConversionCallKey{ + origin: userType.Origin(), + view: canonicalValidatorView(conversion.viewName), + toResult: conversion.toResult, + }] + } + for _, field := range conversion.fields { + userType := field.attribute.Type.(expr.UserType) + field.call = lookup[viewConversionCallKey{ + origin: userType.Origin(), + view: canonicalValidatorView(field.view), + toResult: conversion.toResult, + }] + } + } + } + } +} + +// interceptorHasStreamingAccess reports whether interceptor causes a wrapped +// stream implementation to be emitted. +func interceptorHasStreamingAccess(interceptor *expr.InterceptorExpr) bool { + return interceptor.ReadStreamingPayload != nil || interceptor.WriteStreamingPayload != nil || + interceptor.ReadStreamingResult != nil || interceptor.WriteStreamingResult != nil +} + +// interceptorNamed reports whether interceptors contains name. +func interceptorNamed(interceptors []*expr.InterceptorExpr, name string) bool { + for _, interceptor := range interceptors { + if interceptor.Name == name { + return true + } + } + return false +} diff --git a/codegen/service/plan_lifecycle.go b/codegen/service/plan_lifecycle.go new file mode 100644 index 0000000000..c165090512 --- /dev/null +++ b/codegen/service/plan_lifecycle.go @@ -0,0 +1,398 @@ +// This file prepares every service design used by one run. It rejects missing +// or repeated designs and chooses each shared Go name once. +package service + +import ( + "fmt" + "path" + "strings" + + "goa.design/goa/v3/codegen" + "goa.design/goa/v3/expr" +) + +type ( + // HTTPMethodNames contains the Go names used by one service method in an HTTP + // package. The HTTP generator reuses these names instead of choosing new ones. + HTTPMethodNames struct { + // Method is the name used for the service endpoint field and receiver method. + Method string + // ServerStream is the service's server stream type name. + ServerStream string + // ClientStream is the service's client stream type name. + ClientStream string + } +) + +// NewPlans reads every service design in generation. It returns the data used +// to write each service and chooses shared Go declaration names once. +func NewPlans(generation *codegen.Generation, inputs ...PlanInput) ([]*Plan, error) { + owned := make(map[*expr.RootExpr]struct{}) + for _, candidate := range generation.Roots() { + if root, ok := candidate.(*expr.RootExpr); ok { + owned[root] = struct{}{} + } + } + seen := make(map[*expr.RootExpr]struct{}, len(inputs)) + for _, input := range inputs { + if _, ok := owned[input.Root]; !ok { + return nil, rootMembershipError(input.Root) + } + if _, ok := seen[input.Root]; ok { + return nil, fmt.Errorf("service root %p is planned more than once", input.Root) + } + seen[input.Root] = struct{}{} + } + if len(inputs) != len(owned) { + return nil, fmt.Errorf( + "service planning requires all %d generation roots, got %d", + len(owned), + len(inputs), + ) + } + roots := make([]*expr.RootExpr, len(inputs)) + for index, input := range inputs { + roots[index] = input.Root + } + if validation := expr.ValidateSharedErrorNames(roots...); len(validation.Errors) > 0 { + return nil, validation + } + servicePaths, err := allocateServicePackagePaths(generation.GenPkg(), inputs) + if err != nil { + return nil, err + } + plans := make([]*Plan, len(inputs)) + for index, input := range inputs { + facts, err := collectRootFacts(input.Root, generation, input.Examples, servicePaths) + if err != nil { + return nil, err + } + plans[index] = &Plan{generation: generation, facts: facts} + } + allFacts := make([]*rootFacts, len(plans)) + for index, plan := range plans { + allFacts[index] = plan.facts + } + if err := collectGeneratedPackageEmissions(allFacts); err != nil { + return nil, err + } + if err := collectExternalConversions(allFacts, generation); err != nil { + return nil, err + } + return plans, nil +} + +// NewPlan reads the only service design in generation. Call NewPlans when a run +// contains several designs so shared files and methods receive names only once. +func NewPlan(root *expr.RootExpr, generation *codegen.Generation, examples *expr.ExampleGenerator) (*Plan, error) { + plans, err := NewPlans(generation, PlanInput{Root: root, Examples: examples}) + if err != nil { + return nil, err + } + return plans[0], nil +} + +// Root returns the service design used by this plan. Other file writers use it +// to reject a plan created for a different design. +func (p *Plan) Root() *expr.RootExpr { + return p.facts.root +} + +// ExampleImports returns copies of the application and interceptor imports +// selected while this service plan was created. +func (p *Plan) ExampleImports() []*codegen.ImportSpec { + imports := make([]*codegen.ImportSpec, len(p.facts.exampleImports)) + for index, spec := range p.facts.exampleImports { + copy := *spec + imports[index] = © + } + return imports +} + +// ProjectedResult returns a copy of the result fields included in the views for +// method. It reports an error when method is absent or has no views. +func (p *Plan) ProjectedResult(method *expr.MethodExpr) (*expr.AttributeExpr, error) { + viewed, err := p.projectedResultFacts(method) + if err != nil { + return nil, err + } + projected := expr.AsObject(viewed.wrapped.Attribute().Type).Attribute("projected") + return expr.DupAtt(projected), nil +} + +// ProjectedResultDeclaration returns the generated view type used by method. +// HTTP and gRPC generators use the same record so references and names follow +// the exact type chosen by the service plan. +func (p *Plan) ProjectedResultDeclaration(method *expr.MethodExpr) (*codegen.TypeDeclaration, error) { + viewed, err := p.projectedResultFacts(method) + if err != nil { + return nil, err + } + return viewed.projected.declaration, nil +} + +// HTTPMethodNames returns the Go names already chosen for method. It returns an +// error when the service design does not contain method. +func (p *Plan) HTTPMethodNames(method *expr.MethodExpr) (HTTPMethodNames, error) { + for _, service := range p.facts.services { + facts := service.methodByExpr[method] + if facts == nil { + continue + } + return HTTPMethodNames{ + Method: facts.varName, + ServerStream: facts.serverStreamVarName, + ClientStream: facts.clientStreamVarName, + }, nil + } + if method == nil { + return HTTPMethodNames{}, fmt.Errorf("service method is not part of this plan") + } + return HTTPMethodNames{}, fmt.Errorf("service method %q is not part of this plan", method.Name) +} + +// MethodPayloadLayout returns the Go fields stored by method's payload. For a +// named payload, it returns the definition containing those fields instead of +// the outer reference to the named type. +func (p *Plan) MethodPayloadLayout(method *expr.MethodExpr) (*codegen.GoTypePlan, error) { + for _, service := range p.facts.services { + facts := service.methodByExpr[method] + if facts == nil { + continue + } + if facts.payload == nil || facts.payload.layout == nil { + return nil, fmt.Errorf("service method %q does not have a payload", method.Name) + } + if facts.payload.definition != nil { + return facts.payload.definition, nil + } + return facts.payload.layout, nil + } + if method == nil { + return nil, fmt.Errorf("service method is not part of this plan") + } + return nil, fmt.Errorf("service method %q is not part of this plan", method.Name) +} + +// MethodResultLayout returns the Go fields stored by method's result. For a +// named result, it returns the definition containing those fields instead of +// the outer reference to the named type. +func (p *Plan) MethodResultLayout(method *expr.MethodExpr) (*codegen.GoTypePlan, error) { + for _, service := range p.facts.services { + facts := service.methodByExpr[method] + if facts == nil { + continue + } + if facts.result == nil || facts.result.layout == nil { + return nil, fmt.Errorf("service method %q does not have a result", method.Name) + } + if facts.result.definition != nil { + return facts.result.definition, nil + } + return facts.result.layout, nil + } + if method == nil { + return nil, fmt.Errorf("service method is not part of this plan") + } + return nil, fmt.Errorf("service method %q is not part of this plan", method.Name) +} + +// StreamingResultLayout returns the Go type layout used by method's stream. +// An explicitly empty streaming result uses the ordinary result layout, which +// is the type implemented by the generated client and server stream methods. +// Transport planners use this fact to decide whether their decoded value is +// directly assignable or needs a generated conversion. +func (p *Plan) StreamingResultLayout(method *expr.MethodExpr) (*codegen.GoTypePlan, error) { + for _, service := range p.facts.services { + facts := service.methodByExpr[method] + if facts == nil { + continue + } + if facts.streamingResult != nil && facts.streamingResult.present { + return facts.streamingResult.layout, nil + } + if facts.result == nil || facts.result.layout == nil { + return nil, fmt.Errorf("service method %q does not have a streaming result", method.Name) + } + return facts.result.layout, nil + } + if method == nil { + return nil, fmt.Errorf("service method is not part of this plan") + } + return nil, fmt.Errorf("service method %q is not part of this plan", method.Name) +} + +// MethodTypeLayout returns the Go fields and declarations used when a +// transport converts attribute for method. The supplied attribute may contain +// only the fields selected by one result view; the returned plan keeps that +// selection while binding each named value to the declaration already chosen +// by service generation. +func (p *Plan) MethodTypeLayout(method *expr.MethodExpr, attribute *expr.AttributeExpr) (*codegen.GoTypePlan, error) { + for _, service := range p.facts.services { + facts := service.methodByExpr[method] + if facts == nil { + continue + } + owner := service.packagePath + projected := make(map[expr.UserType]*codegen.TypeDeclaration) + for _, projection := range service.projections { + for _, planned := range projection.types { + projected[planned.pair.projected.Origin()] = planned.declaration + } + } + if userType, ok := attribute.Type.(expr.UserType); ok && projected[userType.Origin()] != nil { + owner = service.viewsPath + } + serviceBinder := serviceGoTypeBinder(p.facts.rootTypes, p.generation) + binder := func(request codegen.GoTypeBindingRequest) (codegen.GoTypeBinding, error) { + if request.Kind == codegen.GoNamed { + userType := request.Attribute.Type.(expr.UserType) + if declaration := projected[userType.Origin()]; declaration != nil { + return codegen.GoTypeBinding{Owner: service.viewsPath, Type: declaration}, nil + } + } + return serviceBinder(request) + } + return codegen.PlanGoType(attribute, codegen.GoTypePlanOptions{ + Owner: owner, + RetainNamedValue: true, + Policy: codegen.GoLayoutPolicy{ + UseDefault: true, + SumType: true, + }, + Bind: binder, + }) + } + if method == nil { + return nil, fmt.Errorf("service method is not part of this plan") + } + return nil, fmt.Errorf("service method %q is not part of this plan", method.Name) +} + +// ServicePackageImports returns the generated service and views package +// preferences recorded before Generation.Freeze. Transport generators use it +// for service-level files that may not contain a method, such as an HTTP file +// server. +func (p *Plan) ServicePackageImports( + serviceExpression *expr.ServiceExpr, +) (servicePackage, viewsPackage *codegen.ImportSpec, err error) { + for _, service := range p.facts.services { + if service.service != serviceExpression { + continue + } + serviceCopy := *service.packageImport + viewsCopy := *service.viewsImport + return &serviceCopy, &viewsCopy, nil + } + if serviceExpression == nil { + return nil, nil, fmt.Errorf("service is not part of this plan") + } + return nil, nil, fmt.Errorf("service %q is not part of this plan", serviceExpression.Name) +} + +// MethodPackageImports returns the generated service package preference and, +// for a viewed result, its views package preference. These are the names and +// paths recorded before Generation.Freeze; an importing output package may +// receive a numbered qualifier when another import requests the same name. +func (p *Plan) MethodPackageImports( + method *expr.MethodExpr, +) (servicePackage, viewsPackage *codegen.ImportSpec, err error) { + for _, service := range p.facts.services { + facts := service.methodByExpr[method] + if facts == nil { + continue + } + serviceCopy := *service.packageImport + if facts.viewedResult == nil { + return &serviceCopy, nil, nil + } + viewsCopy := *service.viewsImport + return &serviceCopy, &viewsCopy, nil + } + if method == nil { + return nil, nil, fmt.Errorf("service method is not part of this plan") + } + return nil, nil, fmt.Errorf("service method %q is not part of this plan", method.Name) +} + +// projectedResultFacts returns the stored view plan for one method and reports +// whether the method is missing or has no viewed result. +func (p *Plan) projectedResultFacts(method *expr.MethodExpr) (*viewedResultFacts, error) { + for _, service := range p.facts.services { + facts := service.methodByExpr[method] + if facts == nil { + continue + } + if facts.viewedResult == nil { + return nil, fmt.Errorf("service method %q does not have a viewed result", method.Name) + } + return facts.viewedResult, nil + } + if method == nil { + return nil, fmt.Errorf("service method is not part of this plan") + } + return nil, fmt.Errorf("service method %q is not part of this plan", method.Name) +} + +// collectRootFacts reads one service design and chooses names used only by that +// design before shared files receive their names. +func collectRootFacts(root *expr.RootExpr, generation *codegen.Generation, examples *expr.ExampleGenerator, servicePaths map[string]string) (*rootFacts, error) { + examplePackageScope := codegen.NewNameScope() + for _, service := range root.Services { + examplePackageScope.Unique(strings.ToLower(codegen.Goify(service.Name, false))) + } + facts := &rootFacts{ + root: root, + apiName: root.API.Name, + apiVersion: root.API.Version, + examplePackageName: examplePackageScope.Unique(strings.ToLower(codegen.Goify(root.API.Name, false)), "api"), + serviceByID: make(map[string]*serviceFacts, len(root.Services)), + types: append([]expr.UserType(nil), root.Types...), + rootTypes: newRootTypeSet(root), + examples: examples, + } + for _, service := range root.Services { + serviceFacts := collectServiceFacts(root, service, examples) + serviceFacts.packagePath = servicePaths[service.Name] + serviceFacts.viewsPath = serviceFacts.packagePath + "/views" + serviceFacts.packageImport = codegen.NewImport( + strings.ToLower(codegen.Goify(service.Name, false)), + serviceFacts.packagePath, + ) + serviceFacts.viewsImport = codegen.NewImport( + serviceFacts.packageImport.Name+"views", + serviceFacts.viewsPath, + ) + facts.services = append(facts.services, serviceFacts) + facts.serviceByID[service.Name] = serviceFacts + } + rootPath := path.Dir(generation.GenPkg()) + facts.exampleImports = append(facts.exampleImports, codegen.NewImport(facts.examplePackageName, rootPath)) + for _, service := range facts.services { + if len(service.serverInterceptors) > 0 || len(service.clientInterceptors) > 0 { + facts.exampleImports = append(facts.exampleImports, codegen.NewImport("interceptors", rootPath+"/interceptors")) + break + } + } + if err := collectServiceDeclarations(facts, generation); err != nil { + return nil, err + } + for _, serviceFacts := range facts.services { + if err := collectServiceNames(serviceFacts, facts.rootTypes, generation); err != nil { + return nil, err + } + if err := collectServiceTypeFacts(serviceFacts, facts.types, facts.rootTypes, generation); err != nil { + return nil, err + } + if err := collectServiceUnionFacts(serviceFacts, facts.rootTypes, generation); err != nil { + return nil, err + } + if err := planServiceTypeLayouts(serviceFacts, facts.rootTypes, generation); err != nil { + return nil, err + } + if err := planServiceFileImports(serviceFacts, facts.rootTypes, generation); err != nil { + return nil, err + } + } + return facts, nil +} diff --git a/codegen/service/projected_result_test.go b/codegen/service/projected_result_test.go new file mode 100644 index 0000000000..77d29c01cf --- /dev/null +++ b/codegen/service/projected_result_test.go @@ -0,0 +1,84 @@ +// This file checks that HTTP generation can copy the result fields selected by +// a view before names are assigned to the service package. +package service + +import ( + "testing" + + "github.com/stretchr/testify/require" + + "goa.design/goa/v3/codegen" + "goa.design/goa/v3/dsl" + "goa.design/goa/v3/eval" + "goa.design/goa/v3/expr" +) + +func TestPlanProjectedResultBeforeLink(t *testing.T) { + var viewed, plain *expr.MethodExpr + root := codegen.RunDSL(t, func() { + result := dsl.ResultType("application/vnd.projected-result", func() { + dsl.TypeName("ProjectedResult") + dsl.Attribute("name", dsl.String) + dsl.View("default", func() { dsl.Attribute("name") }) + dsl.View("summary", func() { dsl.Attribute("name") }) + }) + dsl.Service("Values", func() { + viewed = dsl.Method("Viewed", func() { dsl.Result(result) }) + plain = dsl.Method("Plain", func() { dsl.Result(dsl.String) }) + }) + }) + generation := mustTestGeneration(t, "generated.local/gen", []eval.Root{root}) + plan, err := NewPlan(root, generation, expr.NewExampleGenerator(root.API.RandomizerFactory)) + require.NoError(t, err) + + first, err := plan.ProjectedResult(viewed) + require.NoError(t, err) + first.Description = "changed by caller" + second, err := plan.ProjectedResult(viewed) + require.NoError(t, err) + require.NotEqual(t, first.Description, second.Description) + require.NotSame(t, first, second) + declaration, err := plan.ProjectedResultDeclaration(viewed) + require.NoError(t, err) + require.NotNil(t, declaration) + + _, err = plan.ProjectedResult(plain) + require.EqualError(t, err, `service method "Plain" does not have a viewed result`) + _, err = plan.ProjectedResultDeclaration(plain) + require.EqualError(t, err, `service method "Plain" does not have a viewed result`) + foreign := &expr.MethodExpr{Name: "Foreign"} + _, err = plan.ProjectedResult(foreign) + require.EqualError(t, err, `service method "Foreign" is not part of this plan`) + _, err = plan.ProjectedResultDeclaration(foreign) + require.EqualError(t, err, `service method "Foreign" is not part of this plan`) + + require.NoError(t, generation.Freeze()) + require.Equal(t, "ProjectedResultView", declaration.Name()) + require.NoError(t, plan.Link()) + linked := plan.Services().Get("Values").Method("Viewed").ViewedResult + require.NotNil(t, linked) + require.NotEqual(t, "changed by caller", expr.AsObject(linked.Type).Attribute("projected").Description) +} + +func TestPlanHTTPMethodNamesBeforeLink(t *testing.T) { + var watch *expr.MethodExpr + root := codegen.RunDSL(t, func() { + dsl.Service("Values", func() { + watch = dsl.Method("Watch", func() { + dsl.StreamingResult(dsl.String) + }) + }) + }) + generation := mustTestGeneration(t, "generated.local/gen", []eval.Root{root}) + plan, err := NewPlan(root, generation, expr.NewExampleGenerator(root.API.RandomizerFactory)) + require.NoError(t, err) + + names, err := plan.HTTPMethodNames(watch) + require.NoError(t, err) + require.Equal(t, "Watch", names.Method) + require.Equal(t, "WatchServerStream", names.ServerStream) + require.Equal(t, "WatchClientStream", names.ClientStream) + + _, err = plan.HTTPMethodNames(&expr.MethodExpr{Name: "Foreign"}) + require.EqualError(t, err, `service method "Foreign" is not part of this plan`) +} diff --git a/codegen/service/render_name_compatibility_test.go b/codegen/service/render_name_compatibility_test.go new file mode 100644 index 0000000000..b3c4aaca6a --- /dev/null +++ b/codegen/service/render_name_compatibility_test.go @@ -0,0 +1,106 @@ +// This file verifies that plugins still receive the released Go-name strings +// while Goa templates use the matching planned declarations. +package service + +import ( + "testing" + + "github.com/stretchr/testify/require" + + "goa.design/goa/v3/codegen" + "goa.design/goa/v3/dsl" +) + +func TestLinkedRenderNamesMatchDeclarations(t *testing.T) { + root := codegen.RunDSL(t, func() { + customError := dsl.Type("CustomError", func() { + dsl.Attribute("message", dsl.String) + dsl.Required("message") + }) + reading := dsl.ResultType("application/vnd.reading", func() { + dsl.TypeName("Reading") + dsl.Attribute("value", dsl.String, func() { + dsl.MinLength(1) + }) + dsl.Required("value") + dsl.View("default", func() { + dsl.Attribute("value") + }) + }) + dsl.Service("Values", func() { + dsl.Method("Read", func() { + dsl.Result(reading) + dsl.Error("failed") + dsl.Error("custom", customError) + }) + }) + }) + + data := mustServicePlan(t, root).Services().Get("Values") + endpoints := endpointData(data) + require.Equal(t, endpoints.EndpointsDeclaration.Name(), endpoints.VarName) + require.Equal(t, endpoints.ClientDeclaration.Name(), endpoints.ClientVarName) + require.Equal(t, endpoints.ServiceDeclaration.Name(), endpoints.ServiceVarName) + for _, method := range endpoints.Methods { + require.Equal(t, method.ClientDeclaration.Name(), method.ClientVarName) + require.Equal(t, method.ServiceDeclaration.Name(), method.ServiceVarName) + } + require.Len(t, data.errorInits, 1) + require.Equal(t, "MakeFailed", data.errorInits[0].Name) + assertErrorInitName(t, data.errorInits[0]) + + method := data.Method("Read") + require.NotEmpty(t, data.ViewsPkg) + require.Len(t, method.Errors, 2) + for _, serviceError := range method.Errors { + switch serviceError.ErrName { + case "failed": + assertErrorInitName(t, serviceError) + case "custom": + require.Nil(t, serviceError.Declaration) + require.Empty(t, serviceError.Name) + default: + t.Errorf("unexpected service error %q", serviceError.ErrName) + } + } + require.NotNil(t, method.ViewedResult) + require.Equal(t, "NewViewedReading", method.ViewedResult.Init.Name) + require.Equal(t, "ValidateReading", method.ViewedResult.Validate.Name) + assertInitName(t, method.ViewedResult.Init) + assertInitName(t, method.ViewedResult.ResultInit) + assertValidateName(t, method.ViewedResult.Validate) + + for _, projected := range data.projectedTypes { + require.Equal(t, data.ViewsPkg, projected.ViewsPkg) + for _, init := range projected.Projections { + assertInitName(t, init) + } + for _, init := range projected.TypeInits { + assertInitName(t, init) + } + for _, validation := range projected.Validations { + assertValidateName(t, validation) + } + } +} + +// assertErrorInitName checks the compatibility name exposed to plugins. +func assertErrorInitName(t *testing.T, data *ErrorInitData) { + t.Helper() + require.NotEmpty(t, data.Name) + require.Equal(t, data.Declaration.Name(), data.Name) +} + +// assertInitName checks the compatibility name exposed to plugins. +func assertInitName(t *testing.T, data *InitData) { + t.Helper() + require.NotEmpty(t, data.Name) + require.Equal(t, data.Declaration.Name(), data.Name) +} + +// assertValidateName checks the compatibility name exposed to plugins. +func assertValidateName(t *testing.T, data *ValidateData) { + t.Helper() + require.NotEmpty(t, data.Name) + require.Equal(t, data.Declaration.Name(), data.Name) +} diff --git a/codegen/service/retained_expression_mutation_contract_test.go b/codegen/service/retained_expression_mutation_contract_test.go new file mode 100644 index 0000000000..a61df98dd0 --- /dev/null +++ b/codegen/service/retained_expression_mutation_contract_test.go @@ -0,0 +1,167 @@ +// This file proves service rendering uses only facts collected before the +// generation freezes, even if callers later mutate the evaluated expressions. +package service + +import ( + "testing" + + "github.com/stretchr/testify/require" + + "goa.design/goa/v3/codegen" + "goa.design/goa/v3/dsl" + "goa.design/goa/v3/eval" + "goa.design/goa/v3/expr" +) + +type retainedExpressionFixture struct { + root *expr.RootExpr + service *expr.ServiceExpr + method *expr.MethodExpr + result *expr.ResultTypeExpr + interceptor *expr.InterceptorExpr +} + +// TestServicePlanIgnoresRetainedExpressionMutation catches linking and +// rendering that reread mutable service, method, view, error, interceptor, +// security, example, or stream expressions after NewPlan returns. +func TestServicePlanIgnoresRetainedExpressionMutation(t *testing.T) { + baselineFixture := retainedExpressionMutationFixture(t) + baselinePlan := retainedServicePlanForPackage(t, baselineFixture.root) + baseline := renderedPlanAndExamples(t, baselinePlan) + baselineMethod := baselinePlan.Services().Get("RetainedMutable").Methods[0] + + tests := []struct { + name string + mutate func(*retainedExpressionFixture) + }{ + {"service and method", func(f *retainedExpressionFixture) { + f.service.Name = "MutatedService" + f.service.Description = "mutated service" + f.method.Name = "MutatedMethod" + f.method.Description = "mutated method" + f.method.Idempotent = !f.method.Idempotent + }}, + {"errors", func(f *retainedExpressionFixture) { + f.service.Errors[0].Description = "mutated service error" + f.method.Errors[0].Description = "mutated method error" + f.method.Errors[0].Meta = expr.MetaExpr{"goa:error:fault": nil} + }}, + {"interceptor", func(f *retainedExpressionFixture) { + f.interceptor.Description = "mutated interceptor" + f.interceptor.ReadPayload = nil + f.interceptor.ReadStreamingPayload = nil + }}, + {"security", func(f *retainedExpressionFixture) { + f.method.Requirements[0].Scopes[0] = "mutated" + f.method.Requirements[0].Schemes[0].Scopes[0].Name = "mutated" + }}, + {"examples", func(f *retainedExpressionFixture) { + f.method.Payload.UserExamples[0].Value = map[string]any{"key": "mutated"} + f.method.StreamingPayload.UserExamples[0].Value = map[string]any{"chunk": "mutated"} + }}, + {"stream", func(f *retainedExpressionFixture) { + f.method.Stream = expr.NoStreamKind + f.method.StreamingPayload.Description = "mutated streaming payload" + f.method.StreamingResult.Description = "mutated streaming result" + }}, + {"type layout", func(f *retainedExpressionFixture) { + field := expr.AsObject(f.method.Payload.Type).Attribute("key") + field.Description = "mutated field" + field.Meta = expr.MetaExpr{"struct:field:name": []string{"MutatedKey"}} + }}, + {"result and view", func(f *retainedExpressionFixture) { + f.method.Result.Description = "mutated result" + f.result.Views[0].Description = "mutated view" + viewObject := expr.AsObject(f.result.Views[0].Type) + viewObject.Set("extra", &expr.AttributeExpr{Type: expr.String}) + }}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + fixture := retainedExpressionMutationFixture(t) + generation := mustTestGeneration(t, "generated.local/gen", []eval.Root{fixture.root}) + plan, err := NewPlan(fixture.root, generation, expr.NewExampleGenerator(fixture.root.API.RandomizerFactory)) + require.NoError(t, err) + test.mutate(fixture) + require.NoError(t, generation.Freeze()) + require.NoError(t, plan.Link()) + requireRenderedServiceFilesEqual(t, baseline, renderedPlanAndExamples(t, plan)) + + method := plan.Services().Get("RetainedMutable").Methods[0] + require.Equal(t, baselineMethod.PayloadEx, method.PayloadEx) + require.Equal(t, baselineMethod.StreamingPayloadEx, method.StreamingPayloadEx) + }) + } +} + +// retainedExpressionMutationFixture builds one service that exercises every +// expression family the retained core service plan must finish collecting. +func retainedExpressionMutationFixture(t *testing.T) *retainedExpressionFixture { + t.Helper() + fixture := new(retainedExpressionFixture) + fixture.root = codegen.RunDSL(t, func() { + auth := dsl.APIKeySecurity("key", func() { + dsl.Scope("read", "Read values") + }) + fixture.interceptor = dsl.Interceptor("Audit", func() { + dsl.Description("Audits request values.") + dsl.ReadPayload(func() { dsl.Attribute("key") }) + dsl.ReadStreamingPayload(func() { dsl.Attribute("chunk") }) + }) + result := dsl.ResultType("application/vnd.retained", func() { + dsl.TypeName("RetainedResult") + dsl.Description("The retained result.") + dsl.Attribute("value", dsl.String) + dsl.Required("value") + dsl.View("default", func() { dsl.Attribute("value") }) + dsl.View("summary", func() { dsl.Attribute("value") }) + }) + fixture.result = result + streamResult := dsl.Type("RetainedStreamResult", func() { + dsl.Attribute("value", dsl.String) + dsl.Required("value") + }) + serviceError := dsl.Type("RetainedServiceError", func() { + dsl.Attribute("message", dsl.String) + dsl.Required("message") + }) + methodError := dsl.Type("RetainedMethodError", func() { + dsl.Attribute("message", dsl.String) + dsl.Required("message") + }) + fixture.service = dsl.Service("RetainedMutable", func() { + dsl.Description("The retained mutable service.") + dsl.Security(auth, func() { dsl.Scope("read") }) + dsl.ServerInterceptor(fixture.interceptor) + dsl.ClientInterceptor(fixture.interceptor) + dsl.Error("service_failed", serviceError, "The service failed.") + fixture.method = dsl.Method("Watch", func() { + dsl.Description("Watches retained values.") + dsl.Payload(func() { + dsl.APIKey("key", "key", dsl.String) + dsl.Required("key") + dsl.Example(map[string]any{"key": "original"}) + }) + dsl.StreamingPayload(func() { + dsl.Attribute("chunk", dsl.String) + dsl.Required("chunk") + dsl.Example(map[string]any{"chunk": "original"}) + }) + dsl.Result(result) + dsl.StreamingResult(streamResult) + dsl.Error("method_failed", methodError, "The method failed.") + }) + }) + }) + return fixture +} + +// renderedPlanAndExamples renders both generated service packages and their +// starter implementation so post-link expression reads cannot hide in either. +func renderedPlanAndExamples(t *testing.T, plan *Plan) map[string][]byte { + t.Helper() + files, err := Files(plan) + require.NoError(t, err) + files = append(files, ExampleServiceFiles(plan)...) + return renderedServiceFiles(t, files) +} diff --git a/codegen/service/retained_plan_test.go b/codegen/service/retained_plan_test.go new file mode 100644 index 0000000000..455832b4b8 --- /dev/null +++ b/codegen/service/retained_plan_test.go @@ -0,0 +1,93 @@ +// This file verifies that service planning retains one immutable render model +// per design root. Definitions and references must consume the exact package- +// owned declaration record collected by that plan. +package service + +import ( + "testing" + + "github.com/stretchr/testify/require" + + "goa.design/goa/v3/codegen" + "goa.design/goa/v3/dsl" + "goa.design/goa/v3/eval" + "goa.design/goa/v3/expr" +) + +// TestServicePlanSharesDefinitionAndReferenceDeclaration catches service +// analysis that reconstructs a payload name independently from its definition. +func TestServicePlanSharesDefinitionAndReferenceDeclaration(t *testing.T) { + var payload expr.UserType + root := codegen.RunDSL(t, func() { + payload = dsl.Type("Payload", func() { + dsl.Attribute("value", dsl.String) + dsl.Required("value") + }) + dsl.Service("Values", func() { + dsl.Method("Read", func() { + dsl.Payload(payload) + }) + }) + }) + + generation := mustTestGeneration(t, "generated.local/gen", []eval.Root{root}) + plan, err := NewPlan(root, generation, expr.NewExampleGenerator(root.API.RandomizerFactory)) + require.NoError(t, err) + root.Service("Values").Methods = nil + require.NoError(t, generation.Freeze()) + require.NoError(t, plan.Link()) + + owner := generation.Package("generated.local/gen/values") + declaration, err := owner.UserType(payload) + require.NoError(t, err) + services := plan.Services() + require.Len(t, services.Get("Values").Methods, 1) + require.Same(t, declaration, services.Get("Values").Methods[0].PayloadDeclaration) +} + +// TestServicePlanSharesNestedValidatorDeclaration verifies that a projected +// parent call and the child function definition retain one package declaration +// even when another projected type collides with the child's preferred +// validator name. +func TestServicePlanSharesNestedValidatorDeclaration(t *testing.T) { + root := codegen.RunDSL(t, func() { + child := dsl.ResultType("application/vnd.child", func() { + dsl.TypeName("Child") + dsl.Attribute("name", dsl.String) + dsl.Required("name") + }) + collision := dsl.Type("ValidateChild", func() { + dsl.Attribute("value", dsl.String) + }) + parent := dsl.ResultType("application/vnd.parent", func() { + dsl.TypeName("Parent") + dsl.Attribute("child", child) + dsl.Attribute("collision", collision) + dsl.Required("child") + }) + dsl.Service("Values", func() { + dsl.Method("Read", func() { + dsl.Result(parent) + }) + }) + }) + + plan := mustServicePlan(t, root) + data := plan.Services().Get("Values") + var child, parent *ValidateData + for _, projected := range data.projectedTypes { + for _, validation := range projected.Validations { + switch projected.Name { + case "ChildView": + child = validation + case "ParentView": + parent = validation + } + } + } + require.NotNil(t, child) + require.NotNil(t, parent) + require.Len(t, parent.Calls, 1) + require.Same(t, child.Declaration, parent.Calls[0].Declaration) + require.Equal(t, child.Declaration.Name(), parent.Calls[0].Declaration.Name()) +} diff --git a/codegen/service/security_data.go b/codegen/service/security_data.go new file mode 100644 index 0000000000..1ac7a9249d --- /dev/null +++ b/codegen/service/security_data.go @@ -0,0 +1,78 @@ +// This file formats evaluated security schemes and authorization attributes for service templates. +package service + +import ( + "goa.design/goa/v3/codegen" + "goa.design/goa/v3/expr" +) + +// BuildSchemeData builds the scheme data for the given scheme and method expr. +func BuildSchemeData(s *expr.SchemeExpr, m *expr.MethodExpr) *SchemeData { + if !expr.IsObject(m.Payload.Type) { + return nil + } + if s.Kind == expr.BasicAuthKind { + userAtt := expr.TaggedAttribute(m.Payload, "security:username") + passAtt := expr.TaggedAttribute(m.Payload, "security:password") + return &SchemeData{ + Type: s.Kind.String(), + SchemeName: s.SchemeName, + UsernameAttr: userAtt, + UsernameField: codegen.Goify(userAtt, true), + UsernamePointer: m.Payload.IsPrimitivePointer(userAtt, true), + UsernameRequired: m.Payload.IsRequired(userAtt), + PasswordAttr: passAtt, + PasswordField: codegen.Goify(passAtt, true), + PasswordPointer: m.Payload.IsPrimitivePointer(passAtt, true), + PasswordRequired: m.Payload.IsRequired(passAtt), + Scopes: schemeScopes(s), + } + } + // The remaining scheme kinds all carry a single credential attribute + // identified by a kind-specific security tag on the method payload. + var tag string + switch s.Kind { + case expr.APIKeyKind: + tag = "security:apikey:" + s.SchemeName + case expr.BearerKind: + tag = "security:bearer" + case expr.JWTKind: + tag = "security:token" + case expr.OAuth2Kind: + tag = "security:accesstoken" + default: + return nil + } + keyAtt := expr.TaggedAttribute(m.Payload, tag) + if keyAtt == "" { + return nil + } + data := &SchemeData{ + Type: s.Kind.String(), + Name: s.Name, + SchemeName: s.SchemeName, + CredField: codegen.Goify(keyAtt, true), + CredPointer: m.Payload.IsPrimitivePointer(keyAtt, true), + CredRequired: m.Payload.IsRequired(keyAtt), + KeyAttr: keyAtt, + Scopes: schemeScopes(s), + In: s.In, + } + if s.Kind == expr.OAuth2Kind { + data.Flows = s.Flows + } + return data +} + +// schemeScopes returns the authorization scope names defined by the scheme. It +// returns nil when the scheme defines none. +func schemeScopes(s *expr.SchemeExpr) []string { + if len(s.Scopes) == 0 { + return nil + } + scopes := make([]string, len(s.Scopes)) + for i, sc := range s.Scopes { + scopes[i] = sc.Name + } + return scopes +} diff --git a/codegen/service/security_test.go b/codegen/service/security_test.go index 0ed2ab5e5b..96921351cc 100644 --- a/codegen/service/security_test.go +++ b/codegen/service/security_test.go @@ -24,9 +24,9 @@ func TestSecureEndpointInit(t *testing.T) { for _, c := range cases { t.Run(c.Name, func(t *testing.T) { root := codegen.RunDSL(t, c.DSL) - services := NewServicesData(root) + plan := mustServicePlan(t, root) require.Len(t, root.Services, 1) - fs := EndpointFile("", root.Services[0], services) + fs := endpointFile(plan, plan.facts.services[0]) require.NotNil(t, fs) sections := fs.SectionTemplates require.Greater(t, len(sections), 1) @@ -51,9 +51,9 @@ func TestSecureEndpoint(t *testing.T) { for _, c := range cases { t.Run(c.Name, func(t *testing.T) { root := codegen.RunDSL(t, c.DSL) - services := NewServicesData(root) + plan := mustServicePlan(t, root) require.Len(t, root.Services, 1) - fs := EndpointFile("", root.Services[0], services) + fs := endpointFile(plan, plan.facts.services[0]) require.NotNil(t, fs) sections := fs.SectionTemplates code := codegen.SectionCode(t, sections[4]) @@ -73,9 +73,9 @@ func TestSecureWithSkipRequestBodyEncodeDecode(t *testing.T) { for _, c := range cases { t.Run(c.Name, func(t *testing.T) { root := codegen.RunDSL(t, c.DSL) - services := NewServicesData(root) + plan := mustServicePlan(t, root) require.Len(t, root.Services, 1) - fs := EndpointFile("", root.Services[0], services) + fs := endpointFile(plan, plan.facts.services[0]) require.NotNil(t, fs) sections := fs.SectionTemplates code := codegen.SectionCode(t, sections[5]) diff --git a/codegen/service/service.go b/codegen/service/service.go index ba6ed8928c..663042689e 100644 --- a/codegen/service/service.go +++ b/codegen/service/service.go @@ -1,8 +1,13 @@ +// This file renders service declarations and groups declarations with explicit +// package locations into the generated Go package and file where each one is +// written. package service import ( "fmt" + "path" "path/filepath" + "slices" "sort" "strings" @@ -10,52 +15,133 @@ import ( "goa.design/goa/v3/expr" ) -// Files returns the generated files for the given service as well as a map -// indexing user type names by custom path as defined by the "struct:pkg:path" -// metadata. The map is built over each invocation of Files to avoid duplicate -// type definitions. -func Files(genpkg string, service *expr.ServiceExpr, services *ServicesData, userTypePkgs map[string][]string) []*codegen.File { - svc := services.Get(service.Name) +type ( + // serviceTypeSectionPhase identifies the stable group containing a service + // type-file section. Type declarations must precede methods defined on them. + serviceTypeSectionPhase uint8 + + // serviceTypeSection retains the explicit ordering facts for one section in + // service.go instead of encoding its group in a decorated string key. + serviceTypeSection struct { + phase serviceTypeSectionPhase + name string + section *codegen.SectionTemplate + } +) + +const ( + serviceTypeDefinitionPhase serviceTypeSectionPhase = iota + serviceErrorImplementationPhase +) + +// Files renders every service file described by plans. Each plan must be +// linked so every renderer reads the declarations copied before their names +// were chosen instead of rebuilding service analysis from the expression root. +func Files(plans ...*Plan) ([]*codegen.File, error) { + var files []*codegen.File + if len(plans) == 0 { + return files, nil + } + generation := plans[0].generation + ownedRoots := make(map[*expr.RootExpr]struct{}) + for _, candidate := range generation.Roots() { + if root, ok := candidate.(*expr.RootExpr); ok { + ownedRoots[root] = struct{}{} + } + } + for _, plan := range plans[1:] { + if plan.generation != generation { + return nil, fmt.Errorf("service plans belong to different generations") + } + } + if len(plans) != len(ownedRoots) { + return nil, fmt.Errorf("service rendering requires all %d planned roots, got %d", len(ownedRoots), len(plans)) + } + seenRoots := make(map[*expr.RootExpr]struct{}, len(plans)) + for _, plan := range plans { + if _, owned := ownedRoots[plan.facts.root]; !owned { + return nil, rootMembershipError(plan.facts.root) + } + if _, exists := seenRoots[plan.facts.root]; exists { + return nil, fmt.Errorf("service root %p is rendered more than once", plan.facts.root) + } + seenRoots[plan.facts.root] = struct{}{} + } + analyses := make([]*ServicesData, len(plans)) + for index, plan := range plans { + analyses[index] = plan.Services() + for _, facts := range plan.facts.services { + files = append(files, serviceFiles(plan, facts)...) + } + } + generatedFiles, err := generatedPackageFiles(analyses) + if err != nil { + return nil, err + } + files = append(files, generatedFiles...) + for _, plan := range plans { + for _, facts := range plan.facts.services { + files = append(files, + endpointFile(plan, facts), + clientFile(plan, facts), + ) + if file := viewsFile(plan, facts); file != nil { + files = append(files, file) + } + } + } + conversionFiles, err := externalConversionFiles(plans) + if err != nil { + return nil, err + } + files = append(files, convertFiles(conversionFiles)...) + return files, nil +} + +// serviceFiles renders the declarations and helpers owned exclusively by one +// service package. Relocated declarations and all union definitions are +// emitted later by generatedPackageFiles. +func serviceFiles(plan *Plan, facts *serviceFacts) []*codegen.File { + services := plan.Services() + svc := services.Get(facts.name) svcName := svc.PathName svcPath := filepath.Join(codegen.Gendir, svcName, "service.go") seen := make(map[string]struct{}) - typeDefSections := make(map[string]map[string]*codegen.SectionTemplate) - typesByPath := make(map[string][]string) + typeSections := make([]serviceTypeSection, 0) svcSections := make([]*codegen.SectionTemplate, 0, 10) - addTypeDefSection := func(path, name string, section *codegen.SectionTemplate) { - if typeDefSections[path] == nil { - typeDefSections[path] = make(map[string]*codegen.SectionTemplate) - } - typeDefSections[path][name] = section - typesByPath[path] = append(typesByPath[path], name) + addTypeDefSection := func(name string, section *codegen.SectionTemplate) { + typeSections = append(typeSections, serviceTypeSection{ + phase: serviceTypeDefinitionPhase, + name: name, + section: section, + }) seen[name] = struct{}{} } - for _, m := range svc.Methods { - payloadPath := pathWithDefault(m.PayloadLoc, svcPath) - resultPath := pathWithDefault(m.ResultLoc, svcPath) - if m.PayloadDef != "" { + for i, m := range svc.Methods { + method := facts.orderedMethods[i] + if m.PayloadLoc == nil && m.PayloadDef != "" { if _, ok := seen[m.Payload]; !ok { - addTypeDefSection(payloadPath, m.Payload, &codegen.SectionTemplate{ + addTypeDefSection(m.Payload, &codegen.SectionTemplate{ Name: "service-payload", Source: serviceTemplates.Read(payloadT), Data: m, }) } } - if m.StreamingPayloadDef != "" { + if method.streamingPayload != nil && method.streamingPayload.location == nil && m.StreamingPayloadDef != "" { if _, ok := seen[m.StreamingPayload]; !ok { - addTypeDefSection(payloadPath, m.StreamingPayload, &codegen.SectionTemplate{ + addTypeDefSection(m.StreamingPayload, &codegen.SectionTemplate{ Name: "service-streaming-payload", Source: serviceTemplates.Read(streamingPayloadT), Data: m, }) } } - if m.ResultDef != "" { + if m.ResultLoc == nil && m.ResultDef != "" { if _, ok := seen[m.Result]; !ok { - addTypeDefSection(resultPath, m.Result, &codegen.SectionTemplate{ + addTypeDefSection(m.Result, &codegen.SectionTemplate{ Name: "service-result", Source: serviceTemplates.Read(resultT), Data: m, @@ -63,9 +149,9 @@ func Files(genpkg string, service *expr.ServiceExpr, services *ServicesData, use } } // Generate streaming result type if different from result - if m.StreamingResultDef != "" && m.StreamingResult != m.Result { + if method.streamingResult != nil && method.streamingResult.location == nil && m.StreamingResultDef != "" && m.StreamingResult != m.Result { if _, ok := seen[m.StreamingResult]; !ok { - addTypeDefSection(resultPath, m.StreamingResult, &codegen.SectionTemplate{ + addTypeDefSection(m.StreamingResult, &codegen.SectionTemplate{ Name: "service-streaming-result", Source: serviceTemplates.Read(resultT), Data: map[string]any{ @@ -78,53 +164,42 @@ func Files(genpkg string, service *expr.ServiceExpr, services *ServicesData, use } } for _, ut := range svc.userTypes { - if _, ok := seen[ut.VarName]; !ok { - addTypeDefSection(pathWithDefault(ut.Loc, svcPath), ut.VarName, &codegen.SectionTemplate{ - Name: "service-user-type", - Source: serviceTemplates.Read(userTypeT), - Data: ut, - }) + if ut.Loc == nil { + if _, ok := seen[ut.VarName]; !ok { + addTypeDefSection(ut.VarName, &codegen.SectionTemplate{ + Name: "service-user-type", + Source: serviceTemplates.Read(userTypeT), + Data: ut, + }) + } } } - for _, u := range svc.unions { - addTypeDefSection(pathWithDefault(u.Loc, svcPath), "~union:"+u.Name, &codegen.SectionTemplate{ - Name: "service-union-type", - Source: serviceTemplates.Read(unionTypeT), - Data: u, - }) - } - var errorTypes []*UserTypeData seenErrs := make(map[string]struct{}) for _, et := range svc.errorTypes { - if et.Type == expr.ErrorResult { + if et.IsServiceError || et.Loc != nil { continue } - if _, ok := seenErrs[et.Name]; !ok { - seenErrs[et.Name] = struct{}{} - if _, ok := seen[et.Name]; !ok { - addTypeDefSection(pathWithDefault(et.Loc, svcPath), et.Name, &codegen.SectionTemplate{ + if _, ok := seenErrs[et.VarName]; !ok { + seenErrs[et.VarName] = struct{}{} + if _, ok := seen[et.VarName]; !ok { + addTypeDefSection(et.VarName, &codegen.SectionTemplate{ Name: "error-user-type", Source: serviceTemplates.Read(userTypeT), Data: et, }) } - errorTypes = append(errorTypes, et) + typeSections = append(typeSections, serviceTypeSection{ + phase: serviceErrorImplementationPhase, + name: et.VarName, + section: &codegen.SectionTemplate{ + Name: "service-error", + Source: serviceTemplates.Read(errorT), + Data: et, + }, + }) } } - - for _, et := range errorTypes { - // Don't override the section created for the error type - // declaration, make sure the key does not clash with existing - // type names, make it generated last. - key := "|" + et.Name - addTypeDefSection(pathWithDefault(et.Loc, svcPath), key, &codegen.SectionTemplate{ - Name: "service-error", - Source: serviceTemplates.Read(errorT), - FuncMap: map[string]any{"errorName": errorName}, - Data: et, - }) - } for _, er := range svc.errorInits { svcSections = append(svcSections, &codegen.SectionTemplate{ Name: "error-init-func", @@ -167,280 +242,186 @@ func Files(genpkg string, service *expr.ServiceExpr, services *ServicesData, use }) } - imports := []*codegen.ImportSpec{ - codegen.SimpleImport("context"), - codegen.SimpleImport("io"), - codegen.GoaImport(""), - codegen.GoaImport("security"), - codegen.NewImport(svc.ViewsPkg, genpkg+"/"+svcName+"/views"), - } - if len(svc.unions) > 0 { - imports = append(imports, - codegen.SimpleImport("bytes"), - codegen.SimpleImport("encoding/json"), - codegen.SimpleImport("fmt"), - ) - } - header := codegen.Header(service.Name+" service", svc.PkgName, imports) + header := codegen.Header(facts.name+" service", svc.PkgName, facts.imports.service.Imports()) def := &codegen.SectionTemplate{ Name: "service", Source: serviceTemplates.Read(serviceT), Data: svc, FuncMap: map[string]any{ - "hasJSONRPCStreaming": hasJSONRPCStreaming, - "isJSONRPCWebSocket": hasJSONRPCWebSocket, - "streamInterfaceFor": streamInterfaceFor, - "dedupeByResult": dedupeByResult, + "streamInterfaceFor": streamInterfaceFor, }, } - // service.go - var sections []*codegen.SectionTemplate - { - names := make([]string, len(typeDefSections[svcPath])) - i := 0 - for n := range typeDefSections[svcPath] { - names[i] = n - i++ - } - sections = make([]*codegen.SectionTemplate, 0, 2+len(names)+len(svcSections)) - sections = append(sections, header, def) - sort.Strings(names) - for _, n := range names { - sections = append(sections, typeDefSections[svcPath][n]) + sort.Slice(typeSections, func(i, j int) bool { + if typeSections[i].phase != typeSections[j].phase { + return typeSections[i].phase < typeSections[j].phase } - sections = append(sections, svcSections...) - } - files := []*codegen.File{{Path: svcPath, SectionTemplates: sections}} + return typeSections[i].name < typeSections[j].name + }) + sections := make([]*codegen.SectionTemplate, 0, 2+len(typeSections)+len(svcSections)) + sections = append(sections, header, def) + for _, record := range typeSections { + sections = append(sections, record.section) + } + sections = append(sections, svcSections...) + interceptors := interceptorsFiles(plan, facts) + files := make([]*codegen.File, 1, 1+len(interceptors)) + files[0] = &codegen.File{Path: svcPath, SectionTemplates: sections} + return append(files, interceptors...) +} - // service and client interceptors - files = append(files, InterceptorsFiles(genpkg, service, services)...) +// generatedPackageFiles renders each relocated user type in its configured +// file and one sorted unions.go for every package that owns unions. +func generatedPackageFiles(analyses []*ServicesData) ([]*codegen.File, error) { + packages, err := aggregateGeneratedPackages(analyses) + if err != nil { + return nil, err + } + if len(packages) == 0 { + return nil, nil + } + packageOwners := make([]*codegen.GeneratedPackage, 0, len(packages)) + for owner := range packages { + packageOwners = append(packageOwners, owner) + } + slices.SortFunc(packageOwners, func(left, right *codegen.GeneratedPackage) int { + return strings.Compare(left.ImportPath(), right.ImportPath()) + }) - // user types - paths := make([]string, len(typeDefSections)) - i := 0 - for p := range typesByPath { - paths[i] = p - i++ - } - sort.Strings(paths) - for _, p := range paths { - if p == svcPath { - continue + var files []*codegen.File + for _, owner := range packageOwners { + packagePath := owner.ImportPath() + packageName := strings.ToLower(codegen.Goify(path.Base(packagePath), false)) + generatedPackage := packages[owner] + typesByFile := make(map[string][]*generatedTypeData) + for _, generatedType := range generatedPackage.types { + filePath := filepath.Join(owner.OutputDirectory(), filepath.Base(generatedType.location.FilePath)) + typesByFile[filePath] = append(typesByFile[filePath], generatedType) } - var secs []*codegen.SectionTemplate - hasUnion := false - ts := typesByPath[p] - sort.Strings(ts) - for _, name := range ts { - if strings.HasPrefix(name, "~union:") { - hasUnion = true + filePaths := make([]string, 0, len(typesByFile)) + for filePath := range typesByFile { + filePaths = append(filePaths, filePath) + } + sort.Strings(filePaths) + for _, filePath := range filePaths { + generatedTypes := typesByFile[filePath] + sort.Slice(generatedTypes, func(i, j int) bool { + return generatedTypes[i].declaration.Name() < generatedTypes[j].declaration.Name() + }) + var imports []*codegen.ImportSpec + for _, generatedType := range generatedTypes { + imports = appendImportSpecs(imports, generatedType.imports) } - hasName := false - for _, n := range userTypePkgs[p] { - if hasName = n == name; hasName { - break - } + sections := []*codegen.SectionTemplate{ + codegen.Header("User types", packageName, imports), } - if hasName { - continue + for _, generatedType := range generatedTypes { + sections = append(sections, generatedType.section) + if generatedType.error != nil { + sections = append(sections, generatedType.error) + } } - userTypePkgs[p] = append(userTypePkgs[p], name) - secs = append(secs, typeDefSections[p][name]) - } - if len(secs) == 0 { - continue - } - fullRelPath := filepath.Join(codegen.Gendir, p) - dir, _ := filepath.Split(fullRelPath) - imports := []*codegen.ImportSpec{ - codegen.SimpleImport("fmt"), - codegen.GoaImport(""), - } - if hasUnion { - imports = append(imports, - codegen.SimpleImport("bytes"), - codegen.SimpleImport("encoding/json"), - ) + files = append(files, &codegen.File{Path: filePath, SectionTemplates: sections}) } - h := codegen.Header("User types", codegen.Goify(filepath.Base(dir), false), imports) - sections := append([]*codegen.SectionTemplate{h}, secs...) - files = append(files, &codegen.File{Path: fullRelPath, SectionTemplates: sections}) - } - - return files -} -// dedupeByResult returns a slice of methods where only a single representative -// per unique ResultRef is kept (first occurrence wins). Methods without a -// ResultRef are ignored. -func dedupeByResult(ms []*MethodData) []*MethodData { - seen := make(map[string]struct{}) - out := make([]*MethodData, 0, len(ms)) - for _, m := range ms { - key := m.Result - if key == "" { - key = m.StreamingResult - } - if key == "" { - continue - } - if _, ok := seen[key]; ok { - continue - } - seen[key] = struct{}{} - out = append(out, m) - } - return out -} - -// SetUserTypeImports sets the import paths for user types declared in custom -// packages with the Meta key "struct:pkg:path". -func SetUserTypeImports(genpkg string, d *Data) { - d.UserTypeImports = userTypeImports(genpkg, d) -} - -// AddServiceDataMetaTypeImports adds all imports defined by struct:field:type -// metadata for the service data. -func AddServiceDataMetaTypeImports(header *codegen.SectionTemplate, d *Data) { - codegen.AddImport(header, d.metaTypeImports...) -} - -// AddUserTypeImports adds the imports for user types declared in custom -// packages with the Meta key "struct:pkg:path". -func AddUserTypeImports(header *codegen.SectionTemplate, d *Data) { - codegen.AddImport(header, d.UserTypeImports...) -} - -func metaTypeImports(svcExpr *expr.ServiceExpr, svcData *Data) []*codegen.ImportSpec { - seen := make(map[codegen.ImportSpec]struct{}) - var imports []*codegen.ImportSpec - for _, m := range svcExpr.Methods { - imports = appendUniqueImport(imports, seen, codegen.GetMetaTypeImports(m.Payload)...) - imports = appendUniqueImport(imports, seen, codegen.GetMetaTypeImports(m.StreamingPayload)...) - imports = appendUniqueImport(imports, seen, codegen.GetMetaTypeImports(m.Result)...) - } - for _, ut := range svcData.userTypes { - imports = appendUniqueImport(imports, seen, codegen.GetMetaTypeImports(ut.Type.Attribute())...) - } - for _, et := range svcData.errorTypes { - imports = appendUniqueImport(imports, seen, codegen.GetMetaTypeImports(et.Type.Attribute())...) - } - for _, t := range svcData.viewedResultTypes { - imports = appendUniqueImport(imports, seen, codegen.GetMetaTypeImports(t.Type.Attribute())...) - } - for _, t := range svcData.projectedTypes { - imports = appendUniqueImport(imports, seen, codegen.GetMetaTypeImports(t.Type.Attribute())...) - } - return imports -} - -func userTypeImports(genpkg string, d *Data) []*codegen.ImportSpec { - importsByPath := make(map[string]*codegen.ImportSpec) - - initLoc := func(loc *codegen.Location) { - if loc == nil { - return - } - importsByPath[loc.FilePath] = &codegen.ImportSpec{Name: loc.PackageName(), Path: genpkg + "/" + loc.RelImportPath} - } - - // Process method-specific locations - for _, m := range d.Methods { - initLoc(m.PayloadLoc) - initLoc(m.ResultLoc) - for _, l := range m.ErrorLocs { - initLoc(l) + if len(generatedPackage.unions) > 0 { + unions := make([]*UnionTypeData, 0, len(generatedPackage.unions)) + for _, union := range generatedPackage.unions { + unions = append(unions, union) + } + sort.Slice(unions, func(i, j int) bool { + return unions[i].Name < unions[j].Name + }) + sections := []*codegen.SectionTemplate{ + codegen.Header("Union types", packageName, generatedPackage.unionImports), + } + for _, union := range unions { + sections = append(sections, &codegen.SectionTemplate{ + Name: "service-union-type", + Source: serviceTemplates.Read(unionTypeT), + Data: union, + }) + } + files = append(files, &codegen.File{ + Path: filepath.Join(owner.OutputDirectory(), "unions.go"), + SectionTemplates: sections, + }) } } - - // Process service-level types once (not per method) - for _, ut := range d.userTypes { - initLoc(ut.Loc) - } - for _, et := range d.errorTypes { - initLoc(et.Loc) - } - - imports := make([]*codegen.ImportSpec, 0, len(importsByPath)) - for _, imp := range importsByPath { // Order does not matter, imports are sorted during formatting. - imports = append(imports, imp) - } - return imports + return files, nil } -func appendUniqueImport(imports []*codegen.ImportSpec, seen map[codegen.ImportSpec]struct{}, specs ...*codegen.ImportSpec) []*codegen.ImportSpec { - for _, spec := range specs { - if _, ok := seen[*spec]; ok { - continue +// aggregateGeneratedPackages selects one render section for each generated +// package declaration across all analyzed roots without changing generation +// state. +func aggregateGeneratedPackages(analyses []*ServicesData) (map[*codegen.GeneratedPackage]*generatedPackageData, error) { + packages := make(map[*codegen.GeneratedPackage]*generatedPackageData) + for _, services := range analyses { + for owner, analyzedPackage := range services.packages { + generatedPackage, ok := packages[owner] + if !ok { + generatedPackage = &generatedPackageData{ + types: make(map[*codegen.TypeDeclaration]*generatedTypeData), + unions: make(map[*codegen.UnionDeclaration]*UnionTypeData), + } + packages[owner] = generatedPackage + } + for declaration, generatedType := range analyzedPackage.types { + if _, exists := generatedPackage.types[declaration]; exists { + return nil, fmt.Errorf( + "generated type declaration %q was assigned to more than one service plan", + declaration.Name(), + ) + } + generatedPackage.types[declaration] = generatedType + } + for declaration, union := range analyzedPackage.unions { + if _, exists := generatedPackage.unions[declaration]; exists { + return nil, fmt.Errorf( + "generated union declaration %q was assigned to more than one service plan", + union.Name, + ) + } + generatedPackage.unions[declaration] = union + } + generatedPackage.unionImports = appendImportSpecs(generatedPackage.unionImports, analyzedPackage.unionImports) } - seen[*spec] = struct{}{} - imports = append(imports, spec) } - return imports + return packages, nil } -func errorName(et *UserTypeData) string { - obj := expr.AsObject(et.Type) - if obj != nil { - for _, att := range *obj { - if _, ok := att.Attribute.Meta["struct:error:name"]; ok { - return fmt.Sprintf("e.%s", codegen.GoifyAtt(att.Attribute, att.Name, true)) - } - } +// appendImportSpecs merges exact file contributions by complete package path +// and returns them in deterministic path order. +func appendImportSpecs(existing, added []*codegen.ImportSpec) []*codegen.ImportSpec { + byPath := make(map[string]*codegen.ImportSpec, len(existing)+len(added)) + for _, spec := range existing { + byPath[spec.Path] = spec } - // if error type is a custom user type and used by at most one error, then - // error Finalize should have added "struct:error:name" to the user type - // attribute's meta. - if v, ok := et.Type.Attribute().Meta["struct:error:name"]; ok { - return fmt.Sprintf("%q", v[0]) + for _, spec := range added { + byPath[spec.Path] = spec } - return fmt.Sprintf("%q", et.Name) -} - -// hasJSONRPCStreaming returns true if the service has a JSON-RPC streaming -// endpoint (WebSocket or SSE). -func hasJSONRPCStreaming(sd *Data) bool { - for _, m := range sd.Methods { - if m.IsJSONRPC && m.ServerStream != nil { - return true - } + paths := make([]string, 0, len(byPath)) + for importPath := range byPath { + paths = append(paths, importPath) } - return false -} - -// hasJSONRPCWebSocket returns true if the service has a JSON-RPC streaming -// endpoint that uses the WebSocket transport. -func hasJSONRPCWebSocket(sd *Data) bool { - for _, m := range sd.Methods { - if m.IsJSONRPCWebSocket { - return true - } + sort.Strings(paths) + result := make([]*codegen.ImportSpec, len(paths)) + for index, importPath := range paths { + result[index] = byPath[importPath] } - return false + return result } // streamInterfaceFor builds the data to generate the client and server stream // interfaces for the given endpoint. func streamInterfaceFor(typ string, m *MethodData, stream *StreamData) map[string]any { return map[string]any{ - "Type": typ, - "Endpoint": m.Name, - "Stream": stream, - "MethodVarName": m.VarName, - "IsJSONRPC": m.IsJSONRPC, - "IsJSONRPCSSE": m.IsJSONRPCSSE && typ == "server", - "IsJSONRPCWebSocket": m.IsJSONRPCWebSocket, + "Type": typ, + "Endpoint": m.Name, + "Stream": stream, // If a view is explicitly set (ViewName is not empty) in the Result // expression, we can use that view to render the result type instead // of iterating through the list of views defined in the result type. "IsViewedResult": m.ViewedResult != nil && m.ViewedResult.ViewName == "", } } - -func pathWithDefault(loc *codegen.Location, def string) string { - if loc == nil { - return def - } - return loc.FilePath -} diff --git a/codegen/service/service_data.go b/codegen/service/service_data.go index dc18b3eea9..d81f705939 100644 --- a/codegen/service/service_data.go +++ b/codegen/service/service_data.go @@ -1,11 +1,10 @@ +// This file builds the values passed to service templates. Package-level Go +// names are shared by every file in the package, while each file may choose +// additional private helper names. package service import ( - "bytes" "fmt" - "slices" - "sort" - "strings" "text/template" "goa.design/goa/v3/codegen" @@ -35,11 +34,50 @@ type ( ServicesData struct { Root *expr.RootExpr Services map[string]*Data + + generation *codegen.Generation + examples *expr.ExampleGenerator + aliases *importAliases + packages map[*codegen.GeneratedPackage]*generatedPackageData + facts *rootFacts } // Data contains the data used to render the code related to a single // service. Data struct { + // ServiceDeclaration is the exact package-level service interface record. + ServiceDeclaration *codegen.NameDeclaration + // AutherDeclaration is the exact package-level authorization interface + // record. It is nil when the service has no security schemes. + AutherDeclaration *codegen.NameDeclaration + // APINameDeclaration is the exact package-level API name constant record. + APINameDeclaration *codegen.NameDeclaration + // APIVersionDeclaration is the exact package-level API version constant record. + APIVersionDeclaration *codegen.NameDeclaration + // ServiceNameDeclaration is the exact package-level service name constant record. + ServiceNameDeclaration *codegen.NameDeclaration + // MethodNamesDeclaration is the exact package-level method names variable record. + MethodNamesDeclaration *codegen.NameDeclaration + // EndpointsDeclaration is the exact package-level endpoint collection record. + EndpointsDeclaration *codegen.NameDeclaration + // NewEndpointsDeclaration is the exact endpoint constructor record. + NewEndpointsDeclaration *codegen.NameDeclaration + // ClientDeclaration is the exact package-level client record. + ClientDeclaration *codegen.NameDeclaration + // NewClientDeclaration is the exact client constructor record. + NewClientDeclaration *codegen.NameDeclaration + // ServerInterceptorsDeclaration is the server interceptor interface record. + ServerInterceptorsDeclaration *codegen.NameDeclaration + // ClientInterceptorsDeclaration is the client interceptor interface record. + ClientInterceptorsDeclaration *codegen.NameDeclaration + // ExampleStructDeclaration is the starter implementation struct record. + ExampleStructDeclaration *codegen.NameDeclaration + // ExampleConstructorDeclaration is the starter constructor record. + ExampleConstructorDeclaration *codegen.NameDeclaration + // ExampleServerInterceptorsConstructorDeclaration creates the starter + // server interceptor implementation. It is nil when the service has no + // server interceptors. + ExampleServerInterceptorsConstructorDeclaration *codegen.NameDeclaration // Name is the service name. Name string // Description is the service description. @@ -50,15 +88,16 @@ type ( APIVersion string // StructName is the service struct name. StructName string - // VarName is the service variable name (first letter in lowercase). + // VarName is the local Go variable that holds the service implementation in + // generated starter programs. VarName string // PathName is the service name as used in file and import paths. PathName string // PkgName is the name of the package containing the generated service // code. PkgName string - // ViewsPkg is the name of the package containing the projected and viewed - // result types. + // ViewsPkg is the final views package name kept for existing plugins. It + // is empty when the service does not generate a views package. ViewsPkg string // Methods lists the service interface methods. Methods []*MethodData @@ -77,10 +116,6 @@ type ( // ProtoImports lists the import specifications for the custom // proto types used by the service. ProtoImports []*codegen.ImportSpec - // UserTypeImports lists the import specifications for the user types - // used by the service. - UserTypeImports []*codegen.ImportSpec - // userTypes lists the type definitions that the service depends on. userTypes []*UserTypeData // errorTypes lists the error type definitions that the service depends on. @@ -91,17 +126,35 @@ type ( // projectedTypes lists the types which uses pointers for all fields to // define view specific validation logic. projectedTypes []*ProjectedTypeData - // unions lists the sum-type unions defined for the service. + // unions lists the values that hold one selected branch for the service. unions []*UnionTypeData + // viewUnions lists the values that hold one selected branch in the views package. + viewUnions []*UnionTypeData // viewedResultTypes lists all the viewed method result types. viewedResultTypes []*ViewedResultTypeData - // metaTypeImports lists the imports derived from struct:field:type - // metadata for the service. - metaTypeImports []*codegen.ImportSpec + // viewDerived binds the independently rebuilt view graph to declarations + // reserved while the service was planned. + viewDerived map[expr.UserType]codegen.DerivedTypeID } // MethodData describes a single service method. MethodData struct { + // EndpointDeclaration is the exact package-level endpoint constructor. + EndpointDeclaration *codegen.NameDeclaration + // EndpointInputDeclaration is the exact streaming endpoint input record. + EndpointInputDeclaration *codegen.NameDeclaration + // ServerStreamDeclaration is the exact server stream interface record. + ServerStreamDeclaration *codegen.NameDeclaration + // ClientStreamDeclaration is the exact client stream interface record. + ClientStreamDeclaration *codegen.NameDeclaration + // RequestDeclaration is the exact JSON-RPC request data record. + RequestDeclaration *codegen.NameDeclaration + // ResponseDeclaration is the exact JSON-RPC response data record. + ResponseDeclaration *codegen.NameDeclaration + // ServerEndpointWrapperDeclaration is the exact server endpoint wrapper. + ServerEndpointWrapperDeclaration *codegen.NameDeclaration + // ClientEndpointWrapperDeclaration is the exact client endpoint wrapper. + ClientEndpointWrapperDeclaration *codegen.NameDeclaration // Name is the method name. Name string // Description is the method description. @@ -120,6 +173,9 @@ type ( PayloadDef string // PayloadRef is a reference to the payload type if any, PayloadRef string + // PayloadDeclaration supplies the generated Go type name for a named payload. + // It is nil for primitive payloads. + PayloadDeclaration *codegen.TypeDeclaration // PayloadDesc is the payload type description if any. PayloadDesc string // PayloadEx is an example of a valid payload value. @@ -132,6 +188,9 @@ type ( StreamingPayloadDef string // StreamingPayloadRef is a reference to the streaming payload type if any. StreamingPayloadRef string + // StreamingPayloadDeclaration supplies the generated Go type name for a + // named streaming payload. It is nil for primitive payloads. + StreamingPayloadDeclaration *codegen.TypeDeclaration // StreamingPayloadDesc is the streaming payload type description if any. StreamingPayloadDesc string // StreamingPayloadEx is an example of a valid streaming payload value. @@ -142,6 +201,9 @@ type ( StreamingResultDef string // StreamingResultRef is the reference to the streaming result type if any. StreamingResultRef string + // StreamingResultDeclaration supplies the generated Go type name for a named + // streaming result. It is nil for primitive results. + StreamingResultDeclaration *codegen.TypeDeclaration // StreamingResultDesc is the streaming result type description if any. StreamingResultDesc string // StreamingResultEx is an example of a valid streaming result value. @@ -155,6 +217,9 @@ type ( ResultDef string // ResultRef is the reference to the result type if any. ResultRef string + // ResultDeclaration supplies the generated Go type name for a named result. + // It is nil for primitive results. + ResultDeclaration *codegen.TypeDeclaration // ResultDesc is the result type description if any. ResultDesc string // ResultEx is an example of a valid result value. @@ -164,12 +229,6 @@ type ( // ErrorLocs lists the file and Go package of the error type // if overridden via Meta indexed by error name. ErrorLocs map[string]*codegen.Location - // IsJSONRPC indicates if the endpoint is a JSON-RPC endpoint. - IsJSONRPC bool - // IsJSONRPCSSE indicates if the JSON-RPC endpoint uses SSE transport. - IsJSONRPCSSE bool - // IsJSONRPCWebSocket indicates if the JSON-RPC endpoint uses WebSocket transport. - IsJSONRPCWebSocket bool // Requirements contains the security requirements for the // method. Requirements RequirementsData @@ -194,9 +253,9 @@ type ( // StreamKind is the kind of the stream (payload or result or // bidirectional). StreamKind expr.StreamKind - // HasMixedResults indicates whether the method defines both Result and - // StreamingResult with different types, enabling content negotiation at - // the transport layer (e.g. JSON vs SSE over HTTP). + // HasMixedResults indicates whether the method defines Result and + // StreamingResult separately so HTTP can return one normal response or an + // SSE stream. HasMixedResults bool // SkipRequestBodyEncodeDecode is true if the method payload includes // the raw HTTP request body reader. @@ -231,8 +290,8 @@ type ( StreamData struct { // Interface is the name of the stream interface. Interface string - // VarName is the name of the struct type that implements the stream - // interface. + // VarName is the unexported Go type name used by transport packages for this + // stream implementation. VarName string // SendName is the name of the send function. SendName string @@ -246,14 +305,6 @@ type ( SendTypeName string // SendTypeRef is the reference to the type sent through the stream. SendTypeRef string - // SendAndCloseName is the name of the send and close function (SSE only). - SendAndCloseName string - // SendAndCloseDesc is the description for the send and close function. - SendAndCloseDesc string - // SendAndCloseWithContextName is the name of the send and close function with context. - SendAndCloseWithContextName string - // SendAndCloseWithContextDesc is the description for the send and close function with context. - SendAndCloseWithContextDesc string // RecvName is the name of the receive function. RecvName string // RecvDesc is the description for the recv function. @@ -276,10 +327,17 @@ type ( Kind expr.StreamKind } - // ErrorInitData describes an error returned by a service method of type - // ErrorResult. + // ErrorInitData describes an error returned by a service method. ErrorInitData struct { - // Name is the name of the init function. + // Declaration is the package-level constructor submitted while the service + // was planned. It is nil for custom errors because the service package does + // not generate constructors for them. + Declaration *codegen.NameDeclaration + // Name is a read-only copy of the final constructor name kept for existing + // plugins. It is empty for custom errors because they have no generated + // service constructor. + // + // Deprecated: Use Declaration.Name(). Name string // Description is the error description. Description string @@ -300,12 +358,24 @@ type ( // InterceptorData contains the data required to render the service-level // interceptor code. interceptors.go.tpl InterceptorData struct { + // InfoDeclaration is the exact interceptor metadata record. + InfoDeclaration *codegen.NameDeclaration + // PayloadDeclaration is the exact payload accessor interface when emitted. + PayloadDeclaration *codegen.NameDeclaration + // ResultDeclaration is the exact result accessor interface when emitted. + ResultDeclaration *codegen.NameDeclaration + // StreamingPayloadDeclaration is the exact streaming payload accessor interface when emitted. + StreamingPayloadDeclaration *codegen.NameDeclaration + // StreamingResultDeclaration is the exact streaming result accessor interface when emitted. + StreamingResultDeclaration *codegen.NameDeclaration // Name is the name of the interceptor used in the generated code. Name string // DesignName is the name of the interceptor as defined in the design. DesignName string // Description is the description of the interceptor from the design. Description string + // Service is the service name returned to this interceptor. + Service string // Methods Methods []*MethodInterceptorData // ReadPayload contains payload attributes that the interceptor can @@ -344,6 +414,33 @@ type ( // MethodInterceptorData contains the data required to render the // method-level interceptor code. MethodInterceptorData struct { + // InfoDeclaration is the private type that returns this method's name and + // provides its field access methods. + InfoDeclaration *codegen.NameDeclaration + // ServerUnaryInfoDeclaration is the private call information type used by + // the server endpoint call. + ServerUnaryInfoDeclaration *codegen.NameDeclaration + // ClientUnaryInfoDeclaration is the private call information type used by + // the client endpoint call. + ClientUnaryInfoDeclaration *codegen.NameDeclaration + // StreamingSendInfoDeclaration is the private call information type used + // while a stream value is sent. + StreamingSendInfoDeclaration *codegen.NameDeclaration + // StreamingRecvInfoDeclaration is the private call information type used + // while a stream value is received. + StreamingRecvInfoDeclaration *codegen.NameDeclaration + // PayloadAccessDeclaration is the exact private payload accessor struct. + PayloadAccessDeclaration *codegen.NameDeclaration + // ResultAccessDeclaration is the exact private result accessor struct. + ResultAccessDeclaration *codegen.NameDeclaration + // StreamingPayloadAccessDeclaration is the exact private streaming payload accessor struct. + StreamingPayloadAccessDeclaration *codegen.NameDeclaration + // StreamingResultAccessDeclaration is the exact private streaming result accessor struct. + StreamingResultAccessDeclaration *codegen.NameDeclaration + // ServerWrapperDeclaration is the exact server interceptor wrapper function. + ServerWrapperDeclaration *codegen.NameDeclaration + // ClientWrapperDeclaration is the exact client interceptor wrapper function. + ClientWrapperDeclaration *codegen.NameDeclaration // MethodName is the name of the method. MethodName string // PayloadAccess is the name of the payload access struct. @@ -370,6 +467,10 @@ type ( // StreamInterceptorData is the stream data for an interceptor. StreamInterceptorData struct { + // InterfaceDeclaration is the exact stream interface wrapped by this record. + InterfaceDeclaration *codegen.NameDeclaration + // WrapperDeclaration is the exact private interceptor stream wrapper struct. + WrapperDeclaration *codegen.NameDeclaration // Interface is the name of the stream interface. Interface string // SendName is the name of the send function. @@ -419,12 +520,20 @@ type ( // UserTypeData contains the data describing a user-defined type. UserTypeData struct { + // Declaration supplies this type's generated Go name and output package. + Declaration *codegen.TypeDeclaration // Name is the type name. Name string // VarName is the corresponding Go type name. VarName string // Description is the type human description. Description string + // ErrorDescription is the authored text returned by Error. + ErrorDescription string + // ErrorName is the Go expression returned by GoaErrorName during planning. + ErrorName string + // IsServiceError reports whether this is Goa's built-in service error. + IsServiceError bool // Def is the type definition Go code. Def string // Ref is the reference to the type. @@ -436,18 +545,26 @@ type ( Type expr.UserType } - // UnionTypeData describes a generated sum-type union for a service. + // UnionTypeData describes a generated value that holds exactly one branch. UnionTypeData struct { - // Name is the Go type name of the union struct. + // TypeDeclaration supplies the generated union type name. + TypeDeclaration *codegen.NameDeclaration + // KindDeclaration supplies the generated type that records the selected branch. + KindDeclaration *codegen.NameDeclaration + // Name is the final union type name copied for existing plugins. + // + // Deprecated: Use TypeDeclaration. Name string - // KindName is the Go type name of the discriminator kind. + // KindName is the final selected-branch type name copied for existing plugins. + // + // Deprecated: Use KindDeclaration. KindName string // Fields describes each union branch. Fields []*UnionFieldData // Loc defines the file and Go package of the union type if overridden via // Meta. When nil the type is generated in the default service file. Loc *codegen.Location - // TypeKey is the discriminator field name for JSON marshaling (defaults to "type"). + // TypeKey is the field that records the selected branch in JSON (defaults to "type"). TypeKey string // ValueKey is the value field name for JSON marshaling (defaults to "value"). ValueKey string @@ -457,14 +574,26 @@ type ( UnionFieldData struct { // Name is the branch name as defined in the DSL. Name string - // KindConst is the Go identifier for the kind constant of this branch. + // KindConst is the final branch constant name copied for existing plugins. + // + // Deprecated: Use KindDeclaration. KindConst string - // FieldName is the struct field name in the union. + // Constructor is the final branch constructor name copied for existing plugins. + // + // Deprecated: Use ConstructorDeclaration. + Constructor string + // KindDeclaration supplies the generated constant name for this branch. + KindDeclaration *codegen.NameDeclaration + // ConstructorDeclaration supplies the generated constructor name for this branch. + ConstructorDeclaration *codegen.NameDeclaration + // FieldName is the branch name used by the public As and Set methods. FieldName string + // StorageName is the private struct field that stores this branch value. + StorageName string // FieldType is the Go type used in the union struct field and public API. FieldType string - // Nilable is true when the Go branch value can be nil even though the - // canonical union value is required. + // Nilable is true when the Go branch value can be nil even though selecting + // a non-nil Goa OneOf branch value is required. Nilable bool // EmitPrimitiveAlias is true when the branch uses a generated primitive alias // that must be declared in the same file as the union type. @@ -472,7 +601,7 @@ type ( // PrimitiveAliasType is the underlying Go type used by the generated branch // alias (for example "string" or "float64"). PrimitiveAliasType string - // TypeTag is the JSON "type" discriminator value for this branch. + // TypeTag is the JSON "type" value that selects this branch. TypeTag string } @@ -570,6 +699,13 @@ type ( Attributes []string // TypeVarName is the Go variable name of the type that defines the view. TypeVarName string + // MapDeclaration is the exact package-level view map record for this type. + MapDeclaration *codegen.NameDeclaration + // ToProjected is the private constructor that copies only this view's + // fields from a service result. + ToProjected *codegen.NameDeclaration + // ToResult is the exact private constructor that removes this view. + ToResult *codegen.NameDeclaration } // ProjectedTypeData contains the data used to generate a projected type for @@ -593,7 +729,7 @@ type ( // corresponding service type. If the projected type corresponds to a // result type, then a function for each view is generated. TypeInits []*InitData - // ViewsPkg is the views package name. + // ViewsPkg is the final views package name kept for existing plugins. ViewsPkg string // Views lists the views defined on the projected type. Views []*ViewData @@ -602,7 +738,13 @@ type ( // InitData contains the data to render a constructor to initialize service // types from viewed result types and vice versa. InitData struct { - // Name is the name of the constructor function. + // Declaration is the package-level constructor submitted while the service + // was planned. + Declaration *codegen.NameDeclaration + // Name is a read-only copy of the final constructor name kept for existing + // plugins. + // + // Deprecated: Use Declaration.Name(). Name string // Description is the function description. Description string @@ -627,7 +769,13 @@ type ( // ValidateData contains data to render a validate function to validate a // projected type or a viewed result type based on views. ValidateData struct { - // Name is the validation function name. + // Declaration is the package-level validation function submitted while the + // service was planned. + Declaration *codegen.NameDeclaration + // Name is a read-only copy of the final validation function name kept for + // existing plugins. + // + // Deprecated: Use Declaration.Name(). Name string // Ref is the reference to the type on which the validation function // is defined. @@ -636,29 +784,179 @@ type ( Description string // Validate is the validation code. Validate string + // Calls lists nested validator functions called by Validate. + Calls []*ValidationCallData + } + + // ValidationCallData records the exact generated function called to validate + // one nested result value. + ValidationCallData struct { + // Declaration is the exact package-level validator function record. + Declaration *codegen.NameDeclaration + // View is the selected result-type view. + View string + // Default reports whether View is the default result-type view. + Default bool + } + + // validationFieldData describes a nested result field checked by the + // validation function for its parent's selected view. + validationFieldData struct { + Name string + Call *ValidationCallData + IsRequired bool + } + + // constructorFieldData associates one child result field with the private + // constructor called by its parent conversion. + constructorFieldData struct { + VarName string + Declaration *codegen.NameDeclaration + } + + // unionDataKey selects one Goa OneOf definition by its generated definition + // key and Go package path. + unionDataKey struct { + packagePath string + identity codegen.UnionDeclarationID + } + + // userTypeDataKey distinguishes in-memory design types and the generated Go + // declaration selected for each one. + userTypeDataKey struct { + origin expr.UserType + declaration *codegen.TypeDeclaration + } + + // projectedTypePair records one result type rebuilt with only a view's fields + // and the exact source declaration used to find its DerivedTypeID. + projectedTypePair struct { + source expr.UserType + projected expr.UserType + sourceAttribute *expr.AttributeExpr + projectedAttribute *expr.AttributeExpr } ) -// NewServicesData creates a new ServicesData instance for the given root. -func NewServicesData(root *expr.RootExpr) *ServicesData { - return &ServicesData{ - Services: make(map[string]*Data), - Root: root, +// linkServicesData builds service template data from the values copied during +// planning and the Go names chosen by Generation.Freeze. +func linkServicesData(facts *rootFacts, generation *codegen.Generation, aliases *importAliases) (*ServicesData, error) { + root := facts.root + data := &ServicesData{ + Root: root, + Services: make(map[string]*Data), + generation: generation, + examples: facts.examples, + aliases: aliases, + packages: make(map[*codegen.GeneratedPackage]*generatedPackageData), + facts: facts, + } + for _, service := range facts.services { + analyzed, err := data.analyze(service) + if err != nil { + return nil, err + } + service.data = analyzed + data.Services[service.name] = analyzed + } + data.registerPackageData() + return data, nil +} + +// Example computes an example for attribute. The supplied ExampleIdentity +// selects the repeatable sequence from which the values are drawn. +func (d *ServicesData) Example(attribute *expr.AttributeExpr, owner expr.ExampleIdentity) any { + return attribute.Example(d.examples.At(owner)) +} + +// FieldExample computes attribute's example from the same repeatable sequence +// as the matching field in parent. Fields of a named user type use that type's +// ExampleIdentity; fields of an anonymous parent use the caller's value. +func (d *ServicesData) FieldExample(attribute, parent *expr.AttributeExpr, name string, owner expr.ExampleIdentity) any { + if typ, ok := parent.Type.(expr.UserType); ok { + owner = expr.UserTypeExampleIdentity(typ) } + return attribute.Example(d.examples.At(owner).Member(name)) } -// Get retrieves the data for the service with the given name computing it if -// needed. It returns nil if there is no service with the given name. +// Get retrieves the analyzed data for the service with the given name. It +// returns nil if there is no service with the given name. func (d *ServicesData) Get(name string) *Data { - if data, ok := d.Services[name]; ok { - return data + return d.Services[name] +} + +// GenPkg returns the generated module import path shared by every declaration, +// import alias, and transport built from this service analysis. +func (d *ServicesData) GenPkg() string { + return d.generation.GenPkg() +} + +// ServiceImport returns the import path and Go name used by outputPackage for +// the generated package of service name. The returned value is a copy that +// callers may add to one file. +func (d *ServicesData) ServiceImport(outputPackage, name string) *codegen.ImportSpec { + serviceFacts := d.facts.serviceByID[name] + if serviceFacts == nil { + panic(fmt.Sprintf("service %q is not part of the analyzed design root", name)) } - service := d.Root.Service(name) - if service == nil { - return nil + spec := d.aliases.spec(outputPackage, serviceFacts.packagePath) + return &codegen.ImportSpec{Name: spec.Name, Path: spec.Path} +} + +// ViewImport returns the import path and Go name used by outputPackage for the +// views package of service name. The returned value is a copy that callers may +// add to one file. +func (d *ServicesData) ViewImport(outputPackage, name string) *codegen.ImportSpec { + serviceFacts := d.facts.serviceByID[name] + if serviceFacts == nil { + panic(fmt.Sprintf("service %q is not part of the analyzed design root", name)) } - d.Services[name] = d.analyze(service) - return d.Services[name] + spec := d.aliases.spec(outputPackage, serviceFacts.viewsPath) + return &codegen.ImportSpec{Name: spec.Name, Path: spec.Path} +} + +// PackageImport returns the import path and Go name used by outputPackage for +// importPath. The returned value is a copy that callers may add to one file. +func (d *ServicesData) PackageImport(outputPackage, importPath string) *codegen.ImportSpec { + spec := d.aliases.spec(outputPackage, importPath) + return &codegen.ImportSpec{Name: spec.Name, Path: spec.Path} +} + +// ServiceAttributor returns a type writer for service name as referenced from +// outputPackage. It follows explicit generated package locations and uses the +// same import names as service templates. +func (d *ServicesData) ServiceAttributor(name, outputPackage string) codegen.Attributor { + serviceFacts := d.facts.serviceByID[name] + if serviceFacts == nil { + panic(fmt.Sprintf("service %q is not part of the analyzed design root", name)) + } + return newServiceResolver( + d.generation, + d.aliases, + serviceFacts.name, + serviceFacts.packagePath, + outputPackage, + ). + withValidators(serviceFacts.validators) +} + +// ViewAttributor returns a type writer for service name's result views as +// referenced from outputPackage. +func (d *ServicesData) ViewAttributor(name, outputPackage string) codegen.Attributor { + serviceFacts := d.facts.serviceByID[name] + if serviceFacts == nil { + panic(fmt.Sprintf("service %q is not part of the analyzed design root", name)) + } + data := d.Services[name] + return newViewResolver( + d.generation, + d.aliases, + serviceFacts.name, + serviceFacts.viewsPath, + data.viewDerived, + ). + withValidators(serviceFacts.validators). + withOutputPackage(outputPackage) } // Method returns the service method data for the method with the given name, @@ -737,1598 +1035,3 @@ func (s SchemesData) DedupeByType() SchemesData { return uniqueSchemes } - -// analyze creates the data necessary to render the code of the given service. -// It records the user types needed by the service definition in userTypes. -func (d *ServicesData) analyze(service *expr.ServiceExpr) *Data { - var ( - types []*UserTypeData - errTypes []*UserTypeData - errorInits []*ErrorInitData - projTypes []*ProjectedTypeData - viewedRTs []*ViewedResultTypeData - ) - scope := codegen.NewNameScope() - scope.Unique("Use") // Reserve "Use" for Endpoints struct Use method. - scope.Unique("websocket") // Reserve "websocket" to avoid collision with gorilla/websocket - viewScope := codegen.NewNameScope() - pkgName := scope.HashedUnique(service, strings.ToLower(codegen.Goify(service.Name, false)), "svc") - viewspkg := pkgName + "views" - seen := make(map[string]struct{}) - seenErrors := make(map[string]struct{}) - seenProj := make(map[string]*ProjectedTypeData) - seenViewed := make(map[string]*ViewedResultTypeData) - - // A function to collect user types from an error expression - recordError := func(er *expr.ErrorExpr) { - errTypes = append(errTypes, collectTypes(er.AttributeExpr, scope, seen, nil)...) - if er.Type == expr.ErrorResult { - if _, ok := seenErrors[er.Name]; ok { - return - } - seenErrors[er.Name] = struct{}{} - errorInits = append(errorInits, buildErrorInitData(er, scope)) - } - } - for _, er := range service.Errors { - recordError(er) - } - - // A function to collect inner user types from an attribute expression - collectUserTypes := func(att *expr.AttributeExpr) { - if att == nil { - return - } - var loc *codegen.Location - if ut, ok := att.Type.(expr.UserType); ok { - loc = codegen.UserTypeLocation(ut) - att = ut.Attribute() - } - types = append(types, collectTypes(att, scope, seen, loc)...) - } - for _, m := range service.Methods { - // collect inner user types - collectUserTypes(m.Payload) - collectUserTypes(m.StreamingPayload) - collectUserTypes(m.Result) - // Collect streaming result types if different from Result - if m.HasMixedResults() { - collectUserTypes(m.StreamingResult) - } - // Collect projected types - if hasResultType(m.Result) { - projected, result := projectedResultRoot(service, m) - ptypes := collectProjectedTypes(projected, result, viewspkg, scope, viewScope, seenProj) - projTypes = append(projTypes, ptypes...) - } - for _, er := range m.Errors { - recordError(er) - } - } - - // A function to record method user types so that forced types are not - // collected twice. Raw object method types are wrapped into synthesized - // user types by codegen.NormalizeRoot before any generator runs: analyze - // reads the design and never mutates it, so a raw object here means the - // root was not normalized. - recordMethodType := func(m *expr.MethodExpr, att *expr.AttributeExpr) { - if att == nil { - return - } - if _, ok := att.Type.(*expr.Object); ok { - panic(fmt.Sprintf( - "service %q method %q declares a raw object type: codegen.NormalizeRoot must run after eval finalization and before the generators read the design", - service.Name, m.Name)) // bug - } - if ut, ok := att.Type.(expr.UserType); ok { - seen[ut.ID()] = struct{}{} - } - } - - for _, m := range service.Methods { - recordMethodType(m, m.Payload) - recordMethodType(m, m.StreamingPayload) - recordMethodType(m, m.Result) - if m.HasMixedResults() { - recordMethodType(m, m.StreamingResult) - } - } - - // Add forced types - for _, t := range d.Root.Types { - svcs, ok := t.Attribute().Meta["type:generate:force"] - if !ok { - continue - } - att := &expr.AttributeExpr{Type: t} - if len(svcs) > 0 { - // Force generate type only in the specified services - if slices.Contains(svcs, service.Name) { - types = append(types, collectTypes(att, scope, seen, nil)...) - } - continue - } - // Force generate type in all the services - types = append(types, collectTypes(att, scope, seen, nil)...) - } - - var ( - methods []*MethodData - schemes SchemesData - ) - methods = make([]*MethodData, len(service.Methods)) - for i, e := range service.Methods { - m := d.buildMethodData(e, scope) - methods[i] = m - for _, s := range m.Schemes { - schemes = schemes.Append(s) - } - rt, ok := e.Result.Type.(*expr.ResultTypeExpr) - if !ok { - continue - } - var view string - if v, ok := e.Result.Meta.Last(expr.ViewMetaKey); ok { - view = v - } - if vrt, ok := seenViewed[m.Result+"::"+view]; ok { - m.ViewedResult = vrt - continue - } - projected := seenProj[rt.ID()] - projAtt := &expr.AttributeExpr{Type: projected.Type} - vrt := buildViewedResultType(e.Result, projAtt, viewspkg, scope, viewScope) - found := false - for _, rt := range viewedRTs { - if rt.Type.ID() == vrt.Type.ID() { - found = true - break - } - } - if !found { - viewedRTs = append(viewedRTs, vrt) - } - m.ViewedResult = vrt - seenViewed[vrt.Name+"::"+view] = vrt - } - - // Compute unique EndpointField names using the service-level scope, after - // method names are set. This records field identifiers without changing - // existing method names. - for _, m := range methods { - m.EndpointField = scope.Unique(m.VarName+"Endpoint", "") - if m.HasMixedResults { - m.StreamEndpointField = scope.Unique(m.VarName+"StreamEndpoint", "") - } - } - - // Collect union sum-type definitions for the service. - unionByPackage := make(map[string]*UnionTypeData) - seen = make(map[string]struct{}) - collectUnions := func(att *expr.AttributeExpr, loc *codegen.Location) { - collectUnionTypes(att, scope, loc, unionByPackage, seen, false) - } - for _, t := range types { - collectUnions(&expr.AttributeExpr{Type: t.Type}, t.Loc) - } - for _, t := range errTypes { - collectUnions(&expr.AttributeExpr{Type: t.Type}, t.Loc) - } - for _, m := range service.Methods { - if m.Payload != nil { - collectUnions(m.Payload, codegen.UserTypeLocation(m.Payload.Type)) - } - if m.StreamingPayload != nil { - collectUnions(m.StreamingPayload, codegen.UserTypeLocation(m.StreamingPayload.Type)) - } - if m.Result != nil { - collectUnions(m.Result, codegen.UserTypeLocation(m.Result.Type)) - } - for _, e := range m.Errors { - collectUnions(e.AttributeExpr, codegen.UserTypeLocation(e.Type)) - } - } - unions := make([]*UnionTypeData, 0, len(unionByPackage)) - for _, u := range unionByPackage { - unions = append(unions, u) - } - sort.Slice(unions, func(i, j int) bool { - if unions[i].Name != unions[j].Name { - return unions[i].Name < unions[j].Name - } - return unionPackageKey(unions[i].Loc, false) < unionPackageKey(unions[j].Loc, false) - }) - - desc := service.Description - if desc == "" { - desc = fmt.Sprintf("Service is the %s service interface.", service.Name) - } - - varName := codegen.Goify(service.Name, false) - data := &Data{ - Name: service.Name, - Description: desc, - APIName: d.Root.API.Name, - APIVersion: d.Root.API.Version, - VarName: varName, - PathName: codegen.SnakeCase(varName), - StructName: codegen.Goify(service.Name, true), - PkgName: pkgName, - ViewsPkg: viewspkg, - Methods: methods, - Schemes: schemes, - ServerInterceptors: d.collectInterceptors(service, methods, scope, true), - ClientInterceptors: d.collectInterceptors(service, methods, scope, false), - Scope: scope, - ViewScope: viewScope, - errorTypes: errTypes, - errorInits: errorInits, - userTypes: types, - projectedTypes: projTypes, - viewedResultTypes: viewedRTs, - unions: unions, - } - data.metaTypeImports = metaTypeImports(service, data) - - d.Services[service.Name] = data - - return data -} - -// collectInterceptors returns the set of interceptors defined on the given -// service including any interceptor defined on specific service methods or API. -func (d *ServicesData) collectInterceptors(svc *expr.ServiceExpr, methods []*MethodData, scope *codegen.NameScope, server bool) []*InterceptorData { - var ints []*expr.InterceptorExpr - if server { - ints = d.Root.API.ServerInterceptors - ints = append(ints, svc.ServerInterceptors...) - for _, m := range svc.Methods { - ints = append(ints, m.ServerInterceptors...) - } - } else { - ints = d.Root.API.ClientInterceptors - ints = append(ints, svc.ClientInterceptors...) - for _, m := range svc.Methods { - ints = append(ints, m.ClientInterceptors...) - } - } - // remove duplicate interceptors - sort.Slice(ints, func(i, j int) bool { - return ints[i].Name < ints[j].Name - }) - for i := 1; i < len(ints); i++ { - if ints[i-1].Name == ints[i].Name { - ints = append(ints[:i], ints[i+1:]...) - i-- - } - } - - res := make([]*InterceptorData, 0, len(ints)) - for _, i := range ints { - res = append(res, buildInterceptorData(svc, methods, i, scope, server)) - } - return res -} - -// typeContext returns a contextual attribute for service types. Service types -// are Go types and uses non-pointers to hold attributes having default values. -func typeContext(scope *codegen.NameScope) *codegen.AttributeContext { - return codegen.NewAttributeContext(false, false, true, "", scope) -} - -// projectedTypeContext returns a contextual attribute for a projected type. -// Projected types are Go types that uses pointers for all attributes (even the -// required ones). -func projectedTypeContext(pkg string, ptr bool, scope *codegen.NameScope) *codegen.AttributeContext { - return codegen.NewAttributeContext(ptr, false, true, pkg, scope) -} - -// collectTypes recurses through the attribute to gather all user types and -// records them in userTypes. -func collectTypes(at *expr.AttributeExpr, scope *codegen.NameScope, seen map[string]struct{}, loc *codegen.Location) (data []*UserTypeData) { - if at == nil || at.Type == expr.Empty { - return data - } - collect := func(at *expr.AttributeExpr, loc *codegen.Location) []*UserTypeData { - return collectTypes(at, scope, seen, loc) - } - switch dt := at.Type.(type) { - case expr.UserType: - if _, ok := seen[dt.ID()]; ok { - return nil - } - typeLoc := codegen.UserTypeLocation(dt) - if typeLoc == nil { - typeLoc = loc - } - data = append(data, &UserTypeData{ - Name: dt.Name(), - VarName: scope.GoTypeName(at), - Description: dt.Attribute().Description, - Def: scope.GoTypeDef(dt.Attribute(), false, true), - Ref: scope.GoTypeRef(at), - Loc: typeLoc, - Type: dt, - }) - seen[dt.ID()] = struct{}{} - data = append(data, collect(dt.Attribute(), typeLoc)...) - case *expr.Object: - for _, nat := range *dt { - data = append(data, collect(nat.Attribute, loc)...) - } - case *expr.Array: - data = append(data, collect(dt.ElemType, loc)...) - case *expr.Map: - data = append(data, collect(dt.KeyType, loc)...) - data = append(data, collect(dt.ElemType, loc)...) - case *expr.Union: - for _, nat := range dt.Values { - data = append(data, collect(nat.Attribute, loc)...) - } - } - return data -} - -// collectUnionTypes traverses the attribute to gather all union sum-type -// definitions referenced by the service. It records each union by its hash and -// generated package so Extend can copy one union into multiple packages while -// duplicate uses within one package still share a definition. When view is true -// the provided location is used for all nested user types so that unions are -// generated in the views package and refer to view-local types (preventing -// import cycles). -func collectUnionTypes(att *expr.AttributeExpr, scope *codegen.NameScope, loc *codegen.Location, unions map[string]*UnionTypeData, seen map[string]struct{}, view bool) { - if att == nil || att.Type == expr.Empty { - return - } - switch dt := att.Type.(type) { - case expr.UserType: - if _, ok := seen[dt.ID()]; ok { - return - } - seen[dt.ID()] = struct{}{} - typeLoc := loc - if !view { - typeLoc = codegen.UserTypeLocation(dt) - } - collectUnionTypes(dt.Attribute(), scope, typeLoc, unions, seen, view) - case *expr.Object: - for _, nat := range sortedNamedAttributes(*dt) { - collectUnionTypes(nat.Attribute, scope, loc, unions, seen, view) - } - case *expr.Array: - collectUnionTypes(dt.ElemType, scope, loc, unions, seen, view) - case *expr.Map: - collectUnionTypes(dt.KeyType, scope, loc, unions, seen, view) - collectUnionTypes(dt.ElemType, scope, loc, unions, seen, view) - case *expr.Union: - key := dt.Hash() + "\x00" + unionPackageKey(loc, view) - if _, ok := unions[key]; !ok { - unions[key] = buildUnionTypeData(dt, scope, loc, view) - } - for _, nat := range dt.Values { - collectUnionTypes(nat.Attribute, scope, loc, unions, seen, view) - } - } -} - -// unionPackageKey identifies the generated package that owns a union. A nil -// location is the current service package; viewed unions share the views -// package regardless of locations inherited from service types. -func unionPackageKey(loc *codegen.Location, view bool) string { - if view { - return "views" - } - if loc == nil { - return "" - } - return loc.RelImportPath -} - -// buildUnionTypeData creates the data needed to generate a sum-type union -// struct, its discriminator kind, and branch metadata. When view is true the -// union is generated in the views package: field types are computed using the -// view scope and are always emitted unqualified so they refer to the -// view-local projected types. -func buildUnionTypeData(u *expr.Union, scope *codegen.NameScope, loc *codegen.Location, view bool) *UnionTypeData { - att := &expr.AttributeExpr{Type: u} - name := scope.GoTypeName(att) - kindName := scope.Unique(name + "Kind") - var unionPkg string - if !view { - unionPkg = loc.PackageName() - } - - fields := make([]*UnionFieldData, len(u.Values)) - for i, nat := range u.Values { - fieldName := codegen.Goify(nat.Name, true) - var pkg string - if !view { - if tloc := codegen.UserTypeLocation(nat.Attribute.Type); tloc != nil { - pkg = tloc.PackageName() - if pkg == unionPkg { - pkg = "" - } - } - } - fieldType := scope.GoFullTypeRef(nat.Attribute, pkg) - primitiveAliasType, hasPrimitiveAlias := primitiveAliasGoType(nat.Attribute.Type) - _, isUserType := nat.Attribute.Type.(expr.UserType) - emitPrimitiveAlias := hasPrimitiveAlias && !isUserType && pkg == "" - kindConst := kindName + codegen.Goify(nat.Name, true) - fields[i] = &UnionFieldData{ - Name: nat.Name, - KindConst: kindConst, - FieldName: fieldName, - FieldType: fieldType, - Nilable: codegen.IsNilable(nat.Attribute.Type), - EmitPrimitiveAlias: emitPrimitiveAlias, - PrimitiveAliasType: primitiveAliasType, - TypeTag: nat.Name, - } - } - - return &UnionTypeData{ - Name: name, - KindName: kindName, - Fields: fields, - Loc: loc, - TypeKey: u.GetTypeKey(), - ValueKey: u.GetValueKey(), - } -} - -// sortedNamedAttributes returns object fields sorted by attribute name. -// Union naming uses NameScope uniqueness, so callers that discover unions while -// traversing objects must use a deterministic field order to avoid oscillating -// generated identifiers across runs. -func sortedNamedAttributes(attrs []*expr.NamedAttributeExpr) []*expr.NamedAttributeExpr { - if len(attrs) < 2 { - return attrs - } - sorted := slices.Clone(attrs) - sort.Slice(sorted, func(i, j int) bool { - return sorted[i].Name < sorted[j].Name - }) - return sorted -} - -// primitiveAliasGoType resolves the native Go type for a primitive alias branch. -// It uses expr.IsPrimitive to enforce the type contract and then unwraps aliases. -func primitiveAliasGoType(dt expr.DataType) (string, bool) { - if !expr.IsPrimitive(dt) { - return "", false - } - for { - ut, ok := dt.(expr.UserType) - if !ok { - return codegen.GoNativeTypeName(dt), true - } - dt = ut.Attribute().Type - } -} - -// buildErrorInitData creates the data needed to generate code around endpoint error return values. -func buildErrorInitData(er *expr.ErrorExpr, scope *codegen.NameScope) *ErrorInitData { - _, temporary := er.Meta["goa:error:temporary"] - _, timeout := er.Meta["goa:error:timeout"] - _, fault := er.Meta["goa:error:fault"] - var pkg string - if ut, ok := er.Type.(expr.UserType); ok { - pkg = codegen.UserTypeLocation(ut).PackageName() - } - return &ErrorInitData{ - Name: fmt.Sprintf("Make%s", codegen.Goify(er.Name, true)), - Description: er.Description, - ErrName: er.Name, - TypeName: scope.GoTypeName(er.AttributeExpr), - TypeRef: scope.GoFullTypeRef(er.AttributeExpr, pkg), - Temporary: temporary, - Timeout: timeout, - Fault: fault, - } -} - -// buildMethodData creates the data needed to render the given endpoint. It -// records the user types needed by the service definition in userTypes. -func (d *ServicesData) buildMethodData(m *expr.MethodExpr, scope *codegen.NameScope) *MethodData { - var ( - vname string - desc string - payloadName string - payloadLoc *codegen.Location - payloadDef string - payloadRef string - payloadDesc string - payloadEx any - rname string - resultLoc *codegen.Location - resultDef string - resultRef string - resultDesc string - resultEx any - errors []*ErrorInitData - errorLocs map[string]*codegen.Location - isJSONRPC bool - reqs = make(RequirementsData, 0, len(m.Requirements)) - schemes SchemesData - ) - vname = scope.Unique(codegen.Goify(m.Name, true), "Endpoint") - desc = m.Description - if desc == "" { - desc = codegen.Goify(m.Name, true) + " implements " + m.Name + "." - } - if m.Payload.Type != expr.Empty { - payloadName = scope.GoTypeName(m.Payload) - if dt, ok := m.Payload.Type.(expr.UserType); ok { - payloadDef = scope.GoTypeDef(dt.Attribute(), false, true) - payloadLoc = codegen.UserTypeLocation(dt) - } - payloadRef = scope.GoFullTypeRef(m.Payload, payloadLoc.PackageName()) - payloadDesc = m.Payload.Description - if payloadDesc == "" { - payloadDesc = fmt.Sprintf("%s is the payload type of the %s service %s method.", - payloadName, m.Service.Name, m.Name) - } - payloadEx = m.Payload.Example(d.Root.API.ExampleGenerator) - } - if m.Result.Type != expr.Empty { - rname = scope.GoTypeName(m.Result) - if dt, ok := m.Result.Type.(expr.UserType); ok { - resultDef = scope.GoTypeDef(dt.Attribute(), false, true) - resultLoc = codegen.UserTypeLocation(dt) - } - resultRef = scope.GoFullTypeRef(m.Result, resultLoc.PackageName()) - resultDesc = m.Result.Description - if resultDesc == "" { - resultDesc = fmt.Sprintf("%s is the result type of the %s service %s method.", - rname, m.Service.Name, m.Name) - } - resultEx = m.Result.Example(d.Root.API.ExampleGenerator) - } - if len(m.Errors) > 0 { - errors = make([]*ErrorInitData, len(m.Errors)) - errorLocs = make(map[string]*codegen.Location, len(m.Errors)) - for i, er := range m.Errors { - errors[i] = buildErrorInitData(er, scope) - errorLocs[er.Name] = codegen.UserTypeLocation(er.Type) - } - } - - _, isJSONRPC = m.Meta["jsonrpc"] - - // Check if this JSON-RPC method uses SSE or WebSocket - var isJSONRPCSSE bool - var isJSONRPCWebSocket bool - if isJSONRPC && m.IsStreaming() { - if httpJSONRPCSvc := d.Root.API.JSONRPC.HTTPExpr.Service(m.Service.Name); httpJSONRPCSvc != nil { - for _, e := range httpJSONRPCSvc.HTTPEndpoints { - if e.MethodExpr.Name == m.Name { - if e.SSE != nil { - isJSONRPCSSE = true - } else { - isJSONRPCWebSocket = true - } - break - } - } - } - } - - for _, req := range expr.EffectiveSecurityRequirements(m.Requirements) { - var rs SchemesData - for _, s := range req.Schemes { - sch := BuildSchemeData(s, m) - rs = rs.Append(sch) - schemes = schemes.Append(sch) - } - reqs = append(reqs, &RequirementData{Schemes: rs, Scopes: req.Scopes}) - } - - // Unfortunately we can't completely isolate the service codegen from - // the underlying transport when wanting to skip Goa's built-in decoding. - skipRequestBodyEncodeDecode := false - skipResponseBodyEncodeDecode := false - var httpSvc *expr.HTTPServiceExpr - for _, svc := range d.Root.API.HTTP.Services { - if svc.Name() == m.Service.Name { - httpSvc = svc - break - } - } - if httpSvc != nil { - if httpMet := httpSvc.Endpoint(m.Name); httpMet != nil { - skipRequestBodyEncodeDecode = httpMet.SkipRequestBodyEncodeDecode - skipResponseBodyEncodeDecode = httpMet.SkipResponseBodyEncodeDecode - } - } - - data := &MethodData{ - Name: m.Name, - VarName: vname, - Description: desc, - Idempotent: m.Idempotent, - Payload: payloadName, - PayloadLoc: payloadLoc, - PayloadDef: payloadDef, - PayloadRef: payloadRef, - PayloadDesc: payloadDesc, - PayloadEx: payloadEx, - PayloadDefault: m.Payload.DefaultValue, - Result: rname, - ResultLoc: resultLoc, - ResultDef: resultDef, - ResultRef: resultRef, - ResultDesc: resultDesc, - ResultEx: resultEx, - Errors: errors, - ErrorLocs: errorLocs, - IsJSONRPC: isJSONRPC, - IsJSONRPCSSE: isJSONRPCSSE, - IsJSONRPCWebSocket: isJSONRPCWebSocket, - Requirements: reqs, - Schemes: schemes, - StreamKind: m.Stream, - HasMixedResults: m.HasMixedResults(), - SkipRequestBodyEncodeDecode: skipRequestBodyEncodeDecode, - SkipResponseBodyEncodeDecode: skipResponseBodyEncodeDecode, - RequestStruct: vname + "RequestData", - ResponseStruct: vname + "ResponseData", - } - - d.initStreamData(data, m, vname, rname, resultRef, scope) - return data -} - -// initStreamData initializes the streaming payload data structures and methods. -func (d *ServicesData) initStreamData(data *MethodData, m *expr.MethodExpr, vname, rname, resultRef string, scope *codegen.NameScope) { - if !m.IsStreaming() && !m.HasMixedResults() { - return - } - var ( - spayloadName string - spayloadRef string - spayloadDef string - spayloadDesc string - spayloadEx any - srname = rname // streaming result name - srref = resultRef // streaming result ref - ) - - // If StreamingResult is different from Result, use it for streaming - if m.HasMixedResults() && m.StreamingResult != nil && m.StreamingResult.Type != expr.Empty { - srname = scope.GoTypeName(m.StreamingResult) - srref = scope.GoTypeRef(m.StreamingResult) - data.StreamingResult = srname - data.StreamingResultRef = srref - if dt, ok := m.StreamingResult.Type.(expr.UserType); ok { - data.StreamingResultDef = scope.GoTypeDef(dt.Attribute(), false, true) - } - data.StreamingResultDesc = m.StreamingResult.Description - if data.StreamingResultDesc == "" { - data.StreamingResultDesc = fmt.Sprintf("%s is the streaming result type of the %s service %s method.", - srname, m.Service.Name, m.Name) - } - data.StreamingResultEx = m.StreamingResult.Example(d.Root.API.ExampleGenerator) - } - - if m.StreamingPayload != nil && m.StreamingPayload.Type != expr.Empty { - spayloadName = scope.GoTypeName(m.StreamingPayload) - spayloadRef = scope.GoTypeRef(m.StreamingPayload) - if dt, ok := m.StreamingPayload.Type.(expr.UserType); ok { - spayloadDef = scope.GoTypeDef(dt.Attribute(), false, true) - } - spayloadDesc = m.StreamingPayload.Description - if spayloadDesc == "" { - spayloadDesc = fmt.Sprintf("%s is the streaming payload type of the %s service %s method.", - spayloadName, m.Service.Name, m.Name) - } - spayloadEx = m.StreamingPayload.Example(d.Root.API.ExampleGenerator) - } - // For JSON-RPC WebSocket: - // - Client streaming (no result streaming): no endpoint struct needed, just payload - // - Bidirectional streaming: endpoint struct needed for both payload and stream - endpointStruct := vname + "EndpointInput" - if data.IsJSONRPC && m.IsStreaming() && !data.IsJSONRPCSSE && m.Stream == expr.ClientStreamKind { - endpointStruct = "" - } - // For mixed results with SSE, treat as server streaming - streamKind := m.Stream - if m.HasMixedResults() && !m.IsStreaming() { - // Mixed results with SSE should be treated as server streaming - streamKind = expr.ServerStreamKind - } - svrStream := &StreamData{ - Interface: vname + "ServerStream", - VarName: scope.Unique(codegen.Goify(m.Name, true), "ServerStream"), - EndpointStruct: endpointStruct, - Kind: streamKind, - SendName: "Send", - SendDesc: fmt.Sprintf("Send streams instances of %q.", srname), - SendWithContextName: "SendWithContext", - SendWithContextDesc: fmt.Sprintf("SendWithContext streams instances of %q with context.", srname), - SendTypeName: srname, - SendTypeRef: srref, - MustClose: true, - } - cliStream := &StreamData{ - Interface: vname + "ClientStream", - VarName: scope.Unique(codegen.Goify(m.Name, true), "ClientStream"), - Kind: streamKind, - RecvName: "Recv", - RecvDesc: fmt.Sprintf("Recv reads instances of %q from the stream.", srname), - RecvWithContextName: "RecvWithContext", - RecvWithContextDesc: fmt.Sprintf("RecvWithContext reads instances of %q from the stream with context.", srname), - RecvTypeName: srname, - RecvTypeRef: srref, - } - // For SSE server streaming, we need both Send (for notifications) and SendAndClose (for final response) - if data.IsJSONRPCSSE && m.Stream == expr.ServerStreamKind && resultRef != "" { - svrStream.SendAndCloseName = "SendAndClose" - svrStream.SendAndCloseDesc = fmt.Sprintf("SendAndClose sends a final response with %q and closes the stream.", srname) - // For JSON-RPC SSE, methods take context directly; align names accordingly - svrStream.SendWithContextName = "Send" - svrStream.RecvWithContextName = "Recv" - // Update Send description to clarify it's for notifications only - svrStream.SendDesc = fmt.Sprintf("Send streams JSON-RPC notifications with %q. Notifications do not expect a response.", srname) - } - if streamKind == expr.ClientStreamKind || streamKind == expr.BidirectionalStreamKind { - switch streamKind { - case expr.ClientStreamKind: - if srref != "" { - svrStream.SendName = "SendAndClose" - svrStream.SendDesc = fmt.Sprintf("SendAndClose streams instances of %q and closes the stream.", srname) - svrStream.SendWithContextName = "SendAndCloseWithContext" - svrStream.SendWithContextDesc = fmt.Sprintf("SendAndCloseWithContext streams instances of %q and closes the stream with context.", srname) - svrStream.MustClose = false - cliStream.RecvName = "CloseAndRecv" - cliStream.RecvDesc = fmt.Sprintf("CloseAndRecv stops sending messages to the stream and reads instances of %q from the stream.", srname) - cliStream.RecvWithContextName = "CloseAndRecvWithContext" - cliStream.RecvWithContextDesc = fmt.Sprintf("CloseAndRecvWithContext stops sending messages to the stream and reads instances of %q from the stream with context.", srname) - } else { - cliStream.MustClose = true - } - case expr.BidirectionalStreamKind: - cliStream.MustClose = true - } - svrStream.RecvName = "Recv" - svrStream.RecvDesc = fmt.Sprintf("Recv reads instances of %q from the stream.", spayloadName) - svrStream.RecvWithContextName = "RecvWithContext" - svrStream.RecvWithContextDesc = fmt.Sprintf("RecvWithContext reads instances of %q from the stream with context.", spayloadName) - svrStream.RecvTypeName = spayloadName - svrStream.RecvTypeRef = spayloadRef - cliStream.SendName = "Send" - cliStream.SendDesc = fmt.Sprintf("Send streams instances of %q.", spayloadName) - cliStream.SendWithContextName = "SendWithContext" - cliStream.SendWithContextDesc = fmt.Sprintf("SendWithContext streams instances of %q with context.", spayloadName) - cliStream.SendTypeName = spayloadName - cliStream.SendTypeRef = spayloadRef - } - data.ClientStream = cliStream - data.ServerStream = svrStream - data.StreamingPayload = spayloadName - data.StreamingPayloadDef = spayloadDef - data.StreamingPayloadRef = spayloadRef - data.StreamingPayloadDesc = spayloadDesc - data.StreamingPayloadEx = spayloadEx -} - -// buildInterceptorData creates the data needed to generate interceptor code. -func buildInterceptorData(svc *expr.ServiceExpr, methods []*MethodData, i *expr.InterceptorExpr, scope *codegen.NameScope, server bool) *InterceptorData { - data := &InterceptorData{ - Name: codegen.Goify(i.Name, true), - DesignName: i.Name, - Description: i.Description, - } - if len(svc.Methods) == 0 { - return data - } - attributesCollected := false - for _, m := range svc.Methods { - applies := false - intExprs := m.ServerInterceptors - if !server { - intExprs = m.ClientInterceptors - } - for _, in := range intExprs { - if in.Name == i.Name { - if !attributesCollected { - payload, result, streamingPayload := m.Payload, m.Result, m.StreamingPayload - data.ReadPayload = collectAttributes(i.ReadPayload, payload, scope) - data.WritePayload = collectAttributes(i.WritePayload, payload, scope) - data.ReadResult = collectAttributes(i.ReadResult, result, scope) - data.WriteResult = collectAttributes(i.WriteResult, result, scope) - data.ReadStreamingPayload = collectAttributes(i.ReadStreamingPayload, streamingPayload, scope) - data.WriteStreamingPayload = collectAttributes(i.WriteStreamingPayload, streamingPayload, scope) - data.ReadStreamingResult = collectAttributes(i.ReadStreamingResult, result, scope) - data.WriteStreamingResult = collectAttributes(i.WriteStreamingResult, result, scope) - if len(data.ReadPayload) > 0 || len(data.WritePayload) > 0 { - data.HasPayloadAccess = true - } - if len(data.ReadResult) > 0 || len(data.WriteResult) > 0 { - data.HasResultAccess = true - } - if len(data.ReadStreamingPayload) > 0 || len(data.WriteStreamingPayload) > 0 { - data.HasStreamingPayloadAccess = true - } - if len(data.ReadStreamingResult) > 0 || len(data.WriteStreamingResult) > 0 { - data.HasStreamingResultAccess = true - } - attributesCollected = true - } - applies = true - break - } - } - if !applies { - continue - } - var md *MethodData - for _, mt := range methods { - if m.Name == mt.Name { - md = mt - break - } - } - data.Methods = append(data.Methods, buildInterceptorMethodData(i, md)) - if server { - md.ServerInterceptors = append(md.ServerInterceptors, i.Name) - } else { - md.ClientInterceptors = append(md.ClientInterceptors, i.Name) - } - } - return data -} - -// buildInterceptorMethodData creates the data needed to generate interceptor -// method code. -func buildInterceptorMethodData(i *expr.InterceptorExpr, md *MethodData) *MethodInterceptorData { - var serverStream, clientStream *StreamInterceptorData - if md.ServerStream != nil { - serverStream = &StreamInterceptorData{ - Interface: md.ServerStream.Interface, - SendName: md.ServerStream.SendName, - SendWithContextName: md.ServerStream.SendWithContextName, - SendTypeRef: md.ServerStream.SendTypeRef, - RecvName: md.ServerStream.RecvName, - RecvWithContextName: md.ServerStream.RecvWithContextName, - RecvTypeRef: md.ServerStream.RecvTypeRef, - MustClose: md.ServerStream.MustClose, - EndpointStruct: md.ServerStream.EndpointStruct, - } - } - if md.ClientStream != nil { - clientStream = &StreamInterceptorData{ - Interface: md.ClientStream.Interface, - SendName: md.ClientStream.SendName, - SendWithContextName: md.ClientStream.SendWithContextName, - SendTypeRef: md.ClientStream.SendTypeRef, - RecvName: md.ClientStream.RecvName, - RecvWithContextName: md.ClientStream.RecvWithContextName, - RecvTypeRef: md.ClientStream.RecvTypeRef, - MustClose: md.ClientStream.MustClose, - } - } - var payloadAccess, resultAccess, streamingPayloadAccess, streamingResultAccess string - if i.ReadPayload != nil || i.WritePayload != nil { - payloadAccess = codegen.Goify(i.Name, false) + md.VarName + "Payload" - } - if i.ReadResult != nil || i.WriteResult != nil { - resultAccess = codegen.Goify(i.Name, false) + md.VarName + "Result" - } - if i.ReadStreamingPayload != nil || i.WriteStreamingPayload != nil { - streamingPayloadAccess = codegen.Goify(i.Name, false) + md.VarName + "StreamingPayload" - } - if i.ReadStreamingResult != nil || i.WriteStreamingResult != nil { - streamingResultAccess = codegen.Goify(i.Name, false) + md.VarName + "StreamingResult" - } - return &MethodInterceptorData{ - MethodName: md.VarName, - PayloadAccess: payloadAccess, - ResultAccess: resultAccess, - PayloadRef: md.PayloadRef, - ResultRef: md.ResultRef, - StreamingPayloadAccess: streamingPayloadAccess, - StreamingPayloadRef: md.StreamingPayloadRef, - StreamingResultAccess: streamingResultAccess, - StreamingResultRef: md.ResultRef, - ClientStream: clientStream, - ServerStream: serverStream, - } -} - -// BuildSchemeData builds the scheme data for the given scheme and method expr. -func BuildSchemeData(s *expr.SchemeExpr, m *expr.MethodExpr) *SchemeData { - if !expr.IsObject(m.Payload.Type) { - return nil - } - if s.Kind == expr.BasicAuthKind { - userAtt := expr.TaggedAttribute(m.Payload, "security:username") - passAtt := expr.TaggedAttribute(m.Payload, "security:password") - return &SchemeData{ - Type: s.Kind.String(), - SchemeName: s.SchemeName, - UsernameAttr: userAtt, - UsernameField: codegen.Goify(userAtt, true), - UsernamePointer: m.Payload.IsPrimitivePointer(userAtt, true), - UsernameRequired: m.Payload.IsRequired(userAtt), - PasswordAttr: passAtt, - PasswordField: codegen.Goify(passAtt, true), - PasswordPointer: m.Payload.IsPrimitivePointer(passAtt, true), - PasswordRequired: m.Payload.IsRequired(passAtt), - Scopes: schemeScopes(s), - } - } - // The remaining scheme kinds all carry a single credential attribute - // identified by a kind-specific security tag on the method payload. - var tag string - switch s.Kind { - case expr.APIKeyKind: - tag = "security:apikey:" + s.SchemeName - case expr.BearerKind: - tag = "security:bearer" - case expr.JWTKind: - tag = "security:token" - case expr.OAuth2Kind: - tag = "security:accesstoken" - default: - return nil - } - keyAtt := expr.TaggedAttribute(m.Payload, tag) - if keyAtt == "" { - return nil - } - data := &SchemeData{ - Type: s.Kind.String(), - Name: s.Name, - SchemeName: s.SchemeName, - CredField: codegen.Goify(keyAtt, true), - CredPointer: m.Payload.IsPrimitivePointer(keyAtt, true), - CredRequired: m.Payload.IsRequired(keyAtt), - KeyAttr: keyAtt, - Scopes: schemeScopes(s), - In: s.In, - } - if s.Kind == expr.OAuth2Kind { - data.Flows = s.Flows - } - return data -} - -// schemeScopes returns the scope names defined by the scheme, nil when the -// scheme defines none. -func schemeScopes(s *expr.SchemeExpr) []string { - if len(s.Scopes) == 0 { - return nil - } - scopes := make([]string, len(s.Scopes)) - for i, sc := range s.Scopes { - scopes[i] = sc.Name - } - return scopes -} - -// collectAttributes builds AttributeData from an AttributeExpr -func collectAttributes(attrNames, parent *expr.AttributeExpr, scope *codegen.NameScope) []*AttributeData { - if attrNames == nil { - return nil - } - obj := expr.AsObject(attrNames.Type) - if obj == nil { - return nil - } - data := make([]*AttributeData, len(*obj)) - for i, nat := range *obj { - parentAttr := parent.Find(nat.Name) - if parentAttr == nil { - // Attribute references are validated at design time so a miss - // here would surface as a nil deref at template render time. - panic(fmt.Sprintf("attribute %q not found in parent attribute", nat.Name)) // bug - } - var pkg string - if loc := codegen.UserTypeLocation(parentAttr.Type); loc != nil { - pkg = loc.PackageName() - } - data[i] = &AttributeData{ - Name: codegen.Goify(nat.Name, true), - TypeRef: scope.GoFullTypeRef(parentAttr, pkg), - Pointer: parent.IsPrimitivePointer(nat.Name, true), - } - } - return data -} - -// collectProjectedTypes builds a projected type for every user type found when -// recursing through the attributes. The projected types live in the views -// package and support the marshaling and unmarshalling of result types that -// make use of views. We need to build projected types for all user types - not -// just result types - because user types may contain result types and thus may -// need to be marshalled in different ways depending on the view being used. -func collectProjectedTypes(projected, att *expr.AttributeExpr, viewspkg string, scope, viewScope *codegen.NameScope, seen map[string]*ProjectedTypeData) []*ProjectedTypeData { - collect := func(projected, att *expr.AttributeExpr) []*ProjectedTypeData { - return collectProjectedTypes(projected, att, viewspkg, scope, viewScope, seen) - } - var data []*ProjectedTypeData - switch pt := projected.Type.(type) { - case expr.UserType: - dt := att.Type.(expr.UserType) - if pd, ok := seen[dt.ID()]; ok { - // a projected type is already created for this user type. We change the - // attribute type to this seen projected type. The seen projected type - // can be nil if the attribute type has a circular type definition in - // which case we don't change the attribute type until the projected type - // is created during the recursion. - if pd != nil { - projected.Type = pd.Type - } - return data - } - seen[dt.ID()] = nil - pt.Rename(pt.Name() + "View") - // We recurse before building the projected type so that user types within - // a projected type is also converted to their respective projected types. - types := collect(pt.Attribute(), dt.Attribute()) - pd := buildProjectedType(projected, att, viewspkg, scope, viewScope) - seen[dt.ID()] = pd - data = append(data, pd) - data = append(data, types...) - case *expr.Array: - dt := att.Type.(*expr.Array) - types := collect(pt.ElemType, dt.ElemType) - data = append(data, types...) - case *expr.Map: - dt := att.Type.(*expr.Map) - types := collect(pt.KeyType, dt.KeyType) - data = append(data, types...) - types = collect(pt.ElemType, dt.ElemType) - data = append(data, types...) - case *expr.Object: - dt := att.Type.(*expr.Object) - for _, n := range *pt { - types := collect(n.Attribute, dt.Attribute(n.Name)) - data = append(data, types...) - } - case *expr.Union: - dt := att.Type.(*expr.Union) - for i, n := range pt.Values { - types := collect(n.Attribute, dt.Values[i].Attribute) - data = append(data, types...) - } - } - return data -} - -// projectedResultRoot returns the root attribute used to collect projected -// view types for m.Result. NormalizeRoot synthesizes user types for raw object -// method results before service analysis; projected view collection keeps the -// pre-normalization shape by traversing those synthetic wrappers' attributes -// directly instead of generating view-local types for the wrappers themselves. -func projectedResultRoot(service *expr.ServiceExpr, m *expr.MethodExpr) (*expr.AttributeExpr, *expr.AttributeExpr) { - if ut, ok := m.Result.Type.(*expr.UserTypeExpr); ok && ut.ID() == normalizedMethodTypeID(service, m, "Result") { - return expr.DupAtt(ut.Attribute()), ut.Attribute() - } - return expr.DupAtt(m.Result), m.Result -} - -func normalizedMethodTypeID(service *expr.ServiceExpr, m *expr.MethodExpr, suffix string) string { - return service.Name + "#" + codegen.Goify(m.Name, true) + suffix -} - -// hasResultType returns true if the given attribute has a result type recursively. -func hasResultType(att *expr.AttributeExpr, seens ...map[string]struct{}) bool { - if _, ok := att.Type.(*expr.ResultTypeExpr); ok { - return true - } - var seen map[string]struct{} - if len(seens) > 0 { - seen = seens[0] - } else { - seen = make(map[string]struct{}) - } - switch a := att.Type.(type) { - case expr.UserType: - if _, ok := seen[a.ID()]; ok { - return false - } - seen[a.ID()] = struct{}{} - return hasResultType(a.Attribute(), seen) - case *expr.Array: - return hasResultType(a.ElemType, seen) - case *expr.Map: - return hasResultType(a.KeyType, seen) || hasResultType(a.ElemType, seen) - case *expr.Object: - for _, nat := range *a { - if hasResultType(nat.Attribute, seen) { - return true - } - } - case *expr.Union: - for _, nat := range a.Values { - if hasResultType(nat.Attribute, seen) { - return true - } - } - } - return false -} - -// buildProjectedType builds projected type for the given user type. -// -// viewspkg is the name of the views package -func buildProjectedType(projected, att *expr.AttributeExpr, viewspkg string, scope, viewScope *codegen.NameScope) *ProjectedTypeData { - var ( - projections []*InitData - typeInits []*InitData - views []*ViewData - - varname = viewScope.GoTypeName(projected) - pt = projected.Type.(expr.UserType) - ) - if _, isrt := pt.(*expr.ResultTypeExpr); isrt { - typeInits = buildViewConversions(projected, att, viewspkg, scope, viewScope, true) - projections = buildViewConversions(projected, att, viewspkg, scope, viewScope, false) - views = buildViews(att.Type.(*expr.ResultTypeExpr), viewScope) - } - validations := buildValidations(projected, viewScope) - removeMeta(projected) - return &ProjectedTypeData{ - UserTypeData: &UserTypeData{ - Name: varname, - Description: fmt.Sprintf("%s is a type that runs validations on a projected type.", varname), - VarName: varname, - Def: viewScope.GoTypeDef(pt.Attribute(), true, true), - Ref: viewScope.GoTypeRef(projected), - Type: pt, - }, - Projections: projections, - TypeInits: typeInits, - Validations: validations, - ViewsPkg: viewspkg, - Views: views, - } -} - -// buildViews builds the view data for all the views in the given result type. -func buildViews(rt *expr.ResultTypeExpr, viewScope *codegen.NameScope) []*ViewData { - views := make([]*ViewData, len(rt.Views)) - for i, view := range rt.Views { - vatt := expr.AsObject(view.Type) - attrs := make([]string, len(*vatt)) - for j, nat := range *vatt { - attrs[j] = nat.Name - } - views[i] = &ViewData{ - Name: view.Name, - Description: view.Description, - Attributes: attrs, - TypeVarName: viewScope.GoTypeName(&expr.AttributeExpr{Type: rt}), - } - } - return views -} - -// buildViewedResultType builds a viewed result type from the given result type -// and projected type. -func buildViewedResultType(att, projected *expr.AttributeExpr, viewspkg string, scope, viewScope *codegen.NameScope) *ViewedResultTypeData { - // collect result type views - rt := att.Type.(*expr.ResultTypeExpr) - isarr := expr.IsArray(att.Type) - var viewName string - if !rt.HasMultipleViews() { - viewName = expr.DefaultView - } - if v, ok := att.Meta.Last(expr.ViewMetaKey); ok { - viewName = v - } - views := buildViews(rt, viewScope) - - // build validation data - resvar := scope.GoTypeName(att) - resref := scope.GoTypeRef(att) - data := map[string]any{ - "Projected": scope.GoTypeName(projected), - "ArgVar": "result", - "Source": "result", - "Views": views, - "IsViewed": true, - } - buf := &bytes.Buffer{} - if err := validateTypeCodeTmpl.Execute(buf, data); err != nil { - panic(err) // bug - } - name := "Validate" + resvar - validate := &ValidateData{ - Name: name, - Description: fmt.Sprintf("%s runs the validations defined on the viewed result type %s.", name, resvar), - Ref: resref, - Validate: buf.String(), - } - - // build constructor to initialize viewed result type from result type - vresref := viewScope.GoFullTypeRef(att, viewspkg) - data = map[string]any{ - "ToViewed": true, - "ArgVar": "res", - "ReturnVar": "vres", - "Views": views, - "ReturnTypeRef": vresref, - "IsCollection": isarr, - "TargetType": scope.GoFullTypeName(att, viewspkg), - "InitName": "new" + viewScope.GoTypeName(projected), - } - buf = &bytes.Buffer{} - if err := initTypeCodeTmpl.Execute(buf, data); err != nil { - panic(err) // bug - } - pkg := "" - if loc := codegen.UserTypeLocation(att.Type); loc != nil { - pkg = loc.PackageName() - } - name = "NewViewed" + resvar - init := &InitData{ - Name: name, - Description: fmt.Sprintf("%s initializes viewed result type %s from result type %s using the given view.", name, resvar, resvar), - Args: []*InitArgData{ - {Name: "res", Ref: scope.GoFullTypeRef(att, pkg)}, - {Name: "view", Ref: "string"}, - }, - ReturnTypeRef: vresref, - Code: buf.String(), - } - - // build constructor to initialize result type from viewed result type - if loc := codegen.UserTypeLocation(att.Type); loc != nil { - resref = scope.GoFullTypeRef(att, loc.PackageName()) - } - data = map[string]any{ - "ToResult": true, - "ArgVar": "vres", - "ReturnVar": "res", - "Views": views, - "ReturnTypeRef": resref, - "InitName": "new" + scope.GoTypeName(att), - } - buf = &bytes.Buffer{} - if err := initTypeCodeTmpl.Execute(buf, data); err != nil { - panic(err) // bug - } - name = "New" + resvar - resinit := &InitData{ - Name: name, - Description: fmt.Sprintf("%s initializes result type %s from viewed result type %s.", name, resvar, resvar), - Args: []*InitArgData{{Name: "vres", Ref: scope.GoFullTypeRef(att, viewspkg)}}, - ReturnTypeRef: resref, - Code: buf.String(), - } - - projT := wrapProjected(projected.Type.(expr.UserType)) - return &ViewedResultTypeData{ - UserTypeData: &UserTypeData{ - Name: resvar, - Description: fmt.Sprintf("%s is the viewed result type that is projected based on a view.", resvar), - VarName: resvar, - Def: viewScope.GoTypeDef(projT.Attribute(), false, true), - Ref: resref, - Type: projT, - }, - FullName: scope.GoFullTypeName(att, viewspkg), - FullRef: vresref, - ResultInit: resinit, - Init: init, - Views: views, - Validate: validate, - IsCollection: isarr, - ViewName: viewName, - ViewsPkg: viewspkg, - } -} - -// wrapProjected builds a viewed result type by wrapping the given projected -// in a result type with "projected" and "view" attributes. -func wrapProjected(projected expr.UserType) expr.UserType { - rt := projected.(*expr.ResultTypeExpr) - pratt := &expr.NamedAttributeExpr{ - Name: "projected", - Attribute: &expr.AttributeExpr{Type: rt, Description: "Type to project"}, - } - prview := &expr.NamedAttributeExpr{ - Name: "view", - Attribute: &expr.AttributeExpr{Type: expr.String, Description: "View to render"}, - } - return &expr.ResultTypeExpr{ - UserTypeExpr: &expr.UserTypeExpr{ - AttributeExpr: &expr.AttributeExpr{ - Type: &expr.Object{pratt, prview}, - Validation: &expr.ValidationExpr{Required: []string{"projected", "view"}}, - }, - TypeName: rt.TypeName, - }, - Identifier: rt.Identifier, - Views: rt.Views, - } -} - -// buildViewConversions builds the data to generate the constructor code that -// converts between a result type and its projected type, one constructor per -// view. When toResult is true the constructors initialize the result type from -// the projected type, otherwise they project the result type to the projected -// type based on the view. -func buildViewConversions(projected, att *expr.AttributeExpr, viewspkg string, scope, viewScope *codegen.NameScope, toResult bool) []*InitData { - vrt := att.Type.(*expr.ResultTypeExpr) - if toResult { - vrt = projected.Type.(*expr.ResultTypeExpr) - } - pobj := expr.AsObject(projected.Type) - parr := expr.AsArray(projected.Type) - if parr != nil { - // result type collection - pobj = expr.AsObject(parr.ElemType.Type) - } - - init := make([]*InitData, 0, len(vrt.Views)) - for _, view := range vrt.Views { - var typ expr.DataType - obj := &expr.Object{} - walkViewAttrs(pobj, view, func(name string, att, _ *expr.AttributeExpr) { - obj.Set(name, att) - }) - typ = obj - if parr != nil { - ename := parr.ElemType.Type.Name() - if toResult { - ename = scope.GoTypeName(parr.ElemType) - } - typ = &expr.Array{ElemType: &expr.AttributeExpr{ - Type: &expr.ResultTypeExpr{ - UserTypeExpr: &expr.UserTypeExpr{ - AttributeExpr: &expr.AttributeExpr{Type: obj}, - TypeName: ename, - }, - }, - }} - } - wname := projected.Type.Name() - if toResult { - wname = scope.GoTypeName(projected) - } - // viewed is the projected type narrowed down to the view attributes. - viewed := &expr.AttributeExpr{ - Type: &expr.ResultTypeExpr{ - UserTypeExpr: &expr.UserTypeExpr{ - AttributeExpr: &expr.AttributeExpr{Type: typ}, - TypeName: wname, - }, - Views: vrt.Views, - Identifier: vrt.Identifier, - }, - } - - pkg := "" - if loc := codegen.UserTypeLocation(att.Type); loc != nil { - pkg = loc.PackageName() - } - if toResult { - srcCtx := projectedTypeContext(viewspkg, true, viewScope) - tgtCtx := typeContext(scope) - resvar := scope.GoTypeName(att) - name := "new" + resvar - if view.Name != expr.DefaultView { - name += codegen.Goify(view.Name, true) - } - code, helpers := buildConstructorCode(viewed, att, "vres", "res", srcCtx, tgtCtx, view.Name) - init = append(init, &InitData{ - Name: name, - Description: fmt.Sprintf("%s converts projected type %s to service type %s.", name, resvar, resvar), - Args: []*InitArgData{{Name: "vres", Ref: viewScope.GoFullTypeRef(projected, viewspkg)}}, - ReturnTypeRef: scope.GoFullTypeRef(att, pkg), - Code: code, - Helpers: helpers, - }) - } else { - srcCtx := typeContext(scope) - tgtCtx := projectedTypeContext(viewspkg, true, viewScope) - tname := scope.GoTypeName(projected) - name := "new" + tname - if view.Name != expr.DefaultView { - name += codegen.Goify(view.Name, true) - } - code, helpers := buildConstructorCode(att, viewed, "res", "vres", srcCtx, tgtCtx, view.Name) - init = append(init, &InitData{ - Name: name, - Description: fmt.Sprintf("%s projects result type %s to projected type %s using the %q view.", name, scope.GoTypeName(att), tname, view.Name), - Args: []*InitArgData{{Name: "res", Ref: scope.GoFullTypeRef(att, pkg)}}, - ReturnTypeRef: viewScope.GoFullTypeRef(projected, viewspkg), - Code: code, - Helpers: helpers, - }) - } - } - return init -} - -// buildValidations builds the data required to generate validations for the -// projected types. -func buildValidations(projected *expr.AttributeExpr, scope *codegen.NameScope) []*ValidateData { - ut := projected.Type.(expr.UserType) - tname := scope.GoTypeName(projected) - var validations []*ValidateData - if rt, isrt := ut.(*expr.ResultTypeExpr); isrt { - // for result types we create a validation function containing view - // specific validation logic for each view - arr := expr.AsArray(projected.Type) - for _, view := range rt.Views { - data := map[string]any{ - "Projected": tname, - "ArgVar": "result", - "Source": "result", - "IsCollection": arr != nil, - } - var vn string - name := "Validate" + tname - if view.Name != expr.DefaultView { - vn = codegen.Goify(view.Name, true) - name += vn - } - - if arr != nil { - // dealing with an array type - data["Source"] = "item" - data["ValidateVar"] = "Validate" + scope.GoTypeName(arr.ElemType) + vn - } else { - var fields []map[string]any - o := &expr.Object{} - walkViewAttrs(expr.AsObject(projected.Type), view, func(name string, attr, vatt *expr.AttributeExpr) { - if rt, ok := attr.Type.(*expr.ResultTypeExpr); ok { - // use explicitly specified view (if any) for the attribute, - // otherwise use default - vw := "" - if v, ok := vatt.Meta.Last(expr.ViewMetaKey); ok && v != expr.DefaultView { - vw = v - } - fields = append(fields, map[string]any{ - "Name": name, - "ValidateVar": "Validate" + scope.GoTypeName(attr) + codegen.Goify(vw, true), - "IsRequired": rt.Attribute().IsRequired(name), - }) - } else { - o.Set(name, attr) - } - }) - ctx := projectedTypeContext("", !expr.IsPrimitive(projected.Type), scope) - data["Validate"] = codegen.ValidationCode(&expr.AttributeExpr{Type: o, Validation: rt.Validation}, rt, ctx, true, false, true, "result") - data["Fields"] = fields - } - - buf := &bytes.Buffer{} - if err := validateTypeCodeTmpl.Execute(buf, data); err != nil { - panic(err) // bug - } - - validations = append(validations, &ValidateData{ - Name: name, - Description: fmt.Sprintf("%s runs the validations defined on %s using the %q view.", name, tname, view.Name), - Ref: scope.GoTypeRef(projected), - Validate: buf.String(), - }) - } - } else { - // for a user type or a result type with single view, we generate only one validation - // function containing the validation logic - name := "Validate" + tname - ctx := projectedTypeContext("", !expr.IsPrimitive(projected.Type), scope) - validations = append(validations, &ValidateData{ - Name: name, - Description: fmt.Sprintf("%s runs the validations defined on %s.", name, tname), - Ref: scope.GoTypeRef(projected), - Validate: codegen.ValidationCode(ut.Attribute(), ut, ctx, true, expr.IsAlias(ut), true, "result"), - }) - } - return validations -} - -// buildConstructorCode builds the transformation code to create a projected -// type from a service type and vice versa. -// -// source and target contains the projected/service contextual attributes -// -// sourceVar and targetVar contains the variable name that holds the source and -// target data structures in the transformation code. -// -// view is used to generate the constructor function name. -func buildConstructorCode(src, tgt *expr.AttributeExpr, sourceVar, targetVar string, sourceCtx, targetCtx *codegen.AttributeContext, view string) (string, []*codegen.TransformFunctionData) { - var ( - helpers []*codegen.TransformFunctionData - buf bytes.Buffer - ) - rt := src.Type.(*expr.ResultTypeExpr) - arr := expr.AsArray(tgt.Type) - - data := map[string]any{ - "ArgVar": sourceVar, - "ReturnVar": targetVar, - "IsCollection": arr != nil, - "TargetType": targetCtx.Scope.Name(tgt, targetCtx.Pkg(tgt), targetCtx.Pointer, targetCtx.UseDefault), - } - - if arr != nil { - // result type collection - init := "new" + targetCtx.Scope.Name(arr.ElemType, "", targetCtx.Pointer, targetCtx.UseDefault) - if view != "" && view != expr.DefaultView { - init += codegen.Goify(view, true) - } - data["InitName"] = init - if err := initTypeCodeTmpl.Execute(&buf, data); err != nil { - panic(err) // bug - } - return buf.String(), helpers - } - - // service type to projected type (or vice versa) - targetRTs := &expr.Object{} - tatt := expr.DupAtt(tgt) - tobj := expr.AsObject(tatt.Type) - for _, nat := range *tobj { - if _, ok := nat.Attribute.Type.(*expr.ResultTypeExpr); ok { - targetRTs.Set(nat.Name, nat.Attribute) - tobj.Delete(nat.Name) - } - } - data["Source"] = sourceVar - data["Target"] = targetVar - - // build code for target with no result types - code, helpers, err := codegen.GoTransform(src, tatt, sourceVar, targetVar, sourceCtx, targetCtx, "transform", true) - if err != nil { - panic(err) // bug - } - data["Code"] = code - - if view != "" { - data["InitName"] = targetCtx.Scope.Name(src, "", targetCtx.Pointer, targetCtx.UseDefault) - } - fields := make([]map[string]any, 0, len(*targetRTs)) - // iterate through the result types found in the target and add the - // code to initialize them - for _, nat := range *targetRTs { - finit := "new" + targetCtx.Scope.Name(nat.Attribute, "", targetCtx.Pointer, targetCtx.UseDefault) - if view != "" { - v := "" - if vatt := rt.View(view).Find(nat.Name); vatt != nil { - if attv, ok := vatt.Meta.Last(expr.ViewMetaKey); ok && attv != expr.DefaultView { - // view is explicitly set for the result type on the attribute - v = attv - } - } - finit += codegen.Goify(v, true) - } - fields = append(fields, map[string]any{ - "VarName": codegen.Goify(nat.Name, true), - "FieldInit": finit, - }) - } - data["Fields"] = fields - - if err := initTypeCodeTmpl.Execute(&buf, data); err != nil { - panic(err) // bug - } - return buf.String(), helpers -} - -// walkViewAttrs iterates through the attributes in att that are found in the -// given view and executes the walker function. -func walkViewAttrs(obj *expr.Object, view *expr.ViewExpr, walker func(name string, attr, vatt *expr.AttributeExpr)) { - for _, nat := range *expr.AsObject(view.Type) { - if attr := obj.Attribute(nat.Name); attr != nil { - walker(nat.Name, attr, nat.Attribute) - } - } -} - -// removeMeta removes the meta attributes from the given attribute. This is -// needed to make sure that any field name overriding is removed when -// generating protobuf types (as protogen itself won't honor these overrides). -func removeMeta(att *expr.AttributeExpr) { - _ = codegen.Walk(att, func(a *expr.AttributeExpr) error { - delete(a.Meta, "struct:pkg:path") - return nil - }) -} diff --git a/codegen/service/service_data_union_nilability_test.go b/codegen/service/service_data_union_nilability_test.go index 2db6834805..f145681fac 100644 --- a/codegen/service/service_data_union_nilability_test.go +++ b/codegen/service/service_data_union_nilability_test.go @@ -1,9 +1,12 @@ +// This file verifies pointer/value semantics recorded for generated union +// branch fields. package service import ( "testing" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" "goa.design/goa/v3/codegen" "goa.design/goa/v3/expr" @@ -11,12 +14,23 @@ import ( func TestBuildUnionTypeDataMarksNilableBranches(t *testing.T) { union := unionWithBranchTypes() - data := buildUnionTypeData( - union, - codegen.NewNameScope(), - &codegen.Location{RelImportPath: "gen/service"}, - false, - ) + unionAttribute := &expr.AttributeExpr{Type: union} + generation := mustTestGeneration(t, "gen", nil) + pkg := mustClaimTestPackage(t, generation, "gen/service") + declaration, err := pkg.DeclareUnion(unionAttribute) + require.NoError(t, err) + facts := &unionFacts{ + attribute: unionAttribute, + union: union, + identity: codegen.NewUnionDeclarationID(unionAttribute), + typeKey: union.GetTypeKey(), + valueKey: union.GetValueKey(), + location: &codegen.Location{RelImportPath: "gen/service"}, + declaration: declaration, + } + require.NoError(t, planUnionRenderFacts(facts, nil, pkg)) + require.NoError(t, generation.Freeze()) + data := buildRetainedUnionTypeData(facts, &importAliases{generation: generation}) nilable := make(map[string]bool, len(data.Fields)) for _, field := range data.Fields { @@ -30,6 +44,19 @@ func TestBuildUnionTypeDataMarksNilableBranches(t *testing.T) { "object": true, "string": false, }, nilable) + + storageNames := make(map[string]string, len(data.Fields)) + for _, field := range data.Fields { + storageNames[field.Name] = field.StorageName + } + assert.Equal(t, map[string]string{ + "array": "array", + "bool": "bool_", + "bytes": "bytes", + "map": "map_", + "object": "object", + "string": "string_", + }, storageNames) } func unionWithBranchTypes() *expr.Union { diff --git a/codegen/service/service_data_union_order_test.go b/codegen/service/service_data_union_order_test.go index a673bce1c6..c2f8b2c852 100644 --- a/codegen/service/service_data_union_order_test.go +++ b/codegen/service/service_data_union_order_test.go @@ -1,92 +1,70 @@ +// This file verifies retained service union declarations keep deterministic +// names regardless of design traversal order. package service import ( + "sort" + "strings" "testing" "github.com/stretchr/testify/require" "goa.design/goa/v3/codegen" - "goa.design/goa/v3/expr" + "goa.design/goa/v3/dsl" ) -func TestCollectUnionTypesDeterministicAcrossObjectOrder(t *testing.T) { - sourceFromSignals := makeUnionForOrderTest("source", - "physical_point", - "synthetic_series", - ) - sourceFromInputs := makeUnionForOrderTest("source", - "time_series", - "energy_rates", - ) +func TestServicePlanUnionNamesAreIndependentOfObjectOrder(t *testing.T) { + forward := retainedUnionNames(t, false) + reverse := retainedUnionNames(t, true) - forward := &expr.AttributeExpr{ - Type: &expr.Object{ - { - Name: "alpha", - Attribute: &expr.AttributeExpr{ - Type: sourceFromSignals, - }, - }, - { - Name: "beta", - Attribute: &expr.AttributeExpr{ - Type: sourceFromInputs, - }, - }, - }, - } - reverse := &expr.AttributeExpr{ - Type: &expr.Object{ - { - Name: "beta", - Attribute: &expr.AttributeExpr{ - Type: sourceFromInputs, - }, - }, - { - Name: "alpha", - Attribute: &expr.AttributeExpr{ - Type: sourceFromSignals, - }, - }, - }, - } - - loc := &codegen.Location{ - RelImportPath: "gen/service", - } - forwardNames := collectServiceUnionTypeNames(forward, loc) - reverseNames := collectServiceUnionTypeNames(reverse, loc) - - require.Len(t, forwardNames, 2) - require.Equal(t, forwardNames, reverseNames) -} - -func collectServiceUnionTypeNames(att *expr.AttributeExpr, loc *codegen.Location) map[string]string { - scope := codegen.NewNameScope() - seen := make(map[string]struct{}) - unionByHash := make(map[string]*UnionTypeData) - collectUnionTypes(att, scope, loc, unionByHash, seen, false) - - names := make(map[string]string, len(unionByHash)) - for hash, data := range unionByHash { - names[hash] = data.Name - } - return names + require.Len(t, forward, 2) + require.Equal(t, forward, reverse) } -func makeUnionForOrderTest(typeName string, variants ...string) *expr.Union { - values := make([]*expr.NamedAttributeExpr, len(variants)) - for i, variant := range variants { - values[i] = &expr.NamedAttributeExpr{ - Name: variant, - Attribute: &expr.AttributeExpr{ - Type: expr.String, - }, +// retainedUnionNames plans two same-base unions in the requested field order +// and indexes their frozen names by their ordered branch contract. +func retainedUnionNames(t *testing.T, reverse bool) map[string]string { + t.Helper() + root := codegen.RunDSL(t, func() { + signals := dsl.Type("Signals", func() { + dsl.Meta("struct:pkg:path", "types") + dsl.OneOf("source", func() { + dsl.TypeName("SignalSource") + dsl.Attribute("physical_point", dsl.String) + dsl.Attribute("synthetic_series", dsl.String) + }) + }) + inputs := dsl.Type("Inputs", func() { + dsl.Meta("struct:pkg:path", "types") + dsl.OneOf("source", func() { + dsl.TypeName("InputSource") + dsl.Attribute("time_series", dsl.String) + dsl.Attribute("energy_rates", dsl.String) + }) + }) + dsl.Service("test", func() { + dsl.Method("read", func() { + dsl.Payload(func() { + if reverse { + dsl.Attribute("beta", inputs) + dsl.Attribute("alpha", signals) + } else { + dsl.Attribute("alpha", signals) + dsl.Attribute("beta", inputs) + } + }) + }) + }) + }) + plan := mustServicePlan(t, root) + names := make(map[string]string) + for _, union := range plan.Services().Get("test").unions { + branches := make([]string, len(union.Fields)) + for index, field := range union.Fields { + branches[index] = field.Name } + sort.Strings(branches) + names[strings.Join(branches, ",")] = union.Name } - return &expr.Union{ - TypeName: typeName, - Values: values, - } + return names } diff --git a/codegen/service/service_declaration_condition_contract_test.go b/codegen/service/service_declaration_condition_contract_test.go new file mode 100644 index 0000000000..0d5c48904d --- /dev/null +++ b/codegen/service/service_declaration_condition_contract_test.go @@ -0,0 +1,45 @@ +// This file verifies service package declarations are collected only for the +// conditions that emit them and never depend on declarations in other packages. +package service + +import ( + "testing" + + "github.com/stretchr/testify/require" + + "goa.design/goa/v3/codegen" + "goa.design/goa/v3/dsl" +) + +// TestRelocatedResultViewConstructorsCompile catches constructor declarations +// that incorrectly depend on a result type declaration owned by another Go +// package. +func TestRelocatedResultViewConstructorsCompile(t *testing.T) { + root := codegen.RunDSL(t, func() { + reading := dsl.ResultType("application/vnd.reading", func() { + dsl.TypeName("Reading") + dsl.Meta("struct:pkg:path", "types") + dsl.Attribute("name", dsl.String) + dsl.Attribute("value", dsl.Int) + dsl.Required("name", "value") + dsl.View("default", func() { + dsl.Attribute("name") + dsl.Attribute("value") + }) + dsl.View("summary", func() { + dsl.Attribute("name") + }) + }) + dsl.Service("Values", func() { + dsl.Method("Read", func() { + dsl.Result(reading) + }) + }) + }) + + plan := retainedServicePlanForPackage(t, root) + files, err := Files(plan) + require.NoError(t, err) + files = append(files, ExampleServiceFiles(plan)...) + compileGeneratedServiceFiles(t, files) +} diff --git a/codegen/service/service_dedup_test.go b/codegen/service/service_dedup_test.go deleted file mode 100644 index 3ddf22599e..0000000000 --- a/codegen/service/service_dedup_test.go +++ /dev/null @@ -1,35 +0,0 @@ -package service - -import ( - "bytes" - "strings" - "testing" - - "github.com/stretchr/testify/require" - - "goa.design/goa/v3/codegen" - stest "goa.design/goa/v3/codegen/service/testdata" -) - -// TestService_DedupEventMarkers verifies that when multiple streaming methods share the -// same result type the generated service code only emits a single event marker method. -func TestService_DedupEventMarkers(t *testing.T) { - root := codegen.RunDSL(t, stest.StreamingDuplicateResultTypesDSL) - services := NewServicesData(root) - require.Len(t, root.Services, 1) - - files := Files("goa.design/goa/example", root.Services[0], services, make(map[string][]string)) - require.Greater(t, len(files), 0) - - // Generate the service.go content - buf := new(bytes.Buffer) - for _, s := range files[0].SectionTemplates[1:] { - require.NoError(t, s.Write(buf)) - } - code := buf.String() - - // Count occurrences of the service-level event marker method for SharedEvent - // The marker has the shape: func (*SharedEvent) isdupStreamServiceEvent() {} - occurrences := strings.Count(code, "func (*SharedEvent) isdupStreamServiceEvent()") - require.Equal(t, 1, occurrences, "expected a single event marker for SharedEvent, got %d", occurrences) -} diff --git a/codegen/service/service_fact_plan.go b/codegen/service/service_fact_plan.go new file mode 100644 index 0000000000..68727a00f1 --- /dev/null +++ b/codegen/service/service_fact_plan.go @@ -0,0 +1,307 @@ +// This file copies the methods, errors, stream settings, and interceptors used +// by one service before generated Go names are chosen. +package service + +import ( + "sort" + + "goa.design/goa/v3/codegen" + "goa.design/goa/v3/expr" +) + +// collectServiceFacts copies the service fields and transport choices needed by +// templates, so later steps do not walk design collections that plugins could +// change. +func collectServiceFacts(root *expr.RootExpr, service *expr.ServiceExpr, examples *expr.ExampleGenerator) *serviceFacts { + facts := &serviceFacts{ + service: service, + apiName: root.API.Name, + name: service.Name, + description: service.Description, + methods: append([]*expr.MethodExpr(nil), service.Methods...), + methodByExpr: make(map[*expr.MethodExpr]*methodFacts, len(service.Methods)), + errors: append([]*expr.ErrorExpr(nil), service.Errors...), + reachableTypes: make(map[expr.UserType]struct{}), + projections: make(map[*expr.MethodExpr]*projectionFacts), + } + for _, serviceError := range facts.errors { + facts.errorFacts = append(facts.errorFacts, retainErrorRenderFacts(serviceError)) + facts.referenceAttributes = append(facts.referenceAttributes, serviceError.AttributeExpr) + retainServiceValueTypes(facts, serviceError.AttributeExpr) + } + methodScope := codegen.NewNameScope() + methodScope.Unique("Use") + methodScope.Unique("websocket") + for _, method := range service.Methods { + methodFacts := &methodFacts{ + method: method, + serviceName: service.Name, + name: method.Name, + description: method.Description, + idempotent: method.Idempotent, + payload: retainMethodAttribute(method.Payload, examples.At(expr.MethodPayloadExampleIdentity(method))), + result: retainMethodAttribute(method.Result, examples.At(expr.MethodResultExampleIdentity(method))), + streamKind: method.Stream, + isStreaming: method.IsStreaming(), + hasMixedResults: method.HasMixedResults(), + varName: methodScope.Unique(codegen.Goify(method.Name, true), "Endpoint"), + } + methodFacts.streamingPayload = retainMethodAttribute( + method.StreamingPayload, + examples.At(expr.MethodStreamingPayloadExampleIdentity(method)), + ) + methodFacts.streamingResult = retainMethodAttribute( + method.StreamingResult, + examples.At(expr.MethodStreamingResultExampleIdentity(method)), + ) + methodFacts.requirements, methodFacts.schemes = retainMethodSecurity(method) + for _, methodError := range method.Errors { + methodFacts.errors = append(methodFacts.errors, retainErrorRenderFacts(methodError)) + } + if method.IsStreaming() || method.HasMixedResults() { + methodFacts.serverStreamVarName = methodScope.Unique(codegen.Goify(method.Name, true), "ServerStream") + methodFacts.clientStreamVarName = methodScope.Unique(codegen.Goify(method.Name, true), "ClientStream") + } + for _, httpService := range root.API.HTTP.Services { + if httpService.Name() != service.Name { + continue + } + if endpoint := httpService.Endpoint(method.Name); endpoint != nil { + methodFacts.skipRequestBodyEncodeDecode = endpoint.SkipRequestBodyEncodeDecode + methodFacts.skipResponseBodyEncodeDecode = endpoint.SkipResponseBodyEncodeDecode + } + break + } + facts.methodByExpr[method] = methodFacts + facts.orderedMethods = append(facts.orderedMethods, methodFacts) + facts.referenceAttributes = append( + facts.referenceAttributes, + method.Payload, + method.StreamingPayload, + method.Result, + ) + retainServiceValueTypes(facts, method.Payload) + retainServiceValueTypes(facts, method.StreamingPayload) + retainServiceValueTypes(facts, method.Result) + if method.HasMixedResults() { + facts.referenceAttributes = append(facts.referenceAttributes, method.StreamingResult) + retainServiceValueTypes(facts, method.StreamingResult) + } + for _, methodError := range method.Errors { + facts.referenceAttributes = append(facts.referenceAttributes, methodError.AttributeExpr) + retainServiceValueTypes(facts, methodError.AttributeExpr) + } + } + for _, method := range facts.methods { + methodFacts := facts.methodByExpr[method] + methodFacts.endpointField = methodScope.Unique(methodFacts.varName+"Endpoint", "") + if method.HasMixedResults() { + methodFacts.streamEndpointField = methodScope.Unique(methodFacts.varName+"StreamEndpoint", "") + } + } + facts.serverInterceptors = retainedInterceptors(root.API.ServerInterceptors, service.ServerInterceptors, facts.methods, true) + facts.clientInterceptors = retainedInterceptors(root.API.ClientInterceptors, service.ClientInterceptors, facts.methods, false) + facts.serverInterceptorFacts = collectInterceptorFacts(facts.serverInterceptors, facts.methods, facts.methodByExpr, true) + facts.clientInterceptorFacts = collectInterceptorFacts(facts.clientInterceptors, facts.methods, facts.methodByExpr, false) + return facts +} + +// retainServiceValueTypes records every named type reachable from a payload, +// result, error, or stream value. User-supplied Go type mappings use this set to +// generate conversions for all four kinds of service data. +func retainServiceValueTypes(facts *serviceFacts, attribute *expr.AttributeExpr) { + if attribute == nil || attribute.Type == expr.Empty { + return + } + err := codegen.Walk(attribute, func(attribute *expr.AttributeExpr) error { + if userType, ok := attribute.Type.(expr.UserType); ok { + facts.reachableTypes[userType.Origin()] = struct{}{} + } + return nil + }) + if err != nil { + panic(err) // the collector callback cannot return an error + } +} + +// collectInterceptorFacts records the methods that call each interceptor, so +// template data can be built without walking the design again. +func collectInterceptorFacts(interceptors []*expr.InterceptorExpr, methods []*expr.MethodExpr, methodFacts map[*expr.MethodExpr]*methodFacts, server bool) []*interceptorFacts { + result := make([]*interceptorFacts, len(interceptors)) + for index, interceptor := range interceptors { + facts := &interceptorFacts{ + name: interceptor.Name, + description: interceptor.Description, + readPayload: interceptor.ReadPayload, + writePayload: interceptor.WritePayload, + readResult: interceptor.ReadResult, + writeResult: interceptor.WriteResult, + readStreamingPayload: interceptor.ReadStreamingPayload, + writeStreamingPayload: interceptor.WriteStreamingPayload, + readStreamingResult: interceptor.ReadStreamingResult, + writeStreamingResult: interceptor.WriteStreamingResult, + } + for _, method := range methods { + applied := method.ClientInterceptors + if server { + applied = method.ServerInterceptors + } + if interceptorNamed(applied, interceptor.Name) { + facts.methods = append(facts.methods, methodFacts[method]) + } + } + result[index] = facts + } + return result +} + +// retainMethodAttribute copies one payload or result's description, metadata, +// default, and example. GoTypePlan separately records its nested Go fields. +func retainMethodAttribute(attribute *expr.AttributeExpr, examples *expr.ExampleGenerator) *methodAttributeFacts { + if attribute == nil { + return nil + } + retained := *attribute + if attribute.Meta != nil { + retained.Meta = attribute.Meta.Dup() + } + return &methodAttributeFacts{ + attribute: &retained, + present: attribute.Type != expr.Empty, + isObject: expr.IsObject(attribute.Type), + location: codegen.UserTypeLocation(attribute.Type), + description: attribute.Description, + defaultValue: cloneRetainedValue(attribute.DefaultValue), + example: cloneRetainedValue(attribute.Example(examples)), + } +} + +// retainErrorRenderFacts copies the error description, type, output location, +// and temporary, timeout, and fault settings used by generated constructors +// and client comments. +func retainErrorRenderFacts(errorExpression *expr.ErrorExpr) *errorRenderFacts { + _, temporary := errorExpression.Meta["goa:error:temporary"] + _, timeout := errorExpression.Meta["goa:error:timeout"] + _, fault := errorExpression.Meta["goa:error:fault"] + attribute := *errorExpression.AttributeExpr + if errorExpression.Meta != nil { + attribute.Meta = errorExpression.Meta.Dup() + } + return &errorRenderFacts{ + attribute: &attribute, + name: errorExpression.Name, + description: errorExpression.Description, + location: codegen.UserTypeLocation(errorExpression.Type), + temporary: temporary, + timeout: timeout, + fault: fault, + serviceType: expr.IsErrorResult(errorExpression.Type), + } +} + +// retainMethodSecurity copies credential fields and required authorization +// scope names from the evaluated method before template data is built. +func retainMethodSecurity(method *expr.MethodExpr) (RequirementsData, SchemesData) { + requirements := make(RequirementsData, 0, len(method.Requirements)) + var schemes SchemesData + for _, requirement := range expr.EffectiveSecurityRequirements(method.Requirements) { + var requirementSchemes SchemesData + for _, scheme := range requirement.Schemes { + data := cloneSchemeData(BuildSchemeData(scheme, method)) + requirementSchemes = requirementSchemes.Append(data) + schemes = schemes.Append(data) + } + requirements = append(requirements, &RequirementData{ + Schemes: requirementSchemes, + Scopes: append([]string(nil), requirement.Scopes...), + }) + } + return requirements, schemes +} + +// cloneSchemeData copies one security scheme and its slices so later changes to +// the design cannot change generated template data. +func cloneSchemeData(source *SchemeData) *SchemeData { + if source == nil { + return nil + } + cloned := *source + cloned.Scopes = append([]string(nil), source.Scopes...) + cloned.Flows = make([]*expr.FlowExpr, len(source.Flows)) + for index, flow := range source.Flows { + copy := *flow + cloned.Flows[index] = © + } + return &cloned +} + +// This helper copies maps and slices used in Goa examples and defaults. +// Numbers, strings, booleans, and other value types may be shared. +func cloneRetainedValue(source any) any { + switch actual := source.(type) { + case expr.Val: + cloned := make(expr.Val, len(actual)) + for name, value := range actual { + cloned[name] = cloneRetainedValue(value) + } + return cloned + case expr.ArrayVal: + cloned := make(expr.ArrayVal, len(actual)) + for index, value := range actual { + cloned[index] = cloneRetainedValue(value) + } + return cloned + case expr.MapVal: + cloned := make(expr.MapVal, len(actual)) + for key, value := range actual { + cloned[cloneRetainedValue(key)] = cloneRetainedValue(value) + } + return cloned + case []any: + cloned := make([]any, len(actual)) + for index, value := range actual { + cloned[index] = cloneRetainedValue(value) + } + return cloned + case []byte: + return append([]byte(nil), actual...) + case map[string]any: + cloned := make(map[string]any, len(actual)) + for name, value := range actual { + cloned[name] = cloneRetainedValue(value) + } + return cloned + case map[any]any: + cloned := make(map[any]any, len(actual)) + for key, value := range actual { + cloned[cloneRetainedValue(key)] = cloneRetainedValue(value) + } + return cloned + default: + return actual + } +} + +// This helper returns each applicable interceptor once, sorted by name, without +// changing a slice stored in the design. +func retainedInterceptors(api, service []*expr.InterceptorExpr, methods []*expr.MethodExpr, server bool) []*expr.InterceptorExpr { + interceptors := append([]*expr.InterceptorExpr(nil), api...) + interceptors = append(interceptors, service...) + for _, method := range methods { + if server { + interceptors = append(interceptors, method.ServerInterceptors...) + } else { + interceptors = append(interceptors, method.ClientInterceptors...) + } + } + sort.Slice(interceptors, func(i, j int) bool { + return interceptors[i].Name < interceptors[j].Name + }) + result := interceptors[:0] + for _, interceptor := range interceptors { + if len(result) == 0 || result[len(result)-1].Name != interceptor.Name { + result = append(result, interceptor) + } + } + return result +} diff --git a/codegen/service/service_link.go b/codegen/service/service_link.go new file mode 100644 index 0000000000..dfe04c5e11 --- /dev/null +++ b/codegen/service/service_link.go @@ -0,0 +1,425 @@ +// This file turns stored service information into the data used by templates +// after all generated names and imported package names are final. +package service + +import ( + "fmt" + "path" + "slices" + "sort" + "strings" + + "goa.design/goa/v3/codegen" + "goa.design/goa/v3/expr" +) + +// Link reads the final Go declaration and import names and builds the data used +// by service templates. Generation.Freeze must run first so each definition +// and every reference to it use the same name. +func (p *Plan) Link() error { + if !p.generation.Frozen() { + return fmt.Errorf("service plan cannot link before generation freeze") + } + if p.services != nil { + return fmt.Errorf("service plan is already linked") + } + aliases, err := newImportAliases(p.facts.root, p.generation) + if err != nil { + return err + } + for _, facts := range p.facts.services { + if err := linkServiceFileImports(facts); err != nil { + return err + } + } + if err := linkExternalConversions(p.facts, p.generation, aliases); err != nil { + return err + } + services, err := linkServicesData(p.facts, p.generation, aliases) + if err != nil { + return err + } + p.services = services + return nil +} + +// Services returns the service data passed to templates. It panics before Link +// because that data does not exist until the final Go names are available. +func (p *Plan) Services() *ServicesData { + if p.services == nil { + panic("service render model requested before plan linking") + } + return p.services +} + +// analyze creates the data necessary to render the code of the given service. +// It records the user types needed by the service definition in userTypes. +func (d *ServicesData) analyze(facts *serviceFacts) (*Data, error) { + var ( + types []*UserTypeData + errTypes []*UserTypeData + errorInits []*ErrorInitData + projTypes []*ProjectedTypeData + viewedRTs []*ViewedResultTypeData + ) + servicePackage := d.generation.Package(facts.packagePath) + scope := servicePackage.Scope() + viewScope := d.generation.Package( + facts.viewsPath, + ).Scope() + pkgName := strings.ToLower(codegen.Goify(path.Base(servicePackage.ImportPath()), false)) + var viewsPkg string + seenErrors := make(map[string]struct{}) + type viewedResultKey struct { + origin expr.UserType + view string + } + seenViewed := make(map[viewedResultKey]*ViewedResultTypeData) + seenViewedDeclarations := make(map[*codegen.TypeDeclaration]struct{}) + viewDerived := make(map[expr.UserType]codegen.DerivedTypeID) + serviceResolver := newServiceResolver( + d.generation, + d.aliases, + facts.name, + facts.packagePath, + facts.packagePath, + ).withValidators(facts.validators) + types = formatUserTypeFacts(facts.userTypes, d.aliases) + errTypes = formatUserTypeFacts(facts.errorTypes, d.aliases) + + // recordError formats each selected ErrorResult constructor once. + recordError := func(errorFacts *errorRenderFacts) { + if errorFacts.serviceType { + if _, ok := seenErrors[errorFacts.name]; ok { + return + } + seenErrors[errorFacts.name] = struct{}{} + errorInits = append(errorInits, buildRetainedErrorInitData( + errorFacts, + serviceResolver, + facts.errorConstructors[errorFacts.name], + )) + } + } + for _, errorFacts := range facts.errorFacts { + recordError(errorFacts) + } + + for _, method := range facts.orderedMethods { + // Build template data for each result type containing only a view's fields. + if projection := method.projection; projection != nil { + viewsPkg = d.aliases.spec(facts.packagePath, facts.viewsPath).Name + views := d.generation.Package(facts.viewsPath) + for _, projectedFacts := range projection.types { + pair := projectedFacts.pair + identity := codegen.NewProjectedTypeID(pair.source) + viewDerived[pair.projected.Origin()] = identity + } + viewResolver := newViewResolver( + d.generation, + d.aliases, + facts.name, + facts.viewsPath, + viewDerived, + ). + withValidators(facts.validators) + for _, projectedFacts := range projection.types { + pair := projectedFacts.pair + identity := codegen.NewProjectedTypeID(pair.source) + declaration, err := views.DerivedType(identity) + if err != nil { + return nil, err + } + projectedType := buildProjectedType( + projectedFacts, + serviceResolver, + viewResolver, + declaration, + viewsPkg, + ) + projTypes = append(projTypes, projectedType) + } + } + for _, errorFacts := range method.errors { + recordError(errorFacts) + } + } + viewUnions := d.formatViewUnions(facts) + + var ( + methods []*MethodData + schemes SchemesData + ) + methods = make([]*MethodData, len(facts.orderedMethods)) + methodDataByFacts := make(map[*methodFacts]*MethodData, len(facts.orderedMethods)) + for i, method := range facts.orderedMethods { + m := buildMethodData(method, serviceResolver, facts) + methods[i] = m + methodDataByFacts[method] = m + for _, s := range m.Schemes { + schemes = schemes.Append(s) + } + viewedFacts := method.viewedResult + if viewedFacts == nil { + continue + } + viewsPkg = d.aliases.spec(facts.packagePath, facts.viewsPath).Name + key := viewedResultKey{origin: viewedFacts.origin, view: viewedFacts.viewName} + if vrt, ok := seenViewed[key]; ok { + m.ViewedResult = vrt + continue + } + vrt := buildViewedResultType( + viewedFacts, + viewsPkg, + serviceResolver, + newViewResolver( + d.generation, + d.aliases, + facts.name, + facts.viewsPath, + viewDerived, + ). + withValidators(facts.validators), + viewedFacts.declaration, + ) + if _, found := seenViewedDeclarations[viewedFacts.declaration]; !found { + viewedRTs = append(viewedRTs, vrt) + seenViewedDeclarations[viewedFacts.declaration] = struct{}{} + } + m.ViewedResult = vrt + seenViewed[key] = vrt + } + + unions := d.formatServiceUnions(facts) + + desc := facts.description + if desc == "" { + desc = fmt.Sprintf("Service is the %s service interface.", facts.name) + } + + varName := codegen.Goify(facts.name, false) + data := &Data{ + ServiceDeclaration: facts.names.declaration(serviceSymbolID{role: serviceInterfaceNameRole, service: facts.name}), + AutherDeclaration: facts.names[serviceSymbolID{role: serviceAutherNameRole, service: facts.name}].declaration, + APINameDeclaration: facts.names.declaration(serviceSymbolID{role: serviceAPINameRole, service: facts.name}), + APIVersionDeclaration: facts.names.declaration(serviceSymbolID{role: serviceAPIVersionNameRole, service: facts.name}), + ServiceNameDeclaration: facts.names.declaration(serviceSymbolID{role: serviceNameConstantRole, service: facts.name}), + MethodNamesDeclaration: facts.names.declaration(serviceSymbolID{role: serviceMethodNamesRole, service: facts.name}), + EndpointsDeclaration: facts.names.declaration(serviceSymbolID{role: serviceEndpointsNameRole, service: facts.name}), + NewEndpointsDeclaration: facts.names.declaration(serviceSymbolID{role: serviceNewEndpointsNameRole, service: facts.name}), + ClientDeclaration: facts.names.declaration(serviceSymbolID{role: serviceClientNameRole, service: facts.name}), + NewClientDeclaration: facts.names.declaration(serviceSymbolID{role: serviceNewClientNameRole, service: facts.name}), + ServerInterceptorsDeclaration: facts.names[serviceSymbolID{ + role: serviceServerInterceptorsNameRole, service: facts.name, + }].declaration, + ClientInterceptorsDeclaration: facts.names[serviceSymbolID{ + role: serviceClientInterceptorsNameRole, service: facts.name, + }].declaration, + ExampleStructDeclaration: facts.exampleStruct, + ExampleConstructorDeclaration: facts.exampleConstructor, + ExampleServerInterceptorsConstructorDeclaration: facts.exampleServerConstructor, + Name: facts.name, + Description: desc, + APIName: d.facts.apiName, + APIVersion: d.facts.apiVersion, + VarName: varName, + PathName: path.Base(facts.packagePath), + StructName: codegen.Goify(facts.name, true), + PkgName: pkgName, + ViewsPkg: viewsPkg, + Methods: methods, + Schemes: schemes, + ServerInterceptors: d.collectInterceptors(facts, facts.serverInterceptorFacts, methodDataByFacts, serviceResolver, true), + ClientInterceptors: d.collectInterceptors(facts, facts.clientInterceptorFacts, methodDataByFacts, serviceResolver, false), + Scope: scope, + ViewScope: viewScope, + errorTypes: errTypes, + errorInits: errorInits, + userTypes: types, + projectedTypes: projTypes, + viewedResultTypes: viewedRTs, + unions: unions, + viewUnions: viewUnions, + viewDerived: viewDerived, + } + return data, nil +} + +// collectInterceptors returns the set of interceptors defined on the given +// service including any interceptor defined on specific service methods or API. +func (d *ServicesData) collectInterceptors(service *serviceFacts, facts []*interceptorFacts, methods map[*methodFacts]*MethodData, resolver *declarationResolver, server bool) []*InterceptorData { + res := make([]*InterceptorData, 0, len(facts)) + for _, interceptor := range facts { + res = append(res, buildInterceptorData(service, interceptor, methods, resolver, server)) + } + return res +} + +// declarationContext configures transformations and validations to resolve +// every named service or view type through its planned package declaration. +func declarationContext(resolver codegen.Attributor, pointer bool) *codegen.AttributeContext { + return &codegen.AttributeContext{ + Pointer: pointer, + UseDefault: true, + Scope: resolver, + } +} + +// formatUserTypeFacts resolves the final names and definitions of types that +// collection already selected and assigned to generated packages. +func formatUserTypeFacts(facts []*userTypeFacts, aliases *importAliases) []*UserTypeData { + data := make([]*UserTypeData, len(facts)) + for index, facts := range facts { + linked := facts.layout.Link( + facts.declaration.PackagePath(), + retainedTypeQualifier(aliases, facts.declaration.PackagePath()), + ) + data[index] = &UserTypeData{ + Declaration: facts.declaration, + Name: facts.name, + VarName: facts.declaration.Name(), + Description: facts.description, + ErrorDescription: facts.description, + ErrorName: facts.errorName, + IsServiceError: facts.serviceError, + Def: linked.Def(), + Ref: facts.declaration.Ref(facts.userType), + Loc: facts.location, + Type: facts.userType, + } + } + return data +} + +// formatServiceUnions builds template data for the Goa OneOf declarations +// recorded during collection and adds one entry for each generated package. +func (d *ServicesData) formatServiceUnions(facts *serviceFacts) []*UnionTypeData { + unions := make([]*UnionTypeData, 0, len(facts.unions)) + for _, facts := range facts.unions { + union := buildRetainedUnionTypeData(facts, d.aliases) + facts.data = union + unions = append(unions, union) + } + sort.Slice(unions, func(i, j int) bool { + if unions[i].Name != unions[j].Name { + return unions[i].Name < unions[j].Name + } + var left, right string + if unions[i].Loc != nil { + left = unions[i].Loc.RelImportPath + } + if unions[j].Loc != nil { + right = unions[j].Loc.RelImportPath + } + return left < right + }) + return unions +} + +// formatViewUnions builds template data for the Goa OneOf declarations found +// while collecting result views. It does not walk the result types again. +func (d *ServicesData) formatViewUnions(facts *serviceFacts) []*UnionTypeData { + unions := make([]*UnionTypeData, len(facts.viewUnions)) + for index, union := range facts.viewUnions { + unions[index] = buildRetainedUnionTypeData(union, d.aliases) + } + sort.Slice(unions, func(i, j int) bool { + return unions[i].Name < unions[j].Name + }) + return unions +} + +// This helper builds template data for one Goa OneOf type from the branch names +// and Go types selected during planning. +func buildRetainedUnionTypeData(facts *unionFacts, aliases *importAliases) *UnionTypeData { + fields := make([]*UnionFieldData, len(facts.branches)) + for index, branch := range facts.branches { + fields[index] = &UnionFieldData{ + Name: branch.name, + KindConst: branch.declaration.KindConst(), + Constructor: branch.declaration.Constructor(), + KindDeclaration: branch.declaration.KindDeclaration(), + ConstructorDeclaration: branch.declaration.ConstructorDeclaration(), + FieldName: branch.fieldName, + StorageName: branch.storageName, + FieldType: branch.layout.Link(facts.declaration.PackagePath(), retainedTypeQualifier(aliases, facts.declaration.PackagePath())).Ref(), + Nilable: branch.nilable, + EmitPrimitiveAlias: branch.emitPrimitiveAlias, + PrimitiveAliasType: branch.primitiveAliasType, + TypeTag: branch.name, + } + } + return &UnionTypeData{ + TypeDeclaration: facts.declaration.Declaration(), + KindDeclaration: facts.declaration.KindDeclaration(), + Name: facts.declaration.Name(), + KindName: facts.declaration.KindName(), + Fields: fields, + Loc: facts.location, + TypeKey: facts.typeKey, + ValueKey: facts.valueKey, + } +} + +// sortedNamedAttributes returns object fields sorted by attribute name. +// Union naming uses NameScope uniqueness, so callers that discover unions while +// traversing objects must use a deterministic field order to avoid oscillating +// generated identifiers across runs. +func sortedNamedAttributes(attrs []*expr.NamedAttributeExpr) []*expr.NamedAttributeExpr { + if len(attrs) < 2 { + return attrs + } + sorted := slices.Clone(attrs) + sort.Slice(sorted, func(i, j int) bool { + return sorted[i].Name < sorted[j].Name + }) + return sorted +} + +// primitiveAliasGoType resolves the native Go type for a primitive alias branch. +// It uses expr.IsPrimitive to enforce the type contract and then unwraps aliases. +func primitiveAliasGoType(dt expr.DataType) (string, bool) { + if !expr.IsPrimitive(dt) { + return "", false + } + for { + ut, ok := dt.(expr.UserType) + if !ok { + return codegen.GoNativeTypeName(dt), true + } + dt = ut.Attribute().Type + } +} + +// This helper builds constructor data for an error copied during collection +// without reading the design expression again. +func buildRetainedErrorInitData(facts *errorRenderFacts, resolver *declarationResolver, declaration *codegen.NameDeclaration) *ErrorInitData { + if facts.layout == nil { + panic(fmt.Sprintf("retained error %q has no Go type layout", facts.name)) + } + linked := facts.layout.Link(resolver.outputPath, retainedTypeQualifier(resolver.aliases, resolver.outputPath)) + name := "" + if facts.serviceType { + name = declaration.Name() + } + return &ErrorInitData{ + Declaration: declaration, + Name: name, + Description: facts.description, + ErrName: facts.name, + TypeName: linked.Name(), + TypeRef: linked.Ref(), + Temporary: facts.temporary, + Timeout: facts.timeout, + Fault: facts.fault, + } +} + +// This helper returns the Go import name chosen for a type recorded during +// planning. +func retainedTypeQualifier(aliases *importAliases, outputPackage string) codegen.GoTypeQualifier { + return func(importPath string) string { + return aliases.name(outputPackage, importPath) + } +} diff --git a/codegen/service/service_name_collision_contract_test.go b/codegen/service/service_name_collision_contract_test.go new file mode 100644 index 0000000000..792bde69ac --- /dev/null +++ b/codegen/service/service_name_collision_contract_test.go @@ -0,0 +1,154 @@ +// This file verifies every core service package symbol participates in the +// single Go package namespace with authored types and other generated symbols. +package service + +import ( + "fmt" + "testing" + + "github.com/stretchr/testify/require" + + "goa.design/goa/v3/codegen" + "goa.design/goa/v3/expr" +) + +type serviceCollisionResult struct { + authored string + subject string + competitor string +} + +// TestEveryServiceNameRoleSharesOnePackageNamespace catches a service symbol +// family that bypasses exact authored types or uses discovery order to choose +// collision suffixes. +func TestEveryServiceNameRoleSharesOnePackageNamespace(t *testing.T) { + roles := []serviceNameRole{ + serviceInterfaceNameRole, + serviceAutherNameRole, + serviceAPINameRole, + serviceAPIVersionNameRole, + serviceNameConstantRole, + serviceMethodNamesRole, + serviceServerStreamNameRole, + serviceClientStreamNameRole, + serviceErrorConstructorNameRole, + serviceViewConstructorNameRole, + servicePrivateProjectionConstructorNameRole, + serviceValidatorNameRole, + serviceViewMapNameRole, + serviceEndpointsNameRole, + serviceNewEndpointsNameRole, + serviceClientNameRole, + serviceNewClientNameRole, + serviceMethodEndpointNameRole, + serviceEndpointInputNameRole, + serviceRequestNameRole, + serviceResponseNameRole, + serviceServerInterceptorsNameRole, + serviceClientInterceptorsNameRole, + serviceInterceptorInfoNameRole, + serviceInterceptorPayloadNameRole, + serviceInterceptorResultNameRole, + serviceInterceptorStreamingPayloadNameRole, + serviceInterceptorStreamingResultNameRole, + serviceInterceptorPayloadAccessNameRole, + serviceInterceptorResultAccessNameRole, + serviceInterceptorStreamingPayloadAccessNameRole, + serviceInterceptorStreamingResultAccessNameRole, + serviceInterceptorMethodInfoNameRole, + serviceInterceptorServerUnaryInfoNameRole, + serviceInterceptorClientUnaryInfoNameRole, + serviceInterceptorStreamingSendInfoNameRole, + serviceInterceptorStreamingRecvInfoNameRole, + serviceServerEndpointWrapperNameRole, + serviceClientEndpointWrapperNameRole, + serviceServerInterceptorWrapperNameRole, + serviceClientInterceptorWrapperNameRole, + serviceServerStreamWrapperNameRole, + serviceClientStreamWrapperNameRole, + serviceTransformHelperNameRole, + serviceExampleStructNameRole, + serviceExampleConstructorNameRole, + serviceExampleServerInterceptorsStructNameRole, + serviceExampleServerInterceptorsConstructorNameRole, + serviceExampleClientInterceptorsStructNameRole, + serviceExampleClientInterceptorsConstructorNameRole, + } + + for _, role := range roles { + t.Run(fmt.Sprintf("role-%d", role), func(t *testing.T) { + forward := serviceCollisionNames(t, role, false) + reverse := serviceCollisionNames(t, role, true) + wantGenerated := []string{"Symbol2", "Symbol3"} + if role.visibility() == codegen.UnexportedName { + wantGenerated = []string{"symbol", "symbol2"} + } + + require.Equal(t, forward, reverse) + require.Equal(t, "Symbol", forward.authored) + require.ElementsMatch(t, wantGenerated, []string{ + forward.subject, + forward.competitor, + }) + }) + } +} + +// serviceCollisionNames declares one role and an unrelated generated function +// in the requested order, then returns their frozen names. +func serviceCollisionNames(t *testing.T, role serviceNameRole, reverse bool) serviceCollisionResult { + t.Helper() + generation := mustTestGeneration(t, "generated.local/gen", nil) + generatedPackage := mustClaimTestPackage(t, generation, "generated.local/gen/calc") + authored := &expr.UserTypeExpr{ + AttributeExpr: &expr.AttributeExpr{Type: expr.String}, + TypeName: "Symbol", + UID: fmt.Sprintf("authored-symbol-%d", role), + } + authoredDeclaration, err := generatedPackage.DeclareUserType(authored) + require.NoError(t, err) + + competitorRole := serviceErrorConstructorNameRole + if role.visibility() == codegen.UnexportedName { + competitorRole = servicePrivateProjectionConstructorNameRole + if role == competitorRole { + competitorRole = serviceTransformHelperNameRole + } + } else if role == competitorRole { + competitorRole = serviceValidatorNameRole + } + subjectID := serviceSymbolID{ + role: role, + service: "calc", + subject: "subject", + } + competitorID := serviceSymbolID{ + role: competitorRole, + service: "calc", + subject: "competitor", + } + names := make(serviceNames) + declare := func(id serviceSymbolID) *codegen.NameDeclaration { + declaration, err := names.declare(generatedPackage, id, "Symbol") + require.NoError(t, err) + repeated, err := names.declare(generatedPackage, id, "Symbol") + require.NoError(t, err) + require.Same(t, declaration, repeated) + return declaration + } + var subject, competitor *codegen.NameDeclaration + if reverse { + competitor = declare(competitorID) + subject = declare(subjectID) + } else { + subject = declare(subjectID) + competitor = declare(competitorID) + } + require.NoError(t, generation.Freeze()) + + return serviceCollisionResult{ + authored: authoredDeclaration.Name(), + subject: subject.Name(), + competitor: competitor.Name(), + } +} diff --git a/codegen/service/service_names.go b/codegen/service/service_names.go new file mode 100644 index 0000000000..2c05e3072f --- /dev/null +++ b/codegen/service/service_names.go @@ -0,0 +1,334 @@ +// This file records every package-level Go declaration written by the service +// and views generators. Each definition and reference reads its name from the +// same NameDeclaration. +package service + +import ( + "cmp" + "fmt" + + "goa.design/goa/v3/codegen" + "goa.design/goa/v3/expr" +) + +type ( + // serviceNameRole identifies what one package-level declaration does in the + // generated service or views package. + serviceNameRole uint8 + + // serviceNameOrder contains design names and fixed categories that order two + // declarations requesting the same Go name. It does not depend on the order + // in which generators find them. + serviceNameOrder struct { + role serviceNameRole + service string + api string + method string + subject string + view string + source string + target string + side string + definition codegen.TransformHelperDefinitionLocation + } + + // serviceSymbolID identifies one package declaration without using the Go + // name that will be chosen later. Source and target distinguish conversion + // helpers; subject and view distinguish constructors and validators. + serviceSymbolID serviceNameOrder + + // serviceName stores the requested Go name and the NameDeclaration created + // for it. Repeated collection must return that same declaration. + serviceName struct { + preferred string + base *codegen.NameDeclaration + prefix string + suffix string + declaration *codegen.NameDeclaration + } + + // serviceNames maps each service declaration purpose to the NameDeclaration + // stored in its generated Go package. + serviceNames map[serviceSymbolID]serviceName +) + +const ( + serviceInterfaceNameRole serviceNameRole = iota + 1 + serviceAutherNameRole + serviceAPINameRole + serviceAPIVersionNameRole + serviceNameConstantRole + serviceMethodNamesRole + serviceServerStreamNameRole + serviceClientStreamNameRole + serviceErrorConstructorNameRole + serviceViewConstructorNameRole + servicePrivateProjectionConstructorNameRole + serviceValidatorNameRole + serviceViewMapNameRole + serviceEndpointsNameRole + serviceNewEndpointsNameRole + serviceClientNameRole + serviceNewClientNameRole + serviceMethodEndpointNameRole + serviceEndpointInputNameRole + serviceRequestNameRole + serviceResponseNameRole + serviceServerInterceptorsNameRole + serviceClientInterceptorsNameRole + serviceInterceptorInfoNameRole + serviceInterceptorPayloadNameRole + serviceInterceptorResultNameRole + serviceInterceptorStreamingPayloadNameRole + serviceInterceptorStreamingResultNameRole + serviceInterceptorPayloadAccessNameRole + serviceInterceptorResultAccessNameRole + serviceInterceptorStreamingPayloadAccessNameRole + serviceInterceptorStreamingResultAccessNameRole + serviceInterceptorMethodInfoNameRole + serviceInterceptorServerUnaryInfoNameRole + serviceInterceptorClientUnaryInfoNameRole + serviceInterceptorStreamingSendInfoNameRole + serviceInterceptorStreamingRecvInfoNameRole + serviceServerEndpointWrapperNameRole + serviceClientEndpointWrapperNameRole + serviceServerInterceptorWrapperNameRole + serviceClientInterceptorWrapperNameRole + serviceServerStreamWrapperNameRole + serviceClientStreamWrapperNameRole + serviceTransformHelperNameRole + serviceExampleStructNameRole + serviceExampleConstructorNameRole + serviceExampleServerInterceptorsStructNameRole + serviceExampleServerInterceptorsConstructorNameRole + serviceExampleClientInterceptorsStructNameRole + serviceExampleClientInterceptorsConstructorNameRole +) + +// ComparePackageName orders service declarations by their purpose and design +// names, so discovery order cannot change generated Go names. +func (o serviceNameOrder) ComparePackageName(other codegen.PackageNameOrder) int { + right := other.(serviceNameOrder) + if compared := cmp.Compare(o.role, right.role); compared != 0 { + return compared + } + if compared := cmp.Compare(o.service, right.service); compared != 0 { + return compared + } + if compared := cmp.Compare(o.api, right.api); compared != 0 { + return compared + } + if compared := cmp.Compare(o.method, right.method); compared != 0 { + return compared + } + if compared := cmp.Compare(o.subject, right.subject); compared != 0 { + return compared + } + if compared := cmp.Compare(o.view, right.view); compared != 0 { + return compared + } + if compared := cmp.Compare(o.source, right.source); compared != 0 { + return compared + } + if compared := cmp.Compare(o.target, right.target); compared != 0 { + return compared + } + if compared := cmp.Compare(o.side, right.side); compared != 0 { + return compared + } + return o.definition.Compare(right.definition) +} + +// kind returns whether this role writes a Go type, function, constant, or +// variable. An unknown role means the generator omitted a supported case. +func (r serviceNameRole) kind() codegen.PackageNameKind { + switch r { + case serviceAPINameRole, serviceAPIVersionNameRole, serviceNameConstantRole: + return codegen.NameConstant + case serviceMethodNamesRole, serviceViewMapNameRole: + return codegen.NameVariable + case serviceErrorConstructorNameRole, + serviceViewConstructorNameRole, + servicePrivateProjectionConstructorNameRole, + serviceValidatorNameRole, + serviceNewEndpointsNameRole, + serviceNewClientNameRole, + serviceMethodEndpointNameRole, + serviceServerEndpointWrapperNameRole, + serviceClientEndpointWrapperNameRole, + serviceServerInterceptorWrapperNameRole, + serviceClientInterceptorWrapperNameRole, + serviceTransformHelperNameRole, + serviceExampleConstructorNameRole, + serviceExampleServerInterceptorsConstructorNameRole, + serviceExampleClientInterceptorsConstructorNameRole: + return codegen.NameFunction + case serviceInterfaceNameRole, + serviceAutherNameRole, + serviceServerStreamNameRole, + serviceClientStreamNameRole, + serviceEndpointsNameRole, + serviceClientNameRole, + serviceEndpointInputNameRole, + serviceRequestNameRole, + serviceResponseNameRole, + serviceServerInterceptorsNameRole, + serviceClientInterceptorsNameRole, + serviceInterceptorInfoNameRole, + serviceInterceptorPayloadNameRole, + serviceInterceptorResultNameRole, + serviceInterceptorStreamingPayloadNameRole, + serviceInterceptorStreamingResultNameRole, + serviceInterceptorPayloadAccessNameRole, + serviceInterceptorResultAccessNameRole, + serviceInterceptorStreamingPayloadAccessNameRole, + serviceInterceptorStreamingResultAccessNameRole, + serviceInterceptorMethodInfoNameRole, + serviceInterceptorServerUnaryInfoNameRole, + serviceInterceptorClientUnaryInfoNameRole, + serviceInterceptorStreamingSendInfoNameRole, + serviceInterceptorStreamingRecvInfoNameRole, + serviceServerStreamWrapperNameRole, + serviceClientStreamWrapperNameRole, + serviceExampleStructNameRole, + serviceExampleServerInterceptorsStructNameRole, + serviceExampleClientInterceptorsStructNameRole: + return codegen.NameType + default: + panic(fmt.Sprintf("unknown service package name role %d", r)) + } +} + +// visibility reports whether callers outside the generated package can use the +// declaration. +func (r serviceNameRole) visibility() codegen.PackageNameVisibility { + switch r { + case servicePrivateProjectionConstructorNameRole, + serviceInterceptorPayloadAccessNameRole, + serviceInterceptorResultAccessNameRole, + serviceInterceptorStreamingPayloadAccessNameRole, + serviceInterceptorStreamingResultAccessNameRole, + serviceInterceptorMethodInfoNameRole, + serviceInterceptorServerUnaryInfoNameRole, + serviceInterceptorClientUnaryInfoNameRole, + serviceInterceptorStreamingSendInfoNameRole, + serviceInterceptorStreamingRecvInfoNameRole, + serviceServerInterceptorWrapperNameRole, + serviceClientInterceptorWrapperNameRole, + serviceServerStreamWrapperNameRole, + serviceClientStreamWrapperNameRole, + serviceTransformHelperNameRole, + serviceExampleStructNameRole: + return codegen.UnexportedName + default: + return codegen.ExportedName + } +} + +// declare submits one requested Go name to pkg. Repeated calls for the same id +// return the same NameDeclaration and reject a different requested name. +func (n serviceNames) declare(pkg *codegen.GeneratedPackage, id serviceSymbolID, preferred string) (*codegen.NameDeclaration, error) { + return n.declareForAPI(pkg, id, preferred, "") +} + +// declareForAPI submits one generated name using the API to distinguish two +// roots that intentionally contribute to the same service package. +func (n serviceNames) declareForAPI(pkg *codegen.GeneratedPackage, id serviceSymbolID, preferred, api string) (*codegen.NameDeclaration, error) { + if existing, ok := n[id]; ok { + if existing.base != nil || existing.preferred != preferred { + return nil, fmt.Errorf( + "service symbol role %d cannot declare both %q and %q", + id.role, + existing.preferred, + preferred, + ) + } + if err := pkg.DeclareName(existing.declaration); err != nil { + return nil, err + } + return existing.declaration, nil + } + + order := serviceNameOrder(id) + order.api = api + declaration := codegen.NewPreferredName( + id.role.kind(), + preferred, + id.role.visibility(), + order, + ) + if err := pkg.DeclareName(declaration); err != nil { + return nil, err + } + n[id] = serviceName{preferred: preferred, declaration: declaration} + return declaration, nil +} + +// declareDependent submits a declaration whose Go name is built by adding +// prefix and suffix to base's final name. Repeated calls for the same id must +// use the same base, prefix, and suffix. +func (n serviceNames) declareDependent(pkg *codegen.GeneratedPackage, id serviceSymbolID, base *codegen.NameDeclaration, prefix, suffix string) (*codegen.NameDeclaration, error) { + return n.declareDependentForAPI(pkg, id, base, prefix, suffix, "") +} + +// declareDependentForAPI submits one dependent generated name using the API to +// distinguish two roots that intentionally contribute to the same package. +func (n serviceNames) declareDependentForAPI(pkg *codegen.GeneratedPackage, id serviceSymbolID, base *codegen.NameDeclaration, prefix, suffix, api string) (*codegen.NameDeclaration, error) { + if existing, ok := n[id]; ok { + if existing.base != base || existing.prefix != prefix || existing.suffix != suffix { + return nil, fmt.Errorf("service symbol role %d cannot change its dependent declaration family", id.role) + } + if err := pkg.DeclareName(existing.declaration); err != nil { + return nil, err + } + return existing.declaration, nil + } + + order := serviceNameOrder(id) + order.api = api + declaration, err := pkg.DeclareDependentName( + id.role.kind(), + base, + prefix, + suffix, + order, + ) + if err != nil { + return nil, err + } + n[id] = serviceName{ + base: base, + prefix: prefix, + suffix: suffix, + declaration: declaration, + } + return declaration, nil +} + +// declaration returns the NameDeclaration previously stored for id. It panics +// when name collection did not submit that id. +func (n serviceNames) declaration(id serviceSymbolID) *codegen.NameDeclaration { + name, ok := n[id] + if !ok { + panic(fmt.Sprintf("service symbol role %d was not declared", id.role)) + } + return name.declaration +} + +// transformDataTypeName returns the design name and ID used to order one side +// of a generated conversion helper. +func transformDataTypeName(dataType expr.DataType) (string, string) { + if userType, ok := dataType.(expr.UserType); ok { + return userType.Name(), userType.ID() + } + return dataType.Name(), "" +} + +// canonicalValidatorView returns an empty string for the default result view +// so it matches validation calls that omit a view name. +func canonicalValidatorView(view string) string { + if view == expr.DefaultView { + return "" + } + return view +} diff --git a/codegen/service/service_names_test.go b/codegen/service/service_names_test.go new file mode 100644 index 0000000000..f10ff39310 --- /dev/null +++ b/codegen/service/service_names_test.go @@ -0,0 +1,191 @@ +// This file verifies the typed package-level declaration identities used by +// retained service plans before any generated source is rendered. +package service + +import ( + "fmt" + "testing" + + "github.com/stretchr/testify/require" + + "goa.design/goa/v3/codegen" + "goa.design/goa/v3/expr" +) + +// TestServiceNamesAreIndependentOfDiscoveryOrder verifies that stable semantic +// identities, rather than traversal order, decide collision suffixes. +func TestServiceNamesAreIndependentOfDiscoveryOrder(t *testing.T) { + ids := []serviceSymbolID{ + {role: serviceValidatorNameRole, service: "calc", subject: "Result"}, + {role: serviceErrorConstructorNameRole, service: "calc", subject: "Result"}, + {role: serviceMethodEndpointNameRole, service: "calc", method: "add"}, + } + + generate := func(order []int) map[serviceSymbolID]string { + generation := mustTestGeneration(t, "generated.local/gen", nil) + pkg := mustClaimTestPackage(t, generation, "generated.local/gen/calc") + names := make(serviceNames) + for _, index := range order { + _, err := names.declare(pkg, ids[index], "Build") + require.NoError(t, err) + } + require.NoError(t, generation.Freeze()) + result := make(map[serviceSymbolID]string, len(ids)) + for _, id := range ids { + result[id] = names.declaration(id).Name() + } + return result + } + + require.Equal(t, generate([]int{0, 1, 2}), generate([]int{2, 0, 1})) +} + +// TestServiceNamesShareTheAuthoredPackageNamespace verifies that generated +// functions collide with exact authored types and with one another in the one +// namespace enforced by the Go compiler. +func TestServiceNamesShareTheAuthoredPackageNamespace(t *testing.T) { + generation := mustTestGeneration(t, "generated.local/gen", nil) + pkg := mustClaimTestPackage(t, generation, "generated.local/gen/calc") + authored := &expr.UserTypeExpr{ + AttributeExpr: &expr.AttributeExpr{Type: expr.String}, + TypeName: "ValidateResult", + UID: "authored-validate-result", + } + authoredDeclaration, err := pkg.DeclareUserType(authored) + require.NoError(t, err) + + names := make(serviceNames) + validator, err := names.declare(pkg, serviceSymbolID{ + role: serviceValidatorNameRole, + service: "calc", + subject: "Result", + }, "ValidateResult") + require.NoError(t, err) + constructor, err := names.declare(pkg, serviceSymbolID{ + role: serviceErrorConstructorNameRole, + service: "calc", + subject: "Result", + }, "ValidateResult") + require.NoError(t, err) + + require.NoError(t, generation.Freeze()) + require.Equal(t, "ValidateResult", authoredDeclaration.Name()) + require.Equal(t, "ValidateResult2", constructor.Name()) + require.Equal(t, "ValidateResult3", validator.Name()) +} + +// TestServiceNamesOwnOneCanonicalDeclaration verifies that rebuilding an exact +// semantic identity returns the original record and rejects changed spelling +// or ownership. +func TestServiceNamesOwnOneCanonicalDeclaration(t *testing.T) { + generation := mustTestGeneration(t, "generated.local/gen", nil) + servicePackage := mustClaimTestPackage(t, generation, "generated.local/gen/calc") + viewsPackage := mustClaimTestPackage(t, generation, "generated.local/gen/calc/views") + names := make(serviceNames) + id := serviceSymbolID{role: serviceViewConstructorNameRole, service: "calc", subject: "Result"} + + first, err := names.declare(servicePackage, id, "NewViewedResult") + require.NoError(t, err) + second, err := names.declare(servicePackage, id, "NewViewedResult") + require.NoError(t, err) + require.Same(t, first, second) + + _, err = names.declare(servicePackage, id, "NewResultView") + require.ErrorContains(t, err, "cannot declare both") + _, err = names.declare(viewsPackage, id, "NewViewedResult") + require.ErrorContains(t, err, "already belongs") +} + +// TestServiceNamesDeriveCompanionsFromFrozenTypes verifies validators follow +// the exact projected type declaration when that type receives a suffix. +func TestServiceNamesDeriveCompanionsFromFrozenTypes(t *testing.T) { + generation := mustTestGeneration(t, "generated.local/gen", nil) + pkg := mustClaimTestPackage(t, generation, "generated.local/gen/calc/views") + require.NoError(t, pkg.DeclareName(codegen.NewExactName(codegen.NameType, "Result"))) + base := codegen.NewPreferredName(codegen.NameType, "Result", codegen.ExportedName, serviceNameOrder{ + role: serviceInterfaceNameRole, + service: "calc", + subject: "result", + }) + require.NoError(t, pkg.DeclareName(base)) + names := make(serviceNames) + validator, err := names.declareDependent(pkg, serviceSymbolID{ + role: serviceValidatorNameRole, + service: "calc", + subject: "result", + }, base, "Validate", "") + require.NoError(t, err) + + require.NoError(t, generation.Freeze()) + require.Equal(t, "Result2", base.Name()) + require.Equal(t, "ValidateResult2", validator.Name()) +} + +// TestServiceNameRolesOwnDeclarationKinds verifies that the closed service +// symbol family, not an individual caller, selects each Go declaration kind. +func TestServiceNameRolesOwnDeclarationKinds(t *testing.T) { + tests := []struct { + role serviceNameRole + kind codegen.PackageNameKind + }{ + {serviceInterfaceNameRole, codegen.NameType}, + {serviceAutherNameRole, codegen.NameType}, + {serviceAPINameRole, codegen.NameConstant}, + {serviceAPIVersionNameRole, codegen.NameConstant}, + {serviceNameConstantRole, codegen.NameConstant}, + {serviceMethodNamesRole, codegen.NameVariable}, + {serviceServerStreamNameRole, codegen.NameType}, + {serviceClientStreamNameRole, codegen.NameType}, + {serviceErrorConstructorNameRole, codegen.NameFunction}, + {serviceViewConstructorNameRole, codegen.NameFunction}, + {servicePrivateProjectionConstructorNameRole, codegen.NameFunction}, + {serviceValidatorNameRole, codegen.NameFunction}, + {serviceViewMapNameRole, codegen.NameVariable}, + {serviceEndpointsNameRole, codegen.NameType}, + {serviceNewEndpointsNameRole, codegen.NameFunction}, + {serviceClientNameRole, codegen.NameType}, + {serviceNewClientNameRole, codegen.NameFunction}, + {serviceMethodEndpointNameRole, codegen.NameFunction}, + {serviceEndpointInputNameRole, codegen.NameType}, + {serviceRequestNameRole, codegen.NameType}, + {serviceResponseNameRole, codegen.NameType}, + {serviceServerInterceptorsNameRole, codegen.NameType}, + {serviceClientInterceptorsNameRole, codegen.NameType}, + {serviceInterceptorInfoNameRole, codegen.NameType}, + {serviceInterceptorPayloadNameRole, codegen.NameType}, + {serviceInterceptorResultNameRole, codegen.NameType}, + {serviceInterceptorStreamingPayloadNameRole, codegen.NameType}, + {serviceInterceptorStreamingResultNameRole, codegen.NameType}, + {serviceInterceptorPayloadAccessNameRole, codegen.NameType}, + {serviceInterceptorResultAccessNameRole, codegen.NameType}, + {serviceInterceptorStreamingPayloadAccessNameRole, codegen.NameType}, + {serviceInterceptorStreamingResultAccessNameRole, codegen.NameType}, + {serviceInterceptorMethodInfoNameRole, codegen.NameType}, + {serviceInterceptorServerUnaryInfoNameRole, codegen.NameType}, + {serviceInterceptorClientUnaryInfoNameRole, codegen.NameType}, + {serviceInterceptorStreamingSendInfoNameRole, codegen.NameType}, + {serviceInterceptorStreamingRecvInfoNameRole, codegen.NameType}, + {serviceServerEndpointWrapperNameRole, codegen.NameFunction}, + {serviceClientEndpointWrapperNameRole, codegen.NameFunction}, + {serviceServerInterceptorWrapperNameRole, codegen.NameFunction}, + {serviceClientInterceptorWrapperNameRole, codegen.NameFunction}, + {serviceServerStreamWrapperNameRole, codegen.NameType}, + {serviceClientStreamWrapperNameRole, codegen.NameType}, + {serviceTransformHelperNameRole, codegen.NameFunction}, + } + + for _, test := range tests { + t.Run(fmt.Sprintf("role-%d", test.role), func(t *testing.T) { + generation := mustTestGeneration(t, "generated.local/gen", nil) + pkg := mustClaimTestPackage(t, generation, "generated.local/gen/calc") + names := make(serviceNames) + declaration, err := names.declare(pkg, serviceSymbolID{ + role: test.role, + service: "calc", + subject: "result", + }, "Symbol") + require.NoError(t, err) + require.Equal(t, test.kind, declaration.Kind()) + }) + } +} diff --git a/codegen/service/service_package_path.go b/codegen/service/service_package_path.go new file mode 100644 index 0000000000..ed3487fc10 --- /dev/null +++ b/codegen/service/service_package_path.go @@ -0,0 +1,96 @@ +// This file assigns every generated service package path once for a complete +// planning run. Later planning phases read the retained path instead of +// rebuilding it from a service name. +package service + +import ( + "fmt" + "path" + "sort" + "strconv" + + "goa.design/goa/v3/codegen" + "goa.design/goa/v3/expr" +) + +type ( + // serviceDesignID identifies declarations contributed by one service in one + // API. Two roots with the same value cannot be ordered without another design + // fact, so planning rejects them. + serviceDesignID struct { + api string + service string + } +) + +// allocateServicePackagePaths returns one generated package path for every +// exact service name in inputs. Exact names share a path across APIs. Different +// names that produce the same normal path receive numeric suffixes ordered by +// their authored names. +func allocateServicePackagePaths(genpkg string, inputs []PlanInput) (map[string]string, error) { + names := make(map[string]struct{}) + designs := make(map[serviceDesignID]*expr.RootExpr) + for _, input := range inputs { + for _, service := range input.Root.Services { + identity := serviceDesignID{api: input.Root.API.Name, service: service.Name} + if root := designs[identity]; root != nil && root != input.Root { + return nil, fmt.Errorf( + "service %q in API %q is planned by more than one root", + service.Name, + input.Root.API.Name, + ) + } + designs[identity] = input.Root + names[service.Name] = struct{}{} + } + } + + orderedNames := make([]string, 0, len(names)) + groups := make(map[string][]string) + reserved := make(map[string]struct{}) + for name := range names { + orderedNames = append(orderedNames, name) + base := servicePackageName(name) + groups[base] = append(groups[base], name) + reserved[base] = struct{}{} + } + sort.Strings(orderedNames) + for _, names := range groups { + sort.Strings(names) + } + + assignedNames := make(map[string]string, len(names)) + used := make(map[string]struct{}, len(names)) + for _, name := range orderedNames { + base := servicePackageName(name) + if groups[base][0] == name { + assignedNames[name] = base + used[base] = struct{}{} + continue + } + for suffix := 2; ; suffix++ { + candidate := base + strconv.Itoa(suffix) + if _, exists := reserved[candidate]; exists { + continue + } + if _, exists := used[candidate]; exists { + continue + } + assignedNames[name] = candidate + used[candidate] = struct{}{} + break + } + } + + paths := make(map[string]string, len(assignedNames)) + for name, packageName := range assignedNames { + paths[name] = path.Join(genpkg, packageName) + } + return paths, nil +} + +// servicePackageName returns the package directory naturally produced by one +// authored service name before collisions are resolved. +func servicePackageName(name string) string { + return codegen.SnakeCase(codegen.Goify(name, false)) +} diff --git a/codegen/service/service_package_path_test.go b/codegen/service/service_package_path_test.go new file mode 100644 index 0000000000..9cdb0454f0 --- /dev/null +++ b/codegen/service/service_package_path_test.go @@ -0,0 +1,154 @@ +// This file verifies one complete service planning run assigns generated +// package paths before any service declarations are collected. +package service + +import ( + "path" + "testing" + + "github.com/stretchr/testify/require" + + "goa.design/goa/v3/codegen" + "goa.design/goa/v3/dsl" + "goa.design/goa/v3/eval" + "goa.design/goa/v3/expr" +) + +type ( + // servicePathTestInput names one API and service included in a complete + // planning run. + servicePathTestInput struct { + api string + service string + } +) + +// TestNewPlansAssignsStableServicePackagePaths verifies distinct service names +// that have the same normal path receive stable unique paths. A service whose +// name naturally contains a numeric suffix keeps that path. +func TestNewPlansAssignsStableServicePackagePaths(t *testing.T) { + forward := plannedServicePackagePaths(t, []servicePathTestInput{ + {api: "dash api", service: "read-value"}, + {api: "underscore api", service: "read_value"}, + {api: "numbered api", service: "read_value2"}, + }) + reverse := plannedServicePackagePaths(t, []servicePathTestInput{ + {api: "numbered api", service: "read_value2"}, + {api: "underscore api", service: "read_value"}, + {api: "dash api", service: "read-value"}, + }) + + require.Equal(t, forward, reverse) + require.Equal(t, map[string]string{ + "read-value": "generated.local/gen/read_value", + "read_value": "generated.local/gen/read_value3", + "read_value2": "generated.local/gen/read_value2", + }, forward) +} + +// TestNewPlansSharesServicePackagePathAcrossRoots verifies the same exact +// service name uses one generated package when two APIs contribute to it. +func TestNewPlansSharesServicePackagePathAcrossRoots(t *testing.T) { + forward := plannedServicePackagePaths(t, []servicePathTestInput{ + {api: "first api", service: "Shared"}, + {api: "second api", service: "Shared"}, + }) + reverse := plannedServicePackagePaths(t, []servicePathTestInput{ + {api: "second api", service: "Shared"}, + {api: "first api", service: "Shared"}, + }) + + require.Equal(t, forward, reverse) + require.Equal(t, map[string]string{ + "first api/Shared": "generated.local/gen/shared", + "second api/Shared": "generated.local/gen/shared", + }, forward) +} + +// TestNewPlansRejectsRepeatedAPIService verifies two roots cannot contribute +// indistinguishable declarations for the same API and service. +func TestNewPlansRejectsRepeatedAPIService(t *testing.T) { + first := servicePathTestRoot(t, "same api", "Shared") + second := servicePathTestRoot(t, "same api", "Shared") + generation := mustTestGeneration(t, "generated.local/gen", []eval.Root{first, second}) + + _, err := NewPlans( + generation, + PlanInput{Root: first, Examples: expr.NewExampleGenerator(first.API.RandomizerFactory)}, + PlanInput{Root: second, Examples: expr.NewExampleGenerator(second.API.RandomizerFactory)}, + ) + + require.EqualError(t, err, `service "Shared" in API "same api" is planned by more than one root`) +} + +// TestNewPlansKeepsNoncollidingServicePackagePaths verifies ordinary service +// names keep the paths generated by earlier Goa versions. +func TestNewPlansKeepsNoncollidingServicePackagePaths(t *testing.T) { + paths := plannedServicePackagePaths(t, []servicePathTestInput{ + {api: "storage api", service: "Storage"}, + {api: "audit api", service: "AuditLog"}, + }) + + require.Equal(t, map[string]string{ + "Storage": "generated.local/gen/storage", + "AuditLog": "generated.local/gen/audit_log", + }, paths) +} + +// plannedServicePackagePaths builds and freezes one complete planning run, then +// returns the retained package path for every input service. +func plannedServicePackagePaths(t *testing.T, inputs []servicePathTestInput) map[string]string { + t.Helper() + roots := make([]*expr.RootExpr, len(inputs)) + evaluated := make([]eval.Root, len(inputs)) + planInputs := make([]PlanInput, len(inputs)) + for index, input := range inputs { + root := servicePathTestRoot(t, input.api, input.service) + roots[index] = root + evaluated[index] = root + planInputs[index] = PlanInput{ + Root: root, + Examples: expr.NewExampleGenerator(root.API.RandomizerFactory), + } + } + generation := mustTestGeneration(t, "generated.local/gen", evaluated) + plans, err := NewPlans(generation, planInputs...) + require.NoError(t, err) + require.NoError(t, generation.Freeze()) + + paths := make(map[string]string, len(inputs)) + sharedNames := make(map[string]int) + for _, input := range inputs { + sharedNames[input.service]++ + } + for index, plan := range plans { + service := roots[index].Service(inputs[index].service) + serviceImport, _, err := plan.ServicePackageImports(service) + require.NoError(t, err) + require.NoError(t, plan.Link()) + require.Equal(t, path.Base(serviceImport.Path), plan.Services().Get(service.Name).PathName) + key := inputs[index].service + if sharedNames[key] > 1 { + key = inputs[index].api + "/" + key + } + paths[key] = serviceImport.Path + } + return paths +} + +// servicePathTestRoot creates one evaluated design with a single service and +// gives its API the identity used to order shared package declarations. +func servicePathTestRoot(t *testing.T, api, service string) *expr.RootExpr { + t.Helper() + root := codegen.RunDSL(t, func() { + dsl.Service(service, func() { + dsl.Method("Read", func() { + dsl.Payload(func() { + dsl.Attribute("value", dsl.String) + }) + }) + }) + }) + root.API.Name = api + return root +} diff --git a/codegen/service/service_plan_compile_contract_test.go b/codegen/service/service_plan_compile_contract_test.go new file mode 100644 index 0000000000..aaf78b7cfd --- /dev/null +++ b/codegen/service/service_plan_compile_contract_test.go @@ -0,0 +1,273 @@ +// This file compiles generated service, views, and starter implementation +// packages for nested validation collisions shaped like AURA tool contracts. +package service + +import ( + "context" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/require" + + "goa.design/goa/v3/codegen" + "goa.design/goa/v3/codegen/service/testdata" + "goa.design/goa/v3/codegen/testutil" + "goa.design/goa/v3/dsl" + "goa.design/goa/v3/eval" + "goa.design/goa/v3/expr" +) + +// TestServicePackageNameUsesClaimedImportPath verifies service package names +// remain lowercase after the final generated import path is claimed. +func TestServicePackageNameUsesClaimedImportPath(t *testing.T) { + root := codegen.RunDSL(t, func() { + dsl.Service("api_key_service", func() { + dsl.Method("Read", func() { + dsl.Payload(func() { + dsl.OneOf("credential", func() { + dsl.Attribute("api_key", dsl.String) + dsl.Attribute("token", dsl.String) + }) + dsl.Required("credential") + }) + }) + }) + }) + plan := retainedServicePlanForPackage(t, root) + require.Equal(t, "apikeyservice", plan.Services().Get("api_key_service").PkgName) + + files, err := Files(plan) + require.NoError(t, err) + directory := t.TempDir() + for _, file := range files { + _, err := file.Render(directory) + require.NoError(t, err) + } + source, err := os.ReadFile(filepath.Join(directory, codegen.Gendir, "api_key_service", "service.go")) + require.NoError(t, err) + require.Contains(t, string(source), "package apikeyservice") + unionSource, err := os.ReadFile(filepath.Join(directory, codegen.Gendir, "api_key_service", "unions.go")) + require.NoError(t, err) + require.Contains(t, string(unionSource), "package apikeyservice") +} + +// TestRepeatedInlineMethodErrorsCompile verifies equivalent method errors use +// one generated public error declaration. +func TestRepeatedInlineMethodErrorsCompile(t *testing.T) { + root := codegen.RunDSL(t, testdata.RepeatedInlineErrorsDSL) + plan := retainedServicePlanForPackage(t, root) + files, err := Files(plan) + require.NoError(t, err) + compileGeneratedServiceFiles(t, files) + + rendered := renderedServiceFiles(t, files) + serviceSource := string(rendered[filepath.Join(codegen.Gendir, "secured", "service.go")]) + require.Equal(t, 1, strings.Count(serviceSource, "type InvalidScopes string")) +} + +// TestExtendedLocatedUnionBranchAliasesCompile verifies that Goa writes each +// compiler-created branch type once when several relocated payloads extend the +// same OneOf owner. +func TestExtendedLocatedUnionBranchAliasesCompile(t *testing.T) { + root := codegen.RunDSL(t, func() { + owner := dsl.Type("RunOwner", func() { + dsl.Meta("struct:pkg:path", "types") + dsl.Meta("type:generate:force") + dsl.OneOf("owner", func() { + dsl.Attribute("session_id", dsl.String, func() { + dsl.MinLength(1) + }) + dsl.Attribute("task_id", dsl.String, func() { + dsl.MinLength(1) + }) + }) + dsl.Required("owner") + }) + first := dsl.Type("FirstPayload", func() { + dsl.Meta("struct:pkg:path", "types") + dsl.Extend(owner) + dsl.Attribute("first", dsl.String) + }) + second := dsl.Type("SecondPayload", func() { + dsl.Meta("struct:pkg:path", "types") + dsl.Extend(owner) + dsl.Attribute("second", dsl.String) + }) + dsl.Service("Runs", func() { + dsl.Method("First", func() { + dsl.Payload(first) + }) + dsl.Method("Second", func() { + dsl.Payload(second) + }) + }) + }) + + plan := retainedServicePlanForPackage(t, root) + files, err := Files(plan) + require.NoError(t, err) + compileGeneratedServiceFiles(t, files) + + rendered := renderedServiceFiles(t, files) + for _, name := range []string{"owner_session_id.go", "owner_task_id.go", "unions.go"} { + path := filepath.Join(codegen.Gendir, "types", name) + source, ok := rendered[path] + require.True(t, ok, "generated file %s", path) + testutil.AssertGo( + t, + filepath.Join("testdata", "golden", "service_extended_located_union_"+name+".golden"), + string(source), + ) + } +} + +// TestNestedViewValidatorCollisionCompiles catches a parent validator that +// reconstructs its child's preferred name after the child function was +// suffixed by another projected declaration in the views package. +func TestNestedViewValidatorCollisionCompiles(t *testing.T) { + root := codegen.RunDSL(t, func() { + child := dsl.ResultType("application/vnd.child", func() { + dsl.TypeName("Child") + dsl.Attribute("name", dsl.String, func() { + dsl.MinLength(1) + }) + dsl.Required("name") + dsl.View("default", func() { + dsl.Attribute("name") + }) + }) + collision := dsl.Type("ValidateChild", func() { + dsl.Attribute("value", dsl.String) + dsl.Required("value") + }) + parent := dsl.ResultType("application/vnd.parent", func() { + dsl.TypeName("Parent") + dsl.Attribute("child", child) + dsl.Attribute("children", dsl.CollectionOf(child)) + dsl.Attribute("validator_name_collision", collision) + dsl.Required("child", "children", "validator_name_collision") + dsl.View("default", func() { + dsl.Attribute("child") + dsl.Attribute("children") + dsl.Attribute("validator_name_collision") + }) + }) + dsl.Service("Values", func() { + dsl.Method("Read", func() { + dsl.Result(parent) + }) + }) + }) + + plan := retainedServicePlanForPackage(t, root) + data := plan.Services().Get("Values") + var childValidation, parentValidation *ValidateData + for _, projected := range data.projectedTypes { + for _, validation := range projected.Validations { + switch projected.Name { + case "ChildView": + childValidation = validation + case "ParentView": + parentValidation = validation + } + } + } + require.NotNil(t, childValidation) + require.NotNil(t, parentValidation) + require.NotEmpty(t, parentValidation.Calls) + require.Same(t, childValidation.Declaration, parentValidation.Calls[0].Declaration) + require.Equal(t, "ValidateChildView2", childValidation.Declaration.Name()) + + files, err := Files(plan) + require.NoError(t, err) + files = append(files, ExampleServiceFiles(plan)...) + compileGeneratedServiceFiles(t, files) +} + +// TestMixedResultStarterCompiles checks that a fresh starter implements the +// service method that returns one normal result and may also send stream values. +func TestMixedResultStarterCompiles(t *testing.T) { + cases := []struct { + Name string + DSL func() + }{ + {"result and stream", testdata.MixedResultsEndpointDSL}, + {"result view and stream", testdata.MixedResultsWithViewsEndpointDSL}, + } + for _, c := range cases { + t.Run(c.Name, func(t *testing.T) { + root := codegen.RunDSL(t, c.DSL) + plan := retainedServicePlanForPackage(t, root) + files, err := Files(plan) + require.NoError(t, err) + files = append(files, ExampleServiceFiles(plan)...) + compileGeneratedServiceFiles(t, files) + }) + } +} + +// retainedServicePlanForPackage builds and links service generation data using +// the import path shared by these compilation tests. +func retainedServicePlanForPackage(t *testing.T, root *expr.RootExpr) *Plan { + t.Helper() + generation, err := codegen.NewGeneration("generated.local/gen", []eval.Root{root}) + require.NoError(t, err) + plan, err := NewPlan(root, generation, expr.NewExampleGenerator(root.API.RandomizerFactory)) + require.NoError(t, err) + require.NoError(t, generation.Freeze()) + require.NoError(t, plan.Link()) + return plan +} + +// compileGeneratedServiceFiles renders files into a temporary module and runs +// the Go compiler against every generated package. +func compileGeneratedServiceFiles(t *testing.T, files []*codegen.File) { + compileGeneratedServiceFilesWith(t, files, nil) +} + +// compileGeneratedServiceFilesWith renders files and additional test source +// into a temporary module, then runs every generated package test. +func compileGeneratedServiceFilesWith(t *testing.T, files []*codegen.File, additional map[string]string) { + t.Helper() + directory := t.TempDir() + goaRoot := serviceModuleDirectory(t, "goa.design/goa/v3") + // Relocated types may import Clue. Pin its Go 1.25-compatible release so + // these fixtures continue testing Goa's minimum supported toolchain. + module := "module generated.local\n\ngo 1.25.0\n\n" + + "require (\n\tgoa.design/goa/v3 v3.0.0\n\tgoa.design/clue v1.2.6\n)\n\n" + + "replace goa.design/goa/v3 => " + filepath.ToSlash(goaRoot) + "\n" + require.NoError(t, os.WriteFile(filepath.Join(directory, "go.mod"), []byte(module), 0o600)) + for _, file := range files { + _, err := file.Render(directory) + require.NoError(t, err) + } + for path, source := range additional { + fullPath := filepath.Join(directory, path) + require.NoError(t, os.MkdirAll(filepath.Dir(fullPath), 0o700)) + require.NoError(t, os.WriteFile(fullPath, []byte(source), 0o600)) + } + + ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) + defer cancel() + command := exec.CommandContext(ctx, "go", "test", "-mod=mod", "./...") + command.Dir = directory + command.Env = append(os.Environ(), "GOWORK=off") + output, err := command.CombinedOutput() + require.NoError(t, err, "compile generated packages:\n%s", output) +} + +// serviceModuleDirectory resolves the local checkout for a module used by a +// temporary generated module. +func serviceModuleDirectory(t *testing.T, module string) string { + t.Helper() + command := exec.Command("go", "list", "-m", "-f", "{{.Dir}}", module) + output, err := command.CombinedOutput() + require.NoError(t, err, "resolve module %s:\n%s", module, output) + directory := strings.TrimSpace(string(output)) + require.NotEmpty(t, directory) + return directory +} diff --git a/codegen/service/service_plan_render_contract_test.go b/codegen/service/service_plan_render_contract_test.go new file mode 100644 index 0000000000..c065770ca3 --- /dev/null +++ b/codegen/service/service_plan_render_contract_test.go @@ -0,0 +1,224 @@ +// This file verifies retained service plans aggregate shared packages +// deterministically and render without changing their declaration catalogs. +package service + +import ( + "bytes" + "path/filepath" + "sort" + "testing" + + "github.com/stretchr/testify/require" + + "goa.design/goa/v3/codegen" + "goa.design/goa/v3/dsl" + "goa.design/goa/v3/eval" + "goa.design/goa/v3/expr" +) + +// TestFilesRejectPlansFromDifferentGenerations verifies that aggregation cannot +// render declarations through a package catalog that did not plan them. +func TestFilesRejectPlansFromDifferentGenerations(t *testing.T) { + first := mustTestGeneration(t, "example.com/first/gen", nil) + second := mustTestGeneration(t, "example.com/second/gen", nil) + + _, err := Files(&Plan{generation: first}, &Plan{generation: second}) + require.ErrorContains(t, err, "different generations") +} + +type retainedServiceNameID struct { + root int + service string + symbol serviceSymbolID +} + +// TestServicePlansRenderByteIdenticallyAcrossRootAndServiceOrder catches +// shared-package names or section order that depend on discovery order. +func TestServicePlansRenderByteIdenticallyAcrossRootAndServiceOrder(t *testing.T) { + forwardPlans := orderedServicePlans(t, false) + forwardFiles, err := Files(forwardPlans...) + require.NoError(t, err) + forward := renderedServiceFiles(t, forwardFiles) + + reversePlans := orderedServicePlans(t, true) + reverseFiles, err := Files(reversePlans...) + require.NoError(t, err) + reverse := renderedServiceFiles(t, reverseFiles) + + requireRenderedServiceFilesEqual(t, forward, reverse) + requireRenderedServiceFile(t, forward, filepath.Join(codegen.Gendir, "types", "alpha_envelope.go")) + requireRenderedServiceFile(t, forward, filepath.Join(codegen.Gendir, "types", "beta_envelope.go")) + requireRenderedServiceFile(t, forward, filepath.Join(codegen.Gendir, "types", "omega_envelope.go")) + requireRenderedServiceFile(t, forward, filepath.Join(codegen.Gendir, "types", "unions.go")) + + compileFiles := append([]*codegen.File(nil), forwardFiles...) + for _, plan := range forwardPlans { + compileFiles = append(compileFiles, ExampleServiceFiles(plan)...) + } + compileGeneratedServiceFiles(t, compileFiles) +} + +// TestServicePlanRenderingIsPure catches renderers that rebuild analysis, +// replace retained declaration records, or append sections on a second read. +func TestServicePlanRenderingIsPure(t *testing.T) { + plans := orderedServicePlans(t, false) + before := retainedServiceNamePointers(plans) + firstFiles, err := Files(plans...) + require.NoError(t, err) + first := renderedServiceFiles(t, firstFiles) + + secondFiles, err := Files(plans...) + require.NoError(t, err) + second := renderedServiceFiles(t, secondFiles) + after := retainedServiceNamePointers(plans) + + require.Equal(t, first, second) + require.Len(t, after, len(before)) + for id, declaration := range before { + require.Same(t, declaration, after[id], "service symbol changed: %+v", id) + } +} + +// requireRenderedServiceFile reports a missing shared-package contribution +// without printing the complete generated output map. +func requireRenderedServiceFile(t *testing.T, files map[string][]byte, path string) { + t.Helper() + _, exists := files[path] + require.True(t, exists, "missing generated file %s", path) +} + +// requireRenderedServiceFilesEqual compares the same sorted output paths one +// at a time so an order-dependent package reports the precise changed file. +func requireRenderedServiceFilesEqual(t *testing.T, expected, actual map[string][]byte) { + t.Helper() + expectedPaths := make([]string, 0, len(expected)) + actualPaths := make([]string, 0, len(actual)) + for path := range expected { + expectedPaths = append(expectedPaths, path) + } + for path := range actual { + actualPaths = append(actualPaths, path) + } + sort.Strings(expectedPaths) + sort.Strings(actualPaths) + require.Equal(t, expectedPaths, actualPaths) + for _, path := range expectedPaths { + require.Equal(t, string(expected[path]), string(actual[path]), path) + } +} + +// orderedServicePlans builds equivalent fresh designs with both root and +// service discovery reversed, then runs collection, freeze, and link once. +func orderedServicePlans(t *testing.T, reverse bool) []*Plan { + t.Helper() + first := orderedServiceRoot(t, reverse) + second := singleServiceRoot(t) + roots := []*expr.RootExpr{first, second} + if reverse { + roots[0], roots[1] = roots[1], roots[0] + } + evaluated := make([]eval.Root, len(roots)) + for index, root := range roots { + evaluated[index] = root + } + generation, err := codegen.NewGeneration("generated.local/gen", evaluated) + require.NoError(t, err) + inputs := make([]PlanInput, len(roots)) + for index, root := range roots { + inputs[index] = PlanInput{Root: root, Examples: expr.NewExampleGenerator(root.API.RandomizerFactory)} + } + plans, err := NewPlans(generation, inputs...) + require.NoError(t, err) + require.NoError(t, generation.Freeze()) + for _, plan := range plans { + require.NoError(t, plan.Link()) + } + return plans +} + +// orderedServiceRoot defines two services that emit separately named unions +// into one relocated package. +func orderedServiceRoot(t *testing.T, reverse bool) *expr.RootExpr { + t.Helper() + return codegen.RunDSL(t, func() { + alpha := relocatedOrderType("AlphaEnvelope", "text", dsl.String) + omega := relocatedOrderType("OmegaEnvelope", "count", dsl.Int) + alphaService := func() { + dsl.Service("Alpha", func() { + dsl.Method("Read", func() { dsl.Payload(alpha) }) + }) + } + omegaService := func() { + dsl.Service("Omega", func() { + dsl.Method("Read", func() { dsl.Payload(omega) }) + }) + } + if reverse { + omegaService() + alphaService() + } else { + alphaService() + omegaService() + } + }) +} + +// singleServiceRoot defines the second root contributing to the same +// relocated package used by orderedServiceRoot. +func singleServiceRoot(t *testing.T) *expr.RootExpr { + t.Helper() + return codegen.RunDSL(t, func() { + beta := relocatedOrderType("BetaEnvelope", "enabled", dsl.Boolean) + dsl.Service("Beta", func() { + dsl.Method("Read", func() { dsl.Payload(beta) }) + }) + }) +} + +// relocatedOrderType creates one force-generated type with an exact union name +// in the shared generated types package. +func relocatedOrderType(name, branch string, dataType expr.DataType) expr.UserType { + return dsl.Type(name, func() { + dsl.Meta("struct:pkg:path", "types") + dsl.Meta("type:generate:force") + dsl.OneOf("Value", func() { + dsl.TypeName(name + "Value") + dsl.Attribute(branch, dataType) + }) + }) +} + +// renderedServiceFiles executes every retained section and indexes the exact +// bytes by output path, rejecting duplicate contributions. +func renderedServiceFiles(t *testing.T, files []*codegen.File) map[string][]byte { + t.Helper() + rendered := make(map[string][]byte, len(files)) + for _, file := range files { + _, duplicate := rendered[file.Path] + require.False(t, duplicate, "duplicate generated file %s", file.Path) + var buffer bytes.Buffer + for _, section := range file.SectionTemplates { + require.NoError(t, section.Write(&buffer)) + } + rendered[file.Path] = bytes.Clone(buffer.Bytes()) + } + return rendered +} + +// retainedServiceNamePointers snapshots the exact declaration records held by +// each retained plan so rendering cannot replace them unnoticed. +func retainedServiceNamePointers(plans []*Plan) map[retainedServiceNameID]*codegen.NameDeclaration { + pointers := make(map[retainedServiceNameID]*codegen.NameDeclaration) + for rootIndex, plan := range plans { + for _, facts := range plan.facts.services { + for id, name := range facts.names { + pointers[retainedServiceNameID{ + root: rootIndex, + service: facts.service.Name, + symbol: id, + }] = name.declaration + } + } + } + return pointers +} diff --git a/codegen/service/service_test.go b/codegen/service/service_test.go index 2e0b23a17e..ae988617a2 100644 --- a/codegen/service/service_test.go +++ b/codegen/service/service_test.go @@ -1,9 +1,12 @@ +// This file verifies service render analysis and the generated service files +// built from its immutable data. package service import ( "bytes" "go/format" "path/filepath" + "slices" "strings" "testing" @@ -12,8 +15,823 @@ import ( "goa.design/goa/v3/codegen" "goa.design/goa/v3/codegen/service/testdata" "goa.design/goa/v3/codegen/testutil" + "goa.design/goa/v3/dsl" + "goa.design/goa/v3/eval" + "goa.design/goa/v3/expr" ) +func TestServicesDataUsesFrozenPackageDeclarations(t *testing.T) { + var shared expr.UserType + root := codegen.RunDSL(t, func() { + shared = dsl.Type("Shared", func() { + dsl.Meta("struct:pkg:path", "types") + dsl.OneOf("Value", func() { + dsl.Attribute("text", dsl.String) + }) + }) + first := dsl.Type("FirstPayload", func() { + dsl.Attribute("shared", shared) + }) + second := dsl.Type("SecondPayload", func() { + dsl.Attribute("shared", shared) + }) + dsl.Service("First", func() { + dsl.Method("Read", func() { + dsl.Payload(first) + }) + }) + dsl.Service("Second", func() { + dsl.Method("Read", func() { + dsl.Payload(second) + }) + }) + }) + + generation := mustTestGeneration(t, "goa.design/goa/example", []eval.Root{root}) + plan, err := NewPlan(root, generation, expr.NewExampleGenerator(root.API.RandomizerFactory)) + require.NoError(t, err) + require.Panics(t, func() { plan.Services() }) + require.NoError(t, generation.Freeze()) + require.NoError(t, plan.Link()) + services := plan.Services() + + first := services.Get("First") + second := services.Get("Second") + firstShared := findUserTypeData(first.userTypes, shared) + secondShared := findUserTypeData(second.userTypes, shared) + require.NotNil(t, firstShared) + require.NotNil(t, secondShared) + require.Same(t, firstShared.Declaration, secondShared.Declaration) + require.Len(t, first.unions, 1) + require.Len(t, second.unions, 1) + require.Same(t, first.unions[0].TypeDeclaration, second.unions[0].TypeDeclaration) + require.Same(t, first.unions[0].KindDeclaration, second.unions[0].KindDeclaration) + require.Equal(t, "Value", first.unions[0].Name) + require.Equal(t, "ValueKind", first.unions[0].KindName) + + _, err = generation.Package("goa.design/goa/example/types").DeclareUserType(shared) + require.ErrorContains(t, err, "frozen") +} + +// TestPlanOwnsNormalizedMethodNames verifies that semantic wrappers receive +// names from the service package catalog and collide only with local exact +// declarations. +func TestPlanOwnsNormalizedMethodNames(t *testing.T) { + var local expr.UserType + root := codegen.RunDSL(t, func() { + local = dsl.Type("UsePayload", func() { + dsl.Attribute("existing", dsl.String) + }) + dsl.Service("Values", func() { + dsl.Method("Existing", func() { + dsl.Payload(local) + }) + dsl.Method("Use", func() { + dsl.Payload(func() { + dsl.Attribute("value", dsl.String) + }) + }) + }) + }) + generation := mustTestGeneration(t, "generated.local/gen", []eval.Root{root}) + require.NoError(t, planTestServices(root, generation)) + require.NoError(t, generation.Freeze()) + + service := root.Service("Values") + wrapper := service.Method("Use").Payload.Type.(expr.UserType) + declaration, err := generation.Package("generated.local/gen/values").Type(wrapper) + require.NoError(t, err) + require.Equal(t, "UsePayload2", declaration.Name()) +} + +// TestPlanPreservesGeneratedPackageClaims verifies that service planning +// rejects distinct metadata spellings before path normalization can merge +// their declarations into one output package. +func TestPlanPreservesGeneratedPackageClaims(t *testing.T) { + tests := []struct { + name string + firstPath string + secondPath string + contains string + }{ + {"normalized collision", "types", "domain/../types", "normalize to import path"}, + {"portable collision", "Types", "types", "case-insensitive filesystem"}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + root := codegen.RunDSL(t, func() { + first := dsl.Type("First", func() { + dsl.Meta("struct:pkg:path", test.firstPath) + dsl.Attribute("value", dsl.String) + }) + second := dsl.Type("Second", func() { + dsl.Meta("struct:pkg:path", test.secondPath) + dsl.Attribute("value", dsl.String) + }) + dsl.Service("Values", func() { + dsl.Method("First", func() { dsl.Payload(first) }) + dsl.Method("Second", func() { dsl.Payload(second) }) + }) + }) + generation := mustTestGeneration(t, "generated.local/gen", []eval.Root{root}) + + err := planTestServices(root, generation) + require.ErrorContains(t, err, test.contains) + }) + } +} + +// TestPlanRejectsInvalidGeneratedPackageLocations verifies that relative Goa +// metadata cannot escape its generated module or use filesystem separators in +// a Go import path. +func TestPlanRejectsInvalidGeneratedPackageLocations(t *testing.T) { + tests := []struct { + name string + location string + }{ + {"absolute", "/outside"}, + {"escape", "../outside"}, + {"backslash", `domain\types`}, + {"colon", "domain:types"}, + {"space", "domain types"}, + {"control", "domain\x00types"}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + root := codegen.RunDSL(t, func() { + value := dsl.Type("Value", func() { + dsl.Meta("struct:pkg:path", test.location) + dsl.Attribute("value", dsl.String) + }) + dsl.Service("Values", func() { + dsl.Method("Read", func() { dsl.Payload(value) }) + }) + }) + generation := mustTestGeneration(t, "generated.local/gen", []eval.Root{root}) + + require.Error(t, planTestServices(root, generation)) + }) + } +} + +// TestPlanIgnoresUnusedRelocatedTypes verifies that a type excluded from +// service output does not needlessly claim a package or contribute imports. +func TestPlanIgnoresUnusedRelocatedTypes(t *testing.T) { + root := codegen.RunDSL(t, func() { + dsl.Type("Unused", func() { + dsl.Meta("struct:pkg:path", "unused") + dsl.Attribute("value", dsl.String) + }) + dsl.Service("Values", func() { + dsl.Method("Read", func() {}) + }) + }) + generation := mustTestGeneration(t, "generated.local/gen", []eval.Root{root}) + + require.NoError(t, planTestServices(root, generation)) + require.NoError(t, generation.Freeze()) +} + +// TestFilesUseCanonicalOwnedOutputDirectory verifies that a lone noncanonical +// metadata spelling emits the declaration beneath its owned canonical package. +func TestFilesUseCanonicalOwnedOutputDirectory(t *testing.T) { + root := codegen.RunDSL(t, func() { + value := dsl.Type("Value", func() { + dsl.Meta("struct:pkg:path", "domain/../types") + dsl.Attribute("value", dsl.String) + }) + dsl.Service("Values", func() { + dsl.Method("Read", func() { dsl.Payload(value) }) + }) + }) + plan := mustServicePlan(t, root) + + require.NotNil(t, findFile(mustServiceFiles(t, plan), + filepath.Join("gen", "types", "value.go"))) +} + +// TestServicesDataUsesRebuiltViewDeclarations verifies that planning and +// rendering can rebuild view expressions while sharing frozen declarations. +func TestServicesDataUsesRebuiltViewDeclarations(t *testing.T) { + var result *expr.ResultTypeExpr + root := codegen.RunDSL(t, func() { + result = dsl.ResultType("application/vnd.value", func() { + dsl.TypeName("Value") + dsl.Attribute("name", dsl.String) + dsl.View("default", func() { + dsl.Attribute("name") + }) + }) + dsl.Service("Values", func() { + dsl.Method("Read", func() { + dsl.Result(result) + }) + }) + }) + + generation := mustTestGeneration(t, "goa.design/goa/example", []eval.Root{root}) + plan, err := NewPlan(root, generation, expr.NewExampleGenerator(root.API.RandomizerFactory)) + require.NoError(t, err) + views := mustClaimTestPackage(t, generation, "goa.design/goa/example/values/views") + plannedProjected, err := views.DerivedType(codegen.NewProjectedTypeID(result)) + require.NoError(t, err) + plannedViewed, err := views.DerivedType(codegen.NewViewedResultTypeID(result)) + require.NoError(t, err) + require.NoError(t, generation.Freeze()) + require.NoError(t, plan.Link()) + services := plan.Services() + service := services.Get("Values") + require.Len(t, service.projectedTypes, 1) + require.Len(t, service.viewedResultTypes, 1) + require.Same(t, plannedProjected, service.projectedTypes[0].Declaration) + require.Same(t, plannedViewed, service.viewedResultTypes[0].Declaration) + require.Equal(t, "ValueView", plannedProjected.Name()) + require.Equal(t, "Value", plannedViewed.Name()) +} + +func TestFilesEmitsPackageDeclarationsOnce(t *testing.T) { + root := codegen.RunDSL(t, testdata.PkgPathUnionNameScopeDSL) + files := mustServiceFiles(t, mustServicePlan(t, root)) + + require.Equal(t, 1, countFiles(files, filepath.Join("gen", "types", "first_value.go"))) + require.Equal(t, 1, countFiles(files, filepath.Join("gen", "types", "second_value.go"))) + require.Equal(t, 1, countFiles(files, filepath.Join("gen", "types", "third_value.go"))) + require.Equal(t, 1, countFiles(files, filepath.Join("gen", "types", "unions.go"))) + + unionFile := findFile(files, filepath.Join("gen", "types", "unions.go")) + require.NotNil(t, unionFile) + code := renderSections(t, unionFile.SectionTemplates) + for _, name := range []string{"FirstValueChoice", "SecondValueChoice", "ThirdValueChoice"} { + require.Equal(t, 1, strings.Count(code, "type "+name+" struct {"), code) + require.Equal(t, 1, strings.Count(code, "type "+name+"Kind string"), code) + } +} + +func TestFilesEmitsDifferentExplicitlyNamedUnions(t *testing.T) { + root := codegen.RunDSL(t, func() { + first := dsl.Type("First", func() { + dsl.Meta("struct:pkg:path", "types") + dsl.OneOf("Value", func() { + dsl.TypeName("FirstChoice") + dsl.Attribute("text", dsl.String) + }) + }) + second := dsl.Type("Second", func() { + dsl.Meta("struct:pkg:path", "types") + dsl.OneOf("Value", func() { + dsl.TypeName("SecondChoice") + dsl.Attribute("number", dsl.Int) + }) + }) + dsl.Service("FirstService", func() { + dsl.Method("Read", func() { + dsl.Payload(first) + }) + }) + dsl.Service("SecondService", func() { + dsl.Method("Read", func() { + dsl.Payload(second) + }) + }) + }) + + files := mustServiceFiles(t, mustServicePlan(t, root)) + unionFile := findFile(files, filepath.Join("gen", "types", "unions.go")) + require.NotNil(t, unionFile) + code := renderSections(t, unionFile.SectionTemplates) + require.Equal(t, 1, strings.Count(code, "type FirstChoice struct {"), code) + require.Equal(t, 1, strings.Count(code, "type SecondChoice struct {"), code) + require.Equal(t, 1, strings.Count(code, "type FirstChoiceKind string"), code) + require.Equal(t, 1, strings.Count(code, "type SecondChoiceKind string"), code) +} + +func TestNewPlanRejectsUnionBranchesWithTheSameGoName(t *testing.T) { + root := codegen.RunDSL(t, func() { + dsl.Service("Values", func() { + dsl.Method("Read", func() { + dsl.Payload(func() { + dsl.OneOf("Value", func() { + dsl.Attribute("foo-bar", dsl.String) + dsl.Attribute("foo_bar", dsl.Int) + }) + }) + }) + }) + }) + generation := mustTestGeneration(t, "goa.design/goa/example", []eval.Root{root}) + + _, err := NewPlan(root, generation, expr.NewExampleGenerator(root.API.RandomizerFactory)) + + require.ErrorContains(t, err, `OneOf "Value" branches "foo-bar" and "foo_bar" both generate Go name "FooBar"`) + require.ErrorContains(t, err, "rename one of the branches") + require.NotContains(t, err.Error(), "TypeName") +} + +func TestFilesEmitsSharedPackagesOnceAcrossRoots(t *testing.T) { + var firstType expr.UserType + firstRoot := codegen.RunDSL(t, func() { + firstType = dsl.Type("First", func() { + dsl.Meta("struct:pkg:path", "types") + dsl.OneOf("Value", func() { + dsl.TypeName("FirstChoice") + dsl.Attribute("text", dsl.String) + }) + }) + dsl.Service("FirstService", func() { + dsl.Method("Read", func() { + dsl.Payload(firstType) + }) + }) + }) + var secondType expr.UserType + secondRoot := codegen.RunDSL(t, func() { + secondType = dsl.Type("Second", func() { + dsl.Meta("struct:pkg:path", "types") + dsl.OneOf("Value", func() { + dsl.TypeName("SecondChoice") + dsl.Attribute("text", dsl.String) + }) + }) + dsl.Service("SecondService", func() { + dsl.Method("Read", func() { + dsl.Payload(secondType) + }) + }) + }) + + generation := mustTestGeneration(t, "goa.design/goa/example", []eval.Root{firstRoot, secondRoot}) + plans, err := NewPlans( + generation, + PlanInput{Root: firstRoot, Examples: expr.NewExampleGenerator(firstRoot.API.RandomizerFactory)}, + PlanInput{Root: secondRoot, Examples: expr.NewExampleGenerator(secondRoot.API.RandomizerFactory)}, + ) + require.NoError(t, err) + firstPlan, secondPlan := plans[0], plans[1] + firstUnion := expr.AsObject(firstType).Attribute("Value") + secondUnion := expr.AsObject(secondType).Attribute("Value") + generatedPackage := mustClaimTestPackage(t, generation, "goa.design/goa/example/types") + firstBranch, err := generatedPackage.UnionBranchType(firstUnion, "text") + require.NoError(t, err) + secondBranch, err := generatedPackage.UnionBranchType(secondUnion, "text") + require.NoError(t, err) + require.NotSame(t, firstBranch, secondBranch) + + require.NoError(t, generation.Freeze()) + require.NoError(t, firstPlan.Link()) + require.NoError(t, secondPlan.Link()) + files := mustServiceFiles(t, firstPlan, secondPlan) + + require.Equal(t, 1, countFiles(files, filepath.Join("gen", "types", "first.go"))) + require.Equal(t, 1, countFiles(files, filepath.Join("gen", "types", "second.go"))) + require.Equal(t, 1, countFiles(files, filepath.Join("gen", "types", "first_choice_text.go"))) + require.Equal(t, 1, countFiles(files, filepath.Join("gen", "types", "second_choice_text.go"))) + require.Equal(t, 1, countFiles(files, filepath.Join("gen", "types", "unions.go"))) + require.Equal(t, 1, countFiles(files, filepath.Join("gen", "first_service", "service.go"))) + require.Equal(t, 1, countFiles(files, filepath.Join("gen", "second_service", "service.go"))) +} + +func TestFilesEmitCanonicalSharedDeclarationAcrossRoots(t *testing.T) { + forwardPlans := sharedDeclarationPlans(t, false) + forwardFiles := mustServiceFiles(t, forwardPlans...) + sharedPath := filepath.Join("gen", "types", "shared.go") + require.Equal(t, 1, countFiles(forwardFiles, sharedPath)) + forward := renderSingleFileAtPath(t, forwardFiles, sharedPath) + require.Contains(t, forward, "// The canonical shared declaration.") + + reversePlans := sharedDeclarationPlans(t, true) + reverseFiles := mustServiceFiles(t, reversePlans...) + require.Equal(t, 1, countFiles(reverseFiles, sharedPath)) + require.Equal(t, forward, renderSingleFileAtPath(t, reverseFiles, sharedPath)) +} + +// TestFilesAddSharedErrorMethodsAcrossRoots checks that an ordinary use and an +// error use contribute one declaration and one error method set in either root +// order. +func TestFilesAddSharedErrorMethodsAcrossRoots(t *testing.T) { + forwardPlans := sharedErrorDeclarationPlans(t, false) + forwardFiles := mustServiceFiles(t, forwardPlans...) + sharedPath := filepath.Join("gen", "types", "shared_error.go") + require.Equal(t, 1, countFiles(forwardFiles, sharedPath)) + forward := renderSingleFileAtPath(t, forwardFiles, sharedPath) + require.Equal(t, 1, strings.Count(forward, "type SharedError struct")) + require.Equal(t, 1, strings.Count(forward, "func (e *SharedError) Error() string")) + + reversePlans := sharedErrorDeclarationPlans(t, true) + reverseFiles := mustServiceFiles(t, reversePlans...) + require.Equal(t, forward, renderSingleFileAtPath(t, reverseFiles, sharedPath)) + compileGeneratedServiceFiles(t, forwardFiles) +} + +func TestNewPlansRejectSharedErrorNamesAcrossRoots(t *testing.T) { + firstRoot, secondRoot := sharedErrorNameRoots(t, false) + for _, roots := range [][]*expr.RootExpr{ + {firstRoot, secondRoot}, + {secondRoot, firstRoot}, + } { + generation := mustTestGeneration(t, "generated.local/gen", []eval.Root{roots[0], roots[1]}) + _, err := NewPlans( + generation, + PlanInput{Root: roots[0], Examples: expr.NewExampleGenerator(roots[0].API.RandomizerFactory)}, + PlanInput{Root: roots[1], Examples: expr.NewExampleGenerator(roots[1].API.RandomizerFactory)}, + ) + require.ErrorContains(t, err, "defines errors first_error, second_error") + } +} + +func TestNewPlansAcceptSharedDynamicErrorNamesAcrossRoots(t *testing.T) { + firstRoot, secondRoot := sharedErrorNameRoots(t, true) + generation := mustTestGeneration(t, "generated.local/gen", []eval.Root{firstRoot, secondRoot}) + _, err := NewPlans( + generation, + PlanInput{Root: firstRoot, Examples: expr.NewExampleGenerator(firstRoot.API.RandomizerFactory)}, + PlanInput{Root: secondRoot, Examples: expr.NewExampleGenerator(secondRoot.API.RandomizerFactory)}, + ) + require.NoError(t, err) +} + +func TestNewPlansRejectConflictingSharedDeclarationEmissionCandidates(t *testing.T) { + firstRoot, secondRoot := conflictingSharedDeclarationRoots(t) + generation := mustTestGeneration(t, "goa.design/goa/example", []eval.Root{firstRoot, secondRoot}) + _, err := NewPlans( + generation, + PlanInput{Root: firstRoot, Examples: expr.NewExampleGenerator(firstRoot.API.RandomizerFactory)}, + PlanInput{Root: secondRoot, Examples: expr.NewExampleGenerator(secondRoot.API.RandomizerFactory)}, + ) + require.ErrorContains(t, err, "conflicting generated type emission") +} + +// TestNewPlansAcceptEquivalentSharedDeclarationCopies proves compiler-created +// copies coalesce when every retained type fact is structurally identical. +func TestNewPlansAcceptEquivalentSharedDeclarationCopies(t *testing.T) { + firstRoot, secondRoot := copiedSharedDeclarationRoots(t, nil) + generation := mustTestGeneration(t, "goa.design/goa/example", []eval.Root{firstRoot, secondRoot}) + plans, err := NewPlans( + generation, + PlanInput{Root: firstRoot, Examples: expr.NewExampleGenerator(firstRoot.API.RandomizerFactory)}, + PlanInput{Root: secondRoot, Examples: expr.NewExampleGenerator(secondRoot.API.RandomizerFactory)}, + ) + require.NoError(t, err) + require.NoError(t, generation.Freeze()) + for _, plan := range plans { + require.NoError(t, plan.Link()) + } + require.Equal(t, 1, countFiles(mustServiceFiles(t, plans...), filepath.Join("gen", "types", "shared.go"))) +} + +// TestNewPlansRejectSharedDeclarationLayoutConflicts proves a shared package +// declaration cannot silently select one compiler copy's field spelling. +func TestNewPlansRejectSharedDeclarationLayoutConflicts(t *testing.T) { + firstRoot, secondRoot := copiedSharedDeclarationRoots(t, func(copy expr.UserType) { + field := expr.AsObject(copy).Attribute("value") + field.AddMeta("struct:field:name", "OtherValue") + }) + generation := mustTestGeneration(t, "goa.design/goa/example", []eval.Root{firstRoot, secondRoot}) + _, err := NewPlans( + generation, + PlanInput{Root: firstRoot, Examples: expr.NewExampleGenerator(firstRoot.API.RandomizerFactory)}, + PlanInput{Root: secondRoot, Examples: expr.NewExampleGenerator(secondRoot.API.RandomizerFactory)}, + ) + require.ErrorContains(t, err, "conflicting generated type emission") +} + +// TestNewPlansAcceptDistinctTransportValidationForSharedDeclaration proves +// validation does not become false service-file ownership. HTTP and gRPC own +// their validation programs; the shared service file owns only the Go layout. +func TestNewPlansAcceptDistinctTransportValidationForSharedDeclaration(t *testing.T) { + firstRoot, secondRoot := copiedSharedDeclarationRoots(t, func(copy expr.UserType) { + expr.AsObject(copy).Attribute("value").Validation = &expr.ValidationExpr{Pattern: "^[a-z]+$"} + }) + generation := mustTestGeneration(t, "goa.design/goa/example", []eval.Root{firstRoot, secondRoot}) + plans, err := NewPlans( + generation, + PlanInput{Root: firstRoot, Examples: expr.NewExampleGenerator(firstRoot.API.RandomizerFactory)}, + PlanInput{Root: secondRoot, Examples: expr.NewExampleGenerator(secondRoot.API.RandomizerFactory)}, + ) + require.NoError(t, err) + require.NoError(t, generation.Freeze()) + for _, plan := range plans { + require.NoError(t, plan.Link()) + } + require.Equal(t, 1, countFiles(mustServiceFiles(t, plans...), filepath.Join("gen", "types", "shared.go"))) +} + +// TestNewPlansRejectSharedUnionBranchLayoutConflicts proves one canonical +// union declaration cannot select between differing retained branch layouts. +func TestNewPlansRejectSharedUnionBranchLayoutConflicts(t *testing.T) { + firstRoot, secondRoot := copiedSharedUnionRoots(t, func(union *expr.Union) { + union.Values[0].Attribute.Description = "a conflicting branch description" + }) + generation := mustTestGeneration(t, "goa.design/goa/example", []eval.Root{firstRoot, secondRoot}) + _, err := NewPlans( + generation, + PlanInput{Root: firstRoot, Examples: expr.NewExampleGenerator(firstRoot.API.RandomizerFactory)}, + PlanInput{Root: secondRoot, Examples: expr.NewExampleGenerator(secondRoot.API.RandomizerFactory)}, + ) + require.ErrorContains(t, err, "conflicting generated union emission") +} + +func TestNewPlanRejectsPartialMultiRootPlanning(t *testing.T) { + firstRoot, secondRoot := sharedDeclarationRoots(t) + generation := mustTestGeneration(t, "goa.design/goa/example", []eval.Root{firstRoot, secondRoot}) + _, err := NewPlan(firstRoot, generation, expr.NewExampleGenerator(firstRoot.API.RandomizerFactory)) + require.ErrorContains(t, err, "requires all 2 generation roots") +} + +// sharedDeclarationPlans builds and links both service plans, optionally in +// reverse order. +func sharedDeclarationPlans(t *testing.T, reverse bool) []*Plan { + t.Helper() + firstRoot, secondRoot := sharedDeclarationRoots(t) + roots := []*expr.RootExpr{firstRoot, secondRoot} + if reverse { + slices.Reverse(roots) + } + evaluated := make([]eval.Root, len(roots)) + for index, root := range roots { + evaluated[index] = root + } + generation := mustTestGeneration(t, "goa.design/goa/example", evaluated) + inputs := make([]PlanInput, len(roots)) + for index, root := range roots { + inputs[index] = PlanInput{Root: root, Examples: expr.NewExampleGenerator(root.API.RandomizerFactory)} + } + plans, err := NewPlans(generation, inputs...) + require.NoError(t, err) + require.NoError(t, generation.Freeze()) + for _, plan := range plans { + require.NoError(t, plan.Link()) + } + return plans +} + +// sharedErrorDeclarationPlans builds and links roots where one service uses a +// shared type as data and another declares that exact type as an error. +func sharedErrorDeclarationPlans(t *testing.T, reverse bool) []*Plan { + t.Helper() + var shared expr.UserType + firstRoot := codegen.RunDSL(t, func() { + shared = dsl.Type("SharedError", func() { + dsl.Meta("struct:pkg:path", "types") + dsl.Meta("type:generate:force") + dsl.Attribute("message", dsl.String) + }) + dsl.Service("Reader", func() { + dsl.Method("Read", func() { + dsl.Result(shared) + }) + }) + }) + secondRoot := codegen.RunDSL(t, func() { + dsl.Service("Writer", func() { + dsl.Method("Write", func() { + dsl.Error("shared_error", shared) + }) + }) + }) + roots := []*expr.RootExpr{firstRoot, secondRoot} + if reverse { + slices.Reverse(roots) + } + evaluated := make([]eval.Root, len(roots)) + inputs := make([]PlanInput, len(roots)) + for index, root := range roots { + evaluated[index] = root + inputs[index] = PlanInput{Root: root, Examples: expr.NewExampleGenerator(root.API.RandomizerFactory)} + } + generation := mustTestGeneration(t, "generated.local/gen", evaluated) + plans, err := NewPlans(generation, inputs...) + require.NoError(t, err) + require.NoError(t, generation.Freeze()) + for _, plan := range plans { + require.NoError(t, plan.Link()) + } + return plans +} + +// sharedErrorNameRoots builds two separately evaluated designs that share one +// authored error type under different names. +func sharedErrorNameRoots(t *testing.T, dynamic bool) (*expr.RootExpr, *expr.RootExpr) { + t.Helper() + var shared expr.UserType + firstRoot := codegen.RunDSL(t, func() { + shared = dsl.Type("SharedError", func() { + dsl.Meta("struct:pkg:path", "types") + dsl.Meta("type:generate:force") + if dynamic { + dsl.ErrorName("name", dsl.String) + dsl.Required("name") + } + dsl.Attribute("message", dsl.String) + }) + dsl.Service("FirstService", func() { + dsl.Method("Read", func() { + dsl.Error("first_error", shared) + }) + }) + }) + secondRoot := codegen.RunDSL(t, func() { + dsl.Service("SecondService", func() { + dsl.Method("Read", func() { + dsl.Error("second_error", shared) + }) + }) + }) + return firstRoot, secondRoot +} + +// sharedDeclarationRoots builds two services that use the same type declared +// by the first service. +func sharedDeclarationRoots(t *testing.T) (*expr.RootExpr, *expr.RootExpr) { + t.Helper() + var shared expr.UserType + firstRoot := codegen.RunDSL(t, func() { + shared = dsl.Type("Shared", func() { + dsl.Description("The canonical shared declaration.") + dsl.Meta("struct:pkg:path", "types") + dsl.Attribute("value", dsl.String) + }) + dsl.Service("FirstService", func() { + dsl.Method("Read", func() { + dsl.Payload(shared) + }) + }) + }) + secondRoot := codegen.RunDSL(t, func() { + dsl.Service("SecondService", func() { + dsl.Method("Read", func() { + dsl.Payload(shared) + }) + }) + }) + return firstRoot, secondRoot +} + +// conflictingSharedDeclarationRoots builds two services whose copies of the +// same type have different definitions. +func conflictingSharedDeclarationRoots(t *testing.T) (*expr.RootExpr, *expr.RootExpr) { + t.Helper() + var shared expr.UserType + firstRoot := codegen.RunDSL(t, func() { + shared = dsl.Type("Shared", func() { + dsl.Description("The first retained declaration.") + dsl.Meta("struct:pkg:path", "types") + dsl.Attribute("value", dsl.String) + }) + dsl.Service("FirstService", func() { + dsl.Method("Read", func() { + dsl.Payload(shared) + }) + }) + }) + conflicting := shared.Dup(expr.DupAtt(shared.Attribute())) + conflicting.Attribute().Description = "The conflicting retained declaration." + secondRoot := codegen.RunDSL(t, func() { + dsl.Service("SecondService", func() { + dsl.Method("Read", func() { + dsl.Payload(conflicting) + }) + }) + }) + return firstRoot, secondRoot +} + +// copiedSharedDeclarationRoots returns two roots whose compiler copies share +// one authored origin and therefore one generated declaration. +func copiedSharedDeclarationRoots(t *testing.T, mutate func(expr.UserType)) (*expr.RootExpr, *expr.RootExpr) { + t.Helper() + var shared expr.UserType + firstRoot := codegen.RunDSL(t, func() { + shared = dsl.Type("Shared", func() { + dsl.Description("The canonical shared declaration.") + dsl.Meta("struct:pkg:path", "types") + dsl.Attribute("value", dsl.String) + }) + dsl.Service("FirstService", func() { + dsl.Method("Read", func() { dsl.Payload(shared) }) + }) + }) + copy := shared.Dup(expr.DupAtt(shared.Attribute())) + if mutate != nil { + mutate(copy) + } + secondRoot := codegen.RunDSL(t, func() { + dsl.Service("SecondService", func() { + dsl.Method("Read", func() { dsl.Payload(copy) }) + }) + }) + return firstRoot, secondRoot +} + +// copiedSharedUnionRoots returns two roots whose equal union identities bind +// the same generated declaration while retaining independent branch facts. +func copiedSharedUnionRoots(t *testing.T, mutate func(*expr.Union)) (*expr.RootExpr, *expr.RootExpr) { + t.Helper() + var container expr.UserType + firstRoot := codegen.RunDSL(t, func() { + container = dsl.Type("Container", func() { + dsl.Meta("struct:pkg:path", "types") + dsl.OneOf("value", func() { + dsl.Attribute("text", dsl.String) + }) + }) + dsl.Service("FirstService", func() { + dsl.Method("Read", func() { dsl.Payload(container) }) + }) + }) + copy := container.Dup(expr.DupAtt(container.Attribute())) + union := expr.AsObject(copy).Attribute("value").Type.(*expr.Union) + if mutate != nil { + mutate(union) + } + secondRoot := codegen.RunDSL(t, func() { + dsl.Service("SecondService", func() { + dsl.Method("Read", func() { dsl.Payload(copy) }) + }) + }) + return firstRoot, secondRoot +} + +func TestGeneratedUnionBranchCollisionDoesNotCanonicalizeToRootType(t *testing.T) { + var ( + exact expr.UserType + container expr.UserType + ) + root := codegen.RunDSL(t, func() { + exact = dsl.Type("Value-Text", dsl.String, func() { + dsl.Meta("struct:pkg:path", "types") + dsl.Meta("type:generate:force") + }) + container = dsl.Type("Container", func() { + dsl.Meta("struct:pkg:path", "types") + dsl.OneOf("Value", func() { + dsl.Attribute("text", dsl.String) + }) + }) + dsl.Service("Collision", func() { + dsl.Method("Read", func() { + dsl.Payload(container) + }) + }) + }) + + generation := mustTestGeneration(t, "goa.design/goa/example", []eval.Root{root}) + plan, err := NewPlan(root, generation, expr.NewExampleGenerator(root.API.RandomizerFactory)) + require.NoError(t, err) + union := expr.AsObject(container).Attribute("Value") + generatedPackage := mustClaimTestPackage(t, generation, "goa.design/goa/example/types") + exactDeclaration, err := generatedPackage.UserType(exact) + require.NoError(t, err) + branchDeclaration, err := generatedPackage.UnionBranchType(union, "text") + require.NoError(t, err) + require.NotSame(t, exactDeclaration, branchDeclaration) + + require.NoError(t, generation.Freeze()) + require.Equal(t, "ValueText", exactDeclaration.Name()) + require.Equal(t, "ValueBranchText", branchDeclaration.Name()) + require.NoError(t, plan.Link()) + typeFile := findFile( + mustServiceFiles(t, plan), + filepath.Join("gen", "types", "value_text.go"), + ) + require.NotNil(t, typeFile) + code := renderSections(t, typeFile.SectionTemplates) + require.Contains(t, code, "type ValueText string") + require.Contains(t, code, "type ValueBranchText string") +} + +// TestForcedRelocatedTypesUseTheirDeclaringPackageForNestedReferences verifies +// that a generated types package can render one forced type nested in another +// without importing the package currently being written. +func TestForcedRelocatedTypesUseTheirDeclaringPackageForNestedReferences(t *testing.T) { + root := codegen.RunDSL(t, func() { + inner := dsl.Type("Inner", func() { + dsl.Meta("struct:pkg:path", "types") + dsl.Meta("type:generate:force") + dsl.Attribute("value", dsl.String) + }) + dsl.Type("Outer", func() { + dsl.Meta("struct:pkg:path", "types") + dsl.Meta("type:generate:force") + dsl.Attribute("inner", inner) + }) + dsl.Service("Values", func() { + dsl.Method("Read", func() {}) + }) + }) + + plan := retainedServicePlanForPackage(t, root) + typeFile := findFile( + mustServiceFiles(t, plan), + filepath.Join("gen", "types", "outer.go"), + ) + require.NotNil(t, typeFile) + code := renderSections(t, typeFile.SectionTemplates) + require.Contains(t, code, "Inner *Inner") + files := mustServiceFiles(t, plan) + files = append(files, ExampleServiceFiles(plan)...) + compileGeneratedServiceFiles(t, files) +} + func TestService(t *testing.T) { cases := []struct { Name string @@ -38,8 +856,11 @@ func TestService(t *testing.T) { {"service-result-with-one-of-type", testdata.ResultWithOneOfTypeMethodDSL}, {"service-result-with-inline-validation", testdata.ResultWithInlineValidationDSL}, {"service-service-level-error", testdata.ServiceErrorDSL}, + {"service-api-error-reference", testdata.APIErrorReferenceDSL}, {"service-custom-errors", testdata.CustomErrorsDSL}, + {"service-nested-custom-error", testdata.NestedCustomErrorDSL}, {"service-custom-errors-custom-field", testdata.CustomErrorsCustomFieldDSL}, + {"service-repeated-inline-errors", testdata.RepeatedInlineErrorsDSL}, {"service-force-generate-type", testdata.ForceGenerateTypeDSL}, {"service-force-generate-type-explicit", testdata.ForceGenerateTypeExplicitDSL}, {"service-streaming-result", testdata.StreamingResultMethodDSL}, @@ -64,19 +885,12 @@ func TestService(t *testing.T) { for _, c := range cases { t.Run(c.Name, func(t *testing.T) { root := codegen.RunDSL(t, c.DSL) - services := NewServicesData(root) + plan := mustServicePlan(t, root) require.Len(t, root.Services, 1) - files := Files("goa.design/goa/example", root.Services[0], services, make(map[string][]string)) + files := mustServiceFiles(t, plan) require.Greater(t, len(files), 0) - // Generate the code - buf := new(bytes.Buffer) - for _, s := range files[0].SectionTemplates[1:] { - require.NoError(t, s.Write(buf)) - } - bs, err := format.Source(buf.Bytes()) - require.NoError(t, err, buf.String()) - code := string(bs) + code := renderServiceGolden(t, files, files[0]) // Compare with golden file testutil.AssertGo(t, "testdata/golden/service_"+c.Name+".go.golden", code) @@ -89,6 +903,7 @@ func TestStructPkgPath(t *testing.T) { recursiveFooPath := filepath.Join("gen", "foo", "recursive_foo.go") barPath := filepath.Join("gen", "bar", "bar.go") bazPath := filepath.Join("gen", "baz", "baz.go") + sharedPath := filepath.Join("gen", "shared", "shared.go") cases := []struct { Name string DSL func() @@ -101,32 +916,26 @@ func TestStructPkgPath(t *testing.T) { {"multiple", testdata.PkgPathMultipleDSL, []string{barPath, bazPath}}, {"nopkg", testdata.PkgPathNoDirDSL, nil}, {"dupes", testdata.PkgPathDupeDSL, []string{fooPath}}, + {"shared_roles", testdata.PkgPathSharedRolesDSL, []string{sharedPath, filepath.Join("gen", "shared", "detail.go")}}, {"payload_attribute", testdata.PkgPathPayloadAttributeDSL, []string{fooPath}}, } for _, c := range cases { t.Run(c.Name, func(t *testing.T) { - userTypePkgs := make(map[string][]string) root := codegen.RunDSL(t, c.DSL) - services := NewServicesData(root) - files := Files("goa.design/goa/example", root.Services[0], services, userTypePkgs) + plan := mustServicePlan(t, root) + services := plan.Services() + files := mustServiceFiles(t, plan) - // Check file count - expectedFiles := len(c.TypeFiles) + 1 - require.Len(t, files, expectedFiles, "unexpected number of files") - - // First file is always the service file - buf := new(bytes.Buffer) - for _, s := range files[0].SectionTemplates[1:] { - require.NoError(t, s.Write(buf)) - } - bs, err := format.Source(buf.Bytes()) - require.NoError(t, err) - testutil.AssertGo(t, "testdata/golden/pkg_path_"+c.Name+"_service.go.golden", string(bs)) + serviceFile := findFile(files, filepath.Join(codegen.Gendir, services.Get(root.Services[0].Name).PathName, "service.go")) + require.NotNil(t, serviceFile) + testutil.AssertGo(t, "testdata/golden/pkg_path_"+c.Name+"_service.go.golden", renderServiceGolden(t, files, serviceFile)) // Type files - for i, typeFile := range c.TypeFiles { + for _, typeFile := range c.TypeFiles { + file := findFile(files, typeFile) + require.NotNil(t, file) buf := new(bytes.Buffer) - for _, s := range files[i+1].SectionTemplates[1:] { + for _, s := range file.SectionTemplates[1:] { require.NoError(t, s.Write(buf)) } bs, err := format.Source(buf.Bytes()) @@ -137,7 +946,7 @@ func TestStructPkgPath(t *testing.T) { // For dupes case, test the second service if c.Name == "dupes" && len(root.Services) > 1 { - files = Files("goa.design/goa/example", root.Services[1], services, userTypePkgs) + files = serviceFiles(plan, plan.facts.services[1]) require.Len(t, files, 1) buf := new(bytes.Buffer) for _, s := range files[0].SectionTemplates[1:] { @@ -151,25 +960,66 @@ func TestStructPkgPath(t *testing.T) { } } +func TestRelocatedTypeDescriptions(t *testing.T) { + cases := []struct { + name string + dsl func() + path string + want string + }{ + { + name: "payload and result", + dsl: testdata.PkgPathDSL, + path: filepath.Join("gen", "foo", "foo.go"), + want: "Foo is the payload and result type of the PkgPathMethod service A method.", + }, + { + name: "nested only", + dsl: testdata.PkgPathArrayDSL, + path: filepath.Join("gen", "foo", "foo.go"), + want: "Foo is a named type defined in the service design.", + }, + { + name: "all method roles", + dsl: testdata.PkgPathSharedRolesDSL, + path: filepath.Join("gen", "shared", "shared.go"), + want: "Shared is the payload, streaming payload, result, and streaming result type\n// of the PkgPathSharedRoles service Exchange method.", + }, + { + name: "several methods and services", + dsl: testdata.PkgPathDupeDSL, + path: filepath.Join("gen", "foo", "foo.go"), + want: "Foo is used by these service methods:\n" + + "// - PkgPathDupeMethod A: payload and result\n" + + "// - PkgPathDupeMethod B: payload and result\n" + + "// - PkgPathDupeMethod2 A: payload and result\n" + + "// - PkgPathDupeMethod2 B: payload and result", + }, + } + for _, test := range cases { + t.Run(test.name, func(t *testing.T) { + root := codegen.RunDSL(t, test.dsl) + plan := mustServicePlan(t, root) + file := findFile(mustServiceFiles(t, plan), test.path) + require.NotNil(t, file) + require.Contains(t, renderSections(t, file.SectionTemplates), test.want) + }) + } +} + func TestStructPkgPath_UnionImportsJSON(t *testing.T) { root := codegen.RunDSL(t, testdata.PkgPathUnionDSL) - services := NewServicesData(root) + plan := mustServicePlan(t, root) require.Len(t, root.Services, 1) - files := Files("goa.design/goa/example", root.Services[0], services, make(map[string][]string)) + files := mustServiceFiles(t, plan) require.GreaterOrEqual(t, len(files), 2, "expected at least service.go + one struct:pkg:path file") - var typeFile *codegen.File - for _, f := range files { - if strings.HasSuffix(f.Path, filepath.Join("gen", "types", "type_with_union.go")) { - typeFile = f - break - } - } - require.NotNil(t, typeFile, "expected generated type file for struct:pkg:path type_with_union") + unionFile := findFile(files, filepath.Join("gen", "types", "unions.go")) + require.NotNil(t, unionFile, "expected generated union file in struct:pkg:path package") buf := new(bytes.Buffer) - for _, s := range typeFile.SectionTemplates { + for _, s := range unionFile.SectionTemplates { require.NoError(t, s.Write(buf)) } code := buf.String() @@ -177,30 +1027,158 @@ func TestStructPkgPath_UnionImportsJSON(t *testing.T) { require.Contains(t, code, "\"encoding/json\"", "expected encoding/json import in generated file:\n%s", code) } +func TestStructPkgPath_UnionNamesRemainExactAcrossServices(t *testing.T) { + root := codegen.RunDSL(t, testdata.PkgPathUnionNameScopeDSL) + var generated strings.Builder + files := mustServiceFiles(t, mustServicePlan(t, root)) + for _, file := range files { + if !strings.Contains(file.Path, filepath.Join("gen", "types")) { + continue + } + for _, section := range file.SectionTemplates { + require.NoError(t, section.Write(&generated)) + } + } + + code := generated.String() + for _, name := range []string{"FirstValueChoice", "SecondValueChoice", "ThirdValueChoice"} { + require.Equal(t, 1, strings.Count(code, "type "+name+" struct {"), code) + require.Equal(t, 1, strings.Count(code, "type "+name+"Kind string"), code) + } + firstUsesValue := unionFieldType(code, "FirstValue") + secondUsesValue := unionFieldType(code, "SecondValue") + thirdUsesValue := unionFieldType(code, "ThirdValue") + require.Equal(t, []string{"FirstValueChoice", "SecondValueChoice", "ThirdValueChoice"}, []string{firstUsesValue, secondUsesValue, thirdUsesValue}) +} + +// unionFieldType returns the generated type of the Value field in the named +// struct. +func unionFieldType(code, owner string) string { + prefix := "type " + owner + " struct {\n\tValue " + start := strings.Index(code, prefix) + if start == -1 { + return "" + } + start += len(prefix) + end := strings.IndexByte(code[start:], '\n') + if end == -1 { + return "" + } + return code[start : start+end] +} + +// mustServicePlan runs the complete retained-plan lifecycle used by service +// renderer tests. +func mustServicePlan(t *testing.T, root *expr.RootExpr) *Plan { + t.Helper() + generation := mustTestGeneration(t, "goa.design/goa/example", []eval.Root{root}) + plan, err := NewPlan(root, generation, expr.NewExampleGenerator(root.API.RandomizerFactory)) + require.NoError(t, err) + require.NoError(t, generation.Freeze()) + require.NoError(t, plan.Link()) + return plan +} + +// mustServiceFiles renders linked plans or fails the calling test. +func mustServiceFiles(t *testing.T, plans ...*Plan) []*codegen.File { + t.Helper() + files, err := Files(plans...) + require.NoError(t, err) + return files +} + +// countFiles returns how many generated files have the given path. +func countFiles(files []*codegen.File, path string) int { + count := 0 + for _, file := range files { + if file.Path == path { + count++ + } + } + return count +} + +// findFile returns the generated file with path, or nil when no file matches. +func findFile(files []*codegen.File, path string) *codegen.File { + for _, file := range files { + if file.Path == path { + return file + } + } + return nil +} + +// findUserTypeData returns the render data for userType. +func findUserTypeData(types []*UserTypeData, userType expr.UserType) *UserTypeData { + for _, data := range types { + if data.Type == userType { + return data + } + } + return nil +} + +// renderSections renders sections without writing a generated file. +func renderSections(t *testing.T, sections []*codegen.SectionTemplate) string { + t.Helper() + var rendered strings.Builder + for _, section := range sections { + require.NoError(t, section.Write(&rendered)) + } + return rendered.String() +} + +// renderServiceGolden reconstructs the former single-file declaration order +// so existing service golden assertions remain unchanged after unions move to +// their package-owned unions.go file. +func renderServiceGolden(t *testing.T, files []*codegen.File, serviceFile *codegen.File) string { + t.Helper() + sections := append([]*codegen.SectionTemplate(nil), serviceFile.SectionTemplates[1:]...) + unionFile := findFile(files, filepath.Join(filepath.Dir(serviceFile.Path), "unions.go")) + if unionFile != nil { + insertAt := len(sections) + for i, section := range sections { + switch section.Name { + case "error-init-func", "viewed-result-type-to-service-result-type", + "service-result-type-to-viewed-result-type", "projected-type-to-service-type", + "service-type-to-projected-type", "transform-helpers": + insertAt = i + } + if insertAt != len(sections) { + break + } + } + sections = append(sections, make([]*codegen.SectionTemplate, len(unionFile.SectionTemplates)-1)...) + copy(sections[insertAt+len(unionFile.SectionTemplates)-1:], sections[insertAt:]) + copy(sections[insertAt:], unionFile.SectionTemplates[1:]) + } + buf := new(bytes.Buffer) + for _, section := range sections { + require.NoError(t, section.Write(buf)) + } + formatted, err := format.Source(buf.Bytes()) + require.NoError(t, err, buf.String()) + return string(formatted) +} + func TestStructPkgPath_UnionJSONFieldBranchesGenerateAliases(t *testing.T) { root := codegen.RunDSL(t, testdata.PkgPathUnionJSONFieldDSL) - services := NewServicesData(root) + plan := mustServicePlan(t, root) require.Len(t, root.Services, 1) - files := Files("goa.design/goa/example", root.Services[0], services, make(map[string][]string)) + files := mustServiceFiles(t, plan) require.GreaterOrEqual(t, len(files), 2, "expected at least service.go + one struct:pkg:path file") - var typeFile *codegen.File - for _, f := range files { - if strings.HasSuffix(f.Path, filepath.Join("gen", "types", "type_with_json_field_union.go")) { - typeFile = f - break - } - } - require.NotNil(t, typeFile, "expected generated type file for struct:pkg:path type_with_json_field_union") + unionFile := findFile(files, filepath.Join("gen", "types", "unions.go")) + require.NotNil(t, unionFile, "expected package-owned union file") buf := new(bytes.Buffer) - for _, s := range typeFile.SectionTemplates { + for _, s := range unionFile.SectionTemplates { require.NoError(t, s.Write(buf)) } code := buf.String() - require.Contains(t, code, "A ValuesA", "expected union field A to use generated alias type:\n%s", code) - require.Contains(t, code, "B ValuesB", "expected union field B to use generated alias type:\n%s", code) + require.Contains(t, code, "a ValuesBranchA", "expected union branch A to use generated alias type:\n%s", code) + require.Contains(t, code, "b ValuesBranchB", "expected union branch B to use generated alias type:\n%s", code) var hasValuesAFile, hasValuesBFile bool for _, f := range files { @@ -217,23 +1195,26 @@ func TestStructPkgPath_UnionJSONFieldBranchesGenerateAliases(t *testing.T) { func TestStructPkgPath_ExtendedUnionGeneratedInEachOwningPackage(t *testing.T) { root := codegen.RunDSL(t, testdata.PkgPathExtendedUnionDSL) - services := NewServicesData(root) + plan := mustServicePlan(t, root) require.Len(t, root.Services, 1) - files := Files("goa.design/goa/example", root.Services[0], services, make(map[string][]string)) + files := mustServiceFiles(t, plan) require.GreaterOrEqual(t, len(files), 2) - var serviceFile, sharedTypeFile *codegen.File + var serviceFile, localUnionFile, sharedUnionFile *codegen.File for _, f := range files { switch { case strings.HasSuffix(f.Path, filepath.Join("gen", "pkg_path_extended_union", "service.go")): serviceFile = f - case strings.HasSuffix(f.Path, filepath.Join("gen", "types", "equipment_scope.go")): - sharedTypeFile = f + case strings.HasSuffix(f.Path, filepath.Join("gen", "pkg_path_extended_union", "unions.go")): + localUnionFile = f + case strings.HasSuffix(f.Path, filepath.Join("gen", "types", "unions.go")): + sharedUnionFile = f } } require.NotNil(t, serviceFile) - require.NotNil(t, sharedTypeFile) + require.NotNil(t, localUnionFile) + require.NotNil(t, sharedUnionFile) render := func(file *codegen.File) string { buf := new(bytes.Buffer) @@ -245,8 +1226,9 @@ func TestStructPkgPath_ExtendedUnionGeneratedInEachOwningPackage(t *testing.T) { return string(code) } serviceCode := render(serviceFile) - sharedTypeCode := render(sharedTypeFile) + localUnionCode := render(localUnionFile) + sharedUnionCode := render(sharedUnionFile) require.Contains(t, serviceCode, "Scope Scope") - require.Contains(t, serviceCode, "type Scope struct") - require.Contains(t, sharedTypeCode, "type Scope struct") + require.Contains(t, localUnionCode, "type Scope struct") + require.Contains(t, sharedUnionCode, "type Scope struct") } diff --git a/codegen/service/templates.go b/codegen/service/templates.go index 8359ee40e7..3ea0f22e2e 100644 --- a/codegen/service/templates.go +++ b/codegen/service/templates.go @@ -36,7 +36,6 @@ const ( exampleServiceInitT = "example_service_init" exampleSecurityAuthfuncsT = "example_security_authfuncs" endpointT = "endpoint" - jsonrpcHandleStreamT = "jsonrpc_handle_stream" // Service templates serviceT = "service" diff --git a/codegen/service/templates/client_interceptor_stream_wrapper_types.go.tpl b/codegen/service/templates/client_interceptor_stream_wrapper_types.go.tpl index 9a250d08f6..2190de264f 100644 --- a/codegen/service/templates/client_interceptor_stream_wrapper_types.go.tpl +++ b/codegen/service/templates/client_interceptor_stream_wrapper_types.go.tpl @@ -1,7 +1,7 @@ {{- range .WrappedClientStreams }} -{{ comment (printf "wrapped%s is a client interceptor wrapper for the %s stream." .Interface .Interface) }} -type wrapped{{ .Interface }} struct { +{{ comment (printf "%s is a client interceptor wrapper for the %s stream." .WrapperDeclaration.Name .InterfaceDeclaration.Name) }} +type {{ .WrapperDeclaration.Name }} struct { ctx context.Context {{- if ne .SendTypeRef "" }} sendWithContext func(context.Context, {{ .SendTypeRef }}) error @@ -9,6 +9,6 @@ type wrapped{{ .Interface }} struct { {{- if ne .RecvTypeRef "" }} recvWithContext func(context.Context) ({{ .RecvTypeRef }}, error) {{- end }} - stream {{ .Interface }} + stream {{ .InterfaceDeclaration.Name }} } {{- end }} diff --git a/codegen/service/templates/client_interceptor_stream_wrappers.go.tpl b/codegen/service/templates/client_interceptor_stream_wrappers.go.tpl index 555960d916..adbdccf58f 100644 --- a/codegen/service/templates/client_interceptor_stream_wrappers.go.tpl +++ b/codegen/service/templates/client_interceptor_stream_wrappers.go.tpl @@ -3,17 +3,17 @@ {{- if ne .SendTypeRef "" }} {{ comment (print "Unwrap returns the underlying stream type.") }} -func (w *wrapped{{ .Interface }}) Unwrap() any { +func (w *{{ .WrapperDeclaration.Name }}) Unwrap() any { return w.stream } -{{ comment (printf "%s streams instances of \"%s\" after executing the applied interceptor." .SendName .Interface) }} -func (w *wrapped{{ .Interface }}) {{ .SendName }}(v {{ .SendTypeRef }}) error { +{{ comment (printf "%s streams instances of \"%s\" after executing the applied interceptor." .SendName .InterfaceDeclaration.Name) }} +func (w *{{ .WrapperDeclaration.Name }}) {{ .SendName }}(v {{ .SendTypeRef }}) error { return w.SendWithContext(w.ctx, v) } -{{ comment (printf "%s streams instances of \"%s\" after executing the applied interceptor with context." .SendWithContextName .Interface) }} -func (w *wrapped{{ .Interface }}) {{ .SendWithContextName }}(ctx context.Context, v {{ .SendTypeRef }}) error { +{{ comment (printf "%s streams instances of \"%s\" after executing the applied interceptor with context." .SendWithContextName .InterfaceDeclaration.Name) }} +func (w *{{ .WrapperDeclaration.Name }}) {{ .SendWithContextName }}(ctx context.Context, v {{ .SendTypeRef }}) error { if w.sendWithContext == nil { return w.stream.{{ .SendWithContextName }}(ctx, v) } @@ -22,13 +22,13 @@ func (w *wrapped{{ .Interface }}) {{ .SendWithContextName }}(ctx context.Context {{- end }} {{- if ne .RecvTypeRef "" }} -{{ comment (printf "%s reads instances of \"%s\" from the stream after executing the applied interceptor." .RecvName .Interface) }} -func (w *wrapped{{ .Interface }}) {{ .RecvName }}() ({{ .RecvTypeRef }}, error) { +{{ comment (printf "%s reads instances of \"%s\" from the stream after executing the applied interceptor." .RecvName .InterfaceDeclaration.Name) }} +func (w *{{ .WrapperDeclaration.Name }}) {{ .RecvName }}() ({{ .RecvTypeRef }}, error) { return w.RecvWithContext(w.ctx) } -{{ comment (printf "%s reads instances of \"%s\" from the stream after executing the applied interceptor with context." .RecvWithContextName .Interface) }} -func (w *wrapped{{ .Interface }}) {{ .RecvWithContextName }}(ctx context.Context) ({{ .RecvTypeRef }}, error) { +{{ comment (printf "%s reads instances of \"%s\" from the stream after executing the applied interceptor with context." .RecvWithContextName .InterfaceDeclaration.Name) }} +func (w *{{ .WrapperDeclaration.Name }}) {{ .RecvWithContextName }}(ctx context.Context) ({{ .RecvTypeRef }}, error) { if w.recvWithContext == nil { return w.stream.{{ .RecvWithContextName }}(ctx) } @@ -38,7 +38,7 @@ func (w *wrapped{{ .Interface }}) {{ .RecvWithContextName }}(ctx context.Context {{- if .MustClose }} // Close closes the stream. -func (w *wrapped{{ .Interface }}) Close() error { +func (w *{{ .WrapperDeclaration.Name }}) Close() error { return w.stream.Close() } {{- end }} diff --git a/codegen/service/templates/client_interceptor_wrappers.go.tpl b/codegen/service/templates/client_interceptor_wrappers.go.tpl index c01590d59c..1bec6c3e07 100644 --- a/codegen/service/templates/client_interceptor_wrappers.go.tpl +++ b/codegen/service/templates/client_interceptor_wrappers.go.tpl @@ -1,17 +1,14 @@ -{{- range .ClientInterceptors }} +{{- range .Interceptors }} {{- $interceptor := . }} {{- range .Methods }} -{{ comment (printf "wrapClient%s%s applies the %s client interceptor to endpoints." $interceptor.Name .MethodName $interceptor.DesignName) }} -func wrapClient{{ .MethodName }}{{ $interceptor.Name }}(endpoint goa.Endpoint, i ClientInterceptors) goa.Endpoint { +{{ comment (printf "%s applies the %s client interceptor to endpoints." .ClientWrapperDeclaration.Name $interceptor.DesignName) }} +func {{ .ClientWrapperDeclaration.Name }}(endpoint goa.Endpoint, i {{ $.InterceptorsDeclaration.Name }}) goa.Endpoint { return func(ctx context.Context, req any) (any, error) { {{- if or $interceptor.HasStreamingPayloadAccess $interceptor.HasStreamingResultAccess }} {{- if $interceptor.HasPayloadAccess }} - info := &{{ $interceptor.Name }}Info{ - service: "{{ $.Service }}", - method: "{{ .MethodName }}", - callType: goa.InterceptorUnary, - rawPayload: req, + info := &{{ .ClientUnaryInfoDeclaration.Name }}{ + {{ .InfoDeclaration.Name }}: {{ .InfoDeclaration.Name }}{rawPayload: req}, } res, err := i.{{ $interceptor.Name }}(ctx, info, endpoint) {{- else }} @@ -21,15 +18,12 @@ func wrapClient{{ .MethodName }}{{ $interceptor.Name }}(endpoint goa.Endpoint, i return res, err } stream := res.({{ .ClientStream.Interface }}) - return &wrapped{{ .ClientStream.Interface }}{ + return &{{ .ClientStream.WrapperDeclaration.Name }}{ ctx: ctx, {{- if $interceptor.HasStreamingPayloadAccess }} sendWithContext: func(ctx context.Context, req {{ .ClientStream.SendTypeRef }}) error { - info := &{{ $interceptor.Name }}Info{ - service: "{{ $.Service }}", - method: "{{ .MethodName }}", - callType: goa.InterceptorStreamingSend, - rawPayload: req, + info := &{{ .StreamingSendInfoDeclaration.Name }}{ + {{ .InfoDeclaration.Name }}: {{ .InfoDeclaration.Name }}{rawPayload: req}, } _, err := i.{{ $interceptor.Name }}(ctx, info, func(ctx context.Context, req any) (any, error) { castReq, _ := req.({{ .ClientStream.SendTypeRef }}) @@ -40,10 +34,8 @@ func wrapClient{{ .MethodName }}{{ $interceptor.Name }}(endpoint goa.Endpoint, i {{- end }} {{- if $interceptor.HasStreamingResultAccess }} recvWithContext: func(ctx context.Context) ({{ .ClientStream.RecvTypeRef }}, error) { - info := &{{ $interceptor.Name }}Info{ - service: "{{ $.Service }}", - method: "{{ .MethodName }}", - callType: goa.InterceptorStreamingRecv, + info := &{{ .StreamingRecvInfoDeclaration.Name }}{ + {{ .InfoDeclaration.Name }}: {{ .InfoDeclaration.Name }}{}, } res, err := i.{{ $interceptor.Name }}(ctx, info, func(ctx context.Context, _ any) (any, error) { return stream.{{ .ClientStream.RecvWithContextName }}(ctx) @@ -55,11 +47,8 @@ func wrapClient{{ .MethodName }}{{ $interceptor.Name }}(endpoint goa.Endpoint, i stream: stream, }, nil {{- else }} - info := &{{ $interceptor.Name }}Info{ - service: "{{ $.Service }}", - method: "{{ .MethodName }}", - callType: goa.InterceptorUnary, - rawPayload: req, + info := &{{ .ClientUnaryInfoDeclaration.Name }}{ + {{ .InfoDeclaration.Name }}: {{ .InfoDeclaration.Name }}{rawPayload: req}, } return i.{{ $interceptor.Name }}(ctx, info, endpoint) {{- end }} diff --git a/codegen/service/templates/client_interceptors.go.tpl b/codegen/service/templates/client_interceptors.go.tpl index 9528e3d123..50725935b7 100644 --- a/codegen/service/templates/client_interceptors.go.tpl +++ b/codegen/service/templates/client_interceptors.go.tpl @@ -2,11 +2,11 @@ // Client interceptors execute after the payload is encoded and before the request // is sent to the server. The implementation is responsible for calling next to // complete the request. -type ClientInterceptors interface { +type {{ .ClientInterceptorsDeclaration.Name }} interface { {{- range .ClientInterceptors }} {{- if .Description }} {{ comment .Description }} {{- end }} - {{ .Name }}(ctx context.Context, info *{{ .Name }}Info, next goa.Endpoint) (any, error) + {{ .Name }}(ctx context.Context, info {{ .InfoDeclaration.Name }}, next goa.Endpoint) (any, error) {{- end }} } diff --git a/codegen/service/templates/client_wrappers.go.tpl b/codegen/service/templates/client_wrappers.go.tpl index bbd1a551d5..eee2c6f8bc 100644 --- a/codegen/service/templates/client_wrappers.go.tpl +++ b/codegen/service/templates/client_wrappers.go.tpl @@ -1,9 +1,9 @@ -{{ comment (printf "Wrap%sClientEndpoint wraps the %s endpoint with the client interceptors defined in the design." .MethodVarName .Method) }} -func Wrap{{ .MethodVarName }}ClientEndpoint(endpoint goa.Endpoint, i ClientInterceptors) goa.Endpoint { +{{ comment (printf "%s wraps the %s endpoint with the client interceptors defined in the design." .Declaration.Name .Method) }} +func {{ .Declaration.Name }}(endpoint goa.Endpoint, i {{ .InterceptorsDeclaration.Name }}) goa.Endpoint { if i != nil { - {{- range .Interceptors }} - endpoint = wrapClient{{ $.MethodVarName }}{{ . }}(endpoint, i) + {{- range .Wrappers }} + endpoint = {{ .Name }}(endpoint, i) {{- end }} } return endpoint diff --git a/codegen/service/templates/endpoint.go.tpl b/codegen/service/templates/endpoint.go.tpl index 95a459bea3..e15f537a4c 100644 --- a/codegen/service/templates/endpoint.go.tpl +++ b/codegen/service/templates/endpoint.go.tpl @@ -1,14 +1,18 @@ {{ comment .Description }} {{- if .ServerStream }} -func (s *{{ .ServiceVarName }}srvc) {{ .VarName }}(ctx context.Context{{ if .PayloadFullRef }}, p {{ .PayloadFullRef }}{{ end }}, stream {{ .StreamInterface }}) (err error) { + {{- if .HasMixedResults }} +func (s *{{ .ExampleStructDeclaration.Name }}) {{ .VarName }}(ctx context.Context{{ if .PayloadFullRef }}, p {{ .PayloadFullRef }}{{ end }}, stream {{ .StreamInterface }}) ({{ if .Result }}res {{ .ResultFullRef }}, {{ end }}{{ if .ViewedResult }}{{ if not .ViewedResult.ViewName }}view string, {{ end }}{{ end }}err error) { + {{- else }} +func (s *{{ .ExampleStructDeclaration.Name }}) {{ .VarName }}(ctx context.Context{{ if .PayloadFullRef }}, p {{ .PayloadFullRef }}{{ end }}, stream {{ .StreamInterface }}) (err error) { + {{- end }} {{- else }} -func (s *{{ .ServiceVarName }}srvc) {{ .VarName }}(ctx context.Context{{ if .PayloadFullRef }}, p {{ .PayloadFullRef }}{{ end }}{{ if .SkipRequestBodyEncodeDecode }}, req io.ReadCloser{{ end }}) ({{ if .Result }}res {{ .ResultFullRef }}, {{ end }}{{ if .SkipResponseBodyEncodeDecode }}resp io.ReadCloser, {{ end }}{{ if .ViewedResult }}{{ if not .ViewedResult.ViewName }}view string, {{ end }}{{ end }}err error) { +func (s *{{ .ExampleStructDeclaration.Name }}) {{ .VarName }}(ctx context.Context{{ if .PayloadFullRef }}, p {{ .PayloadFullRef }}{{ end }}{{ if .SkipRequestBodyEncodeDecode }}, req io.ReadCloser{{ end }}) ({{ if .Result }}res {{ .ResultFullRef }}, {{ end }}{{ if .SkipResponseBodyEncodeDecode }}resp io.ReadCloser, {{ end }}{{ if .ViewedResult }}{{ if not .ViewedResult.ViewName }}view string, {{ end }}{{ end }}err error) { {{- end }} {{- if .SkipRequestBodyEncodeDecode }} // req is the HTTP request body stream. defer req.Close() {{- end }} -{{- if and .Result .ResultIsStruct (not .ServerStream) }} +{{- if and .Result .ResultIsStruct (or (not .ServerStream) .HasMixedResults) }} res = &{{ .ResultFullName }}{} {{- end }} {{- if .SkipResponseBodyEncodeDecode }} @@ -17,7 +21,9 @@ func (s *{{ .ServiceVarName }}srvc) {{ .VarName }}(ctx context.Context{{ if .Pay {{- end }} {{- if .ViewedResult }} {{- if not .ViewedResult.ViewName }} - {{- if .ServerStream }} + {{- if .HasMixedResults }} + view = {{ printf "%q" .ResultView }} + {{- else if .ServerStream }} stream.SetView({{ printf "%q" .ResultView }}) {{- else }} view = {{ printf "%q" .ResultView }} @@ -25,16 +31,5 @@ func (s *{{ .ServiceVarName }}srvc) {{ .VarName }}(ctx context.Context{{ if .Pay {{- end }} {{- end }} log.Printf(ctx, "{{ .ServiceVarName }}.{{ .Name }}") -{{- if and .ServerStream .IsJSONRPC .ResultFullName }} - // Minimal example: emit one progress notification and one final response - { - // Progress notification (no ID) - notif := {{ if .ResultIsStruct }}&{{ .ResultFullName }}{}{{ else }}{{ .ResultFullName }}({{ if eq .ResultFullName "string" }}"progress"{{ else }}0{{ end }}){{ end }} - if err := stream.Send(ctx, notif); err != nil { return err } - // Final response - final := {{ if .ResultIsStruct }}&{{ .ResultFullName }}{}{{ else }}{{ .ResultFullName }}({{ if eq .ResultFullName "string" }}"done"{{ else }}0{{ end }}){{ end }} - return stream.SendAndClose(ctx, final) - } -{{- end }} return } diff --git a/codegen/service/templates/endpoint_wrappers.go.tpl b/codegen/service/templates/endpoint_wrappers.go.tpl index fee83a87ad..13f0164f39 100644 --- a/codegen/service/templates/endpoint_wrappers.go.tpl +++ b/codegen/service/templates/endpoint_wrappers.go.tpl @@ -1,8 +1,8 @@ -{{ comment (printf "Wrap%sEndpoint wraps the %s endpoint with the server-side interceptors defined in the design." .MethodVarName .Method) }} -func Wrap{{ .MethodVarName }}Endpoint(endpoint goa.Endpoint, i ServerInterceptors) goa.Endpoint { +{{ comment (printf "%s wraps the %s endpoint with the server-side interceptors defined in the design." .Declaration.Name .Method) }} +func {{ .Declaration.Name }}(endpoint goa.Endpoint, i {{ .InterceptorsDeclaration.Name }}) goa.Endpoint { if i != nil { - {{- range .Interceptors }} - endpoint = wrap{{ $.MethodVarName }}{{ . }}(endpoint, i) + {{- range .Wrappers }} + endpoint = {{ .Name }}(endpoint, i) {{- end }} } return endpoint diff --git a/codegen/service/templates/error.go.tpl b/codegen/service/templates/error.go.tpl index d995faa1f5..c2994e0497 100644 --- a/codegen/service/templates/error.go.tpl +++ b/codegen/service/templates/error.go.tpl @@ -1,6 +1,6 @@ // Error returns an error description. func (e {{ .Ref }}) Error() string { - return {{ printf "%q" .Description }} + return {{ printf "%q" .ErrorDescription }} } // ErrorName returns the error name. @@ -12,5 +12,5 @@ func (e {{ .Ref }}) ErrorName() string { // GoaErrorName returns the error name. func (e {{ .Ref }}) GoaErrorName() string { - return {{ errorName . }} + return {{ .ErrorName }} } diff --git a/codegen/service/templates/error_init.go.tpl b/codegen/service/templates/error_init.go.tpl index 8b474689b1..fda57b0125 100644 --- a/codegen/service/templates/error_init.go.tpl +++ b/codegen/service/templates/error_init.go.tpl @@ -1,4 +1,4 @@ -{{ printf "%s builds a %s from an error." .Name .TypeName | comment }} -func {{ .Name }}(err error) {{ .TypeRef }} { +{{ printf "%s builds a %s from an error." .Declaration.Name .TypeName | comment }} +func {{ .Declaration.Name }}(err error) {{ .TypeRef }} { return goa.NewServiceError(err, {{ printf "%q" .ErrName }}, {{ printf "%v" .Timeout }}, {{ printf "%v" .Temporary}}, {{ printf "%v" .Fault}}) } diff --git a/codegen/service/templates/example_client_interceptor.go.tpl b/codegen/service/templates/example_client_interceptor.go.tpl index 4dad48f184..d77925209a 100644 --- a/codegen/service/templates/example_client_interceptor.go.tpl +++ b/codegen/service/templates/example_client_interceptor.go.tpl @@ -1,17 +1,17 @@ -// {{ .StructName }}ClientInterceptors implements the client interceptors for the {{ .ServiceName }} service. -type {{ .StructName }}ClientInterceptors struct { +// {{ .StructDeclaration.Name }} implements the client interceptors for the {{ .ServiceName }} service. +type {{ .StructDeclaration.Name }} struct { } -// New{{ .StructName }}ClientInterceptors creates a new client interceptor for the {{ .ServiceName }} service. -func New{{ .StructName }}ClientInterceptors() *{{ .StructName }}ClientInterceptors { - return &{{ .StructName }}ClientInterceptors{} +// {{ .ConstructorDeclaration.Name }} creates a new client interceptor for the {{ .ServiceName }} service. +func {{ .ConstructorDeclaration.Name }}() *{{ .StructDeclaration.Name }} { + return &{{ .StructDeclaration.Name }}{} } -{{- range .ClientInterceptors }} +{{- range .Interceptors }} {{- if .Description }} {{ comment .Description }} {{- end }} -func (i *{{ $.StructName }}ClientInterceptors) {{ .Name }}(ctx context.Context, info *{{ $.PkgName }}.{{ .Name }}Info, next goa.Endpoint) (any, error) { +func (i *{{ $.StructDeclaration.Name }}) {{ .Name }}(ctx context.Context, info {{ $.ServicePkg }}.{{ .Name }}Info, next goa.Endpoint) (any, error) { log.Printf(ctx, "[{{ .Name }}] Sending request: %v", info.RawPayload()) resp, err := next(ctx, info.RawPayload()) if err != nil { diff --git a/codegen/service/templates/example_security_authfuncs.go.tpl b/codegen/service/templates/example_security_authfuncs.go.tpl index 5eb760e38e..ba06534ce6 100644 --- a/codegen/service/templates/example_security_authfuncs.go.tpl +++ b/codegen/service/templates/example_security_authfuncs.go.tpl @@ -1,6 +1,6 @@ {{ range .Schemes }} {{ printf "%sAuth implements the authorization logic for service %q for the %q security scheme." .Type $.Name .SchemeName | comment }} -func (s *{{ $.VarName }}srvc) {{ .Type }}Auth(ctx context.Context, {{ if eq .Type "Basic" }}user, pass{{ else if eq .Type "APIKey" }}key{{ else }}token{{ end }} string, scheme *security.{{ .Type }}Scheme) (context.Context, error) { +func (s *{{ $.ExampleStructDeclaration.Name }}) {{ .Type }}Auth(ctx context.Context, {{ if eq .Type "Basic" }}user, pass{{ else if eq .Type "APIKey" }}key{{ else }}token{{ end }} string, scheme *security.{{ .Type }}Scheme) (context.Context, error) { // // TBD: add authorization logic. // diff --git a/codegen/service/templates/example_server_interceptor.go.tpl b/codegen/service/templates/example_server_interceptor.go.tpl index 9e1ed5086e..172f9b449c 100644 --- a/codegen/service/templates/example_server_interceptor.go.tpl +++ b/codegen/service/templates/example_server_interceptor.go.tpl @@ -1,17 +1,17 @@ -// {{ .StructName }}ServerInterceptors implements the server interceptor for the {{ .ServiceName }} service. -type {{ .StructName }}ServerInterceptors struct { +// {{ .StructDeclaration.Name }} implements the server interceptor for the {{ .ServiceName }} service. +type {{ .StructDeclaration.Name }} struct { } -// New{{ .StructName }}ServerInterceptors creates a new server interceptor for the {{ .ServiceName }} service. -func New{{ .StructName }}ServerInterceptors() *{{ .StructName }}ServerInterceptors { - return &{{ .StructName }}ServerInterceptors{} +// {{ .ConstructorDeclaration.Name }} creates a new server interceptor for the {{ .ServiceName }} service. +func {{ .ConstructorDeclaration.Name }}() *{{ .StructDeclaration.Name }} { + return &{{ .StructDeclaration.Name }}{} } -{{- range .ServerInterceptors }} +{{- range .Interceptors }} {{- if .Description }} {{ comment .Description }} {{- end }} -func (i *{{ $.StructName }}ServerInterceptors) {{ .Name }}(ctx context.Context, info *{{ $.PkgName }}.{{ .Name }}Info, next goa.Endpoint) (any, error) { +func (i *{{ $.StructDeclaration.Name }}) {{ .Name }}(ctx context.Context, info {{ $.ServicePkg }}.{{ .Name }}Info, next goa.Endpoint) (any, error) { log.Printf(ctx, "[{{ .Name }}] Processing request: %v", info.RawPayload()) resp, err := next(ctx, info.RawPayload()) if err != nil { diff --git a/codegen/service/templates/example_service_init.go.tpl b/codegen/service/templates/example_service_init.go.tpl index 0e9a9fca28..fe043cc951 100644 --- a/codegen/service/templates/example_service_init.go.tpl +++ b/codegen/service/templates/example_service_init.go.tpl @@ -1,4 +1,4 @@ {{ printf "New%s returns the %s service implementation." .StructName .Name | comment }} -func New{{ .StructName }}() {{ .PkgName }}.Service { - return &{{ .VarName }}srvc{} +func {{ .ExampleConstructorDeclaration.Name }}() {{ .ServicePkg }}.{{ .ServiceDeclaration.Name }} { + return &{{ .ExampleStructDeclaration.Name }}{} } diff --git a/codegen/service/templates/example_service_struct.go.tpl b/codegen/service/templates/example_service_struct.go.tpl index baefc91ddb..1086752e5d 100644 --- a/codegen/service/templates/example_service_struct.go.tpl +++ b/codegen/service/templates/example_service_struct.go.tpl @@ -1,2 +1,2 @@ {{ printf "%s service example implementation.\nThe example methods log the requests and return zero values." .Name | comment }} -type {{ .VarName }}srvc struct {} +type {{ .ExampleStructDeclaration.Name }} struct {} diff --git a/codegen/service/templates/interceptors.go.tpl b/codegen/service/templates/interceptors.go.tpl index 8c53dbb773..18b01d2803 100644 --- a/codegen/service/templates/interceptors.go.tpl +++ b/codegen/service/templates/interceptors.go.tpl @@ -1,161 +1,106 @@ -// Public accessor methods for Info types +// Methods that provide information about each service call {{- range . }} + {{- $interceptor := . }} + {{- range .Methods }} -// Service returns the name of the service handling the request. -func (info *{{ .Name }}Info) Service() string { - return info.service +// Service returns the service selected for this interceptor call. +func (info *{{ .InfoDeclaration.Name }}) Service() string { + return "{{ $interceptor.Service }}" } -// Method returns the name of the method handling the request. -func (info *{{ .Name }}Info) Method() string { - return info.method +// Method returns the method selected for this interceptor call. +func (info *{{ .InfoDeclaration.Name }}) Method() string { + return "{{ .MethodName }}" } -// CallType returns the type of call the interceptor is handling. -func (info *{{ .Name }}Info) CallType() goa.InterceptorCallType { - return info.callType +// RawPayload returns the payload supplied for this interceptor call. +func (info *{{ .InfoDeclaration.Name }}) RawPayload() any { + return info.rawPayload } + {{- if .ServerUnaryInfoDeclaration }} -// RawPayload returns the raw payload of the request. -func (info *{{ .Name }}Info) RawPayload() any { - return info.rawPayload +// CallType reports that this is a server endpoint call. +func (info *{{ .ServerUnaryInfoDeclaration.Name }}) CallType() goa.InterceptorCallType { + return goa.InterceptorUnary } - {{- if .HasPayloadAccess }} - -// Payload returns a type-safe accessor for the method payload. -func (info *{{ .Name }}Info) Payload() {{ .Name }}Payload { - {{- if gt (len .Methods) 1 }} - switch info.Method() { - {{- range .Methods }} - case "{{ .MethodName }}": - {{- if hasEndpointStruct . }} - switch pay := info.RawPayload().(type) { - case *{{ .ServerStream.EndpointStruct }}: - return &{{ .PayloadAccess }}{payload: pay.Payload} - default: - return &{{ .PayloadAccess }}{payload: pay.({{ .PayloadRef }})} - } - {{- else }} - return &{{ .PayloadAccess }}{payload: info.RawPayload().({{ .PayloadRef }})} - {{- end }} - {{- end }} - default: - return nil - } - {{- else }} - {{- if hasEndpointStruct (index .Methods 0) }} - switch pay := info.RawPayload().(type) { - case *{{ (index .Methods 0).ServerStream.EndpointStruct }}: - return &{{ (index .Methods 0).PayloadAccess }}{payload: pay.Payload} - default: - return &{{ (index .Methods 0).PayloadAccess }}{payload: pay.({{ (index .Methods 0).PayloadRef }})} - } - {{- else }} - return &{{ (index .Methods 0).PayloadAccess }}{payload: info.RawPayload().({{ (index .Methods 0).PayloadRef }})} - {{- end }} {{- end }} -} - {{- end }} + {{- if .ClientUnaryInfoDeclaration }} - {{- if .HasResultAccess }} -// Result returns a type-safe accessor for the method result. -func (info *{{ .Name }}Info) Result(res any) {{ .Name }}Result { - {{- if gt (len .Methods) 1 }} - switch info.Method() { - {{- range .Methods }} - case "{{ .MethodName }}": - return &{{ .ResultAccess }}{result: res.({{ .ResultRef }})} - {{- end }} - default: - return nil - } - {{- else }} - return &{{ (index .Methods 0).ResultAccess }}{result: res.({{ (index .Methods 0).ResultRef }})} +// CallType reports that this is a client endpoint call. +func (info *{{ .ClientUnaryInfoDeclaration.Name }}) CallType() goa.InterceptorCallType { + return goa.InterceptorUnary +} {{- end }} + {{- if .StreamingSendInfoDeclaration }} + +// CallType reports that this is a stream send. +func (info *{{ .StreamingSendInfoDeclaration.Name }}) CallType() goa.InterceptorCallType { + return goa.InterceptorStreamingSend } - {{- end }} + {{- end }} + {{- if .StreamingRecvInfoDeclaration }} - {{- if .HasStreamingPayloadAccess }} -// ClientStreamingPayload returns a type-safe accessor for the method streaming payload for a client-side interceptor. -func (info *{{ .Name }}Info) ClientStreamingPayload() {{ .Name }}StreamingPayload { - {{- if gt (len .Methods) 1 }} - switch info.Method() { - {{- range .Methods }} - case "{{ .MethodName }}": - return &{{ .StreamingPayloadAccess }}{payload: info.RawPayload().({{ .StreamingPayloadRef }})} - {{- end }} - default: - return nil - } - {{- else }} - return &{{ (index .Methods 0).StreamingPayloadAccess }}{payload: info.RawPayload().({{ (index .Methods 0).StreamingPayloadRef }})} +// CallType reports that this is a stream receive. +func (info *{{ .StreamingRecvInfoDeclaration.Name }}) CallType() goa.InterceptorCallType { + return goa.InterceptorStreamingRecv +} {{- end }} + {{- if $interceptor.HasPayloadAccess }} + +// Payload returns this method's payload fields. +func (info *{{ .InfoDeclaration.Name }}) Payload() {{ $interceptor.PayloadDeclaration.Name }} { + return &{{ .PayloadAccessDeclaration.Name }}{payload: info.rawPayload.({{ .PayloadRef }})} } - {{- end }} + {{- if and .ServerUnaryInfoDeclaration (hasEndpointStruct .) }} - {{- if .HasStreamingResultAccess }} -// ClientStreamingResult returns a type-safe accessor for the method streaming result for a client-side interceptor. -func (info *{{ .Name }}Info) ClientStreamingResult(res any) {{ .Name }}StreamingResult { - {{- if gt (len .Methods) 1 }} - switch info.Method() { - {{- range .Methods }} - case "{{ .MethodName }}": - return &{{ .StreamingResultAccess }}{result: res.({{ .StreamingResultRef }})} +// Payload returns this server method's payload fields. +func (info *{{ .ServerUnaryInfoDeclaration.Name }}) Payload() {{ $interceptor.PayloadDeclaration.Name }} { + return &{{ .PayloadAccessDeclaration.Name }}{payload: info.rawPayload.(*{{ .ServerStream.EndpointStruct }}).Payload} +} {{- end }} - default: - return nil - } - {{- else }} - return &{{ (index .Methods 0).StreamingResultAccess }}{result: res.({{ (index .Methods 0).StreamingResultRef }})} {{- end }} -} - {{- end }} + {{- if $interceptor.HasResultAccess }} - {{- if .HasStreamingPayloadAccess }} -// ServerStreamingPayload returns a type-safe accessor for the method streaming payload for a server-side interceptor. -func (info *{{ .Name }}Info) ServerStreamingPayload(pay any) {{ .Name }}StreamingPayload { - {{- if gt (len .Methods) 1 }} - switch info.Method() { - {{- range .Methods }} - case "{{ .MethodName }}": - return &{{ .StreamingPayloadAccess }}{payload: pay.({{ .StreamingPayloadRef }})} - {{- end }} - default: - return nil - } - {{- else }} - return &{{ (index .Methods 0).StreamingPayloadAccess }}{payload: pay.({{ (index .Methods 0).StreamingPayloadRef }})} +// Result returns this method's result fields. +func (info *{{ .InfoDeclaration.Name }}) Result(res any) {{ $interceptor.ResultDeclaration.Name }} { + return &{{ .ResultAccessDeclaration.Name }}{result: res.({{ .ResultRef }})} +} {{- end }} + {{- if $interceptor.HasStreamingPayloadAccess }} + +// ClientStreamingPayload returns this method's outgoing streaming payload fields. +func (info *{{ .InfoDeclaration.Name }}) ClientStreamingPayload() {{ $interceptor.StreamingPayloadDeclaration.Name }} { + return &{{ .StreamingPayloadAccessDeclaration.Name }}{payload: info.rawPayload.({{ .StreamingPayloadRef }})} } - {{- end }} - {{- if .HasStreamingResultAccess }} -// ServerStreamingResult returns a type-safe accessor for the method streaming result for a server-side interceptor. -func (info *{{ .Name }}Info) ServerStreamingResult() {{ .Name }}StreamingResult { - {{- if gt (len .Methods) 1 }} - switch info.Method() { - {{- range .Methods }} - case "{{ .MethodName }}": - return &{{ .StreamingResultAccess }}{result: info.RawPayload().({{ .StreamingResultRef }})} - {{- end }} - default: - return nil - } - {{- else }} - return &{{ (index .Methods 0).StreamingResultAccess }}{result: info.RawPayload().({{ (index .Methods 0).StreamingResultRef }})} +// ServerStreamingPayload returns this method's incoming streaming payload fields. +func (info *{{ .InfoDeclaration.Name }}) ServerStreamingPayload(payload any) {{ $interceptor.StreamingPayloadDeclaration.Name }} { + return &{{ .StreamingPayloadAccessDeclaration.Name }}{payload: payload.({{ .StreamingPayloadRef }})} +} {{- end }} + {{- if $interceptor.HasStreamingResultAccess }} + +// ClientStreamingResult returns this method's incoming streaming result fields. +func (info *{{ .InfoDeclaration.Name }}) ClientStreamingResult(result any) {{ $interceptor.StreamingResultDeclaration.Name }} { + return &{{ .StreamingResultAccessDeclaration.Name }}{result: result.({{ .StreamingResultRef }})} +} + +// ServerStreamingResult returns this method's outgoing streaming result fields. +func (info *{{ .InfoDeclaration.Name }}) ServerStreamingResult() {{ $interceptor.StreamingResultDeclaration.Name }} { + return &{{ .StreamingResultAccessDeclaration.Name }}{result: info.rawPayload.({{ .StreamingResultRef }})} } + {{- end }} {{- end }} {{- end }} -{{- if hasPrivateImplementationTypes . }} -// Private implementation methods +{{- if hasPrivateAccessorMethods . }} +// Methods that read and write the selected payload and result fields {{- range . }} {{ $interceptor := . }} {{- range .Methods }} {{- $method := . }} {{- range $interceptor.ReadPayload }} -func (p *{{ $method.PayloadAccess }}) {{ .Name }}() {{ .TypeRef }} { +func (p *{{ $method.PayloadAccessDeclaration.Name }}) {{ .Name }}() {{ .TypeRef }} { {{- if .Pointer }} if p.payload.{{ .Name }} == nil { var zero {{ .TypeRef }} @@ -169,7 +114,7 @@ func (p *{{ $method.PayloadAccess }}) {{ .Name }}() {{ .TypeRef }} { {{- end }} {{- range $interceptor.WritePayload }} -func (p *{{ $method.PayloadAccess }}) Set{{ .Name }}(v {{ .TypeRef }}) { +func (p *{{ $method.PayloadAccessDeclaration.Name }}) Set{{ .Name }}(v {{ .TypeRef }}) { {{- if .Pointer }} p.payload.{{ .Name }} = &v {{- else }} @@ -179,7 +124,7 @@ func (p *{{ $method.PayloadAccess }}) Set{{ .Name }}(v {{ .TypeRef }}) { {{- end }} {{- range $interceptor.ReadResult }} -func (r *{{ $method.ResultAccess }}) {{ .Name }}() {{ .TypeRef }} { +func (r *{{ $method.ResultAccessDeclaration.Name }}) {{ .Name }}() {{ .TypeRef }} { {{- if .Pointer }} if r.result.{{ .Name }} == nil { var zero {{ .TypeRef }} @@ -193,7 +138,7 @@ func (r *{{ $method.ResultAccess }}) {{ .Name }}() {{ .TypeRef }} { {{- end }} {{- range $interceptor.WriteResult }} -func (r *{{ $method.ResultAccess }}) Set{{ .Name }}(v {{ .TypeRef }}) { +func (r *{{ $method.ResultAccessDeclaration.Name }}) Set{{ .Name }}(v {{ .TypeRef }}) { {{- if .Pointer }} r.result.{{ .Name }} = &v {{- else }} @@ -203,7 +148,7 @@ func (r *{{ $method.ResultAccess }}) Set{{ .Name }}(v {{ .TypeRef }}) { {{- end }} {{- range $interceptor.ReadStreamingPayload }} -func (p *{{ $method.StreamingPayloadAccess }}) {{ .Name }}() {{ .TypeRef }} { +func (p *{{ $method.StreamingPayloadAccessDeclaration.Name }}) {{ .Name }}() {{ .TypeRef }} { {{- if .Pointer }} if p.payload.{{ .Name }} == nil { var zero {{ .TypeRef }} @@ -217,7 +162,7 @@ func (p *{{ $method.StreamingPayloadAccess }}) {{ .Name }}() {{ .TypeRef }} { {{- end }} {{- range $interceptor.WriteStreamingPayload }} -func (p *{{ $method.StreamingPayloadAccess }}) Set{{ .Name }}(v {{ .TypeRef }}) { +func (p *{{ $method.StreamingPayloadAccessDeclaration.Name }}) Set{{ .Name }}(v {{ .TypeRef }}) { {{- if .Pointer }} p.payload.{{ .Name }} = &v {{- else }} @@ -227,7 +172,7 @@ func (p *{{ $method.StreamingPayloadAccess }}) Set{{ .Name }}(v {{ .TypeRef }}) {{- end }} {{- range $interceptor.ReadStreamingResult }} -func (r *{{ $method.StreamingResultAccess }}) {{ .Name }}() {{ .TypeRef }} { +func (r *{{ $method.StreamingResultAccessDeclaration.Name }}) {{ .Name }}() {{ .TypeRef }} { {{- if .Pointer }} if r.result.{{ .Name }} == nil { var zero {{ .TypeRef }} @@ -241,7 +186,7 @@ func (r *{{ $method.StreamingResultAccess }}) {{ .Name }}() {{ .TypeRef }} { {{- end }} {{- range $interceptor.WriteStreamingResult }} -func (r *{{ $method.StreamingResultAccess }}) Set{{ .Name }}(v {{ .TypeRef }}) { +func (r *{{ $method.StreamingResultAccessDeclaration.Name }}) Set{{ .Name }}(v {{ .TypeRef }}) { {{- if .Pointer }} r.result.{{ .Name }} = &v {{- else }} diff --git a/codegen/service/templates/interceptors_types.go.tpl b/codegen/service/templates/interceptors_types.go.tpl index 10b890f470..7713ebc916 100644 --- a/codegen/service/templates/interceptors_types.go.tpl +++ b/codegen/service/templates/interceptors_types.go.tpl @@ -2,20 +2,36 @@ // Access interfaces for interceptor payloads and results type ( {{- range . }} - // {{ .Name }}Info provides metadata about the current interception. - // It includes service name, method name, and access to the endpoint. - {{ .Name }}Info struct { - service string - method string - callType goa.InterceptorCallType - rawPayload any + // {{ .InfoDeclaration.Name }} describes the service call currently passed to the interceptor. + {{ .InfoDeclaration.Name }} interface { + goa.InterceptorInfo + {{- if .HasPayloadAccess }} + // Payload returns the selected fields from the method payload. + Payload() {{ .PayloadDeclaration.Name }} + {{- end }} + {{- if .HasResultAccess }} + // Result returns the selected fields from the method result. + Result(any) {{ .ResultDeclaration.Name }} + {{- end }} + {{- if .HasStreamingPayloadAccess }} + // ClientStreamingPayload returns selected fields from the outgoing stream payload. + ClientStreamingPayload() {{ .StreamingPayloadDeclaration.Name }} + // ServerStreamingPayload returns selected fields from the incoming stream payload. + ServerStreamingPayload(any) {{ .StreamingPayloadDeclaration.Name }} + {{- end }} + {{- if .HasStreamingResultAccess }} + // ClientStreamingResult returns selected fields from the incoming stream result. + ClientStreamingResult(any) {{ .StreamingResultDeclaration.Name }} + // ServerStreamingResult returns selected fields from the outgoing stream result. + ServerStreamingResult() {{ .StreamingResultDeclaration.Name }} + {{- end }} } {{- if .HasPayloadAccess }} - // {{ .Name }}Payload provides type-safe access to the method payload. + // {{ .PayloadDeclaration.Name }} provides type-safe access to the method payload. // It allows reading and writing specific fields of the payload as defined // in the design. - {{ .Name }}Payload interface { + {{ .PayloadDeclaration.Name }} interface { {{- range .ReadPayload }} {{ .Name }}() {{ .TypeRef }} {{- end }} @@ -26,10 +42,10 @@ type ( {{- end }} {{- if .HasResultAccess }} - // {{ .Name }}Result provides type-safe access to the method result. + // {{ .ResultDeclaration.Name }} provides type-safe access to the method result. // It allows reading and writing specific fields of the result as defined // in the design. - {{ .Name }}Result interface { + {{ .ResultDeclaration.Name }} interface { {{- range .ReadResult }} {{ .Name }}() {{ .TypeRef }} {{- end }} @@ -40,10 +56,10 @@ type ( {{- end }} {{- if .HasStreamingPayloadAccess }} - // {{ .Name }}StreamingPayload provides type-safe access to the method streaming payload. + // {{ .StreamingPayloadDeclaration.Name }} provides type-safe access to the method streaming payload. // It allows reading and writing specific fields of the streaming payload as defined // in the design. - {{ .Name }}StreamingPayload interface { + {{ .StreamingPayloadDeclaration.Name }} interface { {{- range .ReadStreamingPayload }} {{ .Name }}() {{ .TypeRef }} {{- end }} @@ -54,10 +70,10 @@ type ( {{- end }} {{- if .HasStreamingResultAccess }} - // {{ .Name }}StreamingResult provides type-safe access to the method streaming result. + // {{ .StreamingResultDeclaration.Name }} provides type-safe access to the method streaming result. // It allows reading and writing specific fields of the streaming result as defined // in the design. - {{ .Name }}StreamingResult interface { + {{ .StreamingResultDeclaration.Name }} interface { {{- range .ReadStreamingResult }} {{ .Name }}() {{ .TypeRef }} {{- end }} @@ -70,12 +86,40 @@ type ( ) {{- if hasPrivateImplementationTypes . }} -// Private implementation types +// Types used to provide information about each service call type ( {{- range . }} {{- range .Methods }} - {{- if .PayloadAccess }} - {{ .PayloadAccess }} struct { + {{ .InfoDeclaration.Name }} struct { + rawPayload any + } + {{- if .ServerUnaryInfoDeclaration }} + {{ .ServerUnaryInfoDeclaration.Name }} struct { + {{ .InfoDeclaration.Name }} + } + {{- end }} + {{- if .ClientUnaryInfoDeclaration }} + {{ .ClientUnaryInfoDeclaration.Name }} struct { + {{ .InfoDeclaration.Name }} + } + {{- end }} + {{- if .StreamingSendInfoDeclaration }} + {{ .StreamingSendInfoDeclaration.Name }} struct { + {{ .InfoDeclaration.Name }} + } + {{- end }} + {{- if .StreamingRecvInfoDeclaration }} + {{ .StreamingRecvInfoDeclaration.Name }} struct { + {{ .InfoDeclaration.Name }} + } + {{- end }} + {{- end }} + {{- end }} + + {{- range . }} + {{- range .Methods }} + {{- if .PayloadAccessDeclaration }} + {{ .PayloadAccessDeclaration.Name }} struct { payload {{ .PayloadRef }} } {{- end }} @@ -84,8 +128,8 @@ type ( {{- range . }} {{- range .Methods }} - {{- if .ResultAccess }} - {{ .ResultAccess }} struct { + {{- if .ResultAccessDeclaration }} + {{ .ResultAccessDeclaration.Name }} struct { result {{ .ResultRef }} } {{- end }} @@ -94,8 +138,8 @@ type ( {{- range . }} {{- range .Methods }} - {{- if .StreamingPayloadAccess }} - {{ .StreamingPayloadAccess }} struct { + {{- if .StreamingPayloadAccessDeclaration }} + {{ .StreamingPayloadAccessDeclaration.Name }} struct { payload {{ .StreamingPayloadRef }} } {{- end }} @@ -104,8 +148,8 @@ type ( {{- range . }} {{- range .Methods }} - {{- if .StreamingResultAccess }} - {{ .StreamingResultAccess }} struct { + {{- if .StreamingResultAccessDeclaration }} + {{ .StreamingResultAccessDeclaration.Name }} struct { result {{ .StreamingResultRef }} } {{- end }} diff --git a/codegen/service/templates/jsonrpc_handle_stream.go.tpl b/codegen/service/templates/jsonrpc_handle_stream.go.tpl deleted file mode 100644 index 310a979a79..0000000000 --- a/codegen/service/templates/jsonrpc_handle_stream.go.tpl +++ /dev/null @@ -1,17 +0,0 @@ -// HandleStream manages a JSON-RPC WebSocket connection, enabling bidirectional -// communication between the server and client. It receives requests from the -// client, dispatches them to the appropriate service methods, and can send -// server-initiated messages back to the client as needed. -func (s *{{ .VarName }}srvc) HandleStream(ctx context.Context, stream {{ .PkgName }}.Stream) error { - log.Printf(ctx, "{{ .VarName }}.HandleStream") - - // Example: In a real implementation you might read from an event source - // and send notifications via stream.Send(ctx, event). This stub returns - // when the context is canceled. - select { - case <-ctx.Done(): - return ctx.Err() - default: - return nil - } -} diff --git a/codegen/service/templates/jsonrpc_streaming_endpoint.go.tpl b/codegen/service/templates/jsonrpc_streaming_endpoint.go.tpl index 868b063886..2ebabb6484 100644 --- a/codegen/service/templates/jsonrpc_streaming_endpoint.go.tpl +++ b/codegen/service/templates/jsonrpc_streaming_endpoint.go.tpl @@ -1,5 +1,5 @@ {{ comment .Description }} -func (s *{{ .ServiceVarName }}srvc) {{ .VarName }}(ctx context.Context{{ if .PayloadFullRef }}, p {{ .PayloadFullRef }}{{ end }}) ({{ if .Result }}res {{ .ResultFullRef }}, {{ end }}err error) { +func (s *{{ .ExampleStructDeclaration.Name }}) {{ .VarName }}(ctx context.Context{{ if .PayloadFullRef }}, p {{ .PayloadFullRef }}{{ end }}) ({{ if .Result }}res {{ .ResultFullRef }}, {{ end }}err error) { {{- if and .Result .ResultIsStruct }} res = &{{ .ResultFullName }}{} {{- end }} @@ -10,4 +10,4 @@ func (s *{{ .ServiceVarName }}srvc) {{ .VarName }}(ctx context.Context{{ if .Pay {{- end }} log.Printf(ctx, "{{ .ServiceVarName }}.{{ .Name }}") return -} \ No newline at end of file +} diff --git a/codegen/service/templates/return_type_init.go.tpl b/codegen/service/templates/return_type_init.go.tpl index 28e7a51b9f..95d387deca 100644 --- a/codegen/service/templates/return_type_init.go.tpl +++ b/codegen/service/templates/return_type_init.go.tpl @@ -2,22 +2,22 @@ {{- if eq (len .Views) 1 }} {{- with (index .Views 0) }} {{- if $.ToViewed -}} - p := {{ $.InitName }}{{ if ne .Name "default" }}{{ goify .Name true }}{{ end }}({{ $.ArgVar }}) + p := {{ .ToProjected.Name }}({{ $.ArgVar }}) return {{ if not $.IsCollection }}&{{ end }}{{ $.TargetType }}{Projected: p, View: {{ printf "%q" .Name }} } {{- else -}} - return {{ $.InitName }}{{ if ne .Name "default" }}{{ goify .Name true }}{{ end }}({{ $.ArgVar }}.Projected) + return {{ .ToResult.Name }}({{ $.ArgVar }}.Projected) {{- end }} {{- end }} {{- else -}} - var {{ .ReturnVar }} {{ .ReturnTypeRef }} + {{ if .ToViewed }}{{ .ReturnVar }} := {{ if not .IsCollection }}&{{ end }}{{ .TargetType }}{View: view}{{ else }}var {{ .ReturnVar }} {{ .ReturnTypeRef }}{{ end }} switch {{ if .ToResult }}{{ .ArgVar }}.View{{ else }}view{{ end }} { {{- range .Views }} case {{ printf "%q" .Name }}{{ if eq .Name "default" }}, ""{{ end }}: {{- if $.ToViewed }} - p := {{ $.InitName }}{{ if ne .Name "default" }}{{ goify .Name true }}{{ end }}({{ $.ArgVar }}) + p := {{ .ToProjected.Name }}({{ $.ArgVar }}) {{ $.ReturnVar }} = {{ if not $.IsCollection }}&{{ end }}{{ $.TargetType }}{Projected: p, View: {{ printf "%q" .Name }} } {{- else }} - {{ $.ReturnVar }} = {{ $.InitName }}{{ if ne .Name "default" }}{{ goify .Name true }}{{ end }}({{ $.ArgVar }}.Projected) + {{ $.ReturnVar }} = {{ .ToResult.Name }}({{ $.ArgVar }}.Projected) {{- end }} {{- end }} } @@ -26,14 +26,14 @@ {{- else if .IsCollection -}} {{ .ReturnVar }} := make({{ .TargetType }}, len({{ .ArgVar }})) for i, n := range {{ .ArgVar }} { - {{ .ReturnVar }}[i] = {{ .InitName }}(n) + {{ .ReturnVar }}[i] = {{ .Init.Name }}(n) } return {{ .ReturnVar }} {{- else -}} {{ .Code }} {{- range .Fields }} if {{ $.Source }}.{{ .VarName }} != nil { - {{ $.Target }}.{{ .VarName }} = {{ .FieldInit }}({{ $.Source }}.{{ .VarName }}) + {{ $.Target }}.{{ .VarName }} = {{ .Declaration.Name }}({{ $.Source }}.{{ .VarName }}) } {{- end }} return {{ .ReturnVar }} diff --git a/codegen/service/templates/server_interceptor_stream_wrapper_types.go.tpl b/codegen/service/templates/server_interceptor_stream_wrapper_types.go.tpl index a33a45fa2a..15f94b74f3 100644 --- a/codegen/service/templates/server_interceptor_stream_wrapper_types.go.tpl +++ b/codegen/service/templates/server_interceptor_stream_wrapper_types.go.tpl @@ -1,7 +1,7 @@ {{- range .WrappedServerStreams }} -{{ comment (printf "wrapped%s is a server interceptor wrapper for the %s stream." .Interface .Interface) }} -type wrapped{{ .Interface }} struct { +{{ comment (printf "%s is a server interceptor wrapper for the %s stream." .WrapperDeclaration.Name .InterfaceDeclaration.Name) }} +type {{ .WrapperDeclaration.Name }} struct { ctx context.Context {{- if ne .SendTypeRef "" }} sendWithContext func(context.Context, {{ .SendTypeRef }}) error @@ -9,6 +9,6 @@ type wrapped{{ .Interface }} struct { {{- if ne .RecvTypeRef "" }} recvWithContext func(context.Context) ({{ .RecvTypeRef }}, error) {{- end }} - stream {{ .Interface }} + stream {{ .InterfaceDeclaration.Name }} } {{- end }} diff --git a/codegen/service/templates/server_interceptor_stream_wrappers.go.tpl b/codegen/service/templates/server_interceptor_stream_wrappers.go.tpl index 26454cd232..dcf2fd89d7 100644 --- a/codegen/service/templates/server_interceptor_stream_wrappers.go.tpl +++ b/codegen/service/templates/server_interceptor_stream_wrappers.go.tpl @@ -1,19 +1,19 @@ {{- range .WrappedServerStreams }} {{ comment (print "Unwrap returns the underlying stream type.") }} -func (w *wrapped{{ .Interface }}) Unwrap() any { +func (w *{{ .WrapperDeclaration.Name }}) Unwrap() any { return w.stream } {{- if ne .SendTypeRef "" }} -{{ comment (printf "%s streams instances of \"%s\" after executing the applied interceptor." .SendName .Interface) }} -func (w *wrapped{{ .Interface }}) {{ .SendName }}(v {{ .SendTypeRef }}) error { +{{ comment (printf "%s streams instances of \"%s\" after executing the applied interceptor." .SendName .InterfaceDeclaration.Name) }} +func (w *{{ .WrapperDeclaration.Name }}) {{ .SendName }}(v {{ .SendTypeRef }}) error { return w.SendWithContext(w.ctx, v) } -{{ comment (printf "%s streams instances of \"%s\" after executing the applied interceptor with context." .SendWithContextName .Interface) }} -func (w *wrapped{{ .Interface }}) {{ .SendWithContextName }}(ctx context.Context, v {{ .SendTypeRef }}) error { +{{ comment (printf "%s streams instances of \"%s\" after executing the applied interceptor with context." .SendWithContextName .InterfaceDeclaration.Name) }} +func (w *{{ .WrapperDeclaration.Name }}) {{ .SendWithContextName }}(ctx context.Context, v {{ .SendTypeRef }}) error { if w.sendWithContext == nil { return w.stream.{{ .SendWithContextName }}(ctx, v) } @@ -22,13 +22,13 @@ func (w *wrapped{{ .Interface }}) {{ .SendWithContextName }}(ctx context.Context {{- end }} {{- if ne .RecvTypeRef "" }} -{{ comment (printf "%s reads instances of \"%s\" from the stream after executing the applied interceptor." .RecvName .Interface) }} -func (w *wrapped{{ .Interface }}) {{ .RecvName }}() ({{ .RecvTypeRef }}, error) { +{{ comment (printf "%s reads instances of \"%s\" from the stream after executing the applied interceptor." .RecvName .InterfaceDeclaration.Name) }} +func (w *{{ .WrapperDeclaration.Name }}) {{ .RecvName }}() ({{ .RecvTypeRef }}, error) { return w.RecvWithContext(w.ctx) } -{{ comment (printf "%s reads instances of \"%s\" from the stream after executing the applied interceptor with context." .RecvWithContextName .Interface) }} -func (w *wrapped{{ .Interface }}) {{ .RecvWithContextName }}(ctx context.Context) ({{ .RecvTypeRef }}, error) { +{{ comment (printf "%s reads instances of \"%s\" from the stream after executing the applied interceptor with context." .RecvWithContextName .InterfaceDeclaration.Name) }} +func (w *{{ .WrapperDeclaration.Name }}) {{ .RecvWithContextName }}(ctx context.Context) ({{ .RecvTypeRef }}, error) { if w.recvWithContext == nil { return w.stream.{{ .RecvWithContextName }}(ctx) } @@ -38,7 +38,7 @@ func (w *wrapped{{ .Interface }}) {{ .RecvWithContextName }}(ctx context.Context {{- if .MustClose }} // Close closes the stream. -func (w *wrapped{{ .Interface }}) Close() error { +func (w *{{ .WrapperDeclaration.Name }}) Close() error { return w.stream.Close() } {{- end }} diff --git a/codegen/service/templates/server_interceptor_wrappers.go.tpl b/codegen/service/templates/server_interceptor_wrappers.go.tpl index 0dac75a6a7..43de2edd85 100644 --- a/codegen/service/templates/server_interceptor_wrappers.go.tpl +++ b/codegen/service/templates/server_interceptor_wrappers.go.tpl @@ -1,21 +1,18 @@ -{{- range .ServerInterceptors }} +{{- range .Interceptors }} {{- $interceptor := . }} {{- range .Methods }} -{{ comment (printf "wrap%s%s applies the %s server interceptor to endpoints." $interceptor.Name .MethodName $interceptor.DesignName) }} -func wrap{{ .MethodName }}{{ $interceptor.Name }}(endpoint goa.Endpoint, i ServerInterceptors) goa.Endpoint { +{{ comment (printf "%s applies the %s server interceptor to endpoints." .ServerWrapperDeclaration.Name $interceptor.DesignName) }} +func {{ .ServerWrapperDeclaration.Name }}(endpoint goa.Endpoint, i {{ $.InterceptorsDeclaration.Name }}) goa.Endpoint { return func(ctx context.Context, req any) (any, error) { {{- if or $interceptor.HasStreamingPayloadAccess $interceptor.HasStreamingResultAccess }} stream := req.(*{{ .ServerStream.EndpointStruct }}).Stream - req.(*{{ .ServerStream.EndpointStruct }}).Stream = &wrapped{{ .ServerStream.Interface }}{ + req.(*{{ .ServerStream.EndpointStruct }}).Stream = &{{ .ServerStream.WrapperDeclaration.Name }}{ ctx: ctx, {{- if $interceptor.HasStreamingResultAccess }} sendWithContext: func(ctx context.Context, req {{ .ServerStream.SendTypeRef }}) error { - info := &{{ $interceptor.Name }}Info{ - service: "{{ $.Service }}", - method: "{{ .MethodName }}", - callType: goa.InterceptorStreamingSend, - rawPayload: req, + info := &{{ .StreamingSendInfoDeclaration.Name }}{ + {{ .InfoDeclaration.Name }}: {{ .InfoDeclaration.Name }}{rawPayload: req}, } _, err := i.{{ $interceptor.Name }}(ctx, info, func(ctx context.Context, req any) (any, error) { castReq, _ := req.({{ .ServerStream.SendTypeRef }}) @@ -26,10 +23,8 @@ func wrap{{ .MethodName }}{{ $interceptor.Name }}(endpoint goa.Endpoint, i Serve {{- end }} {{- if $interceptor.HasStreamingPayloadAccess }} recvWithContext: func(ctx context.Context) ({{ .ServerStream.RecvTypeRef }}, error) { - info := &{{ $interceptor.Name }}Info{ - service: "{{ $.Service }}", - method: "{{ .MethodName }}", - callType: goa.InterceptorStreamingRecv, + info := &{{ .StreamingRecvInfoDeclaration.Name }}{ + {{ .InfoDeclaration.Name }}: {{ .InfoDeclaration.Name }}{}, } res, err := i.{{ $interceptor.Name }}(ctx, info, func(ctx context.Context, _ any) (any, error) { return stream.{{ .ServerStream.RecvWithContextName }}(ctx) @@ -41,22 +36,16 @@ func wrap{{ .MethodName }}{{ $interceptor.Name }}(endpoint goa.Endpoint, i Serve stream: stream, } {{- if $interceptor.HasPayloadAccess }} - info := &{{ $interceptor.Name }}Info{ - service: "{{ $.Service }}", - method: "{{ .MethodName }}", - callType: goa.InterceptorUnary, - rawPayload: req, + info := &{{ .ServerUnaryInfoDeclaration.Name }}{ + {{ .InfoDeclaration.Name }}: {{ .InfoDeclaration.Name }}{rawPayload: req}, } return i.{{ $interceptor.Name }}(ctx, info, endpoint) {{- else }} return endpoint(ctx, req) {{- end }} {{- else }} - info := &{{ $interceptor.Name }}Info{ - service: "{{ $.Service }}", - method: "{{ .MethodName }}", - callType: goa.InterceptorUnary, - rawPayload: req, + info := &{{ .ServerUnaryInfoDeclaration.Name }}{ + {{ .InfoDeclaration.Name }}: {{ .InfoDeclaration.Name }}{rawPayload: req}, } return i.{{ $interceptor.Name }}(ctx, info, endpoint) {{- end }} diff --git a/codegen/service/templates/server_interceptors.go.tpl b/codegen/service/templates/server_interceptors.go.tpl index 3bd263dc95..694b1b83b3 100644 --- a/codegen/service/templates/server_interceptors.go.tpl +++ b/codegen/service/templates/server_interceptors.go.tpl @@ -2,11 +2,11 @@ // Server interceptors execute after the request is decoded and before the // payload is sent to the service. The implementation is responsible for calling // next to complete the request. -type ServerInterceptors interface { +type {{ .ServerInterceptorsDeclaration.Name }} interface { {{- range .ServerInterceptors }} {{- if .Description }} {{ comment .Description }} {{- end }} - {{ .Name }}(ctx context.Context, info *{{ .Name }}Info, next goa.Endpoint) (any, error) + {{ .Name }}(ctx context.Context, info {{ .InfoDeclaration.Name }}, next goa.Endpoint) (any, error) {{- end }} } diff --git a/codegen/service/templates/service.go.tpl b/codegen/service/templates/service.go.tpl index 61626354f1..7a71c78497 100644 --- a/codegen/service/templates/service.go.tpl +++ b/codegen/service/templates/service.go.tpl @@ -1,10 +1,6 @@ {{ comment .Description }} -type Service interface { -{{- if isJSONRPCWebSocket . }} - {{ comment "HandleStream handles the JSON-RPC WebSocket streaming connection. Calling Recv() on the stream will dispatch requests to the appropriate methods below." }} - HandleStream(context.Context, Stream) error -{{- end }} +type {{ .ServiceDeclaration.Name }} interface { {{- range .Methods }} {{ comment .Description }} {{- if .SkipResponseBodyEncodeDecode }} @@ -23,19 +19,12 @@ type Service interface { {{- end }} {{- end }} {{- if .ServerStream }} - {{- if and .IsJSONRPC (not .IsJSONRPCSSE) (eq .ServerStream.Kind 2) }} - {{ .VarName }}(context.Context{{ if .Payload }}, {{ .PayloadRef }}{{ end }}) ({{ if .Result }}res {{ .ResultRef }}, {{ end }}err error) - {{- else if .HasMixedResults }} - {{- /* Mixed results: the method may be invoked in a unary (JSON) or streaming (SSE) mode. - The server stream is non-nil only when the transport negotiates streaming. */}} + {{- if .HasMixedResults }} + {{- /* Mixed results always receive a server stream. Ordinary HTTP supplies + one that discards sent values, while SSE sends them to the client. */}} {{ .VarName }}(context.Context{{ if .Payload }}, {{ .PayloadRef }}{{ end }}, {{ .ServerStream.Interface }}) ({{ if .Result }}res {{ .ResultRef }}, {{ end }}{{ if .ViewedResult }}{{ if not .ViewedResult.ViewName }}view string, {{ end }}{{ end }}err error) {{- else }} - {{- if and .IsJSONRPC (not .IsJSONRPCSSE) (eq .ServerStream.Kind 3) .PayloadRef }} - {{- /* JSON-RPC WebSocket server streaming with non-streaming payload */ -}} - {{ .VarName }}(context.Context, {{ .PayloadRef }}, {{ .ServerStream.Interface }}) (err error) - {{- else }} - {{ .VarName }}(context.Context{{ if .Payload }}, {{ .PayloadRef }}{{ end }}, {{ .ServerStream.Interface }}) (err error) - {{- end }} + {{ .VarName }}(context.Context{{ if .Payload }}, {{ .PayloadRef }}{{ end }}, {{ .ServerStream.Interface }}) (err error) {{- end }} {{- else }} {{ .VarName }}(context.Context{{ if .Payload }}, {{ .PayloadRef }}{{ end }}{{ if .SkipRequestBodyEncodeDecode }}, io.ReadCloser{{ end }}) ({{ if .Result }}res {{ .ResultRef }}, {{ end }}{{ if .SkipResponseBodyEncodeDecode }}body io.ReadCloser, {{ end }}{{ if .Result }}{{ if .ViewedResult }}{{ if not .ViewedResult.ViewName }}view string, {{ end }}{{ end }}{{ end }}err error) @@ -45,7 +34,7 @@ type Service interface { {{- if .Schemes }} // Auther defines the authorization functions to be implemented by the service. -type Auther interface { +type {{ .AutherDeclaration.Name }} interface { {{- range .Schemes.DedupeByType }} {{ printf "%sAuth implements the authorization logic for the %s security scheme." .Type .Type | comment }} {{ .Type }}Auth(ctx context.Context, {{ if eq .Type "Basic" }}user, pass{{ else if eq .Type "APIKey" }}key{{ else }}token{{ end }} string, schema *security.{{ .Type }}Scheme) (context.Context, error) @@ -54,100 +43,47 @@ type Auther interface { {{- end }} // APIName is the name of the API as defined in the design. -const APIName = {{ printf "%q" .APIName }} +const {{ .APINameDeclaration.Name }} = {{ printf "%q" .APIName }} // APIVersion is the version of the API as defined in the design. -const APIVersion = {{ printf "%q" .APIVersion }} +const {{ .APIVersionDeclaration.Name }} = {{ printf "%q" .APIVersion }} // ServiceName is the name of the service as defined in the design. This is the // same value that is set in the endpoint request contexts under the ServiceKey // key. -const ServiceName = {{ printf "%q" .Name }} +const {{ .ServiceNameDeclaration.Name }} = {{ printf "%q" .Name }} // MethodNames lists the service method names as defined in the design. These // are the same values that are set in the endpoint request contexts under the // MethodKey key. -var MethodNames = [{{ len .Methods }}]string{ {{ range .Methods }}{{ printf "%q" .Name }}, {{ end }} } +var {{ .MethodNamesDeclaration.Name }} = [{{ len .Methods }}]string{ {{ range .Methods }}{{ printf "%q" .Name }}, {{ end }} } {{- range .Methods }} {{- if .ServerStream }} {{ template "stream_interface" (streamInterfaceFor "server" . .ServerStream) }} - {{- /* Emit client stream interface */ -}} - {{- if .IsJSONRPC }} - {{- if .ClientStream }} - {{ template "stream_interface" (streamInterfaceFor "client" . .ClientStream) }} - {{- end }} - {{- else }} {{ template "stream_interface" (streamInterfaceFor "client" . .ClientStream) }} - {{- end }} - {{- end }} -{{- end }} - -{{- if hasJSONRPCStreaming . }} - {{- if isJSONRPCWebSocket . }} - {{ template "jsonrpc_websocket_stream" . }} - {{- else }} - {{ template "jsonrpc_sse_stream" . }} {{- end }} {{- end }} {{- define "stream_interface" }} -{{- if and .IsJSONRPCSSE (eq .Type "server") }} -{{ printf "%sEvent is the interface implemented by the result type for the %s method." .MethodVarName .Endpoint | comment }} -type {{ .MethodVarName }}Event interface { - is{{ .MethodVarName }}Event() -} - -{{ printf "is%sEvent implements the %sEvent interface." .MethodVarName .MethodVarName | comment }} -func ({{ .Stream.SendTypeRef }}) is{{ .MethodVarName }}Event() {} - -{{ printf "%s allows streaming instances of %s over SSE." .Stream.Interface .Stream.SendTypeRef | comment }} -type {{ .Stream.Interface }} interface { - {{- if .Stream.SendTypeRef }} - {{ comment .Stream.SendDesc }} - {{ comment "IMPORTANT: Send only sends JSON-RPC notifications. Use SendAndClose to send a final response." }} - Send(ctx context.Context, event {{ .MethodVarName }}Event) error - {{- if .Stream.SendAndCloseName }} - {{ comment .Stream.SendAndCloseDesc }} - {{ comment "The result will be sent as a JSON-RPC response with the original request ID." }} - {{ comment "If the result has an ID field populated, that ID will be used instead of the request ID." }} - {{ .Stream.SendAndCloseName }}(ctx context.Context, event {{ .MethodVarName }}Event) error - {{- end }} - {{- end }} - {{ comment "SendError sends a JSON-RPC error response." }} - SendError(ctx context.Context, id string, err error) error -} -{{- else }} {{- $elemType := .Stream.SendTypeRef -}} {{- if not $elemType }}{{- $elemType = .Stream.RecvTypeRef }}{{- end }} {{ printf "%s allows streaming instances of %s to the client." .Stream.Interface $elemType | comment }} type {{ .Stream.Interface }} interface { {{- if .Stream.SendTypeRef }} - {{- if .IsJSONRPCWebSocket }} - {{ comment "SendNotification sends a JSON-RPC notification (no response expected)." }} - SendNotification(context.Context, {{ .Stream.SendTypeRef }}) error - {{ comment "SendResponse sends a JSON-RPC response with the original request ID." }} - SendResponse(context.Context, {{ .Stream.SendTypeRef }}) error - {{ comment "SendError sends a JSON-RPC error response." }} - SendError(context.Context, error) error - {{- else }} {{ comment .Stream.SendDesc }} {{ .Stream.SendName }}({{ .Stream.SendTypeRef }}) error {{ comment .Stream.SendWithContextDesc }} {{ .Stream.SendWithContextName }}(context.Context, {{ .Stream.SendTypeRef }}) error - {{- end }} {{- end }} - {{- if and .Stream.RecvTypeRef (not .IsJSONRPCWebSocket) }} + {{- if .Stream.RecvTypeRef }} {{ comment .Stream.RecvDesc }} {{ .Stream.RecvName }}() ({{ .Stream.RecvTypeRef }}, error) {{ comment .Stream.RecvWithContextDesc }} {{ .Stream.RecvWithContextName }}(context.Context) ({{ .Stream.RecvTypeRef }}, error) {{- end }} - {{- if .IsJSONRPCWebSocket }} - {{ comment "Close closes the stream." }} - Close() error - {{- else if .Stream.MustClose }} + {{- if .Stream.MustClose }} {{ comment "Close closes the stream." }} Close() error {{- end }} @@ -158,71 +94,3 @@ type {{ .Stream.Interface }} interface { {{- end }} } {{- end }} -{{- end }} - -{{- define "jsonrpc_websocket_stream" }} -{{ printf "Stream defines the interface for managing a WebSocket streaming connection in the %s server. It allows sending results, sending errors, receiving requests, and closing the connection. This interface is used by the service to interact with clients over WebSocket using JSON-RPC." .Name | comment }} -type Stream interface { -{{- range .Methods }} - {{- if .Result }} - {{ printf "Send%sNotification sends a JSON-RPC notification for the %s method (no response expected)." .VarName .Name | comment }} - Send{{ .VarName }}Notification(ctx context.Context, result {{ .ResultRef }}) error - {{ printf "Send%sResponse sends a JSON-RPC response for the %s method with the given ID." .VarName .Name | comment }} - Send{{ .VarName }}Response(ctx context.Context, id any, result {{ .ResultRef }}) error - {{- end }} -{{- end }} - {{ comment "SendError sends a JSON-RPC error response." }} - SendError(ctx context.Context, id any, err error) error - {{ printf "Recv reads JSON-RPC requests from the %s service WebSocket stream and dispatches them to the appropriate method." .Name | comment }} - Recv(ctx context.Context) error - {{ comment "Close closes the stream." }} - Close() error -} -{{- end }} - -{{- define "jsonrpc_sse_stream" }} -{{- $hasResults := false }} -{{- $hasErrors := false }} -{{- $resultTypes := "" }} -{{- range (dedupeByResult .Methods) }} - {{- if .Result }} - {{- $hasResults = true }} - {{- if $resultTypes }} - {{- $resultTypes = printf "%s, %s" $resultTypes .ResultRef }} - {{- else }} - {{- $resultTypes = .ResultRef }} - {{- end }} - {{- end }} -{{- end }} -{{- range .Methods }} - {{- if .Errors }}{{ $hasErrors = true }}{{ end }} -{{- end }} -{{ printf "Stream defines the interface for managing an SSE streaming connection in the %s server. It allows sending notifications and final responses. This interface is used by the service to interact with clients over SSE using JSON-RPC." .Name | comment }} -type Stream interface { -{{- if $hasResults }} - {{ comment "Send sends an event (notification or response) to the client." }} - {{ comment "For notifications, the result should not have an ID field." }} - {{ comment "For responses, the result must have an ID field." }} - {{ printf "Accepted types: %s" $resultTypes | comment }} - Send(ctx context.Context, event Event) error -{{- end }} -{{- if $hasErrors }} - {{ comment "SendError sends a JSON-RPC error response." }} - SendError(ctx context.Context, id string, err error) error -{{- end }} -} - -{{- if $hasResults }} -{{ printf "Event is the interface implemented by all result types that can be sent via the %s Stream." .Name | comment }} -type Event interface { - is{{ .VarName }}Event() -} - - {{- range (dedupeByResult .Methods) }} - {{- if .Result }} -{{ printf "is%sEvent implements the Event interface." $.VarName | comment }} -func ({{ .ResultRef }}) is{{ $.VarName }}Event() {} - {{- end }} - {{- end }} -{{- end }} -{{- end }} diff --git a/codegen/service/templates/service_client.go.tpl b/codegen/service/templates/service_client.go.tpl index 90828cc6a7..6a06c234cc 100644 --- a/codegen/service/templates/service_client.go.tpl +++ b/codegen/service/templates/service_client.go.tpl @@ -1,5 +1,5 @@ -// {{ .ClientVarName }} is the {{ printf "%q" .Name }} service client. -type {{ .ClientVarName }} struct { +// {{ .ClientDeclaration.Name }} is the {{ printf "%q" .Name }} service client. +type {{ .ClientDeclaration.Name }} struct { {{- range .Methods}} {{ .EndpointField }} goa.Endpoint {{- if .HasMixedResults }} diff --git a/codegen/service/templates/service_client_init.go.tpl b/codegen/service/templates/service_client_init.go.tpl index 548b288232..cac6bc4de6 100644 --- a/codegen/service/templates/service_client_init.go.tpl +++ b/codegen/service/templates/service_client_init.go.tpl @@ -1,10 +1,10 @@ -{{ printf "New%s initializes a %q service client given the endpoints." .ClientVarName .Name | comment }} -func New{{ .ClientVarName }}({{ if .ClientInitArgs }}{{ .ClientInitArgs }} goa.Endpoint{{ if .HasClientInterceptors }}, ci ClientInterceptors{{ end }}{{ else }}{{ if .HasClientInterceptors }}ci ClientInterceptors{{ end }}{{ end }}) *{{ .ClientVarName }} { - return &{{ .ClientVarName }}{ +{{ printf "%s initializes a %q service client given the endpoints." .NewClientDeclaration.Name .Name | comment }} +func {{ .NewClientDeclaration.Name }}({{ if .ClientInitArgs }}{{ .ClientInitArgs }} goa.Endpoint{{ if .HasClientInterceptors }}, ci {{ .ClientInterceptorsDeclaration.Name }}{{ end }}{{ else }}{{ if .HasClientInterceptors }}ci {{ .ClientInterceptorsDeclaration.Name }}{{ end }}{{ end }}) *{{ .ClientDeclaration.Name }} { + return &{{ .ClientDeclaration.Name }}{ {{- range .Methods }} - {{ .EndpointField }}: {{ if .ClientInterceptors }}Wrap{{ .VarName }}ClientEndpoint({{ end }}{{ .ArgName }}{{ if .ClientInterceptors }}, ci){{ end }}, + {{ .EndpointField }}: {{ if .ClientInterceptors }}{{ .ClientEndpointWrapperDeclaration.Name }}({{ end }}{{ .ArgName }}{{ if .ClientInterceptors }}, ci){{ end }}, {{- if .HasMixedResults }} - {{ .StreamEndpointField }}: {{ if .ClientInterceptors }}Wrap{{ .VarName }}ClientEndpoint({{ end }}{{ .StreamArgName }}{{ if .ClientInterceptors }}, ci){{ end }}, + {{ .StreamEndpointField }}: {{ if .ClientInterceptors }}{{ .ClientEndpointWrapperDeclaration.Name }}({{ end }}{{ .StreamArgName }}{{ if .ClientInterceptors }}, ci){{ end }}, {{- end }} {{- end }} } diff --git a/codegen/service/templates/service_client_method.go.tpl b/codegen/service/templates/service_client_method.go.tpl index df5c8f1bfd..e8099de478 100644 --- a/codegen/service/templates/service_client_method.go.tpl +++ b/codegen/service/templates/service_client_method.go.tpl @@ -9,7 +9,7 @@ {{- end }} {{- if .HasMixedResults }} {{- $unaryResultType := .ResultRef }} -func (c *{{ .ClientVarName }}) {{ .VarName }}(ctx context.Context{{ if .PayloadRef }}, p {{ .PayloadRef }}{{ end }}{{ if .MethodData.SkipRequestBodyEncodeDecode}}, req io.ReadCloser{{ end }}) ({{ if $unaryResultType }}res {{ $unaryResultType }}, {{ end }}{{ if .MethodData.SkipResponseBodyEncodeDecode }}resp io.ReadCloser, {{ end }}err error) { +func (c *{{ .ClientDeclaration.Name }}) {{ .VarName }}(ctx context.Context{{ if .PayloadRef }}, p {{ .PayloadRef }}{{ end }}{{ if .MethodData.SkipRequestBodyEncodeDecode}}, req io.ReadCloser{{ end }}) ({{ if $unaryResultType }}res {{ $unaryResultType }}, {{ end }}{{ if .MethodData.SkipResponseBodyEncodeDecode }}resp io.ReadCloser, {{ end }}err error) { {{- if or $unaryResultType .MethodData.SkipResponseBodyEncodeDecode }} var ires any {{- end }} @@ -30,7 +30,7 @@ func (c *{{ .ClientVarName }}) {{ .VarName }}(ctx context.Context{{ if .PayloadR } {{ printf "%sStream calls the %q endpoint of the %q service with server streaming enabled." .VarName .Name .ServiceName | comment }} -func (c *{{ .ClientVarName }}) {{ .VarName }}Stream(ctx context.Context{{ if .PayloadRef }}, p {{ .PayloadRef }}{{ end }}{{ if .MethodData.SkipRequestBodyEncodeDecode}}, req io.ReadCloser{{ end }}) (res {{ .ClientStream.Interface }}, err error) { +func (c *{{ .ClientDeclaration.Name }}) {{ .VarName }}Stream(ctx context.Context{{ if .PayloadRef }}, p {{ .PayloadRef }}{{ end }}{{ if .MethodData.SkipRequestBodyEncodeDecode}}, req io.ReadCloser{{ end }}) (res {{ .ClientStream.Interface }}, err error) { var ires any ires, err = c.{{ .StreamEndpointField }}(ctx, {{ if .MethodData.SkipRequestBodyEncodeDecode }}&{{ .RequestStruct }}{ {{ if .PayloadRef }}Payload: p, {{ end }}Body: req }{{ else if .PayloadRef }}p{{ else }}nil{{ end }}) if err != nil { @@ -44,7 +44,7 @@ func (c *{{ .ClientVarName }}) {{ .VarName }}Stream(ctx context.Context{{ if .Pa {{- /* When a client stream exists, always return it from the client method. */ -}} {{- $resultType = .ClientStream.Interface }} {{- end }} -func (c *{{ .ClientVarName }}) {{ .VarName }}(ctx context.Context{{ if .PayloadRef }}, p {{ .PayloadRef }}{{ end }}{{ if .MethodData.SkipRequestBodyEncodeDecode}}, req io.ReadCloser{{ end }}) ({{ if $resultType }}res {{ $resultType }}, {{ end }}{{ if .MethodData.SkipResponseBodyEncodeDecode }}resp io.ReadCloser, {{ end }}err error) { +func (c *{{ .ClientDeclaration.Name }}) {{ .VarName }}(ctx context.Context{{ if .PayloadRef }}, p {{ .PayloadRef }}{{ end }}{{ if .MethodData.SkipRequestBodyEncodeDecode}}, req io.ReadCloser{{ end }}) ({{ if $resultType }}res {{ $resultType }}, {{ end }}{{ if .MethodData.SkipResponseBodyEncodeDecode }}resp io.ReadCloser, {{ end }}err error) { {{- if or $resultType .MethodData.SkipResponseBodyEncodeDecode }} var ires any {{- end }} diff --git a/codegen/service/templates/service_endpoint_method.go.tpl b/codegen/service/templates/service_endpoint_method.go.tpl index e375fd4197..70b37f7d60 100644 --- a/codegen/service/templates/service_endpoint_method.go.tpl +++ b/codegen/service/templates/service_endpoint_method.go.tpl @@ -1,7 +1,7 @@ -{{ printf "New%sEndpoint returns an endpoint function that calls the method %q of service %q." .VarName .Name .ServiceName | comment }} -func New{{ .VarName }}Endpoint(s {{ .ServiceVarName }}{{ range .Schemes.DedupeByType }}, auth{{ .Type }}Fn security.Auth{{ .Type }}Func{{ end }}) goa.Endpoint { +{{ printf "%s returns an endpoint function that calls the method %q of service %q." .EndpointDeclaration.Name .Name .ServiceName | comment }} +func {{ .EndpointDeclaration.Name }}(s {{ .ServiceDeclaration.Name }}{{ range .Schemes.DedupeByType }}, auth{{ .Type }}Fn security.Auth{{ .Type }}Func{{ end }}) goa.Endpoint { return func(ctx context.Context, req any) (any, error) { {{- if .ServerStream }} {{- if .ServerStream.EndpointStruct }} @@ -32,17 +32,17 @@ func New{{ .VarName }}Endpoint(s {{ .ServiceVarName }}{{ range .Schemes.DedupeBy {{- if .UsernamePointer }} var user string if {{ $payload }}.{{ .UsernameField }} != nil { - user = *{{ $payload }}.{{ .UsernameField }} + user = string(*{{ $payload }}.{{ .UsernameField }}) } {{- end }} {{- if .PasswordPointer }} var pass string if {{ $payload }}.{{ .PasswordField }} != nil { - pass = *{{ $payload }}.{{ .PasswordField }} + pass = string(*{{ $payload }}.{{ .PasswordField }}) } {{- end }} - ctx, err = auth{{ .Type }}Fn(ctx, {{ if .UsernamePointer }}user{{ else }}{{ $payload }}.{{ .UsernameField }}{{ end }}, - {{- if .PasswordPointer }}pass{{ else }}{{ $payload }}.{{ .PasswordField }}{{ end }}, &sc) + ctx, err = auth{{ .Type }}Fn(ctx, {{ if .UsernamePointer }}user{{ else }}string({{ $payload }}.{{ .UsernameField }}){{ end }}, + {{- if .PasswordPointer }}pass{{ else }}string({{ $payload }}.{{ .PasswordField }}){{ end }}, &sc) {{- else if eq .Type "APIKey" }} sc := security.APIKeyScheme{ @@ -53,10 +53,10 @@ func New{{ .VarName }}Endpoint(s {{ .ServiceVarName }}{{ range .Schemes.DedupeBy {{- if $s.CredPointer }} var key string if {{ $payload }}.{{ $s.CredField }} != nil { - key = *{{ $payload }}.{{ $s.CredField }} + key = string(*{{ $payload }}.{{ $s.CredField }}) } {{- end }} - ctx, err = auth{{ .Type }}Fn(ctx, {{ if $s.CredPointer }}key{{ else }}{{ $payload }}.{{ $s.CredField }}{{ end }}, &sc) + ctx, err = auth{{ .Type }}Fn(ctx, {{ if $s.CredPointer }}key{{ else }}string({{ $payload }}.{{ $s.CredField }}){{ end }}, &sc) {{- else if or (eq .Type "Bearer") (eq .Type "JWT") }} sc := security.{{ .Type }}Scheme{ @@ -67,10 +67,10 @@ func New{{ .VarName }}Endpoint(s {{ .ServiceVarName }}{{ range .Schemes.DedupeBy {{- if $s.CredPointer }} var token string if {{ $payload }}.{{ $s.CredField }} != nil { - token = *{{ $payload }}.{{ $s.CredField }} + token = string(*{{ $payload }}.{{ $s.CredField }}) } {{- end }} - ctx, err = auth{{ .Type }}Fn(ctx, {{ if $s.CredPointer }}token{{ else }}{{ $payload }}.{{ $s.CredField }}{{ end }}, &sc) + ctx, err = auth{{ .Type }}Fn(ctx, {{ if $s.CredPointer }}token{{ else }}string({{ $payload }}.{{ $s.CredField }}){{ end }}, &sc) {{- else if eq .Type "OAuth2" }} sc := security.OAuth2Scheme{ @@ -99,10 +99,10 @@ func New{{ .VarName }}Endpoint(s {{ .ServiceVarName }}{{ range .Schemes.DedupeBy {{- if $s.CredPointer }} var token string if {{ $payload }}.{{ $s.CredField }} != nil { - token = *{{ $payload }}.{{ $s.CredField }} + token = string(*{{ $payload }}.{{ $s.CredField }}) } {{- end }} - ctx, err = auth{{ .Type }}Fn(ctx, {{ if $s.CredPointer }}token{{ else }}{{ $payload }}.{{ $s.CredField }}{{ end }}, &sc) + ctx, err = auth{{ .Type }}Fn(ctx, {{ if $s.CredPointer }}token{{ else }}string({{ $payload }}.{{ $s.CredField }}){{ end }}, &sc) {{- end }} {{- if ne $sidx 0 }} @@ -121,38 +121,29 @@ func New{{ .VarName }}Endpoint(s {{ .ServiceVarName }}{{ range .Schemes.DedupeBy {{- if .ServerStream }} {{- if .ServerStream.EndpointStruct }} {{- if .HasMixedResults }} + {{- if .ResultRef }} res, {{ if .ViewedResult }}{{ if not .ViewedResult.ViewName }}view, {{ end }}{{ end }}err := s.{{ .VarName }}(ctx, {{ if .PayloadRef }}{{ $payload }}, {{ end }}ep.Stream) if err != nil { return nil, err } {{- if .ViewedResult }} {{- if .ViewedResult.ViewName }} - vres := {{ $.ViewedResult.Init.Name }}(res, {{ printf "%q" .ViewedResult.ViewName }}) + vres := {{ $.ViewedResult.Init.Declaration.Name }}(res, {{ printf "%q" .ViewedResult.ViewName }}) {{- else }} - vres := {{ $.ViewedResult.Init.Name }}(res, view) + vres := {{ $.ViewedResult.Init.Declaration.Name }}(res, view) {{- end }} + if err := {{ .ViewedResult.ViewsPkg }}.{{ .ViewedResult.Validate.Declaration.Name }}(vres); err != nil { + return nil, err + } return vres, nil {{- else }} return res, nil {{- end }} - {{- else }} - return nil, s.{{ .VarName }}(ctx, {{ if .PayloadRef }}{{ $payload }}, {{ end }}ep.Stream) - {{- end }} - {{- else }} - {{- /* JSON-RPC WebSocket client streaming: no stream parameter, just payload */ -}} - {{- if .PayloadRef }} - p := req.({{ .PayloadRef }}) - {{- if .ResultRef }} - return s.{{ .VarName }}(ctx, p) {{- else }} - return nil, s.{{ .VarName }}(ctx, p) + return nil, s.{{ .VarName }}(ctx, {{ if .PayloadRef }}{{ $payload }}, {{ end }}ep.Stream) {{- end }} {{- else }} - {{- if .ResultRef }} - return s.{{ .VarName }}(ctx) - {{- else }} - return nil, s.{{ .VarName }}(ctx) - {{- end }} + return nil, s.{{ .VarName }}(ctx, {{ if .PayloadRef }}{{ $payload }}, {{ end }}ep.Stream) {{- end }} {{- end }} {{- else if .SkipRequestBodyEncodeDecode }} @@ -167,7 +158,10 @@ func New{{ .VarName }}Endpoint(s {{ .ServiceVarName }}{{ range .Schemes.DedupeBy if err != nil { return nil, err } - vres := {{ $.ViewedResult.Init.Name }}(res, {{ if .ViewedResult.ViewName }}{{ printf "%q" .ViewedResult.ViewName }}{{ else }}view{{ end }}) + vres := {{ $.ViewedResult.Init.Declaration.Name }}(res, {{ if .ViewedResult.ViewName }}{{ printf "%q" .ViewedResult.ViewName }}{{ else }}view{{ end }}) + if err := {{ .ViewedResult.ViewsPkg }}.{{ .ViewedResult.Validate.Declaration.Name }}(vres); err != nil { + return nil, err + } return vres, nil {{- else }} return {{ if not .ResultRef }}nil, {{ end }}s.{{ .VarName }}(ctx, {{ if .PayloadRef }}ep.Payload, {{ end }}ep.Body) @@ -177,7 +171,10 @@ func New{{ .VarName }}Endpoint(s {{ .ServiceVarName }}{{ range .Schemes.DedupeBy if err != nil { return nil, err } - vres := {{ $.ViewedResult.Init.Name }}(res, {{ if .ViewedResult.ViewName }}{{ printf "%q" .ViewedResult.ViewName }}{{ else }}view{{ end }}) + vres := {{ $.ViewedResult.Init.Declaration.Name }}(res, {{ if .ViewedResult.ViewName }}{{ printf "%q" .ViewedResult.ViewName }}{{ else }}view{{ end }}) + if err := {{ .ViewedResult.ViewsPkg }}.{{ .ViewedResult.Validate.Declaration.Name }}(vres); err != nil { + return nil, err + } return vres, nil {{- else if .SkipResponseBodyEncodeDecode }} {{ if .ResultRef }}res, {{ end }}body, err := s.{{ .VarName }}(ctx{{ if .PayloadRef }}, {{ $payload}}{{ end }}) diff --git a/codegen/service/templates/service_endpoint_stream_struct.go.tpl b/codegen/service/templates/service_endpoint_stream_struct.go.tpl index b26b32f855..04a4ae33e4 100644 --- a/codegen/service/templates/service_endpoint_stream_struct.go.tpl +++ b/codegen/service/templates/service_endpoint_stream_struct.go.tpl @@ -5,10 +5,6 @@ type {{ .ServerStream.EndpointStruct }} struct { {{- if .PayloadRef }} {{ comment "Payload is the method payload." }} Payload {{ .PayloadRef }} -{{- end }} -{{- if .IsJSONRPC }} - {{ comment "RequestID is the JSON-RPC request ID (available for JSON-RPC transports)." }} - RequestID any {{- end }} {{ printf "Stream is the server stream used by the %q method to send data." .Name | comment }} Stream {{ .ServerStream.Interface }} diff --git a/codegen/service/templates/service_endpoints.go.tpl b/codegen/service/templates/service_endpoints.go.tpl index 547d1242cd..7f46e17d82 100644 --- a/codegen/service/templates/service_endpoints.go.tpl +++ b/codegen/service/templates/service_endpoints.go.tpl @@ -1,5 +1,5 @@ {{ comment .Description }} -type {{ .VarName }} struct { +type {{ .EndpointsDeclaration.Name }} struct { {{- range .Methods}} {{ .VarName }} goa.Endpoint {{- end }} diff --git a/codegen/service/templates/service_endpoints_init.go.tpl b/codegen/service/templates/service_endpoints_init.go.tpl index 38770a22f7..d7e9e57d20 100644 --- a/codegen/service/templates/service_endpoints_init.go.tpl +++ b/codegen/service/templates/service_endpoints_init.go.tpl @@ -1,23 +1,23 @@ -{{ printf "New%s wraps the methods of the %q service with endpoints." .VarName .Name | comment }} -func New{{ .VarName }}(s {{ .ServiceVarName }}{{ if .HasServerInterceptors }}, si ServerInterceptors{{ end }}) *{{ .VarName }} { +{{ printf "%s wraps the methods of the %q service with endpoints." .NewEndpointsDeclaration.Name .Name | comment }} +func {{ .NewEndpointsDeclaration.Name }}(s {{ .ServiceDeclaration.Name }}{{ if .HasServerInterceptors }}, si {{ .ServerInterceptorsDeclaration.Name }}{{ end }}) *{{ .EndpointsDeclaration.Name }} { {{- if .Schemes }} // Casting service to Auther interface a := s.(Auther) {{- end }} {{- if .HasServerInterceptors }} - endpoints := &{{ .VarName }}{ + endpoints := &{{ .EndpointsDeclaration.Name }}{ {{- else }} - return &{{ .VarName }}{ + return &{{ .EndpointsDeclaration.Name }}{ {{- end }} {{- range .Methods }} - {{ .VarName }}: New{{ .VarName }}Endpoint(s{{ range .Schemes.DedupeByType }}, a.{{ .Type }}Auth{{ end }}), + {{ .VarName }}: {{ .EndpointDeclaration.Name }}(s{{ range .Schemes.DedupeByType }}, a.{{ .Type }}Auth{{ end }}), {{- end }} } {{- if .HasServerInterceptors }} {{- range .Methods }} {{- if .ServerInterceptors }} - endpoints.{{ .VarName }} = Wrap{{ .VarName }}Endpoint(endpoints.{{ .VarName }}, si) + endpoints.{{ .VarName }} = {{ .ServerEndpointWrapperDeclaration.Name }}(endpoints.{{ .VarName }}, si) {{- end }} {{- end }} return endpoints diff --git a/codegen/service/templates/service_endpoints_use.go.tpl b/codegen/service/templates/service_endpoints_use.go.tpl index 0539583d91..506cb85f7b 100644 --- a/codegen/service/templates/service_endpoints_use.go.tpl +++ b/codegen/service/templates/service_endpoints_use.go.tpl @@ -1,7 +1,7 @@ {{ printf "Use applies the given middleware to all the %q service endpoints." .Name | comment }} -func (e *{{ .VarName }}) Use(m func(goa.Endpoint) goa.Endpoint) { +func (e *{{ .EndpointsDeclaration.Name }}) Use(m func(goa.Endpoint) goa.Endpoint) { {{- range .Methods }} e.{{ .VarName }} = m(e.{{ .VarName }}) {{- end }} diff --git a/codegen/service/templates/transform_helper.go.tpl b/codegen/service/templates/transform_helper.go.tpl index 0e23fe9e0f..e23f97ba9e 100644 --- a/codegen/service/templates/transform_helper.go.tpl +++ b/codegen/service/templates/transform_helper.go.tpl @@ -1,5 +1,7 @@ -{{ printf "%s builds a value of type %s from a value of type %s." .Name .ResultTypeRef .ParamTypeRef | comment }} -func {{ .Name }}(v {{ .ParamTypeRef }}) {{ .ResultTypeRef }} { +{{- $name := .Name -}} +{{- if .Declaration -}}{{- $name = .Declaration.Name -}}{{- end }} +{{ printf "%s builds a value of type %s from a value of type %s." $name .ResultTypeRef .ParamTypeRef | comment }} +func {{ if .Declaration }}{{ .Declaration.Name }}{{ else }}{{ .Name }}{{ end }}(v {{ .ParamTypeRef }}) {{ .ResultTypeRef }} { {{ .Code }} return res } diff --git a/codegen/service/templates/type_init.go.tpl b/codegen/service/templates/type_init.go.tpl index 9815875413..f372d47e2e 100644 --- a/codegen/service/templates/type_init.go.tpl +++ b/codegen/service/templates/type_init.go.tpl @@ -1,4 +1,4 @@ {{ comment .Description }} -func {{ .Name }}({{ range .Args }}{{ .Name }} {{ .Ref }}, {{ end }}) {{ .ReturnTypeRef }} { +func {{ .Declaration.Name }}({{ range .Args }}{{ .Name }} {{ .Ref }}, {{ end }}) {{ .ReturnTypeRef }} { {{ .Code }} } diff --git a/codegen/service/templates/type_validate.go.tpl b/codegen/service/templates/type_validate.go.tpl index 49ebacc867..93ef668580 100644 --- a/codegen/service/templates/type_validate.go.tpl +++ b/codegen/service/templates/type_validate.go.tpl @@ -1,32 +1,36 @@ {{- if .IsViewed -}} switch {{ .ArgVar }}.View { - {{- range .Views }} -case {{ printf "%q" .Name }}{{ if eq .Name "default" }}, ""{{ end }}: - err = Validate{{ $.Projected }}{{ if ne .Name "default" }}{{ goify .Name true }}{{ end }}({{ $.ArgVar }}.Projected) + {{- range .ValidationCalls }} +case {{ printf "%q" .View }}{{ if .Default }}, ""{{ end }}: + {{- if .Declaration }} + err = {{ .Declaration.Name }}({{ $.ArgVar }}.Projected) + {{- end }} {{- end }} default: - err = goa.InvalidEnumValueError("view", {{ .Source }}.View, []any{ {{ range .Views }}{{ printf "%q" .Name }}, {{ end }} }) + err = goa.InvalidEnumValueError("view", {{ .Source }}.View, []any{ {{ range .ValidationCalls }}{{ printf "%q" .View }}, {{ end }} }) } {{- else -}} {{- if .IsCollection -}} for _, {{ $.Source }} := range {{ $.ArgVar }} { - if err2 := {{ .ValidateVar }}({{ $.Source }}); err2 != nil { + if err2 := {{ .ValidateCall.Declaration.Name }}({{ $.Source }}); err2 != nil { err = goa.MergeErrors(err, err2) } } {{- else -}} - {{ .Validate }} - {{- range .Fields -}} - {{- if .IsRequired -}} + {{ .Validate }} + {{- range .Fields }} + {{- if .IsRequired }} if {{ $.Source }}.{{ goify .Name true }} == nil { err = goa.MergeErrors(err, goa.MissingFieldError({{ printf "%q" .Name }}, {{ printf "%q" $.Source }})) } - {{- end }} + {{- end }} + {{- if .Call }} if {{ $.Source }}.{{ goify .Name true }} != nil { - if err2 := {{ .ValidateVar }}({{ $.Source }}.{{ goify .Name true }}); err2 != nil { + if err2 := {{ .Call.Declaration.Name }}({{ $.Source }}.{{ goify .Name true }}); err2 != nil { err = goa.MergeErrors(err, err2) } } - {{- end -}} + {{- end }} + {{- end }} {{- end -}} {{- end -}} diff --git a/codegen/service/templates/union_type.go.tpl b/codegen/service/templates/union_type.go.tpl index 1c61987691..5ba0fbb662 100644 --- a/codegen/service/templates/union_type.go.tpl +++ b/codegen/service/templates/union_type.go.tpl @@ -1,71 +1,73 @@ -{{- /* Union sum-type definition and helpers. */ -}} +{{- /* Definition and helpers for a value that holds exactly one branch. */ -}} {{- range .Fields }} {{- if .EmitPrimitiveAlias }} type {{ .FieldType }} {{ .PrimitiveAliasType }} {{- end }} {{- end }} -// {{ .Name }} is a sum-type union. -type {{ .Name }} struct { - kind {{ .KindName }} +// {{ .TypeDeclaration.Name }} holds exactly one of its branch values. +type {{ .TypeDeclaration.Name }} struct { + kind {{ .KindDeclaration.Name }} {{- range .Fields }} - {{ .FieldName }} {{ .FieldType }} + {{ .StorageName }} {{ .FieldType }} {{- end }} } -// {{ .KindName }} enumerates the union variants for {{ .Name }}. -type {{ .KindName }} string +// {{ .KindDeclaration.Name }} records which {{ .TypeDeclaration.Name }} branch is selected. +type {{ .KindDeclaration.Name }} string const ( {{- range .Fields }} - // {{ .KindConst }} identifies the {{ .Name }} branch of the union. - {{ .KindConst }} {{ $.KindName }} = "{{ .TypeTag }}" + // {{ .KindDeclaration.Name }} identifies the {{ .Name }} branch. + {{ .KindDeclaration.Name }} {{ $.KindDeclaration.Name }} = "{{ .TypeTag }}" {{- end }} ) -// Kind returns the discriminator value of the union. -func (u {{ .Name }}) Kind() {{ .KindName }} { +// Kind returns the selected branch. +func (u {{ .TypeDeclaration.Name }}) Kind() {{ .KindDeclaration.Name }} { return u.kind } {{- range .Fields }} -// New{{ $.Name }}{{ .FieldName }} constructs {{ $.Name }} with the {{ .Name }} branch set. -func New{{ $.Name }}{{ .FieldName }}(v {{ .FieldType }}) {{ $.Name }} { - return {{ $.Name }}{ - kind: {{ .KindConst }}, - {{ .FieldName }}: v, +// {{ .ConstructorDeclaration.Name }} constructs {{ $.TypeDeclaration.Name }} with the {{ .Name }} branch set. +func {{ .ConstructorDeclaration.Name }}(v {{ .FieldType }}) {{ $.TypeDeclaration.Name }} { + return {{ $.TypeDeclaration.Name }}{ + kind: {{ .KindDeclaration.Name }}, + {{ .StorageName }}: v, } } -// As{{ .FieldName }} returns the value of the {{ .Name }} branch if set. -func (u {{ $.Name }}) As{{ .FieldName }}() (_ {{ .FieldType }}, ok bool) { - if u.kind != {{ .KindConst }} { +// As{{ .FieldName }} returns the value when the {{ .Name }} branch is selected. +func (u {{ $.TypeDeclaration.Name }}) As{{ .FieldName }}() (_ {{ .FieldType }}, ok bool) { + if u.kind != {{ .KindDeclaration.Name }} { return } - return u.{{ .FieldName }}, true + return u.{{ .StorageName }}, true } -// Set{{ .FieldName }} sets the {{ .Name }} branch of the union. -func (u *{{ $.Name }}) Set{{ .FieldName }}(v {{ .FieldType }}) { - u.kind = {{ .KindConst }} - u.{{ .FieldName }} = v +// Set{{ .FieldName }} selects the {{ .Name }} branch and stores v. +func (u *{{ $.TypeDeclaration.Name }}) Set{{ .FieldName }}(v {{ .FieldType }}) { + *u = {{ $.TypeDeclaration.Name }}{ + kind: {{ .KindDeclaration.Name }}, + {{ .StorageName }}: v, + } } {{- end }} -// Validate ensures the union discriminant is valid. -func (u {{ .Name }}) Validate() error { +// Validate ensures exactly one valid branch is selected. +func (u {{ .TypeDeclaration.Name }}) Validate() error { switch u.kind { case "": return goa.InvalidEnumValueError({{ printf "%q" .TypeKey }}, "", []any{ {{- range .Fields }} - string({{ .KindConst }}), + string({{ .KindDeclaration.Name }}), {{- end }} }) {{- range .Fields }} - case {{ .KindConst }}: + case {{ .KindDeclaration.Name }}: {{- if .Nilable }} - if u.{{ .FieldName }} == nil { - return goa.MissingFieldError({{ printf "%q" $.ValueKey }}, "{{ $.Name }}") + if u.{{ .StorageName }} == nil { + return goa.MissingFieldError({{ printf "%q" $.ValueKey }}, "{{ $.TypeDeclaration.Name }}") } {{- end }} return nil @@ -73,14 +75,14 @@ func (u {{ .Name }}) Validate() error { default: return goa.InvalidEnumValueError({{ printf "%q" $.TypeKey }}, u.kind, []any{ {{- range .Fields }} - string({{ .KindConst }}), + string({{ .KindDeclaration.Name }}), {{- end }} }) } } // MarshalJSON marshals the union into the canonical {type,value} JSON shape. -func (u {{ .Name }}) MarshalJSON() ([]byte, error) { +func (u {{ .TypeDeclaration.Name }}) MarshalJSON() ([]byte, error) { if err := u.Validate(); err != nil { return nil, err } @@ -89,11 +91,11 @@ func (u {{ .Name }}) MarshalJSON() ([]byte, error) { ) switch u.kind { {{- range .Fields }} - case {{ .KindConst }}: - value = u.{{ .FieldName }} + case {{ .KindDeclaration.Name }}: + value = u.{{ .StorageName }} {{- end }} default: - return nil, fmt.Errorf("unexpected {{ .Name }} discriminant %q", u.kind) + return nil, fmt.Errorf("unexpected {{ .TypeDeclaration.Name }} kind %q", u.kind) } return json.Marshal(struct { Type string {{ printf "`json:\"%s\"`" .TypeKey }} @@ -105,7 +107,7 @@ func (u {{ .Name }}) MarshalJSON() ([]byte, error) { } // UnmarshalJSON unmarshals the union from the canonical {type,value} JSON shape. -func (u *{{ .Name }}) UnmarshalJSON(data []byte) error { +func (u *{{ .TypeDeclaration.Name }}) UnmarshalJSON(data []byte) error { var raw struct { Type string {{ printf "`json:\"%s\"`" .TypeKey }} Value json.RawMessage {{ printf "`json:\"%s\"`" .ValueKey }} @@ -114,28 +116,27 @@ func (u *{{ .Name }}) UnmarshalJSON(data []byte) error { return err } if len(raw.Value) == 0 { - return goa.MissingFieldError({{ printf "%q" .ValueKey }}, "{{ .Name }}") + return goa.MissingFieldError({{ printf "%q" .ValueKey }}, "{{ .TypeDeclaration.Name }}") } if bytes.Equal(bytes.TrimSpace(raw.Value), []byte("null")) { return goa.InvalidFieldTypeError({{ printf "%q" .ValueKey }}, nil, "non-null JSON value") } switch raw.Type { {{- range .Fields }} - case string({{ .KindConst }}): + case string({{ .KindDeclaration.Name }}): var v {{ .FieldType }} if err := json.Unmarshal(raw.Value, &v); err != nil { return err } - u.kind = {{ .KindConst }} - u.{{ .FieldName }} = v + u.Set{{ .FieldName }}(v) {{- end }} default: if raw.Type == "" { - return goa.MissingFieldError({{ printf "%q" .TypeKey }}, "{{ .Name }}") + return goa.MissingFieldError({{ printf "%q" .TypeKey }}, "{{ .TypeDeclaration.Name }}") } return goa.InvalidEnumValueError({{ printf "%q" .TypeKey }}, raw.Type, []any{ {{- range .Fields }} - string({{ .KindConst }}), + string({{ .KindDeclaration.Name }}), {{- end }} }) } diff --git a/codegen/service/templates/validate.go.tpl b/codegen/service/templates/validate.go.tpl index 2ba16dc703..e427affc10 100644 --- a/codegen/service/templates/validate.go.tpl +++ b/codegen/service/templates/validate.go.tpl @@ -1,5 +1,5 @@ {{ comment .Description }} -func {{ .Name }}(result {{ .Ref }}) (err error) { +func {{ .Declaration.Name }}(result {{ .Ref }}) (err error) { {{ .Validate }} return } diff --git a/codegen/service/templates/viewed_type_map.go.tpl b/codegen/service/templates/viewed_type_map.go.tpl index 249b96c8a9..e3c88ee849 100644 --- a/codegen/service/templates/viewed_type_map.go.tpl +++ b/codegen/service/templates/viewed_type_map.go.tpl @@ -1,7 +1,7 @@ var ( {{- range .ViewedTypes }} - {{ printf "%sMap is a map indexing the attribute names of %s by view name." .Name .Name | comment }} - {{ .Name }}Map = map[string][]string{ + {{ printf "%s is a map indexing the attribute names of %s by view name." .Declaration.Name .TypeName | comment }} + {{ .Declaration.Name }} = map[string][]string{ {{- range .Views }} "{{ .Name }}": { {{- range $n := .Attributes }} diff --git a/codegen/service/test_helpers_test.go b/codegen/service/test_helpers_test.go new file mode 100644 index 0000000000..820f5813af --- /dev/null +++ b/codegen/service/test_helpers_test.go @@ -0,0 +1,43 @@ +// This file provides strict construction helpers for service code-generation +// tests whose package roots and planner claims are deliberately valid. +package service + +import ( + "path" + "testing" + + "github.com/stretchr/testify/require" + + "goa.design/goa/v3/codegen" + "goa.design/goa/v3/eval" + "goa.design/goa/v3/expr" +) + +// mustTestGeneration creates one generation or fails the calling test. +func mustTestGeneration(t *testing.T, genpkg string, roots []eval.Root) *codegen.Generation { + t.Helper() + generation, err := codegen.NewGeneration(genpkg, roots) + require.NoError(t, err) + return generation +} + +// mustClaimTestPackage claims one valid planner path or fails the calling test. +func mustClaimTestPackage(t *testing.T, generation *codegen.Generation, path string) *codegen.GeneratedPackage { + t.Helper() + generatedPackage, err := generation.ClaimPackage(path) + require.NoError(t, err) + return generatedPackage +} + +// planTestServices collects one retained service plan when the test exercises +// declaration collection separately from post-freeze linking. +func planTestServices(root *expr.RootExpr, generation *codegen.Generation) error { + _, err := NewPlan(root, generation, expr.NewExampleGenerator(root.API.RandomizerFactory)) + return err +} + +// servicePackagePath returns the natural generated package path used by tests +// that build one noncolliding service package directly. +func servicePackagePath(genpkg string, service *expr.ServiceExpr) string { + return path.Join(genpkg, servicePackageName(service.Name)) +} diff --git a/codegen/service/testdata/a-nested-alpha/unused.go b/codegen/service/testdata/a-nested-alpha/unused.go new file mode 100644 index 0000000000..385890e057 --- /dev/null +++ b/codegen/service/testdata/a-nested-alpha/unused.go @@ -0,0 +1,8 @@ +// Package nestedalpha supplies an external field that the design deliberately +// does not map, so conversion planning can prove unused fields reserve nothing. +package nestedalpha + +// Child has the same package and type names as a mapped conversion field. +type Child struct { + Value string +} diff --git a/codegen/service/testdata/dedup_event_marker_dsls.go b/codegen/service/testdata/dedup_event_marker_dsls.go deleted file mode 100644 index 75f0625e3b..0000000000 --- a/codegen/service/testdata/dedup_event_marker_dsls.go +++ /dev/null @@ -1,26 +0,0 @@ -package testdata - -import ( - . "goa.design/goa/v3/dsl" -) - -// StreamingDuplicateResultTypesDSL defines two streaming methods that share the same -// result type to ensure event marker methods are not duplicated in generated service code. -var StreamingDuplicateResultTypesDSL = func() { - API("dedup-streaming", func() { JSONRPC(func() {}) }) - var SharedEvent = Type("SharedEvent", func() { - Attribute("message", String) - Required("message") - }) - Service("DupStreamService", func() { - JSONRPC(func() { POST("/stream") }) - Method("A", func() { - StreamingResult(SharedEvent) - JSONRPC(func() { ServerSentEvents() }) - }) - Method("B", func() { - StreamingResult(SharedEvent) - JSONRPC(func() { ServerSentEvents() }) - }) - }) -} diff --git a/codegen/service/testdata/endpoint_code.go b/codegen/service/testdata/endpoint_code.go index 32566bec02..12da85749c 100644 --- a/codegen/service/testdata/endpoint_code.go +++ b/codegen/service/testdata/endpoint_code.go @@ -149,6 +149,9 @@ func NewAEndpoint(s Service) goa.Endpoint { return nil, err } vres := NewViewedRtype(res, "default") + if err := withresultviews.ValidateRtype(vres); err != nil { + return nil, err + } return vres, nil } } @@ -185,6 +188,9 @@ func NewAEndpoint(s Service) goa.Endpoint { return nil, err } vres := NewViewedViewtype(res, "tiny") + if err := withresultmultipleviewsviews.ValidateViewtype(vres); err != nil { + return nil, err + } return vres, nil } } @@ -198,6 +204,9 @@ func NewBEndpoint(s Service) goa.Endpoint { return nil, err } vres := NewViewedViewtype(res, "default") + if err := withresultmultipleviewsviews.ValidateViewtype(vres); err != nil { + return nil, err + } return vres, nil } } diff --git a/codegen/service/testdata/endpoint_dsls.go b/codegen/service/testdata/endpoint_dsls.go index d118014c9a..a2dbb20355 100644 --- a/codegen/service/testdata/endpoint_dsls.go +++ b/codegen/service/testdata/endpoint_dsls.go @@ -138,6 +138,32 @@ var MixedResultsEndpointDSL = func() { }) } +var MixedResultsWithViewsEndpointDSL = func() { + var ResultType = ResultType("application/vnd.mixed-result", func() { + TypeName("MixedResult") + Attributes(func() { + Attribute("id", String) + Attribute("detail", String) + }) + View("default", func() { + Attribute("id") + }) + View("detailed", func() { + Attribute("id") + Attribute("detail") + }) + }) + var EventType = Type("MixedEvent", func() { + Attribute("message", String) + }) + Service("MixedResultsWithViewsEndpoint", func() { + Method("MixedResultsWithViewsMethod", func() { + Result(ResultType) + StreamingResult(EventType) + }) + }) +} + var StreamingPayloadEndpointDSL = func() { var AType = Type("AType", func() { Attribute("a", String) diff --git a/codegen/service/testdata/example_interceptors/api_interceptor_service_client.golden b/codegen/service/testdata/example_interceptors/api_interceptor_service_client.golden index 09d7f741b7..88a2fce5cb 100644 --- a/codegen/service/testdata/example_interceptors/api_interceptor_service_client.golden +++ b/codegen/service/testdata/example_interceptors/api_interceptor_service_client.golden @@ -7,10 +7,9 @@ package interceptors import ( - apiinterceptorservice "api_interceptor_service" "context" - "fmt" "goa.design/clue/log" + apiinterceptorservice "goa.design/goa/example/api_interceptor_service" goa "goa.design/goa/v3/pkg" ) @@ -22,7 +21,7 @@ type APIInterceptorServiceClientInterceptors struct { func NewAPIInterceptorServiceClientInterceptors() *APIInterceptorServiceClientInterceptors { return &APIInterceptorServiceClientInterceptors{} } -func (i *APIInterceptorServiceClientInterceptors) API(ctx context.Context, info *interceptors.APIInfo, next goa.Endpoint) (any, error) { +func (i *APIInterceptorServiceClientInterceptors) API(ctx context.Context, info apiinterceptorservice.APIInfo, next goa.Endpoint) (any, error) { log.Printf(ctx, "[API] Sending request: %v", info.RawPayload()) resp, err := next(ctx, info.RawPayload()) if err != nil { diff --git a/codegen/service/testdata/example_interceptors/api_interceptor_service_server.golden b/codegen/service/testdata/example_interceptors/api_interceptor_service_server.golden index 0cf0d6ca49..5f6ada02ef 100644 --- a/codegen/service/testdata/example_interceptors/api_interceptor_service_server.golden +++ b/codegen/service/testdata/example_interceptors/api_interceptor_service_server.golden @@ -7,10 +7,9 @@ package interceptors import ( - apiinterceptorservice "api_interceptor_service" "context" - "fmt" "goa.design/clue/log" + apiinterceptorservice "goa.design/goa/example/api_interceptor_service" goa "goa.design/goa/v3/pkg" ) @@ -22,7 +21,7 @@ type APIInterceptorServiceServerInterceptors struct { func NewAPIInterceptorServiceServerInterceptors() *APIInterceptorServiceServerInterceptors { return &APIInterceptorServiceServerInterceptors{} } -func (i *APIInterceptorServiceServerInterceptors) API(ctx context.Context, info *interceptors.APIInfo, next goa.Endpoint) (any, error) { +func (i *APIInterceptorServiceServerInterceptors) API(ctx context.Context, info apiinterceptorservice.APIInfo, next goa.Endpoint) (any, error) { log.Printf(ctx, "[API] Processing request: %v", info.RawPayload()) resp, err := next(ctx, info.RawPayload()) if err != nil { diff --git a/codegen/service/testdata/example_interceptors/chained_interceptor_service_client.golden b/codegen/service/testdata/example_interceptors/chained_interceptor_service_client.golden index b719d5252f..9249be24da 100644 --- a/codegen/service/testdata/example_interceptors/chained_interceptor_service_client.golden +++ b/codegen/service/testdata/example_interceptors/chained_interceptor_service_client.golden @@ -7,10 +7,9 @@ package interceptors import ( - chainedinterceptorservice "chained_interceptor_service" "context" - "fmt" "goa.design/clue/log" + chainedinterceptorservice "goa.design/goa/example/chained_interceptor_service" goa "goa.design/goa/v3/pkg" ) @@ -22,7 +21,7 @@ type ChainedInterceptorServiceClientInterceptors struct { func NewChainedInterceptorServiceClientInterceptors() *ChainedInterceptorServiceClientInterceptors { return &ChainedInterceptorServiceClientInterceptors{} } -func (i *ChainedInterceptorServiceClientInterceptors) API(ctx context.Context, info *interceptors.APIInfo, next goa.Endpoint) (any, error) { +func (i *ChainedInterceptorServiceClientInterceptors) API(ctx context.Context, info chainedinterceptorservice.APIInfo, next goa.Endpoint) (any, error) { log.Printf(ctx, "[API] Sending request: %v", info.RawPayload()) resp, err := next(ctx, info.RawPayload()) if err != nil { @@ -32,7 +31,7 @@ func (i *ChainedInterceptorServiceClientInterceptors) API(ctx context.Context, i log.Printf(ctx, "[API] Received response: %v", resp) return resp, nil } -func (i *ChainedInterceptorServiceClientInterceptors) Method(ctx context.Context, info *interceptors.MethodInfo, next goa.Endpoint) (any, error) { +func (i *ChainedInterceptorServiceClientInterceptors) Method(ctx context.Context, info chainedinterceptorservice.MethodInfo, next goa.Endpoint) (any, error) { log.Printf(ctx, "[Method] Sending request: %v", info.RawPayload()) resp, err := next(ctx, info.RawPayload()) if err != nil { @@ -42,7 +41,7 @@ func (i *ChainedInterceptorServiceClientInterceptors) Method(ctx context.Context log.Printf(ctx, "[Method] Received response: %v", resp) return resp, nil } -func (i *ChainedInterceptorServiceClientInterceptors) Service(ctx context.Context, info *interceptors.ServiceInfo, next goa.Endpoint) (any, error) { +func (i *ChainedInterceptorServiceClientInterceptors) Service(ctx context.Context, info chainedinterceptorservice.ServiceInfo, next goa.Endpoint) (any, error) { log.Printf(ctx, "[Service] Sending request: %v", info.RawPayload()) resp, err := next(ctx, info.RawPayload()) if err != nil { diff --git a/codegen/service/testdata/example_interceptors/chained_interceptor_service_server.golden b/codegen/service/testdata/example_interceptors/chained_interceptor_service_server.golden index e9a70a5564..8c55f6e51a 100644 --- a/codegen/service/testdata/example_interceptors/chained_interceptor_service_server.golden +++ b/codegen/service/testdata/example_interceptors/chained_interceptor_service_server.golden @@ -7,10 +7,9 @@ package interceptors import ( - chainedinterceptorservice "chained_interceptor_service" "context" - "fmt" "goa.design/clue/log" + chainedinterceptorservice "goa.design/goa/example/chained_interceptor_service" goa "goa.design/goa/v3/pkg" ) @@ -22,7 +21,7 @@ type ChainedInterceptorServiceServerInterceptors struct { func NewChainedInterceptorServiceServerInterceptors() *ChainedInterceptorServiceServerInterceptors { return &ChainedInterceptorServiceServerInterceptors{} } -func (i *ChainedInterceptorServiceServerInterceptors) API(ctx context.Context, info *interceptors.APIInfo, next goa.Endpoint) (any, error) { +func (i *ChainedInterceptorServiceServerInterceptors) API(ctx context.Context, info chainedinterceptorservice.APIInfo, next goa.Endpoint) (any, error) { log.Printf(ctx, "[API] Processing request: %v", info.RawPayload()) resp, err := next(ctx, info.RawPayload()) if err != nil { @@ -32,7 +31,7 @@ func (i *ChainedInterceptorServiceServerInterceptors) API(ctx context.Context, i log.Printf(ctx, "[API] Response: %v", resp) return resp, nil } -func (i *ChainedInterceptorServiceServerInterceptors) Method(ctx context.Context, info *interceptors.MethodInfo, next goa.Endpoint) (any, error) { +func (i *ChainedInterceptorServiceServerInterceptors) Method(ctx context.Context, info chainedinterceptorservice.MethodInfo, next goa.Endpoint) (any, error) { log.Printf(ctx, "[Method] Processing request: %v", info.RawPayload()) resp, err := next(ctx, info.RawPayload()) if err != nil { @@ -42,7 +41,7 @@ func (i *ChainedInterceptorServiceServerInterceptors) Method(ctx context.Context log.Printf(ctx, "[Method] Response: %v", resp) return resp, nil } -func (i *ChainedInterceptorServiceServerInterceptors) Service(ctx context.Context, info *interceptors.ServiceInfo, next goa.Endpoint) (any, error) { +func (i *ChainedInterceptorServiceServerInterceptors) Service(ctx context.Context, info chainedinterceptorservice.ServiceInfo, next goa.Endpoint) (any, error) { log.Printf(ctx, "[Service] Processing request: %v", info.RawPayload()) resp, err := next(ctx, info.RawPayload()) if err != nil { diff --git a/codegen/service/testdata/example_interceptors/client_interceptor_service_client.golden b/codegen/service/testdata/example_interceptors/client_interceptor_service_client.golden index 72713cbf1b..ed5daef245 100644 --- a/codegen/service/testdata/example_interceptors/client_interceptor_service_client.golden +++ b/codegen/service/testdata/example_interceptors/client_interceptor_service_client.golden @@ -7,10 +7,9 @@ package interceptors import ( - clientinterceptorservice "client_interceptor_service" "context" - "fmt" "goa.design/clue/log" + clientinterceptorservice "goa.design/goa/example/client_interceptor_service" goa "goa.design/goa/v3/pkg" ) @@ -22,7 +21,7 @@ type ClientInterceptorServiceClientInterceptors struct { func NewClientInterceptorServiceClientInterceptors() *ClientInterceptorServiceClientInterceptors { return &ClientInterceptorServiceClientInterceptors{} } -func (i *ClientInterceptorServiceClientInterceptors) Test(ctx context.Context, info *interceptors.TestInfo, next goa.Endpoint) (any, error) { +func (i *ClientInterceptorServiceClientInterceptors) Test(ctx context.Context, info clientinterceptorservice.TestInfo, next goa.Endpoint) (any, error) { log.Printf(ctx, "[Test] Sending request: %v", info.RawPayload()) resp, err := next(ctx, info.RawPayload()) if err != nil { diff --git a/codegen/service/testdata/example_interceptors/multiple_interceptors_service_client.golden b/codegen/service/testdata/example_interceptors/multiple_interceptors_service_client.golden index 55126006cb..39e416240c 100644 --- a/codegen/service/testdata/example_interceptors/multiple_interceptors_service_client.golden +++ b/codegen/service/testdata/example_interceptors/multiple_interceptors_service_client.golden @@ -8,10 +8,9 @@ package interceptors import ( "context" - "fmt" "goa.design/clue/log" + multipleinterceptorsservice "goa.design/goa/example/multiple_interceptors_service" goa "goa.design/goa/v3/pkg" - multipleinterceptorsservice "multiple_interceptors_service" ) // MultipleInterceptorsServiceClientInterceptors implements the client interceptors for the MultipleInterceptorsService service. @@ -22,7 +21,7 @@ type MultipleInterceptorsServiceClientInterceptors struct { func NewMultipleInterceptorsServiceClientInterceptors() *MultipleInterceptorsServiceClientInterceptors { return &MultipleInterceptorsServiceClientInterceptors{} } -func (i *MultipleInterceptorsServiceClientInterceptors) Test2(ctx context.Context, info *interceptors.Test2Info, next goa.Endpoint) (any, error) { +func (i *MultipleInterceptorsServiceClientInterceptors) Test2(ctx context.Context, info multipleinterceptorsservice.Test2Info, next goa.Endpoint) (any, error) { log.Printf(ctx, "[Test2] Sending request: %v", info.RawPayload()) resp, err := next(ctx, info.RawPayload()) if err != nil { @@ -32,7 +31,7 @@ func (i *MultipleInterceptorsServiceClientInterceptors) Test2(ctx context.Contex log.Printf(ctx, "[Test2] Received response: %v", resp) return resp, nil } -func (i *MultipleInterceptorsServiceClientInterceptors) Test4(ctx context.Context, info *interceptors.Test4Info, next goa.Endpoint) (any, error) { +func (i *MultipleInterceptorsServiceClientInterceptors) Test4(ctx context.Context, info multipleinterceptorsservice.Test4Info, next goa.Endpoint) (any, error) { log.Printf(ctx, "[Test4] Sending request: %v", info.RawPayload()) resp, err := next(ctx, info.RawPayload()) if err != nil { diff --git a/codegen/service/testdata/example_interceptors/multiple_interceptors_service_server.golden b/codegen/service/testdata/example_interceptors/multiple_interceptors_service_server.golden index 6ddbb67861..dfe0a82193 100644 --- a/codegen/service/testdata/example_interceptors/multiple_interceptors_service_server.golden +++ b/codegen/service/testdata/example_interceptors/multiple_interceptors_service_server.golden @@ -8,10 +8,9 @@ package interceptors import ( "context" - "fmt" "goa.design/clue/log" + multipleinterceptorsservice "goa.design/goa/example/multiple_interceptors_service" goa "goa.design/goa/v3/pkg" - multipleinterceptorsservice "multiple_interceptors_service" ) // MultipleInterceptorsServiceServerInterceptors implements the server interceptor for the MultipleInterceptorsService service. @@ -22,7 +21,7 @@ type MultipleInterceptorsServiceServerInterceptors struct { func NewMultipleInterceptorsServiceServerInterceptors() *MultipleInterceptorsServiceServerInterceptors { return &MultipleInterceptorsServiceServerInterceptors{} } -func (i *MultipleInterceptorsServiceServerInterceptors) Test(ctx context.Context, info *interceptors.TestInfo, next goa.Endpoint) (any, error) { +func (i *MultipleInterceptorsServiceServerInterceptors) Test(ctx context.Context, info multipleinterceptorsservice.TestInfo, next goa.Endpoint) (any, error) { log.Printf(ctx, "[Test] Processing request: %v", info.RawPayload()) resp, err := next(ctx, info.RawPayload()) if err != nil { @@ -32,7 +31,7 @@ func (i *MultipleInterceptorsServiceServerInterceptors) Test(ctx context.Context log.Printf(ctx, "[Test] Response: %v", resp) return resp, nil } -func (i *MultipleInterceptorsServiceServerInterceptors) Test3(ctx context.Context, info *interceptors.Test3Info, next goa.Endpoint) (any, error) { +func (i *MultipleInterceptorsServiceServerInterceptors) Test3(ctx context.Context, info multipleinterceptorsservice.Test3Info, next goa.Endpoint) (any, error) { log.Printf(ctx, "[Test3] Processing request: %v", info.RawPayload()) resp, err := next(ctx, info.RawPayload()) if err != nil { diff --git a/codegen/service/testdata/example_interceptors/multiple_services_interceptors_service2_client.golden b/codegen/service/testdata/example_interceptors/multiple_services_interceptors_service2_client.golden index be0c25041b..53d6685774 100644 --- a/codegen/service/testdata/example_interceptors/multiple_services_interceptors_service2_client.golden +++ b/codegen/service/testdata/example_interceptors/multiple_services_interceptors_service2_client.golden @@ -8,10 +8,9 @@ package interceptors import ( "context" - "fmt" "goa.design/clue/log" + multipleservicesinterceptorsservice2 "goa.design/goa/example/multiple_services_interceptors_service2" goa "goa.design/goa/v3/pkg" - multipleservicesinterceptorsservice2 "multiple_services_interceptors_service2" ) // MultipleServicesInterceptorsService2ClientInterceptors implements the client interceptors for the MultipleServicesInterceptorsService2 service. @@ -22,7 +21,7 @@ type MultipleServicesInterceptorsService2ClientInterceptors struct { func NewMultipleServicesInterceptorsService2ClientInterceptors() *MultipleServicesInterceptorsService2ClientInterceptors { return &MultipleServicesInterceptorsService2ClientInterceptors{} } -func (i *MultipleServicesInterceptorsService2ClientInterceptors) Test2(ctx context.Context, info *interceptors.Test2Info, next goa.Endpoint) (any, error) { +func (i *MultipleServicesInterceptorsService2ClientInterceptors) Test2(ctx context.Context, info multipleservicesinterceptorsservice2.Test2Info, next goa.Endpoint) (any, error) { log.Printf(ctx, "[Test2] Sending request: %v", info.RawPayload()) resp, err := next(ctx, info.RawPayload()) if err != nil { @@ -32,7 +31,7 @@ func (i *MultipleServicesInterceptorsService2ClientInterceptors) Test2(ctx conte log.Printf(ctx, "[Test2] Received response: %v", resp) return resp, nil } -func (i *MultipleServicesInterceptorsService2ClientInterceptors) Test4(ctx context.Context, info *interceptors.Test4Info, next goa.Endpoint) (any, error) { +func (i *MultipleServicesInterceptorsService2ClientInterceptors) Test4(ctx context.Context, info multipleservicesinterceptorsservice2.Test4Info, next goa.Endpoint) (any, error) { log.Printf(ctx, "[Test4] Sending request: %v", info.RawPayload()) resp, err := next(ctx, info.RawPayload()) if err != nil { diff --git a/codegen/service/testdata/example_interceptors/multiple_services_interceptors_service2_server.golden b/codegen/service/testdata/example_interceptors/multiple_services_interceptors_service2_server.golden index a36e8d46f5..0433a02bf3 100644 --- a/codegen/service/testdata/example_interceptors/multiple_services_interceptors_service2_server.golden +++ b/codegen/service/testdata/example_interceptors/multiple_services_interceptors_service2_server.golden @@ -8,10 +8,9 @@ package interceptors import ( "context" - "fmt" "goa.design/clue/log" + multipleservicesinterceptorsservice2 "goa.design/goa/example/multiple_services_interceptors_service2" goa "goa.design/goa/v3/pkg" - multipleservicesinterceptorsservice2 "multiple_services_interceptors_service2" ) // MultipleServicesInterceptorsService2ServerInterceptors implements the server interceptor for the MultipleServicesInterceptorsService2 service. @@ -22,7 +21,7 @@ type MultipleServicesInterceptorsService2ServerInterceptors struct { func NewMultipleServicesInterceptorsService2ServerInterceptors() *MultipleServicesInterceptorsService2ServerInterceptors { return &MultipleServicesInterceptorsService2ServerInterceptors{} } -func (i *MultipleServicesInterceptorsService2ServerInterceptors) Test(ctx context.Context, info *interceptors.TestInfo, next goa.Endpoint) (any, error) { +func (i *MultipleServicesInterceptorsService2ServerInterceptors) Test(ctx context.Context, info multipleservicesinterceptorsservice2.TestInfo, next goa.Endpoint) (any, error) { log.Printf(ctx, "[Test] Processing request: %v", info.RawPayload()) resp, err := next(ctx, info.RawPayload()) if err != nil { @@ -32,7 +31,7 @@ func (i *MultipleServicesInterceptorsService2ServerInterceptors) Test(ctx contex log.Printf(ctx, "[Test] Response: %v", resp) return resp, nil } -func (i *MultipleServicesInterceptorsService2ServerInterceptors) Test3(ctx context.Context, info *interceptors.Test3Info, next goa.Endpoint) (any, error) { +func (i *MultipleServicesInterceptorsService2ServerInterceptors) Test3(ctx context.Context, info multipleservicesinterceptorsservice2.Test3Info, next goa.Endpoint) (any, error) { log.Printf(ctx, "[Test3] Processing request: %v", info.RawPayload()) resp, err := next(ctx, info.RawPayload()) if err != nil { diff --git a/codegen/service/testdata/example_interceptors/multiple_services_interceptors_service_client.golden b/codegen/service/testdata/example_interceptors/multiple_services_interceptors_service_client.golden index c344ea098b..5fe53ff08c 100644 --- a/codegen/service/testdata/example_interceptors/multiple_services_interceptors_service_client.golden +++ b/codegen/service/testdata/example_interceptors/multiple_services_interceptors_service_client.golden @@ -8,10 +8,9 @@ package interceptors import ( "context" - "fmt" "goa.design/clue/log" + multipleservicesinterceptorsservice "goa.design/goa/example/multiple_services_interceptors_service" goa "goa.design/goa/v3/pkg" - multipleservicesinterceptorsservice "multiple_services_interceptors_service" ) // MultipleServicesInterceptorsServiceClientInterceptors implements the client interceptors for the MultipleServicesInterceptorsService service. @@ -22,7 +21,7 @@ type MultipleServicesInterceptorsServiceClientInterceptors struct { func NewMultipleServicesInterceptorsServiceClientInterceptors() *MultipleServicesInterceptorsServiceClientInterceptors { return &MultipleServicesInterceptorsServiceClientInterceptors{} } -func (i *MultipleServicesInterceptorsServiceClientInterceptors) Test2(ctx context.Context, info *interceptors.Test2Info, next goa.Endpoint) (any, error) { +func (i *MultipleServicesInterceptorsServiceClientInterceptors) Test2(ctx context.Context, info multipleservicesinterceptorsservice.Test2Info, next goa.Endpoint) (any, error) { log.Printf(ctx, "[Test2] Sending request: %v", info.RawPayload()) resp, err := next(ctx, info.RawPayload()) if err != nil { @@ -32,7 +31,7 @@ func (i *MultipleServicesInterceptorsServiceClientInterceptors) Test2(ctx contex log.Printf(ctx, "[Test2] Received response: %v", resp) return resp, nil } -func (i *MultipleServicesInterceptorsServiceClientInterceptors) Test4(ctx context.Context, info *interceptors.Test4Info, next goa.Endpoint) (any, error) { +func (i *MultipleServicesInterceptorsServiceClientInterceptors) Test4(ctx context.Context, info multipleservicesinterceptorsservice.Test4Info, next goa.Endpoint) (any, error) { log.Printf(ctx, "[Test4] Sending request: %v", info.RawPayload()) resp, err := next(ctx, info.RawPayload()) if err != nil { diff --git a/codegen/service/testdata/example_interceptors/multiple_services_interceptors_service_server.golden b/codegen/service/testdata/example_interceptors/multiple_services_interceptors_service_server.golden index aa349d4854..12cab72bae 100644 --- a/codegen/service/testdata/example_interceptors/multiple_services_interceptors_service_server.golden +++ b/codegen/service/testdata/example_interceptors/multiple_services_interceptors_service_server.golden @@ -8,10 +8,9 @@ package interceptors import ( "context" - "fmt" "goa.design/clue/log" + multipleservicesinterceptorsservice "goa.design/goa/example/multiple_services_interceptors_service" goa "goa.design/goa/v3/pkg" - multipleservicesinterceptorsservice "multiple_services_interceptors_service" ) // MultipleServicesInterceptorsServiceServerInterceptors implements the server interceptor for the MultipleServicesInterceptorsService service. @@ -22,7 +21,7 @@ type MultipleServicesInterceptorsServiceServerInterceptors struct { func NewMultipleServicesInterceptorsServiceServerInterceptors() *MultipleServicesInterceptorsServiceServerInterceptors { return &MultipleServicesInterceptorsServiceServerInterceptors{} } -func (i *MultipleServicesInterceptorsServiceServerInterceptors) Test(ctx context.Context, info *interceptors.TestInfo, next goa.Endpoint) (any, error) { +func (i *MultipleServicesInterceptorsServiceServerInterceptors) Test(ctx context.Context, info multipleservicesinterceptorsservice.TestInfo, next goa.Endpoint) (any, error) { log.Printf(ctx, "[Test] Processing request: %v", info.RawPayload()) resp, err := next(ctx, info.RawPayload()) if err != nil { @@ -32,7 +31,7 @@ func (i *MultipleServicesInterceptorsServiceServerInterceptors) Test(ctx context log.Printf(ctx, "[Test] Response: %v", resp) return resp, nil } -func (i *MultipleServicesInterceptorsServiceServerInterceptors) Test3(ctx context.Context, info *interceptors.Test3Info, next goa.Endpoint) (any, error) { +func (i *MultipleServicesInterceptorsServiceServerInterceptors) Test3(ctx context.Context, info multipleservicesinterceptorsservice.Test3Info, next goa.Endpoint) (any, error) { log.Printf(ctx, "[Test3] Processing request: %v", info.RawPayload()) resp, err := next(ctx, info.RawPayload()) if err != nil { diff --git a/codegen/service/testdata/example_interceptors/server_interceptor_by_name_service_server.golden b/codegen/service/testdata/example_interceptors/server_interceptor_by_name_service_server.golden index da77a3dedb..510d569f8b 100644 --- a/codegen/service/testdata/example_interceptors/server_interceptor_by_name_service_server.golden +++ b/codegen/service/testdata/example_interceptors/server_interceptor_by_name_service_server.golden @@ -8,10 +8,9 @@ package interceptors import ( "context" - "fmt" "goa.design/clue/log" + serverinterceptorbynameservice "goa.design/goa/example/server_interceptor_by_name_service" goa "goa.design/goa/v3/pkg" - serverinterceptorbynameservice "server_interceptor_by_name_service" ) // ServerInterceptorByNameServiceServerInterceptors implements the server interceptor for the ServerInterceptorByNameService service. @@ -22,7 +21,7 @@ type ServerInterceptorByNameServiceServerInterceptors struct { func NewServerInterceptorByNameServiceServerInterceptors() *ServerInterceptorByNameServiceServerInterceptors { return &ServerInterceptorByNameServiceServerInterceptors{} } -func (i *ServerInterceptorByNameServiceServerInterceptors) Test(ctx context.Context, info *interceptors.TestInfo, next goa.Endpoint) (any, error) { +func (i *ServerInterceptorByNameServiceServerInterceptors) Test(ctx context.Context, info serverinterceptorbynameservice.TestInfo, next goa.Endpoint) (any, error) { log.Printf(ctx, "[Test] Processing request: %v", info.RawPayload()) resp, err := next(ctx, info.RawPayload()) if err != nil { diff --git a/codegen/service/testdata/example_interceptors/server_interceptor_service_server.golden b/codegen/service/testdata/example_interceptors/server_interceptor_service_server.golden index 7adff8b41a..001136cab3 100644 --- a/codegen/service/testdata/example_interceptors/server_interceptor_service_server.golden +++ b/codegen/service/testdata/example_interceptors/server_interceptor_service_server.golden @@ -8,10 +8,9 @@ package interceptors import ( "context" - "fmt" "goa.design/clue/log" + serverinterceptorservice "goa.design/goa/example/server_interceptor_service" goa "goa.design/goa/v3/pkg" - serverinterceptorservice "server_interceptor_service" ) // ServerInterceptorServiceServerInterceptors implements the server interceptor for the ServerInterceptorService service. @@ -22,7 +21,7 @@ type ServerInterceptorServiceServerInterceptors struct { func NewServerInterceptorServiceServerInterceptors() *ServerInterceptorServiceServerInterceptors { return &ServerInterceptorServiceServerInterceptors{} } -func (i *ServerInterceptorServiceServerInterceptors) Test(ctx context.Context, info *interceptors.TestInfo, next goa.Endpoint) (any, error) { +func (i *ServerInterceptorServiceServerInterceptors) Test(ctx context.Context, info serverinterceptorservice.TestInfo, next goa.Endpoint) (any, error) { log.Printf(ctx, "[Test] Processing request: %v", info.RawPayload()) resp, err := next(ctx, info.RawPayload()) if err != nil { diff --git a/codegen/service/testdata/golden/example_service-mixed-results-with-views.go.golden b/codegen/service/testdata/golden/example_service-mixed-results-with-views.go.golden new file mode 100644 index 0000000000..3469fe70fe --- /dev/null +++ b/codegen/service/testdata/golden/example_service-mixed-results-with-views.go.golden @@ -0,0 +1,25 @@ +package testapi + +import ( + "context" + "goa.design/clue/log" + mixedresultswithviewsendpoint "goa.design/goa/example/mixed_results_with_views_endpoint" +) + +// MixedResultsWithViewsEndpoint service example implementation. +// The example methods log the requests and return zero values. +type mixedResultsWithViewsEndpointsrvc struct{} + +// NewMixedResultsWithViewsEndpoint returns the MixedResultsWithViewsEndpoint +// service implementation. +func NewMixedResultsWithViewsEndpoint() mixedresultswithviewsendpoint.Service { + return &mixedResultsWithViewsEndpointsrvc{} +} + +// MixedResultsWithViewsMethod implements MixedResultsWithViewsMethod. +func (s *mixedResultsWithViewsEndpointsrvc) MixedResultsWithViewsMethod(ctx context.Context, stream mixedresultswithviewsendpoint.MixedResultsWithViewsMethodServerStream) (res *mixedresultswithviewsendpoint.MixedResult, view string, err error) { + res = &mixedresultswithviewsendpoint.MixedResult{} + view = "default" + log.Printf(ctx, "mixedResultsWithViewsEndpoint.MixedResultsWithViewsMethod") + return +} diff --git a/codegen/service/testdata/golden/example_service-mixed-results.go.golden b/codegen/service/testdata/golden/example_service-mixed-results.go.golden new file mode 100644 index 0000000000..9589677a8f --- /dev/null +++ b/codegen/service/testdata/golden/example_service-mixed-results.go.golden @@ -0,0 +1,24 @@ +package testapi + +import ( + "context" + "goa.design/clue/log" + mixedresultsendpoint "goa.design/goa/example/mixed_results_endpoint" +) + +// MixedResultsEndpoint service example implementation. +// The example methods log the requests and return zero values. +type mixedResultsEndpointsrvc struct{} + +// NewMixedResultsEndpoint returns the MixedResultsEndpoint service +// implementation. +func NewMixedResultsEndpoint() mixedresultsendpoint.Service { + return &mixedResultsEndpointsrvc{} +} + +// MixedResultsMethod implements MixedResultsMethod. +func (s *mixedResultsEndpointsrvc) MixedResultsMethod(ctx context.Context, p *mixedresultsendpoint.Payload, stream mixedresultsendpoint.MixedResultsMethodServerStream) (res *mixedresultsendpoint.ResultType, err error) { + res = &mixedresultsendpoint.ResultType{} + log.Printf(ctx, "mixedResultsEndpoint.MixedResultsMethod") + return +} diff --git a/codegen/service/testdata/golden/pkg_path_array_foo.go.golden b/codegen/service/testdata/golden/pkg_path_array_foo.go.golden index 1469ba4254..229ca4ff20 100644 --- a/codegen/service/testdata/golden/pkg_path_array_foo.go.golden +++ b/codegen/service/testdata/golden/pkg_path_array_foo.go.golden @@ -1,4 +1,4 @@ - +// Foo is a named type defined in the service design. type Foo struct { IntField *int } diff --git a/codegen/service/testdata/golden/pkg_path_dupes_foo.go.golden b/codegen/service/testdata/golden/pkg_path_dupes_foo.go.golden index 7bd09a39e3..a4b734a959 100644 --- a/codegen/service/testdata/golden/pkg_path_dupes_foo.go.golden +++ b/codegen/service/testdata/golden/pkg_path_dupes_foo.go.golden @@ -1,4 +1,8 @@ -// Foo is the payload type of the PkgPathDupeMethod service A method. +// Foo is used by these service methods: +// - PkgPathDupeMethod A: payload and result +// - PkgPathDupeMethod B: payload and result +// - PkgPathDupeMethod2 A: payload and result +// - PkgPathDupeMethod2 B: payload and result type Foo struct { IntField *int } diff --git a/codegen/service/testdata/golden/pkg_path_multiple_bar.go.golden b/codegen/service/testdata/golden/pkg_path_multiple_bar.go.golden index af590aae73..ee47af5611 100644 --- a/codegen/service/testdata/golden/pkg_path_multiple_bar.go.golden +++ b/codegen/service/testdata/golden/pkg_path_multiple_bar.go.golden @@ -1,4 +1,5 @@ -// Bar is the payload type of the MultiplePkgPathMethod service A method. +// Bar is the payload and result type of the MultiplePkgPathMethod service A +// method. type Bar struct { IntField *int } diff --git a/codegen/service/testdata/golden/pkg_path_multiple_baz.go.golden b/codegen/service/testdata/golden/pkg_path_multiple_baz.go.golden index 2e6028eeb1..ea6948e82c 100644 --- a/codegen/service/testdata/golden/pkg_path_multiple_baz.go.golden +++ b/codegen/service/testdata/golden/pkg_path_multiple_baz.go.golden @@ -1,4 +1,5 @@ -// Baz is the payload type of the MultiplePkgPathMethod service B method. +// Baz is the payload and result type of the MultiplePkgPathMethod service B +// method. type Baz struct { IntField *int } diff --git a/codegen/service/testdata/golden/pkg_path_payload_attribute_foo.go.golden b/codegen/service/testdata/golden/pkg_path_payload_attribute_foo.go.golden index 1469ba4254..229ca4ff20 100644 --- a/codegen/service/testdata/golden/pkg_path_payload_attribute_foo.go.golden +++ b/codegen/service/testdata/golden/pkg_path_payload_attribute_foo.go.golden @@ -1,4 +1,4 @@ - +// Foo is a named type defined in the service design. type Foo struct { IntField *int } diff --git a/codegen/service/testdata/golden/pkg_path_payload_attribute_service.go.golden b/codegen/service/testdata/golden/pkg_path_payload_attribute_service.go.golden index c52656257a..2c78cff2cd 100644 --- a/codegen/service/testdata/golden/pkg_path_payload_attribute_service.go.golden +++ b/codegen/service/testdata/golden/pkg_path_payload_attribute_service.go.golden @@ -2,7 +2,7 @@ // Service is the PkgPathPayloadAttributeDSL service interface. type Service interface { // Foo implements Foo. - FooEndpoint(context.Context, *Bar) (res *Bar, err error) + Foo(context.Context, *Bar) (res *Bar, err error) } // APIName is the name of the API as defined in the design. diff --git a/codegen/service/testdata/golden/pkg_path_recursive_foo.go.golden b/codegen/service/testdata/golden/pkg_path_recursive_foo.go.golden index 1469ba4254..229ca4ff20 100644 --- a/codegen/service/testdata/golden/pkg_path_recursive_foo.go.golden +++ b/codegen/service/testdata/golden/pkg_path_recursive_foo.go.golden @@ -1,4 +1,4 @@ - +// Foo is a named type defined in the service design. type Foo struct { IntField *int } diff --git a/codegen/service/testdata/golden/pkg_path_recursive_recursive_foo.go.golden b/codegen/service/testdata/golden/pkg_path_recursive_recursive_foo.go.golden index 1d4d0e1fdb..f66c17de5b 100644 --- a/codegen/service/testdata/golden/pkg_path_recursive_recursive_foo.go.golden +++ b/codegen/service/testdata/golden/pkg_path_recursive_recursive_foo.go.golden @@ -1,5 +1,5 @@ -// RecursiveFoo is the payload type of the PkgPathRecursiveMethod service A -// method. +// RecursiveFoo is the payload and result type of the PkgPathRecursiveMethod +// service A method. type RecursiveFoo struct { Foo *Foo } diff --git a/codegen/service/testdata/golden/pkg_path_shared_roles_detail.go.golden b/codegen/service/testdata/golden/pkg_path_shared_roles_detail.go.golden new file mode 100644 index 0000000000..3d39648c6e --- /dev/null +++ b/codegen/service/testdata/golden/pkg_path_shared_roles_detail.go.golden @@ -0,0 +1,4 @@ +// Detail is a named type defined in the service design. +type Detail struct { + Message *string +} diff --git a/codegen/service/testdata/golden/pkg_path_shared_roles_service.go.golden b/codegen/service/testdata/golden/pkg_path_shared_roles_service.go.golden new file mode 100644 index 0000000000..9751c6d575 --- /dev/null +++ b/codegen/service/testdata/golden/pkg_path_shared_roles_service.go.golden @@ -0,0 +1,54 @@ + +// Service is the PkgPathSharedRoles service interface. +type Service interface { + // Exchange implements Exchange. + Exchange(context.Context, *shared.Shared, ExchangeServerStream) (res *shared.Shared, err error) +} + +// APIName is the name of the API as defined in the design. +const APIName = "test api" + +// APIVersion is the version of the API as defined in the design. +const APIVersion = "0.0.1" + +// ServiceName is the name of the service as defined in the design. This is the +// same value that is set in the endpoint request contexts under the ServiceKey +// key. +const ServiceName = "PkgPathSharedRoles" + +// MethodNames lists the service method names as defined in the design. These +// are the same values that are set in the endpoint request contexts under the +// MethodKey key. +var MethodNames = [1]string{"Exchange"} + +// ExchangeServerStream allows streaming instances of *shared.Shared to the +// client. +type ExchangeServerStream interface { + // Send streams instances of "shared.Shared". + Send(*shared.Shared) error + // SendWithContext streams instances of "shared.Shared" with context. + SendWithContext(context.Context, *shared.Shared) error + // Recv reads instances of "shared.Shared" from the stream. + Recv() (*shared.Shared, error) + // RecvWithContext reads instances of "shared.Shared" from the stream with + // context. + RecvWithContext(context.Context) (*shared.Shared, error) + // Close closes the stream. + Close() error +} + +// ExchangeClientStream allows streaming instances of *shared.Shared to the +// client. +type ExchangeClientStream interface { + // Send streams instances of "shared.Shared". + Send(*shared.Shared) error + // SendWithContext streams instances of "shared.Shared" with context. + SendWithContext(context.Context, *shared.Shared) error + // Recv reads instances of "shared.Shared" from the stream. + Recv() (*shared.Shared, error) + // RecvWithContext reads instances of "shared.Shared" from the stream with + // context. + RecvWithContext(context.Context) (*shared.Shared, error) + // Close closes the stream. + Close() error +} diff --git a/codegen/service/testdata/golden/pkg_path_shared_roles_shared.go.golden b/codegen/service/testdata/golden/pkg_path_shared_roles_shared.go.golden new file mode 100644 index 0000000000..2938aeeb52 --- /dev/null +++ b/codegen/service/testdata/golden/pkg_path_shared_roles_shared.go.golden @@ -0,0 +1,23 @@ +// Shared is the payload, streaming payload, result, and streaming result type +// of the PkgPathSharedRoles service Exchange method. +type Shared struct { + IntField *int + Detail *Detail +} + +// Error returns an error description. +func (e *Shared) Error() string { + return "" +} + +// ErrorName returns the error name. +// +// Deprecated: Use GoaErrorName - https://github.com/goadesign/goa/issues/3105 +func (e *Shared) ErrorName() string { + return e.GoaErrorName() +} + +// GoaErrorName returns the error name. +func (e *Shared) GoaErrorName() string { + return "shared_error" +} diff --git a/codegen/service/testdata/golden/pkg_path_single_foo.go.golden b/codegen/service/testdata/golden/pkg_path_single_foo.go.golden index 38cad09709..f7ff6cd37b 100644 --- a/codegen/service/testdata/golden/pkg_path_single_foo.go.golden +++ b/codegen/service/testdata/golden/pkg_path_single_foo.go.golden @@ -1,4 +1,4 @@ -// Foo is the payload type of the PkgPathMethod service A method. +// Foo is the payload and result type of the PkgPathMethod service A method. type Foo struct { IntField *int } diff --git a/codegen/service/testdata/golden/service_extended_located_union_owner_session_id.go.golden b/codegen/service/testdata/golden/service_extended_located_union_owner_session_id.go.golden new file mode 100644 index 0000000000..f4aadc1323 --- /dev/null +++ b/codegen/service/testdata/golden/service_extended_located_union_owner_session_id.go.golden @@ -0,0 +1,11 @@ +// Code generated by goa, DO NOT EDIT. +// +// User types +// +// Command: +// goa + +package types + +// OwnerBranchSessionID is a named type defined in the service design. +type OwnerBranchSessionID string diff --git a/codegen/service/testdata/golden/service_extended_located_union_owner_task_id.go.golden b/codegen/service/testdata/golden/service_extended_located_union_owner_task_id.go.golden new file mode 100644 index 0000000000..9936c40a88 --- /dev/null +++ b/codegen/service/testdata/golden/service_extended_located_union_owner_task_id.go.golden @@ -0,0 +1,11 @@ +// Code generated by goa, DO NOT EDIT. +// +// User types +// +// Command: +// goa + +package types + +// OwnerBranchTaskID is a named type defined in the service design. +type OwnerBranchTaskID string diff --git a/codegen/service/testdata/golden/service_extended_located_union_unions.go.golden b/codegen/service/testdata/golden/service_extended_located_union_unions.go.golden new file mode 100644 index 0000000000..17f66a2a78 --- /dev/null +++ b/codegen/service/testdata/golden/service_extended_located_union_unions.go.golden @@ -0,0 +1,170 @@ +// Code generated by goa, DO NOT EDIT. +// +// Union types +// +// Command: +// goa + +package types + +import ( + "bytes" + "encoding/json" + "fmt" + goa "goa.design/goa/v3/pkg" +) + +// Owner holds exactly one of its branch values. +type Owner struct { + kind OwnerKind + sessionID OwnerBranchSessionID + taskID OwnerBranchTaskID +} + +// OwnerKind records which Owner branch is selected. +type OwnerKind string + +const ( + // OwnerKindSessionID identifies the session_id branch. + OwnerKindSessionID OwnerKind = "session_id" + // OwnerKindTaskID identifies the task_id branch. + OwnerKindTaskID OwnerKind = "task_id" +) + +// Kind returns the selected branch. +func (u Owner) Kind() OwnerKind { + return u.kind +} + +// NewOwnerSessionID constructs Owner with the session_id branch set. +func NewOwnerSessionID(v OwnerBranchSessionID) Owner { + return Owner{ + kind: OwnerKindSessionID, + sessionID: v, + } +} + +// AsSessionID returns the value when the session_id branch is selected. +func (u Owner) AsSessionID() (_ OwnerBranchSessionID, ok bool) { + if u.kind != OwnerKindSessionID { + return + } + return u.sessionID, true +} + +// SetSessionID selects the session_id branch and stores v. +func (u *Owner) SetSessionID(v OwnerBranchSessionID) { + *u = Owner{ + kind: OwnerKindSessionID, + sessionID: v, + } +} + +// NewOwnerTaskID constructs Owner with the task_id branch set. +func NewOwnerTaskID(v OwnerBranchTaskID) Owner { + return Owner{ + kind: OwnerKindTaskID, + taskID: v, + } +} + +// AsTaskID returns the value when the task_id branch is selected. +func (u Owner) AsTaskID() (_ OwnerBranchTaskID, ok bool) { + if u.kind != OwnerKindTaskID { + return + } + return u.taskID, true +} + +// SetTaskID selects the task_id branch and stores v. +func (u *Owner) SetTaskID(v OwnerBranchTaskID) { + *u = Owner{ + kind: OwnerKindTaskID, + taskID: v, + } +} + +// Validate ensures exactly one valid branch is selected. +func (u Owner) Validate() error { + switch u.kind { + case "": + return goa.InvalidEnumValueError("type", "", []any{ + string(OwnerKindSessionID), + string(OwnerKindTaskID), + }) + case OwnerKindSessionID: + return nil + case OwnerKindTaskID: + return nil + default: + return goa.InvalidEnumValueError("type", u.kind, []any{ + string(OwnerKindSessionID), + string(OwnerKindTaskID), + }) + } +} + +// MarshalJSON marshals the union into the canonical {type,value} JSON shape. +func (u Owner) MarshalJSON() ([]byte, error) { + if err := u.Validate(); err != nil { + return nil, err + } + var ( + value any + ) + switch u.kind { + case OwnerKindSessionID: + value = u.sessionID + case OwnerKindTaskID: + value = u.taskID + default: + return nil, fmt.Errorf("unexpected Owner kind %q", u.kind) + } + return json.Marshal(struct { + Type string `json:"type"` + Value any `json:"value"` + }{ + Type: string(u.kind), + Value: value, + }) +} + +// UnmarshalJSON unmarshals the union from the canonical {type,value} JSON shape. +func (u *Owner) UnmarshalJSON(data []byte) error { + var raw struct { + Type string `json:"type"` + Value json.RawMessage `json:"value"` + } + if err := json.Unmarshal(data, &raw); err != nil { + return err + } + if len(raw.Value) == 0 { + return goa.MissingFieldError("value", "Owner") + } + if bytes.Equal(bytes.TrimSpace(raw.Value), []byte("null")) { + return goa.InvalidFieldTypeError("value", nil, "non-null JSON value") + } + switch raw.Type { + case string(OwnerKindSessionID): + var v OwnerBranchSessionID + if err := json.Unmarshal(raw.Value, &v); err != nil { + return err + } + u.SetSessionID(v) + case string(OwnerKindTaskID): + var v OwnerBranchTaskID + if err := json.Unmarshal(raw.Value, &v); err != nil { + return err + } + u.SetTaskID(v) + default: + if raw.Type == "" { + return goa.MissingFieldError("type", "Owner") + } + return goa.InvalidEnumValueError("type", raw.Type, []any{ + string(OwnerKindSessionID), + string(OwnerKindTaskID), + }) + } + return nil +} diff --git a/codegen/service/testdata/golden/service_service-api-error-reference.go.golden b/codegen/service/testdata/golden/service_service-api-error-reference.go.golden new file mode 100644 index 0000000000..74e0e54849 --- /dev/null +++ b/codegen/service/testdata/golden/service_service-api-error-reference.go.golden @@ -0,0 +1,27 @@ + +// Service is the ErrorReference service interface. +type Service interface { + // Run implements Run. + Run(context.Context) (err error) +} + +// APIName is the name of the API as defined in the design. +const APIName = "ErrorReference" + +// APIVersion is the version of the API as defined in the design. +const APIVersion = "0.0.1" + +// ServiceName is the name of the service as defined in the design. This is the +// same value that is set in the endpoint request contexts under the ServiceKey +// key. +const ServiceName = "ErrorReference" + +// MethodNames lists the service method names as defined in the design. These +// are the same values that are set in the endpoint request contexts under the +// MethodKey key. +var MethodNames = [1]string{"Run"} + +// MakeBusy builds a goa.ServiceError from an error. +func MakeBusy(err error) *goa.ServiceError { + return goa.NewServiceError(err, "busy", true, true, true) +} diff --git a/codegen/service/testdata/golden/service_service-bidirectional-streaming-result-with-explicit-view.go.golden b/codegen/service/testdata/golden/service_service-bidirectional-streaming-result-with-explicit-view.go.golden index 86076a3ff5..308cb32cfd 100644 --- a/codegen/service/testdata/golden/service_service-bidirectional-streaming-result-with-explicit-view.go.golden +++ b/codegen/service/testdata/golden/service_service-bidirectional-streaming-result-with-explicit-view.go.golden @@ -78,7 +78,7 @@ func NewMultipleViews(vres *bidirectionalstreamingresultwithexplicitviewservicev // NewViewedMultipleViews initializes viewed result type MultipleViews from // result type MultipleViews using the given view. func NewViewedMultipleViews(res *MultipleViews, view string) *bidirectionalstreamingresultwithexplicitviewserviceviews.MultipleViews { - var vres *bidirectionalstreamingresultwithexplicitviewserviceviews.MultipleViews + vres := &bidirectionalstreamingresultwithexplicitviewserviceviews.MultipleViews{View: view} switch view { case "default", "": p := newMultipleViewsView(res) diff --git a/codegen/service/testdata/golden/service_service-bidirectional-streaming-result-with-views.go.golden b/codegen/service/testdata/golden/service_service-bidirectional-streaming-result-with-views.go.golden index bbf9ce1e4a..bc9948fd96 100644 --- a/codegen/service/testdata/golden/service_service-bidirectional-streaming-result-with-views.go.golden +++ b/codegen/service/testdata/golden/service_service-bidirectional-streaming-result-with-views.go.golden @@ -94,7 +94,7 @@ func NewMultipleViews(vres *bidirectionalstreamingresultwithviewsserviceviews.Mu // NewViewedMultipleViews initializes viewed result type MultipleViews from // result type MultipleViews using the given view. func NewViewedMultipleViews(res *MultipleViews, view string) *bidirectionalstreamingresultwithviewsserviceviews.MultipleViews { - var vres *bidirectionalstreamingresultwithviewsserviceviews.MultipleViews + vres := &bidirectionalstreamingresultwithviewsserviceviews.MultipleViews{View: view} switch view { case "default", "": p := newMultipleViewsView(res) diff --git a/codegen/service/testdata/golden/service_service-custom-errors.go.golden b/codegen/service/testdata/golden/service_service-custom-errors.go.golden index 66cb9919f5..869a11efde 100644 --- a/codegen/service/testdata/golden/service_service-custom-errors.go.golden +++ b/codegen/service/testdata/golden/service_service-custom-errors.go.golden @@ -29,14 +29,14 @@ type APayload struct { OptionalField *string } +// primitive error description +type Primitive string + type Result struct { A *string B string } -// primitive error description -type Primitive string - // Error returns an error description. func (e *APayload) Error() string { return "" @@ -55,35 +55,35 @@ func (e *APayload) GoaErrorName() string { } // Error returns an error description. -func (e *Result) Error() string { - return "" +func (e Primitive) Error() string { + return "primitive error description" } // ErrorName returns the error name. // // Deprecated: Use GoaErrorName - https://github.com/goadesign/goa/issues/3105 -func (e *Result) ErrorName() string { +func (e Primitive) ErrorName() string { return e.GoaErrorName() } // GoaErrorName returns the error name. -func (e *Result) GoaErrorName() string { - return e.B +func (e Primitive) GoaErrorName() string { + return "primitive" } // Error returns an error description. -func (e Primitive) Error() string { - return "primitive error description" +func (e *Result) Error() string { + return "" } // ErrorName returns the error name. // // Deprecated: Use GoaErrorName - https://github.com/goadesign/goa/issues/3105 -func (e Primitive) ErrorName() string { +func (e *Result) ErrorName() string { return e.GoaErrorName() } // GoaErrorName returns the error name. -func (e Primitive) GoaErrorName() string { - return "primitive" +func (e *Result) GoaErrorName() string { + return e.B } diff --git a/codegen/service/testdata/golden/service_service-multi-union.go.golden b/codegen/service/testdata/golden/service_service-multi-union.go.golden index 94cb54030e..aecf2ecc3f 100644 --- a/codegen/service/testdata/golden/service_service-multi-union.go.golden +++ b/codegen/service/testdata/golden/service_service-multi-union.go.golden @@ -34,24 +34,24 @@ type Union struct { Values Values } -// Values is a sum-type union. +// Values holds exactly one of its branch values. type Values struct { kind ValuesKind - A *TypeA - B *TypeB + a *TypeA + b *TypeB } -// ValuesKind enumerates the union variants for Values. +// ValuesKind records which Values branch is selected. type ValuesKind string const ( - // ValuesKindA identifies the a branch of the union. + // ValuesKindA identifies the a branch. ValuesKindA ValuesKind = "a" - // ValuesKindB identifies the b branch of the union. + // ValuesKindB identifies the b branch. ValuesKindB ValuesKind = "b" ) -// Kind returns the discriminator value of the union. +// Kind returns the selected branch. func (u Values) Kind() ValuesKind { return u.kind } @@ -60,47 +60,51 @@ func (u Values) Kind() ValuesKind { func NewValuesA(v *TypeA) Values { return Values{ kind: ValuesKindA, - A: v, + a: v, } } -// AsA returns the value of the a branch if set. +// AsA returns the value when the a branch is selected. func (u Values) AsA() (_ *TypeA, ok bool) { if u.kind != ValuesKindA { return } - return u.A, true + return u.a, true } -// SetA sets the a branch of the union. +// SetA selects the a branch and stores v. func (u *Values) SetA(v *TypeA) { - u.kind = ValuesKindA - u.A = v + *u = Values{ + kind: ValuesKindA, + a: v, + } } // NewValuesB constructs Values with the b branch set. func NewValuesB(v *TypeB) Values { return Values{ kind: ValuesKindB, - B: v, + b: v, } } -// AsB returns the value of the b branch if set. +// AsB returns the value when the b branch is selected. func (u Values) AsB() (_ *TypeB, ok bool) { if u.kind != ValuesKindB { return } - return u.B, true + return u.b, true } -// SetB sets the b branch of the union. +// SetB selects the b branch and stores v. func (u *Values) SetB(v *TypeB) { - u.kind = ValuesKindB - u.B = v + *u = Values{ + kind: ValuesKindB, + b: v, + } } -// Validate ensures the union discriminant is valid. +// Validate ensures exactly one valid branch is selected. func (u Values) Validate() error { switch u.kind { case "": @@ -109,12 +113,12 @@ func (u Values) Validate() error { string(ValuesKindB), }) case ValuesKindA: - if u.A == nil { + if u.a == nil { return goa.MissingFieldError("value", "Values") } return nil case ValuesKindB: - if u.B == nil { + if u.b == nil { return goa.MissingFieldError("value", "Values") } return nil @@ -136,11 +140,11 @@ func (u Values) MarshalJSON() ([]byte, error) { ) switch u.kind { case ValuesKindA: - value = u.A + value = u.a case ValuesKindB: - value = u.B + value = u.b default: - return nil, fmt.Errorf("unexpected Values discriminant %q", u.kind) + return nil, fmt.Errorf("unexpected Values kind %q", u.kind) } return json.Marshal(struct { Type string `json:"type"` @@ -172,15 +176,13 @@ func (u *Values) UnmarshalJSON(data []byte) error { if err := json.Unmarshal(raw.Value, &v); err != nil { return err } - u.kind = ValuesKindA - u.A = v + u.SetA(v) case string(ValuesKindB): var v *TypeB if err := json.Unmarshal(raw.Value, &v); err != nil { return err } - u.kind = ValuesKindB - u.B = v + u.SetB(v) default: if raw.Type == "" { return goa.MissingFieldError("type", "Values") diff --git a/codegen/service/testdata/golden/service_service-nested-custom-error.go.golden b/codegen/service/testdata/golden/service_service-nested-custom-error.go.golden new file mode 100644 index 0000000000..363d11e8b1 --- /dev/null +++ b/codegen/service/testdata/golden/service_service-nested-custom-error.go.golden @@ -0,0 +1,47 @@ + +// Service is the NestedCustomError service interface. +type Service interface { + // Fail implements Fail. + Fail(context.Context) (err error) +} + +// APIName is the name of the API as defined in the design. +const APIName = "test api" + +// APIVersion is the version of the API as defined in the design. +const APIVersion = "0.0.1" + +// ServiceName is the name of the service as defined in the design. This is the +// same value that is set in the endpoint request contexts under the ServiceKey +// key. +const ServiceName = "NestedCustomError" + +// MethodNames lists the service method names as defined in the design. These +// are the same values that are set in the endpoint request contexts under the +// MethodKey key. +var MethodNames = [1]string{"Fail"} + +type Detail struct { + Message *string +} + +type Failure struct { + Detail *Detail +} + +// Error returns an error description. +func (e *Failure) Error() string { + return "" +} + +// ErrorName returns the error name. +// +// Deprecated: Use GoaErrorName - https://github.com/goadesign/goa/issues/3105 +func (e *Failure) ErrorName() string { + return e.GoaErrorName() +} + +// GoaErrorName returns the error name. +func (e *Failure) GoaErrorName() string { + return "failure" +} diff --git a/codegen/service/testdata/golden/service_service-repeated-inline-errors.go.golden b/codegen/service/testdata/golden/service_service-repeated-inline-errors.go.golden new file mode 100644 index 0000000000..2e573b0a27 --- /dev/null +++ b/codegen/service/testdata/golden/service_service-repeated-inline-errors.go.golden @@ -0,0 +1,45 @@ + +// Service is the Secured service interface. +type Service interface { + // Read implements Read. + Read(context.Context) (err error) + // Write implements Write. + Write(context.Context) (err error) + // Delete implements Delete. + Delete(context.Context) (err error) +} + +// APIName is the name of the API as defined in the design. +const APIName = "test api" + +// APIVersion is the version of the API as defined in the design. +const APIVersion = "0.0.1" + +// ServiceName is the name of the service as defined in the design. This is the +// same value that is set in the endpoint request contexts under the ServiceKey +// key. +const ServiceName = "Secured" + +// MethodNames lists the service method names as defined in the design. These +// are the same values that are set in the endpoint request contexts under the +// MethodKey key. +var MethodNames = [3]string{"Read", "Write", "Delete"} + +type InvalidScopes string + +// Error returns an error description. +func (e InvalidScopes) Error() string { + return "" +} + +// ErrorName returns the error name. +// +// Deprecated: Use GoaErrorName - https://github.com/goadesign/goa/issues/3105 +func (e InvalidScopes) ErrorName() string { + return e.GoaErrorName() +} + +// GoaErrorName returns the error name. +func (e InvalidScopes) GoaErrorName() string { + return "invalid_scopes" +} diff --git a/codegen/service/testdata/golden/service_service-result-collection-multiple-views.go.golden b/codegen/service/testdata/golden/service_service-result-collection-multiple-views.go.golden index e2b842d658..de2b29f4c9 100644 --- a/codegen/service/testdata/golden/service_service-result-collection-multiple-views.go.golden +++ b/codegen/service/testdata/golden/service_service-result-collection-multiple-views.go.golden @@ -50,7 +50,7 @@ func NewMultipleViewsCollection(vres resultcollectionmultipleviewsmethodviews.Mu // MultipleViewsCollection from result type MultipleViewsCollection using the // given view. func NewViewedMultipleViewsCollection(res MultipleViewsCollection, view string) resultcollectionmultipleviewsmethodviews.MultipleViewsCollection { - var vres resultcollectionmultipleviewsmethodviews.MultipleViewsCollection + vres := resultcollectionmultipleviewsmethodviews.MultipleViewsCollection{View: view} switch view { case "default", "": p := newMultipleViewsCollectionView(res) diff --git a/codegen/service/testdata/golden/service_service-result-with-explicit-and-default-views.go.golden b/codegen/service/testdata/golden/service_service-result-with-explicit-and-default-views.go.golden index 3441c502e8..74471509f2 100644 --- a/codegen/service/testdata/golden/service_service-result-with-explicit-and-default-views.go.golden +++ b/codegen/service/testdata/golden/service_service-result-with-explicit-and-default-views.go.golden @@ -6,8 +6,8 @@ type Service interface { // - "default" // - "tiny" A(context.Context) (res *MultipleViews, view string, err error) - // A implements A. - AEndpoint(context.Context) (res *MultipleViews, err error) + // B implements B. + B(context.Context) (res *MultipleViews, err error) } // APIName is the name of the API as defined in the design. @@ -24,7 +24,7 @@ const ServiceName = "WithExplicitAndDefaultViews" // MethodNames lists the service method names as defined in the design. These // are the same values that are set in the endpoint request contexts under the // MethodKey key. -var MethodNames = [2]string{"A", "A"} +var MethodNames = [2]string{"A", "B"} // MultipleViews is the result type of the WithExplicitAndDefaultViews service // A method. @@ -49,7 +49,7 @@ func NewMultipleViews(vres *withexplicitanddefaultviewsviews.MultipleViews) *Mul // NewViewedMultipleViews initializes viewed result type MultipleViews from // result type MultipleViews using the given view. func NewViewedMultipleViews(res *MultipleViews, view string) *withexplicitanddefaultviewsviews.MultipleViews { - var vres *withexplicitanddefaultviewsviews.MultipleViews + vres := &withexplicitanddefaultviewsviews.MultipleViews{View: view} switch view { case "default", "": p := newMultipleViewsView(res) diff --git a/codegen/service/testdata/golden/service_service-result-with-inline-validation.go.golden b/codegen/service/testdata/golden/service_service-result-with-inline-validation.go.golden index 3beeca448a..46d1ed37b8 100644 --- a/codegen/service/testdata/golden/service_service-result-with-inline-validation.go.golden +++ b/codegen/service/testdata/golden/service_service-result-with-inline-validation.go.golden @@ -51,6 +51,21 @@ func NewViewedResultInlineValidation(res *ResultInlineValidation, view string) * return &resultwithinlinevalidationviews.ResultInlineValidation{Projected: p, View: "default"} } +// NewResultInlineValidationBResult initializes result type +// ResultInlineValidationBResult from viewed result type +// ResultInlineValidationBResult. +func NewResultInlineValidationBResult(vres *resultwithinlinevalidationviews.ResultInlineValidationBResult) *ResultInlineValidationBResult { + return newResultInlineValidationBResult(vres.Projected) +} + +// NewViewedResultInlineValidationBResult initializes viewed result type +// ResultInlineValidationBResult from result type ResultInlineValidationBResult +// using the given view. +func NewViewedResultInlineValidationBResult(res *ResultInlineValidationBResult, view string) *resultwithinlinevalidationviews.ResultInlineValidationBResult { + p := newResultInlineValidationBResultView(res) + return &resultwithinlinevalidationviews.ResultInlineValidationBResult{Projected: p, View: "default"} +} + // newResultInlineValidation converts projected type ResultInlineValidation to // service type ResultInlineValidation. func newResultInlineValidation(vres *resultwithinlinevalidationviews.ResultInlineValidationView) *ResultInlineValidation { @@ -70,3 +85,26 @@ func newResultInlineValidationView(res *ResultInlineValidation) *resultwithinlin } return vres } + +// newResultInlineValidationBResult converts projected type +// ResultInlineValidationBResult to service type ResultInlineValidationBResult. +func newResultInlineValidationBResult(vres *resultwithinlinevalidationviews.ResultInlineValidationBResultView) *ResultInlineValidationBResult { + res := &ResultInlineValidationBResult{ + B: vres.B, + } + if vres.A != nil { + res.A = *vres.A + } + return res +} + +// newResultInlineValidationBResultView projects result type +// ResultInlineValidationBResult to projected type +// ResultInlineValidationBResultView using the "default" view. +func newResultInlineValidationBResultView(res *ResultInlineValidationBResult) *resultwithinlinevalidationviews.ResultInlineValidationBResultView { + vres := &resultwithinlinevalidationviews.ResultInlineValidationBResultView{ + A: &res.A, + B: res.B, + } + return vres +} diff --git a/codegen/service/testdata/golden/service_service-result-with-multiple-views.go.golden b/codegen/service/testdata/golden/service_service-result-with-multiple-views.go.golden index ce5121e19e..d83c151637 100644 --- a/codegen/service/testdata/golden/service_service-result-with-multiple-views.go.golden +++ b/codegen/service/testdata/golden/service_service-result-with-multiple-views.go.golden @@ -66,7 +66,7 @@ func NewMultipleViews(vres *multiplemethodsresultmultipleviewsviews.MultipleView // NewViewedMultipleViews initializes viewed result type MultipleViews from // result type MultipleViews using the given view. func NewViewedMultipleViews(res *MultipleViews, view string) *multiplemethodsresultmultipleviewsviews.MultipleViews { - var vres *multiplemethodsresultmultipleviewsviews.MultipleViews + vres := &multiplemethodsresultmultipleviewsviews.MultipleViews{View: view} switch view { case "default", "": p := newMultipleViewsView(res) diff --git a/codegen/service/testdata/golden/service_service-result-with-one-of-type.go.golden b/codegen/service/testdata/golden/service_service-result-with-one-of-type.go.golden index 1187d54b64..2d72f115d8 100644 --- a/codegen/service/testdata/golden/service_service-result-with-one-of-type.go.golden +++ b/codegen/service/testdata/golden/service_service-result-with-one-of-type.go.golden @@ -38,24 +38,24 @@ type U struct { Item *Item } -// Result is a sum-type union. +// Result holds exactly one of its branch values. type Result struct { kind ResultKind - T *T - U *U + t *T + u *U } -// ResultKind enumerates the union variants for Result. +// ResultKind records which Result branch is selected. type ResultKind string const ( - // ResultKindT identifies the t branch of the union. + // ResultKindT identifies the t branch. ResultKindT ResultKind = "t" - // ResultKindU identifies the u branch of the union. + // ResultKindU identifies the u branch. ResultKindU ResultKind = "u" ) -// Kind returns the discriminator value of the union. +// Kind returns the selected branch. func (u Result) Kind() ResultKind { return u.kind } @@ -64,47 +64,51 @@ func (u Result) Kind() ResultKind { func NewResultT(v *T) Result { return Result{ kind: ResultKindT, - T: v, + t: v, } } -// AsT returns the value of the t branch if set. +// AsT returns the value when the t branch is selected. func (u Result) AsT() (_ *T, ok bool) { if u.kind != ResultKindT { return } - return u.T, true + return u.t, true } -// SetT sets the t branch of the union. +// SetT selects the t branch and stores v. func (u *Result) SetT(v *T) { - u.kind = ResultKindT - u.T = v + *u = Result{ + kind: ResultKindT, + t: v, + } } // NewResultU constructs Result with the u branch set. func NewResultU(v *U) Result { return Result{ kind: ResultKindU, - U: v, + u: v, } } -// AsU returns the value of the u branch if set. +// AsU returns the value when the u branch is selected. func (u Result) AsU() (_ *U, ok bool) { if u.kind != ResultKindU { return } - return u.U, true + return u.u, true } -// SetU sets the u branch of the union. +// SetU selects the u branch and stores v. func (u *Result) SetU(v *U) { - u.kind = ResultKindU - u.U = v + *u = Result{ + kind: ResultKindU, + u: v, + } } -// Validate ensures the union discriminant is valid. +// Validate ensures exactly one valid branch is selected. func (u Result) Validate() error { switch u.kind { case "": @@ -113,12 +117,12 @@ func (u Result) Validate() error { string(ResultKindU), }) case ResultKindT: - if u.T == nil { + if u.t == nil { return goa.MissingFieldError("value", "Result") } return nil case ResultKindU: - if u.U == nil { + if u.u == nil { return goa.MissingFieldError("value", "Result") } return nil @@ -140,11 +144,11 @@ func (u Result) MarshalJSON() ([]byte, error) { ) switch u.kind { case ResultKindT: - value = u.T + value = u.t case ResultKindU: - value = u.U + value = u.u default: - return nil, fmt.Errorf("unexpected Result discriminant %q", u.kind) + return nil, fmt.Errorf("unexpected Result kind %q", u.kind) } return json.Marshal(struct { Type string `json:"type"` @@ -176,15 +180,13 @@ func (u *Result) UnmarshalJSON(data []byte) error { if err := json.Unmarshal(raw.Value, &v); err != nil { return err } - u.kind = ResultKindT - u.T = v + u.SetT(v) case string(ResultKindU): var v *U if err := json.Unmarshal(raw.Value, &v); err != nil { return err } - u.kind = ResultKindU - u.U = v + u.SetU(v) default: if raw.Type == "" { return goa.MissingFieldError("type", "Result") @@ -218,13 +220,19 @@ func newResultOneof(vres *resultwithoneoftypeviews.ResultOneofView) *ResultOneof switch string(vres.Result.Kind()) { case "t": actual, _ := vres.Result.AsT() - obj := transformResultwithoneoftypeviewsTViewToT(actual) + var obj *T + if actual != nil { + obj = transformResultwithoneoftypeviewsTViewToT(actual) + } u := res.Result u.SetT((*T)(obj)) res.Result = u case "u": actual, _ := vres.Result.AsU() - obj := transformResultwithoneoftypeviewsUViewToU(actual) + var obj *U + if actual != nil { + obj = transformResultwithoneoftypeviewsUViewToU(actual) + } u := res.Result u.SetU((*U)(obj)) res.Result = u @@ -241,13 +249,19 @@ func newResultOneofView(res *ResultOneof) *resultwithoneoftypeviews.ResultOneofV switch string(res.Result.Kind()) { case "t": actual, _ := res.Result.AsT() - obj := transformTToResultwithoneoftypeviewsTView(actual) + var obj *resultwithoneoftypeviews.TView + if actual != nil { + obj = transformTToResultwithoneoftypeviewsTView(actual) + } u := vres.Result u.SetT((*resultwithoneoftypeviews.TView)(obj)) vres.Result = u case "u": actual, _ := res.Result.AsU() - obj := transformUToResultwithoneoftypeviewsUView(actual) + var obj *resultwithoneoftypeviews.UView + if actual != nil { + obj = transformUToResultwithoneoftypeviewsUView(actual) + } u := vres.Result u.SetU((*resultwithoneoftypeviews.UView)(obj)) vres.Result = u @@ -259,9 +273,6 @@ func newResultOneofView(res *ResultOneof) *resultwithoneoftypeviews.ResultOneofV // transformResultwithoneoftypeviewsTViewToT builds a value of type *T from a // value of type *resultwithoneoftypeviews.TView. func transformResultwithoneoftypeviewsTViewToT(v *resultwithoneoftypeviews.TView) *T { - if v == nil { - return nil - } res := &T{ Message: v.Message, } @@ -272,9 +283,6 @@ func transformResultwithoneoftypeviewsTViewToT(v *resultwithoneoftypeviews.TView // transformResultwithoneoftypeviewsUViewToU builds a value of type *U from a // value of type *resultwithoneoftypeviews.UView. func transformResultwithoneoftypeviewsUViewToU(v *resultwithoneoftypeviews.UView) *U { - if v == nil { - return nil - } res := &U{} if v.Item != nil { res.Item = transformResultwithoneoftypeviewsItemViewToItem(v.Item) @@ -286,9 +294,6 @@ func transformResultwithoneoftypeviewsUViewToU(v *resultwithoneoftypeviews.UView // transformResultwithoneoftypeviewsItemViewToItem builds a value of type *Item // from a value of type *resultwithoneoftypeviews.ItemView. func transformResultwithoneoftypeviewsItemViewToItem(v *resultwithoneoftypeviews.ItemView) *Item { - if v == nil { - return nil - } res := &Item{ A: v.A, } @@ -299,9 +304,6 @@ func transformResultwithoneoftypeviewsItemViewToItem(v *resultwithoneoftypeviews // transformTToResultwithoneoftypeviewsTView builds a value of type // *resultwithoneoftypeviews.TView from a value of type *T. func transformTToResultwithoneoftypeviewsTView(v *T) *resultwithoneoftypeviews.TView { - if v == nil { - return nil - } res := &resultwithoneoftypeviews.TView{ Message: v.Message, } @@ -312,9 +314,6 @@ func transformTToResultwithoneoftypeviewsTView(v *T) *resultwithoneoftypeviews.T // transformUToResultwithoneoftypeviewsUView builds a value of type // *resultwithoneoftypeviews.UView from a value of type *U. func transformUToResultwithoneoftypeviewsUView(v *U) *resultwithoneoftypeviews.UView { - if v == nil { - return nil - } res := &resultwithoneoftypeviews.UView{} if v.Item != nil { res.Item = transformItemToResultwithoneoftypeviewsItemView(v.Item) @@ -326,9 +325,6 @@ func transformUToResultwithoneoftypeviewsUView(v *U) *resultwithoneoftypeviews.U // transformItemToResultwithoneoftypeviewsItemView builds a value of type // *resultwithoneoftypeviews.ItemView from a value of type *Item. func transformItemToResultwithoneoftypeviewsItemView(v *Item) *resultwithoneoftypeviews.ItemView { - if v == nil { - return nil - } res := &resultwithoneoftypeviews.ItemView{ A: v.A, } diff --git a/codegen/service/testdata/golden/service_service-result-with-other-result.go.golden b/codegen/service/testdata/golden/service_service-result-with-other-result.go.golden index 03fba976c1..0874c7ec4d 100644 --- a/codegen/service/testdata/golden/service_service-result-with-other-result.go.golden +++ b/codegen/service/testdata/golden/service_service-result-with-other-result.go.golden @@ -52,7 +52,7 @@ func NewMultipleViews(vres *resultwithotherresultviews.MultipleViews) *MultipleV // NewViewedMultipleViews initializes viewed result type MultipleViews from // result type MultipleViews using the given view. func NewViewedMultipleViews(res *MultipleViews, view string) *resultwithotherresultviews.MultipleViews { - var vres *resultwithotherresultviews.MultipleViews + vres := &resultwithotherresultviews.MultipleViews{View: view} switch view { case "default", "": p := newMultipleViewsView(res) diff --git a/codegen/service/testdata/golden/service_service-result-with-result-collection.go.golden b/codegen/service/testdata/golden/service_service-result-with-result-collection.go.golden index 97dae9288a..13d5e56da1 100644 --- a/codegen/service/testdata/golden/service_service-result-with-result-collection.go.golden +++ b/codegen/service/testdata/golden/service_service-result-with-result-collection.go.golden @@ -55,7 +55,7 @@ func NewRT(vres *resultwithresulttypecollectionviews.RT) *RT { // NewViewedRT initializes viewed result type RT from result type RT using the // given view. func NewViewedRT(res *RT, view string) *resultwithresulttypecollectionviews.RT { - var vres *resultwithresulttypecollectionviews.RT + vres := &resultwithresulttypecollectionviews.RT{View: view} switch view { case "default", "": p := newRTView(res) diff --git a/codegen/service/testdata/golden/service_service-streaming-payload-result-with-explicit-view.go.golden b/codegen/service/testdata/golden/service_service-streaming-payload-result-with-explicit-view.go.golden index d2ad9640c4..4d30cd2696 100644 --- a/codegen/service/testdata/golden/service_service-streaming-payload-result-with-explicit-view.go.golden +++ b/codegen/service/testdata/golden/service_service-streaming-payload-result-with-explicit-view.go.golden @@ -76,7 +76,7 @@ func NewMultipleViews(vres *streamingpayloadresultwithexplicitviewserviceviews.M // NewViewedMultipleViews initializes viewed result type MultipleViews from // result type MultipleViews using the given view. func NewViewedMultipleViews(res *MultipleViews, view string) *streamingpayloadresultwithexplicitviewserviceviews.MultipleViews { - var vres *streamingpayloadresultwithexplicitviewserviceviews.MultipleViews + vres := &streamingpayloadresultwithexplicitviewserviceviews.MultipleViews{View: view} switch view { case "default", "": p := newMultipleViewsView(res) diff --git a/codegen/service/testdata/golden/service_service-streaming-payload-result-with-views.go.golden b/codegen/service/testdata/golden/service_service-streaming-payload-result-with-views.go.golden index 5492f7b9c0..4c3fa99d45 100644 --- a/codegen/service/testdata/golden/service_service-streaming-payload-result-with-views.go.golden +++ b/codegen/service/testdata/golden/service_service-streaming-payload-result-with-views.go.golden @@ -91,7 +91,7 @@ func NewMultipleViews(vres *streamingpayloadresultwithviewsserviceviews.Multiple // NewViewedMultipleViews initializes viewed result type MultipleViews from // result type MultipleViews using the given view. func NewViewedMultipleViews(res *MultipleViews, view string) *streamingpayloadresultwithviewsserviceviews.MultipleViews { - var vres *streamingpayloadresultwithviewsserviceviews.MultipleViews + vres := &streamingpayloadresultwithviewsserviceviews.MultipleViews{View: view} switch view { case "default", "": p := newMultipleViewsView(res) diff --git a/codegen/service/testdata/golden/service_service-streaming-result-with-explicit-view.go.golden b/codegen/service/testdata/golden/service_service-streaming-result-with-explicit-view.go.golden index 7aad6c247f..da94ce1180 100644 --- a/codegen/service/testdata/golden/service_service-streaming-result-with-explicit-view.go.golden +++ b/codegen/service/testdata/golden/service_service-streaming-result-with-explicit-view.go.golden @@ -67,7 +67,7 @@ func NewMultipleViews(vres *streamingresultwithexplicitviewserviceviews.Multiple // NewViewedMultipleViews initializes viewed result type MultipleViews from // result type MultipleViews using the given view. func NewViewedMultipleViews(res *MultipleViews, view string) *streamingresultwithexplicitviewserviceviews.MultipleViews { - var vres *streamingresultwithexplicitviewserviceviews.MultipleViews + vres := &streamingresultwithexplicitviewserviceviews.MultipleViews{View: view} switch view { case "default", "": p := newMultipleViewsView(res) diff --git a/codegen/service/testdata/golden/service_service-streaming-result-with-views.go.golden b/codegen/service/testdata/golden/service_service-streaming-result-with-views.go.golden index 5053b27209..12aa65c247 100644 --- a/codegen/service/testdata/golden/service_service-streaming-result-with-views.go.golden +++ b/codegen/service/testdata/golden/service_service-streaming-result-with-views.go.golden @@ -70,7 +70,7 @@ func NewMultipleViews(vres *streamingresultwithviewsserviceviews.MultipleViews) // NewViewedMultipleViews initializes viewed result type MultipleViews from // result type MultipleViews using the given view. func NewViewedMultipleViews(res *MultipleViews, view string) *streamingresultwithviewsserviceviews.MultipleViews { - var vres *streamingresultwithviewsserviceviews.MultipleViews + vres := &streamingresultwithviewsserviceviews.MultipleViews{View: view} switch view { case "default", "": p := newMultipleViewsView(res) diff --git a/codegen/service/testdata/golden/service_service-union-alias-cross-pkg.go.golden b/codegen/service/testdata/golden/service_service-union-alias-cross-pkg.go.golden index 0b42c17758..b3472499c3 100644 --- a/codegen/service/testdata/golden/service_service-union-alias-cross-pkg.go.golden +++ b/codegen/service/testdata/golden/service_service-union-alias-cross-pkg.go.golden @@ -26,24 +26,24 @@ type Scope struct { Values Values } -// Values is a sum-type union. +// Values holds exactly one of its branch values. type Values struct { kind ValuesKind - Device alias.Alias - Metric alias.Alias + device alias.Alias + metric alias.Alias } -// ValuesKind enumerates the union variants for Values. +// ValuesKind records which Values branch is selected. type ValuesKind string const ( - // ValuesKindDevice identifies the Device branch of the union. + // ValuesKindDevice identifies the Device branch. ValuesKindDevice ValuesKind = "Device" - // ValuesKindMetric identifies the Metric branch of the union. + // ValuesKindMetric identifies the Metric branch. ValuesKindMetric ValuesKind = "Metric" ) -// Kind returns the discriminator value of the union. +// Kind returns the selected branch. func (u Values) Kind() ValuesKind { return u.kind } @@ -52,47 +52,51 @@ func (u Values) Kind() ValuesKind { func NewValuesDevice(v alias.Alias) Values { return Values{ kind: ValuesKindDevice, - Device: v, + device: v, } } -// AsDevice returns the value of the Device branch if set. +// AsDevice returns the value when the Device branch is selected. func (u Values) AsDevice() (_ alias.Alias, ok bool) { if u.kind != ValuesKindDevice { return } - return u.Device, true + return u.device, true } -// SetDevice sets the Device branch of the union. +// SetDevice selects the Device branch and stores v. func (u *Values) SetDevice(v alias.Alias) { - u.kind = ValuesKindDevice - u.Device = v + *u = Values{ + kind: ValuesKindDevice, + device: v, + } } // NewValuesMetric constructs Values with the Metric branch set. func NewValuesMetric(v alias.Alias) Values { return Values{ kind: ValuesKindMetric, - Metric: v, + metric: v, } } -// AsMetric returns the value of the Metric branch if set. +// AsMetric returns the value when the Metric branch is selected. func (u Values) AsMetric() (_ alias.Alias, ok bool) { if u.kind != ValuesKindMetric { return } - return u.Metric, true + return u.metric, true } -// SetMetric sets the Metric branch of the union. +// SetMetric selects the Metric branch and stores v. func (u *Values) SetMetric(v alias.Alias) { - u.kind = ValuesKindMetric - u.Metric = v + *u = Values{ + kind: ValuesKindMetric, + metric: v, + } } -// Validate ensures the union discriminant is valid. +// Validate ensures exactly one valid branch is selected. func (u Values) Validate() error { switch u.kind { case "": @@ -122,11 +126,11 @@ func (u Values) MarshalJSON() ([]byte, error) { ) switch u.kind { case ValuesKindDevice: - value = u.Device + value = u.device case ValuesKindMetric: - value = u.Metric + value = u.metric default: - return nil, fmt.Errorf("unexpected Values discriminant %q", u.kind) + return nil, fmt.Errorf("unexpected Values kind %q", u.kind) } return json.Marshal(struct { Type string `json:"type"` @@ -158,15 +162,13 @@ func (u *Values) UnmarshalJSON(data []byte) error { if err := json.Unmarshal(raw.Value, &v); err != nil { return err } - u.kind = ValuesKindDevice - u.Device = v + u.SetDevice(v) case string(ValuesKindMetric): var v alias.Alias if err := json.Unmarshal(raw.Value, &v); err != nil { return err } - u.kind = ValuesKindMetric - u.Metric = v + u.SetMetric(v) default: if raw.Type == "" { return goa.MissingFieldError("type", "Values") diff --git a/codegen/service/testdata/golden/service_service-union.go.golden b/codegen/service/testdata/golden/service_service-union.go.golden index 4f505a5b0c..60139c1bf4 100644 --- a/codegen/service/testdata/golden/service_service-union.go.golden +++ b/codegen/service/testdata/golden/service_service-union.go.golden @@ -26,131 +26,139 @@ type AUnion struct { Values Values } -type ValuesBoolean bool +type ValuesBranchBoolean bool -type ValuesBytes []byte +type ValuesBranchBytes []byte -type ValuesInt int +type ValuesBranchInt int -type ValuesString string +type ValuesBranchString string -// Values is a sum-type union. +// Values holds exactly one of its branch values. type Values struct { kind ValuesKind - Int ValuesInt - String ValuesString - Boolean ValuesBoolean - Bytes ValuesBytes + int_ ValuesBranchInt + string_ ValuesBranchString + boolean ValuesBranchBoolean + bytes ValuesBranchBytes } -// ValuesKind enumerates the union variants for Values. +// ValuesKind records which Values branch is selected. type ValuesKind string const ( - // ValuesKindInt identifies the Int branch of the union. + // ValuesKindInt identifies the Int branch. ValuesKindInt ValuesKind = "Int" - // ValuesKindString identifies the String branch of the union. + // ValuesKindString identifies the String branch. ValuesKindString ValuesKind = "String" - // ValuesKindBoolean identifies the Boolean branch of the union. + // ValuesKindBoolean identifies the Boolean branch. ValuesKindBoolean ValuesKind = "Boolean" - // ValuesKindBytes identifies the Bytes branch of the union. + // ValuesKindBytes identifies the Bytes branch. ValuesKindBytes ValuesKind = "Bytes" ) -// Kind returns the discriminator value of the union. +// Kind returns the selected branch. func (u Values) Kind() ValuesKind { return u.kind } // NewValuesInt constructs Values with the Int branch set. -func NewValuesInt(v ValuesInt) Values { +func NewValuesInt(v ValuesBranchInt) Values { return Values{ kind: ValuesKindInt, - Int: v, + int_: v, } } -// AsInt returns the value of the Int branch if set. -func (u Values) AsInt() (_ ValuesInt, ok bool) { +// AsInt returns the value when the Int branch is selected. +func (u Values) AsInt() (_ ValuesBranchInt, ok bool) { if u.kind != ValuesKindInt { return } - return u.Int, true + return u.int_, true } -// SetInt sets the Int branch of the union. -func (u *Values) SetInt(v ValuesInt) { - u.kind = ValuesKindInt - u.Int = v +// SetInt selects the Int branch and stores v. +func (u *Values) SetInt(v ValuesBranchInt) { + *u = Values{ + kind: ValuesKindInt, + int_: v, + } } // NewValuesString constructs Values with the String branch set. -func NewValuesString(v ValuesString) Values { +func NewValuesString(v ValuesBranchString) Values { return Values{ - kind: ValuesKindString, - String: v, + kind: ValuesKindString, + string_: v, } } -// AsString returns the value of the String branch if set. -func (u Values) AsString() (_ ValuesString, ok bool) { +// AsString returns the value when the String branch is selected. +func (u Values) AsString() (_ ValuesBranchString, ok bool) { if u.kind != ValuesKindString { return } - return u.String, true + return u.string_, true } -// SetString sets the String branch of the union. -func (u *Values) SetString(v ValuesString) { - u.kind = ValuesKindString - u.String = v +// SetString selects the String branch and stores v. +func (u *Values) SetString(v ValuesBranchString) { + *u = Values{ + kind: ValuesKindString, + string_: v, + } } // NewValuesBoolean constructs Values with the Boolean branch set. -func NewValuesBoolean(v ValuesBoolean) Values { +func NewValuesBoolean(v ValuesBranchBoolean) Values { return Values{ kind: ValuesKindBoolean, - Boolean: v, + boolean: v, } } -// AsBoolean returns the value of the Boolean branch if set. -func (u Values) AsBoolean() (_ ValuesBoolean, ok bool) { +// AsBoolean returns the value when the Boolean branch is selected. +func (u Values) AsBoolean() (_ ValuesBranchBoolean, ok bool) { if u.kind != ValuesKindBoolean { return } - return u.Boolean, true + return u.boolean, true } -// SetBoolean sets the Boolean branch of the union. -func (u *Values) SetBoolean(v ValuesBoolean) { - u.kind = ValuesKindBoolean - u.Boolean = v +// SetBoolean selects the Boolean branch and stores v. +func (u *Values) SetBoolean(v ValuesBranchBoolean) { + *u = Values{ + kind: ValuesKindBoolean, + boolean: v, + } } // NewValuesBytes constructs Values with the Bytes branch set. -func NewValuesBytes(v ValuesBytes) Values { +func NewValuesBytes(v ValuesBranchBytes) Values { return Values{ kind: ValuesKindBytes, - Bytes: v, + bytes: v, } } -// AsBytes returns the value of the Bytes branch if set. -func (u Values) AsBytes() (_ ValuesBytes, ok bool) { +// AsBytes returns the value when the Bytes branch is selected. +func (u Values) AsBytes() (_ ValuesBranchBytes, ok bool) { if u.kind != ValuesKindBytes { return } - return u.Bytes, true + return u.bytes, true } -// SetBytes sets the Bytes branch of the union. -func (u *Values) SetBytes(v ValuesBytes) { - u.kind = ValuesKindBytes - u.Bytes = v +// SetBytes selects the Bytes branch and stores v. +func (u *Values) SetBytes(v ValuesBranchBytes) { + *u = Values{ + kind: ValuesKindBytes, + bytes: v, + } } -// Validate ensures the union discriminant is valid. +// Validate ensures exactly one valid branch is selected. func (u Values) Validate() error { switch u.kind { case "": @@ -167,6 +175,9 @@ func (u Values) Validate() error { case ValuesKindBoolean: return nil case ValuesKindBytes: + if u.bytes == nil { + return goa.MissingFieldError("value", "Values") + } return nil default: return goa.InvalidEnumValueError("type", u.kind, []any{ @@ -188,15 +199,15 @@ func (u Values) MarshalJSON() ([]byte, error) { ) switch u.kind { case ValuesKindInt: - value = u.Int + value = u.int_ case ValuesKindString: - value = u.String + value = u.string_ case ValuesKindBoolean: - value = u.Boolean + value = u.boolean case ValuesKindBytes: - value = u.Bytes + value = u.bytes default: - return nil, fmt.Errorf("unexpected Values discriminant %q", u.kind) + return nil, fmt.Errorf("unexpected Values kind %q", u.kind) } return json.Marshal(struct { Type string `json:"type"` @@ -224,33 +235,29 @@ func (u *Values) UnmarshalJSON(data []byte) error { } switch raw.Type { case string(ValuesKindInt): - var v ValuesInt + var v ValuesBranchInt if err := json.Unmarshal(raw.Value, &v); err != nil { return err } - u.kind = ValuesKindInt - u.Int = v + u.SetInt(v) case string(ValuesKindString): - var v ValuesString + var v ValuesBranchString if err := json.Unmarshal(raw.Value, &v); err != nil { return err } - u.kind = ValuesKindString - u.String = v + u.SetString(v) case string(ValuesKindBoolean): - var v ValuesBoolean + var v ValuesBranchBoolean if err := json.Unmarshal(raw.Value, &v); err != nil { return err } - u.kind = ValuesKindBoolean - u.Boolean = v + u.SetBoolean(v) case string(ValuesKindBytes): - var v ValuesBytes + var v ValuesBranchBytes if err := json.Unmarshal(raw.Value, &v); err != nil { return err } - u.kind = ValuesKindBytes - u.Bytes = v + u.SetBytes(v) default: if raw.Type == "" { return goa.MissingFieldError("type", "Values") diff --git a/codegen/service/testdata/interceptors/interceptor-with-external-payload_interceptor_wrappers.go.golden b/codegen/service/testdata/interceptors/interceptor-with-external-payload_interceptor_wrappers.go.golden new file mode 100644 index 0000000000..fa8f0c29ad --- /dev/null +++ b/codegen/service/testdata/interceptors/interceptor-with-external-payload_interceptor_wrappers.go.golden @@ -0,0 +1,11 @@ + + +// wrapAppendIdentify applies the identify server interceptor to endpoints. +func wrapAppendIdentify(endpoint goa.Endpoint, i ServerInterceptors) goa.Endpoint { + return func(ctx context.Context, req any) (any, error) { + info := &identifyAppendServerUnaryInfo{ + identifyAppendInfo: identifyAppendInfo{rawPayload: req}, + } + return i.Identify(ctx, info, endpoint) + } +} diff --git a/codegen/service/testdata/interceptors/interceptor-with-external-payload_service_interceptors.go.golden b/codegen/service/testdata/interceptors/interceptor-with-external-payload_service_interceptors.go.golden new file mode 100644 index 0000000000..c3fdbc1072 --- /dev/null +++ b/codegen/service/testdata/interceptors/interceptor-with-external-payload_service_interceptors.go.golden @@ -0,0 +1,79 @@ +// ServerInterceptors defines the interface for all server-side interceptors. +// Server interceptors execute after the request is decoded and before the +// payload is sent to the service. The implementation is responsible for calling +// next to complete the request. +type ServerInterceptors interface { + Identify(ctx context.Context, info IdentifyInfo, next goa.Endpoint) (any, error) +} + +// Access interfaces for interceptor payloads and results +type ( + // IdentifyInfo describes the service call currently passed to the interceptor. + IdentifyInfo interface { + goa.InterceptorInfo + // Payload returns the selected fields from the method payload. + Payload() IdentifyPayload + } + + // IdentifyPayload provides type-safe access to the method payload. + // It allows reading and writing specific fields of the payload as defined + // in the design. + IdentifyPayload interface { + RuntimeSessionID() string + } +) + +// Types used to provide information about each service call +type ( + identifyAppendInfo struct { + rawPayload any + } + identifyAppendServerUnaryInfo struct { + identifyAppendInfo + } + identifyAppendPayload struct { + payload *types.Event + } +) + +// WrapAppendEndpoint wraps the Append endpoint with the server-side +// interceptors defined in the design. +func WrapAppendEndpoint(endpoint goa.Endpoint, i ServerInterceptors) goa.Endpoint { + if i != nil { + endpoint = wrapAppendIdentify(endpoint, i) + } + return endpoint +} + +// Methods that provide information about each service call + +// Service returns the service selected for this interceptor call. +func (info *identifyAppendInfo) Service() string { + return "InterceptorWithExternalPayload" +} + +// Method returns the method selected for this interceptor call. +func (info *identifyAppendInfo) Method() string { + return "Append" +} + +// RawPayload returns the payload supplied for this interceptor call. +func (info *identifyAppendInfo) RawPayload() any { + return info.rawPayload +} + +// CallType reports that this is a server endpoint call. +func (info *identifyAppendServerUnaryInfo) CallType() goa.InterceptorCallType { + return goa.InterceptorUnary +} + +// Payload returns this method's payload fields. +func (info *identifyAppendInfo) Payload() IdentifyPayload { + return &identifyAppendPayload{payload: info.rawPayload.(*types.Event)} +} + +// Methods that read and write the selected payload and result fields + +func (p *identifyAppendPayload) RuntimeSessionID() string { + return p.payload.RuntimeSessionID +} diff --git a/codegen/service/testdata/interceptors/interceptor-with-external-read-payload_interceptor_wrappers.go.golden b/codegen/service/testdata/interceptors/interceptor-with-external-read-payload_interceptor_wrappers.go.golden new file mode 100644 index 0000000000..0d4b0b5d3d --- /dev/null +++ b/codegen/service/testdata/interceptors/interceptor-with-external-read-payload_interceptor_wrappers.go.golden @@ -0,0 +1,12 @@ + + +// wrapMethodAuthorization applies the authorization server interceptor to +// endpoints. +func wrapMethodAuthorization(endpoint goa.Endpoint, i ServerInterceptors) goa.Endpoint { + return func(ctx context.Context, req any) (any, error) { + info := &authorizationMethodServerUnaryInfo{ + authorizationMethodInfo: authorizationMethodInfo{rawPayload: req}, + } + return i.Authorization(ctx, info, endpoint) + } +} diff --git a/codegen/service/testdata/interceptors/interceptor-with-external-read-payload_service_interceptors.go.golden b/codegen/service/testdata/interceptors/interceptor-with-external-read-payload_service_interceptors.go.golden new file mode 100644 index 0000000000..a483eb26ac --- /dev/null +++ b/codegen/service/testdata/interceptors/interceptor-with-external-read-payload_service_interceptors.go.golden @@ -0,0 +1,79 @@ +// ServerInterceptors defines the interface for all server-side interceptors. +// Server interceptors execute after the request is decoded and before the +// payload is sent to the service. The implementation is responsible for calling +// next to complete the request. +type ServerInterceptors interface { + Authorization(ctx context.Context, info AuthorizationInfo, next goa.Endpoint) (any, error) +} + +// Access interfaces for interceptor payloads and results +type ( + // AuthorizationInfo describes the service call currently passed to the interceptor. + AuthorizationInfo interface { + goa.InterceptorInfo + // Payload returns the selected fields from the method payload. + Payload() AuthorizationPayload + } + + // AuthorizationPayload provides type-safe access to the method payload. + // It allows reading and writing specific fields of the payload as defined + // in the design. + AuthorizationPayload interface { + OrgID() types.UUID + } +) + +// Types used to provide information about each service call +type ( + authorizationMethodInfo struct { + rawPayload any + } + authorizationMethodServerUnaryInfo struct { + authorizationMethodInfo + } + authorizationMethodPayload struct { + payload *MethodPayload + } +) + +// WrapMethodEndpoint wraps the Method endpoint with the server-side +// interceptors defined in the design. +func WrapMethodEndpoint(endpoint goa.Endpoint, i ServerInterceptors) goa.Endpoint { + if i != nil { + endpoint = wrapMethodAuthorization(endpoint, i) + } + return endpoint +} + +// Methods that provide information about each service call + +// Service returns the service selected for this interceptor call. +func (info *authorizationMethodInfo) Service() string { + return "InterceptorWithExternalReadPayload" +} + +// Method returns the method selected for this interceptor call. +func (info *authorizationMethodInfo) Method() string { + return "Method" +} + +// RawPayload returns the payload supplied for this interceptor call. +func (info *authorizationMethodInfo) RawPayload() any { + return info.rawPayload +} + +// CallType reports that this is a server endpoint call. +func (info *authorizationMethodServerUnaryInfo) CallType() goa.InterceptorCallType { + return goa.InterceptorUnary +} + +// Payload returns this method's payload fields. +func (info *authorizationMethodInfo) Payload() AuthorizationPayload { + return &authorizationMethodPayload{payload: info.rawPayload.(*MethodPayload)} +} + +// Methods that read and write the selected payload and result fields + +func (p *authorizationMethodPayload) OrgID() types.UUID { + return p.payload.OrgID +} diff --git a/codegen/service/testdata/interceptors/interceptor-with-read-payload_client_interceptors.go.golden b/codegen/service/testdata/interceptors/interceptor-with-read-payload_client_interceptors.go.golden index c55718e9a4..f3d5c42641 100644 --- a/codegen/service/testdata/interceptors/interceptor-with-read-payload_client_interceptors.go.golden +++ b/codegen/service/testdata/interceptors/interceptor-with-read-payload_client_interceptors.go.golden @@ -3,14 +3,14 @@ // is sent to the server. The implementation is responsible for calling next to // complete the request. type ClientInterceptors interface { - Validation(ctx context.Context, info *ValidationInfo, next goa.Endpoint) (any, error) + Validation(ctx context.Context, info ValidationInfo, next goa.Endpoint) (any, error) } // WrapMethodClientEndpoint wraps the Method endpoint with the client // interceptors defined in the design. func WrapMethodClientEndpoint(endpoint goa.Endpoint, i ClientInterceptors) goa.Endpoint { if i != nil { - endpoint = wrapClientMethodvalidation(endpoint, i) + endpoint = wrapClientMethodValidation(endpoint, i) } return endpoint } diff --git a/codegen/service/testdata/interceptors/interceptor-with-read-payload_interceptor_wrappers.go.golden b/codegen/service/testdata/interceptors/interceptor-with-read-payload_interceptor_wrappers.go.golden index 0cde37225c..cd8ad00325 100644 --- a/codegen/service/testdata/interceptors/interceptor-with-read-payload_interceptor_wrappers.go.golden +++ b/codegen/service/testdata/interceptors/interceptor-with-read-payload_interceptor_wrappers.go.golden @@ -1,27 +1,21 @@ -// wrapValidationMethod applies the validation server interceptor to endpoints. +// wrapMethodValidation applies the validation server interceptor to endpoints. func wrapMethodValidation(endpoint goa.Endpoint, i ServerInterceptors) goa.Endpoint { return func(ctx context.Context, req any) (any, error) { - info := &ValidationInfo{ - service: "InterceptorWithReadPayload", - method: "Method", - callType: goa.InterceptorUnary, - rawPayload: req, + info := &validationMethodServerUnaryInfo{ + validationMethodInfo: validationMethodInfo{rawPayload: req}, } return i.Validation(ctx, info, endpoint) } } -// wrapClientValidationMethod applies the validation client interceptor to +// wrapClientMethodValidation applies the validation client interceptor to // endpoints. func wrapClientMethodValidation(endpoint goa.Endpoint, i ClientInterceptors) goa.Endpoint { return func(ctx context.Context, req any) (any, error) { - info := &ValidationInfo{ - service: "InterceptorWithReadPayload", - method: "Method", - callType: goa.InterceptorUnary, - rawPayload: req, + info := &validationMethodClientUnaryInfo{ + validationMethodInfo: validationMethodInfo{rawPayload: req}, } return i.Validation(ctx, info, endpoint) } diff --git a/codegen/service/testdata/interceptors/interceptor-with-read-payload_service_interceptors.go.golden b/codegen/service/testdata/interceptors/interceptor-with-read-payload_service_interceptors.go.golden index 8510ebab3f..fb47a8c25b 100644 --- a/codegen/service/testdata/interceptors/interceptor-with-read-payload_service_interceptors.go.golden +++ b/codegen/service/testdata/interceptors/interceptor-with-read-payload_service_interceptors.go.golden @@ -3,18 +3,16 @@ // payload is sent to the service. The implementation is responsible for calling // next to complete the request. type ServerInterceptors interface { - Validation(ctx context.Context, info *ValidationInfo, next goa.Endpoint) (any, error) + Validation(ctx context.Context, info ValidationInfo, next goa.Endpoint) (any, error) } // Access interfaces for interceptor payloads and results type ( - // ValidationInfo provides metadata about the current interception. - // It includes service name, method name, and access to the endpoint. - ValidationInfo struct { - service string - method string - callType goa.InterceptorCallType - rawPayload any + // ValidationInfo describes the service call currently passed to the interceptor. + ValidationInfo interface { + goa.InterceptorInfo + // Payload returns the selected fields from the method payload. + Payload() ValidationPayload } // ValidationPayload provides type-safe access to the method payload. @@ -25,8 +23,17 @@ type ( } ) -// Private implementation types +// Types used to provide information about each service call type ( + validationMethodInfo struct { + rawPayload any + } + validationMethodServerUnaryInfo struct { + validationMethodInfo + } + validationMethodClientUnaryInfo struct { + validationMethodInfo + } validationMethodPayload struct { payload *MethodPayload } @@ -36,39 +43,44 @@ type ( // interceptors defined in the design. func WrapMethodEndpoint(endpoint goa.Endpoint, i ServerInterceptors) goa.Endpoint { if i != nil { - endpoint = wrapMethodvalidation(endpoint, i) + endpoint = wrapMethodValidation(endpoint, i) } return endpoint } -// Public accessor methods for Info types +// Methods that provide information about each service call -// Service returns the name of the service handling the request. -func (info *ValidationInfo) Service() string { - return info.service +// Service returns the service selected for this interceptor call. +func (info *validationMethodInfo) Service() string { + return "InterceptorWithReadPayload" } -// Method returns the name of the method handling the request. -func (info *ValidationInfo) Method() string { - return info.method +// Method returns the method selected for this interceptor call. +func (info *validationMethodInfo) Method() string { + return "Method" } -// CallType returns the type of call the interceptor is handling. -func (info *ValidationInfo) CallType() goa.InterceptorCallType { - return info.callType +// RawPayload returns the payload supplied for this interceptor call. +func (info *validationMethodInfo) RawPayload() any { + return info.rawPayload } -// RawPayload returns the raw payload of the request. -func (info *ValidationInfo) RawPayload() any { - return info.rawPayload +// CallType reports that this is a server endpoint call. +func (info *validationMethodServerUnaryInfo) CallType() goa.InterceptorCallType { + return goa.InterceptorUnary +} + +// CallType reports that this is a client endpoint call. +func (info *validationMethodClientUnaryInfo) CallType() goa.InterceptorCallType { + return goa.InterceptorUnary } -// Payload returns a type-safe accessor for the method payload. -func (info *ValidationInfo) Payload() ValidationPayload { - return &validationMethodPayload{payload: info.RawPayload().(*MethodPayload)} +// Payload returns this method's payload fields. +func (info *validationMethodInfo) Payload() ValidationPayload { + return &validationMethodPayload{payload: info.rawPayload.(*MethodPayload)} } -// Private implementation methods +// Methods that read and write the selected payload and result fields func (p *validationMethodPayload) Name() string { return p.payload.Name diff --git a/codegen/service/testdata/interceptors/interceptor-with-read-result_client_interceptors.go.golden b/codegen/service/testdata/interceptors/interceptor-with-read-result_client_interceptors.go.golden index eb23fdc5df..27668ad056 100644 --- a/codegen/service/testdata/interceptors/interceptor-with-read-result_client_interceptors.go.golden +++ b/codegen/service/testdata/interceptors/interceptor-with-read-result_client_interceptors.go.golden @@ -3,14 +3,14 @@ // is sent to the server. The implementation is responsible for calling next to // complete the request. type ClientInterceptors interface { - Caching(ctx context.Context, info *CachingInfo, next goa.Endpoint) (any, error) + Caching(ctx context.Context, info CachingInfo, next goa.Endpoint) (any, error) } // WrapMethodClientEndpoint wraps the Method endpoint with the client // interceptors defined in the design. func WrapMethodClientEndpoint(endpoint goa.Endpoint, i ClientInterceptors) goa.Endpoint { if i != nil { - endpoint = wrapClientMethodcaching(endpoint, i) + endpoint = wrapClientMethodCaching(endpoint, i) } return endpoint } diff --git a/codegen/service/testdata/interceptors/interceptor-with-read-result_interceptor_wrappers.go.golden b/codegen/service/testdata/interceptors/interceptor-with-read-result_interceptor_wrappers.go.golden index 431b0789ce..c89daf37d7 100644 --- a/codegen/service/testdata/interceptors/interceptor-with-read-result_interceptor_wrappers.go.golden +++ b/codegen/service/testdata/interceptors/interceptor-with-read-result_interceptor_wrappers.go.golden @@ -1,26 +1,20 @@ -// wrapCachingMethod applies the caching server interceptor to endpoints. +// wrapMethodCaching applies the caching server interceptor to endpoints. func wrapMethodCaching(endpoint goa.Endpoint, i ServerInterceptors) goa.Endpoint { return func(ctx context.Context, req any) (any, error) { - info := &CachingInfo{ - service: "InterceptorWithReadResult", - method: "Method", - callType: goa.InterceptorUnary, - rawPayload: req, + info := &cachingMethodServerUnaryInfo{ + cachingMethodInfo: cachingMethodInfo{rawPayload: req}, } return i.Caching(ctx, info, endpoint) } } -// wrapClientCachingMethod applies the caching client interceptor to endpoints. +// wrapClientMethodCaching applies the caching client interceptor to endpoints. func wrapClientMethodCaching(endpoint goa.Endpoint, i ClientInterceptors) goa.Endpoint { return func(ctx context.Context, req any) (any, error) { - info := &CachingInfo{ - service: "InterceptorWithReadResult", - method: "Method", - callType: goa.InterceptorUnary, - rawPayload: req, + info := &cachingMethodClientUnaryInfo{ + cachingMethodInfo: cachingMethodInfo{rawPayload: req}, } return i.Caching(ctx, info, endpoint) } diff --git a/codegen/service/testdata/interceptors/interceptor-with-read-result_service_interceptors.go.golden b/codegen/service/testdata/interceptors/interceptor-with-read-result_service_interceptors.go.golden index 07c3c5c8e9..20ba1e8cd2 100644 --- a/codegen/service/testdata/interceptors/interceptor-with-read-result_service_interceptors.go.golden +++ b/codegen/service/testdata/interceptors/interceptor-with-read-result_service_interceptors.go.golden @@ -3,18 +3,16 @@ // payload is sent to the service. The implementation is responsible for calling // next to complete the request. type ServerInterceptors interface { - Caching(ctx context.Context, info *CachingInfo, next goa.Endpoint) (any, error) + Caching(ctx context.Context, info CachingInfo, next goa.Endpoint) (any, error) } // Access interfaces for interceptor payloads and results type ( - // CachingInfo provides metadata about the current interception. - // It includes service name, method name, and access to the endpoint. - CachingInfo struct { - service string - method string - callType goa.InterceptorCallType - rawPayload any + // CachingInfo describes the service call currently passed to the interceptor. + CachingInfo interface { + goa.InterceptorInfo + // Result returns the selected fields from the method result. + Result(any) CachingResult } // CachingResult provides type-safe access to the method result. @@ -25,8 +23,17 @@ type ( } ) -// Private implementation types +// Types used to provide information about each service call type ( + cachingMethodInfo struct { + rawPayload any + } + cachingMethodServerUnaryInfo struct { + cachingMethodInfo + } + cachingMethodClientUnaryInfo struct { + cachingMethodInfo + } cachingMethodResult struct { result *MethodResult } @@ -36,39 +43,44 @@ type ( // interceptors defined in the design. func WrapMethodEndpoint(endpoint goa.Endpoint, i ServerInterceptors) goa.Endpoint { if i != nil { - endpoint = wrapMethodcaching(endpoint, i) + endpoint = wrapMethodCaching(endpoint, i) } return endpoint } -// Public accessor methods for Info types +// Methods that provide information about each service call -// Service returns the name of the service handling the request. -func (info *CachingInfo) Service() string { - return info.service +// Service returns the service selected for this interceptor call. +func (info *cachingMethodInfo) Service() string { + return "InterceptorWithReadResult" } -// Method returns the name of the method handling the request. -func (info *CachingInfo) Method() string { - return info.method +// Method returns the method selected for this interceptor call. +func (info *cachingMethodInfo) Method() string { + return "Method" } -// CallType returns the type of call the interceptor is handling. -func (info *CachingInfo) CallType() goa.InterceptorCallType { - return info.callType +// RawPayload returns the payload supplied for this interceptor call. +func (info *cachingMethodInfo) RawPayload() any { + return info.rawPayload } -// RawPayload returns the raw payload of the request. -func (info *CachingInfo) RawPayload() any { - return info.rawPayload +// CallType reports that this is a server endpoint call. +func (info *cachingMethodServerUnaryInfo) CallType() goa.InterceptorCallType { + return goa.InterceptorUnary +} + +// CallType reports that this is a client endpoint call. +func (info *cachingMethodClientUnaryInfo) CallType() goa.InterceptorCallType { + return goa.InterceptorUnary } -// Result returns a type-safe accessor for the method result. -func (info *CachingInfo) Result(res any) CachingResult { +// Result returns this method's result fields. +func (info *cachingMethodInfo) Result(res any) CachingResult { return &cachingMethodResult{result: res.(*MethodResult)} } -// Private implementation methods +// Methods that read and write the selected payload and result fields func (r *cachingMethodResult) Data() string { return r.result.Data diff --git a/codegen/service/testdata/interceptors/interceptor-with-read-write-payload_client_interceptors.go.golden b/codegen/service/testdata/interceptors/interceptor-with-read-write-payload_client_interceptors.go.golden index c55718e9a4..f3d5c42641 100644 --- a/codegen/service/testdata/interceptors/interceptor-with-read-write-payload_client_interceptors.go.golden +++ b/codegen/service/testdata/interceptors/interceptor-with-read-write-payload_client_interceptors.go.golden @@ -3,14 +3,14 @@ // is sent to the server. The implementation is responsible for calling next to // complete the request. type ClientInterceptors interface { - Validation(ctx context.Context, info *ValidationInfo, next goa.Endpoint) (any, error) + Validation(ctx context.Context, info ValidationInfo, next goa.Endpoint) (any, error) } // WrapMethodClientEndpoint wraps the Method endpoint with the client // interceptors defined in the design. func WrapMethodClientEndpoint(endpoint goa.Endpoint, i ClientInterceptors) goa.Endpoint { if i != nil { - endpoint = wrapClientMethodvalidation(endpoint, i) + endpoint = wrapClientMethodValidation(endpoint, i) } return endpoint } diff --git a/codegen/service/testdata/interceptors/interceptor-with-read-write-payload_interceptor_wrappers.go.golden b/codegen/service/testdata/interceptors/interceptor-with-read-write-payload_interceptor_wrappers.go.golden index 8a1d529196..cd8ad00325 100644 --- a/codegen/service/testdata/interceptors/interceptor-with-read-write-payload_interceptor_wrappers.go.golden +++ b/codegen/service/testdata/interceptors/interceptor-with-read-write-payload_interceptor_wrappers.go.golden @@ -1,27 +1,21 @@ -// wrapValidationMethod applies the validation server interceptor to endpoints. +// wrapMethodValidation applies the validation server interceptor to endpoints. func wrapMethodValidation(endpoint goa.Endpoint, i ServerInterceptors) goa.Endpoint { return func(ctx context.Context, req any) (any, error) { - info := &ValidationInfo{ - service: "InterceptorWithReadWritePayload", - method: "Method", - callType: goa.InterceptorUnary, - rawPayload: req, + info := &validationMethodServerUnaryInfo{ + validationMethodInfo: validationMethodInfo{rawPayload: req}, } return i.Validation(ctx, info, endpoint) } } -// wrapClientValidationMethod applies the validation client interceptor to +// wrapClientMethodValidation applies the validation client interceptor to // endpoints. func wrapClientMethodValidation(endpoint goa.Endpoint, i ClientInterceptors) goa.Endpoint { return func(ctx context.Context, req any) (any, error) { - info := &ValidationInfo{ - service: "InterceptorWithReadWritePayload", - method: "Method", - callType: goa.InterceptorUnary, - rawPayload: req, + info := &validationMethodClientUnaryInfo{ + validationMethodInfo: validationMethodInfo{rawPayload: req}, } return i.Validation(ctx, info, endpoint) } diff --git a/codegen/service/testdata/interceptors/interceptor-with-read-write-payload_service_interceptors.go.golden b/codegen/service/testdata/interceptors/interceptor-with-read-write-payload_service_interceptors.go.golden index 1e77d5d8a4..cc777ed454 100644 --- a/codegen/service/testdata/interceptors/interceptor-with-read-write-payload_service_interceptors.go.golden +++ b/codegen/service/testdata/interceptors/interceptor-with-read-write-payload_service_interceptors.go.golden @@ -3,18 +3,16 @@ // payload is sent to the service. The implementation is responsible for calling // next to complete the request. type ServerInterceptors interface { - Validation(ctx context.Context, info *ValidationInfo, next goa.Endpoint) (any, error) + Validation(ctx context.Context, info ValidationInfo, next goa.Endpoint) (any, error) } // Access interfaces for interceptor payloads and results type ( - // ValidationInfo provides metadata about the current interception. - // It includes service name, method name, and access to the endpoint. - ValidationInfo struct { - service string - method string - callType goa.InterceptorCallType - rawPayload any + // ValidationInfo describes the service call currently passed to the interceptor. + ValidationInfo interface { + goa.InterceptorInfo + // Payload returns the selected fields from the method payload. + Payload() ValidationPayload } // ValidationPayload provides type-safe access to the method payload. @@ -26,8 +24,17 @@ type ( } ) -// Private implementation types +// Types used to provide information about each service call type ( + validationMethodInfo struct { + rawPayload any + } + validationMethodServerUnaryInfo struct { + validationMethodInfo + } + validationMethodClientUnaryInfo struct { + validationMethodInfo + } validationMethodPayload struct { payload *MethodPayload } @@ -37,39 +44,44 @@ type ( // interceptors defined in the design. func WrapMethodEndpoint(endpoint goa.Endpoint, i ServerInterceptors) goa.Endpoint { if i != nil { - endpoint = wrapMethodvalidation(endpoint, i) + endpoint = wrapMethodValidation(endpoint, i) } return endpoint } -// Public accessor methods for Info types +// Methods that provide information about each service call -// Service returns the name of the service handling the request. -func (info *ValidationInfo) Service() string { - return info.service +// Service returns the service selected for this interceptor call. +func (info *validationMethodInfo) Service() string { + return "InterceptorWithReadWritePayload" } -// Method returns the name of the method handling the request. -func (info *ValidationInfo) Method() string { - return info.method +// Method returns the method selected for this interceptor call. +func (info *validationMethodInfo) Method() string { + return "Method" } -// CallType returns the type of call the interceptor is handling. -func (info *ValidationInfo) CallType() goa.InterceptorCallType { - return info.callType +// RawPayload returns the payload supplied for this interceptor call. +func (info *validationMethodInfo) RawPayload() any { + return info.rawPayload } -// RawPayload returns the raw payload of the request. -func (info *ValidationInfo) RawPayload() any { - return info.rawPayload +// CallType reports that this is a server endpoint call. +func (info *validationMethodServerUnaryInfo) CallType() goa.InterceptorCallType { + return goa.InterceptorUnary +} + +// CallType reports that this is a client endpoint call. +func (info *validationMethodClientUnaryInfo) CallType() goa.InterceptorCallType { + return goa.InterceptorUnary } -// Payload returns a type-safe accessor for the method payload. -func (info *ValidationInfo) Payload() ValidationPayload { - return &validationMethodPayload{payload: info.RawPayload().(*MethodPayload)} +// Payload returns this method's payload fields. +func (info *validationMethodInfo) Payload() ValidationPayload { + return &validationMethodPayload{payload: info.rawPayload.(*MethodPayload)} } -// Private implementation methods +// Methods that read and write the selected payload and result fields func (p *validationMethodPayload) Name() string { return p.payload.Name diff --git a/codegen/service/testdata/interceptors/interceptor-with-read-write-result_client_interceptors.go.golden b/codegen/service/testdata/interceptors/interceptor-with-read-write-result_client_interceptors.go.golden index eb23fdc5df..27668ad056 100644 --- a/codegen/service/testdata/interceptors/interceptor-with-read-write-result_client_interceptors.go.golden +++ b/codegen/service/testdata/interceptors/interceptor-with-read-write-result_client_interceptors.go.golden @@ -3,14 +3,14 @@ // is sent to the server. The implementation is responsible for calling next to // complete the request. type ClientInterceptors interface { - Caching(ctx context.Context, info *CachingInfo, next goa.Endpoint) (any, error) + Caching(ctx context.Context, info CachingInfo, next goa.Endpoint) (any, error) } // WrapMethodClientEndpoint wraps the Method endpoint with the client // interceptors defined in the design. func WrapMethodClientEndpoint(endpoint goa.Endpoint, i ClientInterceptors) goa.Endpoint { if i != nil { - endpoint = wrapClientMethodcaching(endpoint, i) + endpoint = wrapClientMethodCaching(endpoint, i) } return endpoint } diff --git a/codegen/service/testdata/interceptors/interceptor-with-read-write-result_interceptor_wrappers.go.golden b/codegen/service/testdata/interceptors/interceptor-with-read-write-result_interceptor_wrappers.go.golden index 40be57c66d..c89daf37d7 100644 --- a/codegen/service/testdata/interceptors/interceptor-with-read-write-result_interceptor_wrappers.go.golden +++ b/codegen/service/testdata/interceptors/interceptor-with-read-write-result_interceptor_wrappers.go.golden @@ -1,26 +1,20 @@ -// wrapCachingMethod applies the caching server interceptor to endpoints. +// wrapMethodCaching applies the caching server interceptor to endpoints. func wrapMethodCaching(endpoint goa.Endpoint, i ServerInterceptors) goa.Endpoint { return func(ctx context.Context, req any) (any, error) { - info := &CachingInfo{ - service: "InterceptorWithReadWriteResult", - method: "Method", - callType: goa.InterceptorUnary, - rawPayload: req, + info := &cachingMethodServerUnaryInfo{ + cachingMethodInfo: cachingMethodInfo{rawPayload: req}, } return i.Caching(ctx, info, endpoint) } } -// wrapClientCachingMethod applies the caching client interceptor to endpoints. +// wrapClientMethodCaching applies the caching client interceptor to endpoints. func wrapClientMethodCaching(endpoint goa.Endpoint, i ClientInterceptors) goa.Endpoint { return func(ctx context.Context, req any) (any, error) { - info := &CachingInfo{ - service: "InterceptorWithReadWriteResult", - method: "Method", - callType: goa.InterceptorUnary, - rawPayload: req, + info := &cachingMethodClientUnaryInfo{ + cachingMethodInfo: cachingMethodInfo{rawPayload: req}, } return i.Caching(ctx, info, endpoint) } diff --git a/codegen/service/testdata/interceptors/interceptor-with-read-write-result_service_interceptors.go.golden b/codegen/service/testdata/interceptors/interceptor-with-read-write-result_service_interceptors.go.golden index 5457dfdafe..9541d7eff7 100644 --- a/codegen/service/testdata/interceptors/interceptor-with-read-write-result_service_interceptors.go.golden +++ b/codegen/service/testdata/interceptors/interceptor-with-read-write-result_service_interceptors.go.golden @@ -3,18 +3,16 @@ // payload is sent to the service. The implementation is responsible for calling // next to complete the request. type ServerInterceptors interface { - Caching(ctx context.Context, info *CachingInfo, next goa.Endpoint) (any, error) + Caching(ctx context.Context, info CachingInfo, next goa.Endpoint) (any, error) } // Access interfaces for interceptor payloads and results type ( - // CachingInfo provides metadata about the current interception. - // It includes service name, method name, and access to the endpoint. - CachingInfo struct { - service string - method string - callType goa.InterceptorCallType - rawPayload any + // CachingInfo describes the service call currently passed to the interceptor. + CachingInfo interface { + goa.InterceptorInfo + // Result returns the selected fields from the method result. + Result(any) CachingResult } // CachingResult provides type-safe access to the method result. @@ -26,8 +24,17 @@ type ( } ) -// Private implementation types +// Types used to provide information about each service call type ( + cachingMethodInfo struct { + rawPayload any + } + cachingMethodServerUnaryInfo struct { + cachingMethodInfo + } + cachingMethodClientUnaryInfo struct { + cachingMethodInfo + } cachingMethodResult struct { result *MethodResult } @@ -37,39 +44,44 @@ type ( // interceptors defined in the design. func WrapMethodEndpoint(endpoint goa.Endpoint, i ServerInterceptors) goa.Endpoint { if i != nil { - endpoint = wrapMethodcaching(endpoint, i) + endpoint = wrapMethodCaching(endpoint, i) } return endpoint } -// Public accessor methods for Info types +// Methods that provide information about each service call -// Service returns the name of the service handling the request. -func (info *CachingInfo) Service() string { - return info.service +// Service returns the service selected for this interceptor call. +func (info *cachingMethodInfo) Service() string { + return "InterceptorWithReadWriteResult" } -// Method returns the name of the method handling the request. -func (info *CachingInfo) Method() string { - return info.method +// Method returns the method selected for this interceptor call. +func (info *cachingMethodInfo) Method() string { + return "Method" } -// CallType returns the type of call the interceptor is handling. -func (info *CachingInfo) CallType() goa.InterceptorCallType { - return info.callType +// RawPayload returns the payload supplied for this interceptor call. +func (info *cachingMethodInfo) RawPayload() any { + return info.rawPayload } -// RawPayload returns the raw payload of the request. -func (info *CachingInfo) RawPayload() any { - return info.rawPayload +// CallType reports that this is a server endpoint call. +func (info *cachingMethodServerUnaryInfo) CallType() goa.InterceptorCallType { + return goa.InterceptorUnary +} + +// CallType reports that this is a client endpoint call. +func (info *cachingMethodClientUnaryInfo) CallType() goa.InterceptorCallType { + return goa.InterceptorUnary } -// Result returns a type-safe accessor for the method result. -func (info *CachingInfo) Result(res any) CachingResult { +// Result returns this method's result fields. +func (info *cachingMethodInfo) Result(res any) CachingResult { return &cachingMethodResult{result: res.(*MethodResult)} } -// Private implementation methods +// Methods that read and write the selected payload and result fields func (r *cachingMethodResult) Data() string { return r.result.Data diff --git a/codegen/service/testdata/interceptors/interceptor-with-write-payload_client_interceptors.go.golden b/codegen/service/testdata/interceptors/interceptor-with-write-payload_client_interceptors.go.golden index c55718e9a4..f3d5c42641 100644 --- a/codegen/service/testdata/interceptors/interceptor-with-write-payload_client_interceptors.go.golden +++ b/codegen/service/testdata/interceptors/interceptor-with-write-payload_client_interceptors.go.golden @@ -3,14 +3,14 @@ // is sent to the server. The implementation is responsible for calling next to // complete the request. type ClientInterceptors interface { - Validation(ctx context.Context, info *ValidationInfo, next goa.Endpoint) (any, error) + Validation(ctx context.Context, info ValidationInfo, next goa.Endpoint) (any, error) } // WrapMethodClientEndpoint wraps the Method endpoint with the client // interceptors defined in the design. func WrapMethodClientEndpoint(endpoint goa.Endpoint, i ClientInterceptors) goa.Endpoint { if i != nil { - endpoint = wrapClientMethodvalidation(endpoint, i) + endpoint = wrapClientMethodValidation(endpoint, i) } return endpoint } diff --git a/codegen/service/testdata/interceptors/interceptor-with-write-payload_interceptor_wrappers.go.golden b/codegen/service/testdata/interceptors/interceptor-with-write-payload_interceptor_wrappers.go.golden index d7059393aa..cd8ad00325 100644 --- a/codegen/service/testdata/interceptors/interceptor-with-write-payload_interceptor_wrappers.go.golden +++ b/codegen/service/testdata/interceptors/interceptor-with-write-payload_interceptor_wrappers.go.golden @@ -1,27 +1,21 @@ -// wrapValidationMethod applies the validation server interceptor to endpoints. +// wrapMethodValidation applies the validation server interceptor to endpoints. func wrapMethodValidation(endpoint goa.Endpoint, i ServerInterceptors) goa.Endpoint { return func(ctx context.Context, req any) (any, error) { - info := &ValidationInfo{ - service: "InterceptorWithWritePayload", - method: "Method", - callType: goa.InterceptorUnary, - rawPayload: req, + info := &validationMethodServerUnaryInfo{ + validationMethodInfo: validationMethodInfo{rawPayload: req}, } return i.Validation(ctx, info, endpoint) } } -// wrapClientValidationMethod applies the validation client interceptor to +// wrapClientMethodValidation applies the validation client interceptor to // endpoints. func wrapClientMethodValidation(endpoint goa.Endpoint, i ClientInterceptors) goa.Endpoint { return func(ctx context.Context, req any) (any, error) { - info := &ValidationInfo{ - service: "InterceptorWithWritePayload", - method: "Method", - callType: goa.InterceptorUnary, - rawPayload: req, + info := &validationMethodClientUnaryInfo{ + validationMethodInfo: validationMethodInfo{rawPayload: req}, } return i.Validation(ctx, info, endpoint) } diff --git a/codegen/service/testdata/interceptors/interceptor-with-write-payload_service_interceptors.go.golden b/codegen/service/testdata/interceptors/interceptor-with-write-payload_service_interceptors.go.golden index c67700cd58..102afa3b57 100644 --- a/codegen/service/testdata/interceptors/interceptor-with-write-payload_service_interceptors.go.golden +++ b/codegen/service/testdata/interceptors/interceptor-with-write-payload_service_interceptors.go.golden @@ -3,18 +3,16 @@ // payload is sent to the service. The implementation is responsible for calling // next to complete the request. type ServerInterceptors interface { - Validation(ctx context.Context, info *ValidationInfo, next goa.Endpoint) (any, error) + Validation(ctx context.Context, info ValidationInfo, next goa.Endpoint) (any, error) } // Access interfaces for interceptor payloads and results type ( - // ValidationInfo provides metadata about the current interception. - // It includes service name, method name, and access to the endpoint. - ValidationInfo struct { - service string - method string - callType goa.InterceptorCallType - rawPayload any + // ValidationInfo describes the service call currently passed to the interceptor. + ValidationInfo interface { + goa.InterceptorInfo + // Payload returns the selected fields from the method payload. + Payload() ValidationPayload } // ValidationPayload provides type-safe access to the method payload. @@ -25,8 +23,17 @@ type ( } ) -// Private implementation types +// Types used to provide information about each service call type ( + validationMethodInfo struct { + rawPayload any + } + validationMethodServerUnaryInfo struct { + validationMethodInfo + } + validationMethodClientUnaryInfo struct { + validationMethodInfo + } validationMethodPayload struct { payload *MethodPayload } @@ -36,39 +43,44 @@ type ( // interceptors defined in the design. func WrapMethodEndpoint(endpoint goa.Endpoint, i ServerInterceptors) goa.Endpoint { if i != nil { - endpoint = wrapMethodvalidation(endpoint, i) + endpoint = wrapMethodValidation(endpoint, i) } return endpoint } -// Public accessor methods for Info types +// Methods that provide information about each service call -// Service returns the name of the service handling the request. -func (info *ValidationInfo) Service() string { - return info.service +// Service returns the service selected for this interceptor call. +func (info *validationMethodInfo) Service() string { + return "InterceptorWithWritePayload" } -// Method returns the name of the method handling the request. -func (info *ValidationInfo) Method() string { - return info.method +// Method returns the method selected for this interceptor call. +func (info *validationMethodInfo) Method() string { + return "Method" } -// CallType returns the type of call the interceptor is handling. -func (info *ValidationInfo) CallType() goa.InterceptorCallType { - return info.callType +// RawPayload returns the payload supplied for this interceptor call. +func (info *validationMethodInfo) RawPayload() any { + return info.rawPayload } -// RawPayload returns the raw payload of the request. -func (info *ValidationInfo) RawPayload() any { - return info.rawPayload +// CallType reports that this is a server endpoint call. +func (info *validationMethodServerUnaryInfo) CallType() goa.InterceptorCallType { + return goa.InterceptorUnary +} + +// CallType reports that this is a client endpoint call. +func (info *validationMethodClientUnaryInfo) CallType() goa.InterceptorCallType { + return goa.InterceptorUnary } -// Payload returns a type-safe accessor for the method payload. -func (info *ValidationInfo) Payload() ValidationPayload { - return &validationMethodPayload{payload: info.RawPayload().(*MethodPayload)} +// Payload returns this method's payload fields. +func (info *validationMethodInfo) Payload() ValidationPayload { + return &validationMethodPayload{payload: info.rawPayload.(*MethodPayload)} } -// Private implementation methods +// Methods that read and write the selected payload and result fields func (p *validationMethodPayload) SetName(v string) { p.payload.Name = v diff --git a/codegen/service/testdata/interceptors/interceptor-with-write-result_client_interceptors.go.golden b/codegen/service/testdata/interceptors/interceptor-with-write-result_client_interceptors.go.golden index eb23fdc5df..27668ad056 100644 --- a/codegen/service/testdata/interceptors/interceptor-with-write-result_client_interceptors.go.golden +++ b/codegen/service/testdata/interceptors/interceptor-with-write-result_client_interceptors.go.golden @@ -3,14 +3,14 @@ // is sent to the server. The implementation is responsible for calling next to // complete the request. type ClientInterceptors interface { - Caching(ctx context.Context, info *CachingInfo, next goa.Endpoint) (any, error) + Caching(ctx context.Context, info CachingInfo, next goa.Endpoint) (any, error) } // WrapMethodClientEndpoint wraps the Method endpoint with the client // interceptors defined in the design. func WrapMethodClientEndpoint(endpoint goa.Endpoint, i ClientInterceptors) goa.Endpoint { if i != nil { - endpoint = wrapClientMethodcaching(endpoint, i) + endpoint = wrapClientMethodCaching(endpoint, i) } return endpoint } diff --git a/codegen/service/testdata/interceptors/interceptor-with-write-result_interceptor_wrappers.go.golden b/codegen/service/testdata/interceptors/interceptor-with-write-result_interceptor_wrappers.go.golden index 89854d6c95..c89daf37d7 100644 --- a/codegen/service/testdata/interceptors/interceptor-with-write-result_interceptor_wrappers.go.golden +++ b/codegen/service/testdata/interceptors/interceptor-with-write-result_interceptor_wrappers.go.golden @@ -1,26 +1,20 @@ -// wrapCachingMethod applies the caching server interceptor to endpoints. +// wrapMethodCaching applies the caching server interceptor to endpoints. func wrapMethodCaching(endpoint goa.Endpoint, i ServerInterceptors) goa.Endpoint { return func(ctx context.Context, req any) (any, error) { - info := &CachingInfo{ - service: "InterceptorWithWriteResult", - method: "Method", - callType: goa.InterceptorUnary, - rawPayload: req, + info := &cachingMethodServerUnaryInfo{ + cachingMethodInfo: cachingMethodInfo{rawPayload: req}, } return i.Caching(ctx, info, endpoint) } } -// wrapClientCachingMethod applies the caching client interceptor to endpoints. +// wrapClientMethodCaching applies the caching client interceptor to endpoints. func wrapClientMethodCaching(endpoint goa.Endpoint, i ClientInterceptors) goa.Endpoint { return func(ctx context.Context, req any) (any, error) { - info := &CachingInfo{ - service: "InterceptorWithWriteResult", - method: "Method", - callType: goa.InterceptorUnary, - rawPayload: req, + info := &cachingMethodClientUnaryInfo{ + cachingMethodInfo: cachingMethodInfo{rawPayload: req}, } return i.Caching(ctx, info, endpoint) } diff --git a/codegen/service/testdata/interceptors/interceptor-with-write-result_service_interceptors.go.golden b/codegen/service/testdata/interceptors/interceptor-with-write-result_service_interceptors.go.golden index 0e7844bbc8..9ec6aa3971 100644 --- a/codegen/service/testdata/interceptors/interceptor-with-write-result_service_interceptors.go.golden +++ b/codegen/service/testdata/interceptors/interceptor-with-write-result_service_interceptors.go.golden @@ -3,18 +3,16 @@ // payload is sent to the service. The implementation is responsible for calling // next to complete the request. type ServerInterceptors interface { - Caching(ctx context.Context, info *CachingInfo, next goa.Endpoint) (any, error) + Caching(ctx context.Context, info CachingInfo, next goa.Endpoint) (any, error) } // Access interfaces for interceptor payloads and results type ( - // CachingInfo provides metadata about the current interception. - // It includes service name, method name, and access to the endpoint. - CachingInfo struct { - service string - method string - callType goa.InterceptorCallType - rawPayload any + // CachingInfo describes the service call currently passed to the interceptor. + CachingInfo interface { + goa.InterceptorInfo + // Result returns the selected fields from the method result. + Result(any) CachingResult } // CachingResult provides type-safe access to the method result. @@ -25,8 +23,17 @@ type ( } ) -// Private implementation types +// Types used to provide information about each service call type ( + cachingMethodInfo struct { + rawPayload any + } + cachingMethodServerUnaryInfo struct { + cachingMethodInfo + } + cachingMethodClientUnaryInfo struct { + cachingMethodInfo + } cachingMethodResult struct { result *MethodResult } @@ -36,39 +43,44 @@ type ( // interceptors defined in the design. func WrapMethodEndpoint(endpoint goa.Endpoint, i ServerInterceptors) goa.Endpoint { if i != nil { - endpoint = wrapMethodcaching(endpoint, i) + endpoint = wrapMethodCaching(endpoint, i) } return endpoint } -// Public accessor methods for Info types +// Methods that provide information about each service call -// Service returns the name of the service handling the request. -func (info *CachingInfo) Service() string { - return info.service +// Service returns the service selected for this interceptor call. +func (info *cachingMethodInfo) Service() string { + return "InterceptorWithWriteResult" } -// Method returns the name of the method handling the request. -func (info *CachingInfo) Method() string { - return info.method +// Method returns the method selected for this interceptor call. +func (info *cachingMethodInfo) Method() string { + return "Method" } -// CallType returns the type of call the interceptor is handling. -func (info *CachingInfo) CallType() goa.InterceptorCallType { - return info.callType +// RawPayload returns the payload supplied for this interceptor call. +func (info *cachingMethodInfo) RawPayload() any { + return info.rawPayload } -// RawPayload returns the raw payload of the request. -func (info *CachingInfo) RawPayload() any { - return info.rawPayload +// CallType reports that this is a server endpoint call. +func (info *cachingMethodServerUnaryInfo) CallType() goa.InterceptorCallType { + return goa.InterceptorUnary +} + +// CallType reports that this is a client endpoint call. +func (info *cachingMethodClientUnaryInfo) CallType() goa.InterceptorCallType { + return goa.InterceptorUnary } -// Result returns a type-safe accessor for the method result. -func (info *CachingInfo) Result(res any) CachingResult { +// Result returns this method's result fields. +func (info *cachingMethodInfo) Result(res any) CachingResult { return &cachingMethodResult{result: res.(*MethodResult)} } -// Private implementation methods +// Methods that read and write the selected payload and result fields func (r *cachingMethodResult) SetData(v string) { r.result.Data = v diff --git a/codegen/service/testdata/interceptors/leading-initialism-interceptor_client_interceptors.go.golden b/codegen/service/testdata/interceptors/leading-initialism-interceptor_client_interceptors.go.golden new file mode 100644 index 0000000000..8a060fcb93 --- /dev/null +++ b/codegen/service/testdata/interceptors/leading-initialism-interceptor_client_interceptors.go.golden @@ -0,0 +1,16 @@ +// ClientInterceptors defines the interface for all client-side interceptors. +// Client interceptors execute after the payload is encoded and before the request +// is sent to the server. The implementation is responsible for calling next to +// complete the request. +type ClientInterceptors interface { + JWTAuth(ctx context.Context, info JWTAuthInfo, next goa.Endpoint) (any, error) +} + +// WrapGetInfoClientEndpoint wraps the GetInfo endpoint with the client +// interceptors defined in the design. +func WrapGetInfoClientEndpoint(endpoint goa.Endpoint, i ClientInterceptors) goa.Endpoint { + if i != nil { + endpoint = wrapClientGetInfoJWTAuth(endpoint, i) + } + return endpoint +} diff --git a/codegen/service/testdata/interceptors/leading-initialism-interceptor_interceptor_wrappers.go.golden b/codegen/service/testdata/interceptors/leading-initialism-interceptor_interceptor_wrappers.go.golden new file mode 100644 index 0000000000..b4f85f8480 --- /dev/null +++ b/codegen/service/testdata/interceptors/leading-initialism-interceptor_interceptor_wrappers.go.golden @@ -0,0 +1,21 @@ + + +// wrapGetInfoJWTAuth applies the JWTAuth server interceptor to endpoints. +func wrapGetInfoJWTAuth(endpoint goa.Endpoint, i ServerInterceptors) goa.Endpoint { + return func(ctx context.Context, req any) (any, error) { + info := &jwtAuthGetInfoServerUnaryInfo{ + jwtAuthGetInfoInfo: jwtAuthGetInfoInfo{rawPayload: req}, + } + return i.JWTAuth(ctx, info, endpoint) + } +} + +// wrapClientGetInfoJWTAuth applies the JWTAuth client interceptor to endpoints. +func wrapClientGetInfoJWTAuth(endpoint goa.Endpoint, i ClientInterceptors) goa.Endpoint { + return func(ctx context.Context, req any) (any, error) { + info := &jwtAuthGetInfoClientUnaryInfo{ + jwtAuthGetInfoInfo: jwtAuthGetInfoInfo{rawPayload: req}, + } + return i.JWTAuth(ctx, info, endpoint) + } +} diff --git a/codegen/service/testdata/interceptors/leading-initialism-interceptor_service_interceptors.go.golden b/codegen/service/testdata/interceptors/leading-initialism-interceptor_service_interceptors.go.golden new file mode 100644 index 0000000000..c6d48ee598 --- /dev/null +++ b/codegen/service/testdata/interceptors/leading-initialism-interceptor_service_interceptors.go.golden @@ -0,0 +1,64 @@ +// ServerInterceptors defines the interface for all server-side interceptors. +// Server interceptors execute after the request is decoded and before the +// payload is sent to the service. The implementation is responsible for calling +// next to complete the request. +type ServerInterceptors interface { + JWTAuth(ctx context.Context, info JWTAuthInfo, next goa.Endpoint) (any, error) +} + +// Access interfaces for interceptor payloads and results +type ( + // JWTAuthInfo describes the service call currently passed to the interceptor. + JWTAuthInfo interface { + goa.InterceptorInfo + } +) + +// Types used to provide information about each service call +type ( + jwtAuthGetInfoInfo struct { + rawPayload any + } + jwtAuthGetInfoServerUnaryInfo struct { + jwtAuthGetInfoInfo + } + jwtAuthGetInfoClientUnaryInfo struct { + jwtAuthGetInfoInfo + } +) + +// WrapGetInfoEndpoint wraps the GetInfo endpoint with the server-side +// interceptors defined in the design. +func WrapGetInfoEndpoint(endpoint goa.Endpoint, i ServerInterceptors) goa.Endpoint { + if i != nil { + endpoint = wrapGetInfoJWTAuth(endpoint, i) + } + return endpoint +} + +// Methods that provide information about each service call + +// Service returns the service selected for this interceptor call. +func (info *jwtAuthGetInfoInfo) Service() string { + return "LeadingInitialismInterceptor" +} + +// Method returns the method selected for this interceptor call. +func (info *jwtAuthGetInfoInfo) Method() string { + return "GetInfo" +} + +// RawPayload returns the payload supplied for this interceptor call. +func (info *jwtAuthGetInfoInfo) RawPayload() any { + return info.rawPayload +} + +// CallType reports that this is a server endpoint call. +func (info *jwtAuthGetInfoServerUnaryInfo) CallType() goa.InterceptorCallType { + return goa.InterceptorUnary +} + +// CallType reports that this is a client endpoint call. +func (info *jwtAuthGetInfoClientUnaryInfo) CallType() goa.InterceptorCallType { + return goa.InterceptorUnary +} diff --git a/codegen/service/testdata/interceptors/merged-interceptors-with-external-client-payload_client_interceptors.go.golden b/codegen/service/testdata/interceptors/merged-interceptors-with-external-client-payload_client_interceptors.go.golden new file mode 100644 index 0000000000..b9208fde0f --- /dev/null +++ b/codegen/service/testdata/interceptors/merged-interceptors-with-external-client-payload_client_interceptors.go.golden @@ -0,0 +1,16 @@ +// ClientInterceptors defines the interface for all client-side interceptors. +// Client interceptors execute after the payload is encoded and before the request +// is sent to the server. The implementation is responsible for calling next to +// complete the request. +type ClientInterceptors interface { + Identify(ctx context.Context, info IdentifyInfo, next goa.Endpoint) (any, error) +} + +// WrapClientMethodClientEndpoint wraps the ClientMethod endpoint with the +// client interceptors defined in the design. +func WrapClientMethodClientEndpoint(endpoint goa.Endpoint, i ClientInterceptors) goa.Endpoint { + if i != nil { + endpoint = wrapClientClientMethodIdentify(endpoint, i) + } + return endpoint +} diff --git a/codegen/service/testdata/interceptors/merged-interceptors-with-external-client-payload_interceptor_wrappers.go.golden b/codegen/service/testdata/interceptors/merged-interceptors-with-external-client-payload_interceptor_wrappers.go.golden new file mode 100644 index 0000000000..3ac566a8a2 --- /dev/null +++ b/codegen/service/testdata/interceptors/merged-interceptors-with-external-client-payload_interceptor_wrappers.go.golden @@ -0,0 +1,23 @@ + + +// wrapServerMethodIdentify applies the identify server interceptor to +// endpoints. +func wrapServerMethodIdentify(endpoint goa.Endpoint, i ServerInterceptors) goa.Endpoint { + return func(ctx context.Context, req any) (any, error) { + info := &identifyServerMethodServerUnaryInfo{ + identifyServerMethodInfo: identifyServerMethodInfo{rawPayload: req}, + } + return i.Identify(ctx, info, endpoint) + } +} + +// wrapClientClientMethodIdentify applies the identify client interceptor to +// endpoints. +func wrapClientClientMethodIdentify(endpoint goa.Endpoint, i ClientInterceptors) goa.Endpoint { + return func(ctx context.Context, req any) (any, error) { + info := &identifyClientMethodClientUnaryInfo{ + identifyClientMethodInfo: identifyClientMethodInfo{rawPayload: req}, + } + return i.Identify(ctx, info, endpoint) + } +} diff --git a/codegen/service/testdata/interceptors/merged-interceptors-with-external-client-payload_service_interceptors.go.golden b/codegen/service/testdata/interceptors/merged-interceptors-with-external-client-payload_service_interceptors.go.golden new file mode 100644 index 0000000000..7d1d0691b2 --- /dev/null +++ b/codegen/service/testdata/interceptors/merged-interceptors-with-external-client-payload_service_interceptors.go.golden @@ -0,0 +1,116 @@ +// ServerInterceptors defines the interface for all server-side interceptors. +// Server interceptors execute after the request is decoded and before the +// payload is sent to the service. The implementation is responsible for calling +// next to complete the request. +type ServerInterceptors interface { + Identify(ctx context.Context, info IdentifyInfo, next goa.Endpoint) (any, error) +} + +// Access interfaces for interceptor payloads and results +type ( + // IdentifyInfo describes the service call currently passed to the interceptor. + IdentifyInfo interface { + goa.InterceptorInfo + // Payload returns the selected fields from the method payload. + Payload() IdentifyPayload + } + + // IdentifyPayload provides type-safe access to the method payload. + // It allows reading and writing specific fields of the payload as defined + // in the design. + IdentifyPayload interface { + RuntimeSessionID() string + } +) + +// Types used to provide information about each service call +type ( + identifyServerMethodInfo struct { + rawPayload any + } + identifyServerMethodServerUnaryInfo struct { + identifyServerMethodInfo + } + identifyClientMethodInfo struct { + rawPayload any + } + identifyClientMethodClientUnaryInfo struct { + identifyClientMethodInfo + } + identifyServerMethodPayload struct { + payload *ServerMethodPayload + } + identifyClientMethodPayload struct { + payload *types.Event + } +) + +// WrapServerMethodEndpoint wraps the ServerMethod endpoint with the +// server-side interceptors defined in the design. +func WrapServerMethodEndpoint(endpoint goa.Endpoint, i ServerInterceptors) goa.Endpoint { + if i != nil { + endpoint = wrapServerMethodIdentify(endpoint, i) + } + return endpoint +} + +// Methods that provide information about each service call + +// Service returns the service selected for this interceptor call. +func (info *identifyServerMethodInfo) Service() string { + return "MergedInterceptorsWithExternalClientPayload" +} + +// Method returns the method selected for this interceptor call. +func (info *identifyServerMethodInfo) Method() string { + return "ServerMethod" +} + +// RawPayload returns the payload supplied for this interceptor call. +func (info *identifyServerMethodInfo) RawPayload() any { + return info.rawPayload +} + +// CallType reports that this is a server endpoint call. +func (info *identifyServerMethodServerUnaryInfo) CallType() goa.InterceptorCallType { + return goa.InterceptorUnary +} + +// Payload returns this method's payload fields. +func (info *identifyServerMethodInfo) Payload() IdentifyPayload { + return &identifyServerMethodPayload{payload: info.rawPayload.(*ServerMethodPayload)} +} + +// Service returns the service selected for this interceptor call. +func (info *identifyClientMethodInfo) Service() string { + return "MergedInterceptorsWithExternalClientPayload" +} + +// Method returns the method selected for this interceptor call. +func (info *identifyClientMethodInfo) Method() string { + return "ClientMethod" +} + +// RawPayload returns the payload supplied for this interceptor call. +func (info *identifyClientMethodInfo) RawPayload() any { + return info.rawPayload +} + +// CallType reports that this is a client endpoint call. +func (info *identifyClientMethodClientUnaryInfo) CallType() goa.InterceptorCallType { + return goa.InterceptorUnary +} + +// Payload returns this method's payload fields. +func (info *identifyClientMethodInfo) Payload() IdentifyPayload { + return &identifyClientMethodPayload{payload: info.rawPayload.(*types.Event)} +} + +// Methods that read and write the selected payload and result fields + +func (p *identifyServerMethodPayload) RuntimeSessionID() string { + return p.payload.RuntimeSessionID +} +func (p *identifyClientMethodPayload) RuntimeSessionID() string { + return p.payload.RuntimeSessionID +} diff --git a/codegen/service/testdata/interceptors/mixed-interceptors-with-external-client-payload_client_interceptors.go.golden b/codegen/service/testdata/interceptors/mixed-interceptors-with-external-client-payload_client_interceptors.go.golden new file mode 100644 index 0000000000..e8c42dcd78 --- /dev/null +++ b/codegen/service/testdata/interceptors/mixed-interceptors-with-external-client-payload_client_interceptors.go.golden @@ -0,0 +1,79 @@ +// ClientInterceptors defines the interface for all client-side interceptors. +// Client interceptors execute after the payload is encoded and before the request +// is sent to the server. The implementation is responsible for calling next to +// complete the request. +type ClientInterceptors interface { + Authorization(ctx context.Context, info AuthorizationInfo, next goa.Endpoint) (any, error) +} + +// Access interfaces for interceptor payloads and results +type ( + // AuthorizationInfo describes the service call currently passed to the interceptor. + AuthorizationInfo interface { + goa.InterceptorInfo + // Payload returns the selected fields from the method payload. + Payload() AuthorizationPayload + } + + // AuthorizationPayload provides type-safe access to the method payload. + // It allows reading and writing specific fields of the payload as defined + // in the design. + AuthorizationPayload interface { + OrgID() types.UUID + } +) + +// Types used to provide information about each service call +type ( + authorizationMethodInfo struct { + rawPayload any + } + authorizationMethodClientUnaryInfo struct { + authorizationMethodInfo + } + authorizationMethodPayload struct { + payload *MethodPayload + } +) + +// WrapMethodClientEndpoint wraps the Method endpoint with the client +// interceptors defined in the design. +func WrapMethodClientEndpoint(endpoint goa.Endpoint, i ClientInterceptors) goa.Endpoint { + if i != nil { + endpoint = wrapClientMethodAuthorization(endpoint, i) + } + return endpoint +} + +// Methods that provide information about each service call + +// Service returns the service selected for this interceptor call. +func (info *authorizationMethodInfo) Service() string { + return "MixedInterceptorsWithExternalClientPayload" +} + +// Method returns the method selected for this interceptor call. +func (info *authorizationMethodInfo) Method() string { + return "Method" +} + +// RawPayload returns the payload supplied for this interceptor call. +func (info *authorizationMethodInfo) RawPayload() any { + return info.rawPayload +} + +// CallType reports that this is a client endpoint call. +func (info *authorizationMethodClientUnaryInfo) CallType() goa.InterceptorCallType { + return goa.InterceptorUnary +} + +// Payload returns this method's payload fields. +func (info *authorizationMethodInfo) Payload() AuthorizationPayload { + return &authorizationMethodPayload{payload: info.rawPayload.(*MethodPayload)} +} + +// Methods that read and write the selected payload and result fields + +func (p *authorizationMethodPayload) OrgID() types.UUID { + return p.payload.OrgID +} diff --git a/codegen/service/testdata/interceptors/mixed-interceptors-with-external-client-payload_interceptor_wrappers.go.golden b/codegen/service/testdata/interceptors/mixed-interceptors-with-external-client-payload_interceptor_wrappers.go.golden new file mode 100644 index 0000000000..7450ba1299 --- /dev/null +++ b/codegen/service/testdata/interceptors/mixed-interceptors-with-external-client-payload_interceptor_wrappers.go.golden @@ -0,0 +1,22 @@ + + +// wrapMethodLogging applies the logging server interceptor to endpoints. +func wrapMethodLogging(endpoint goa.Endpoint, i ServerInterceptors) goa.Endpoint { + return func(ctx context.Context, req any) (any, error) { + info := &loggingMethodServerUnaryInfo{ + loggingMethodInfo: loggingMethodInfo{rawPayload: req}, + } + return i.Logging(ctx, info, endpoint) + } +} + +// wrapClientMethodAuthorization applies the authorization client interceptor +// to endpoints. +func wrapClientMethodAuthorization(endpoint goa.Endpoint, i ClientInterceptors) goa.Endpoint { + return func(ctx context.Context, req any) (any, error) { + info := &authorizationMethodClientUnaryInfo{ + authorizationMethodInfo: authorizationMethodInfo{rawPayload: req}, + } + return i.Authorization(ctx, info, endpoint) + } +} diff --git a/codegen/service/testdata/interceptors/mixed-interceptors-with-external-client-payload_service_interceptors.go.golden b/codegen/service/testdata/interceptors/mixed-interceptors-with-external-client-payload_service_interceptors.go.golden new file mode 100644 index 0000000000..84d0675907 --- /dev/null +++ b/codegen/service/testdata/interceptors/mixed-interceptors-with-external-client-payload_service_interceptors.go.golden @@ -0,0 +1,56 @@ +// ServerInterceptors defines the interface for all server-side interceptors. +// Server interceptors execute after the request is decoded and before the +// payload is sent to the service. The implementation is responsible for calling +// next to complete the request. +type ServerInterceptors interface { + Logging(ctx context.Context, info LoggingInfo, next goa.Endpoint) (any, error) +} + +// Access interfaces for interceptor payloads and results +type ( + // LoggingInfo describes the service call currently passed to the interceptor. + LoggingInfo interface { + goa.InterceptorInfo + } +) + +// Types used to provide information about each service call +type ( + loggingMethodInfo struct { + rawPayload any + } + loggingMethodServerUnaryInfo struct { + loggingMethodInfo + } +) + +// WrapMethodEndpoint wraps the Method endpoint with the server-side +// interceptors defined in the design. +func WrapMethodEndpoint(endpoint goa.Endpoint, i ServerInterceptors) goa.Endpoint { + if i != nil { + endpoint = wrapMethodLogging(endpoint, i) + } + return endpoint +} + +// Methods that provide information about each service call + +// Service returns the service selected for this interceptor call. +func (info *loggingMethodInfo) Service() string { + return "MixedInterceptorsWithExternalClientPayload" +} + +// Method returns the method selected for this interceptor call. +func (info *loggingMethodInfo) Method() string { + return "Method" +} + +// RawPayload returns the payload supplied for this interceptor call. +func (info *loggingMethodInfo) RawPayload() any { + return info.rawPayload +} + +// CallType reports that this is a server endpoint call. +func (info *loggingMethodServerUnaryInfo) CallType() goa.InterceptorCallType { + return goa.InterceptorUnary +} diff --git a/codegen/service/testdata/interceptors/mixed-result-streaming-interceptors_client_interceptors.go.golden b/codegen/service/testdata/interceptors/mixed-result-streaming-interceptors_client_interceptors.go.golden new file mode 100644 index 0000000000..7f47a35c66 --- /dev/null +++ b/codegen/service/testdata/interceptors/mixed-result-streaming-interceptors_client_interceptors.go.golden @@ -0,0 +1,16 @@ +// ClientInterceptors defines the interface for all client-side interceptors. +// Client interceptors execute after the payload is encoded and before the request +// is sent to the server. The implementation is responsible for calling next to +// complete the request. +type ClientInterceptors interface { + Logging(ctx context.Context, info LoggingInfo, next goa.Endpoint) (any, error) +} + +// WrapMethodClientEndpoint wraps the Method endpoint with the client +// interceptors defined in the design. +func WrapMethodClientEndpoint(endpoint goa.Endpoint, i ClientInterceptors) goa.Endpoint { + if i != nil { + endpoint = wrapClientMethodLogging(endpoint, i) + } + return endpoint +} diff --git a/codegen/service/testdata/interceptors/mixed-result-streaming-interceptors_interceptor_wrappers.go.golden b/codegen/service/testdata/interceptors/mixed-result-streaming-interceptors_interceptor_wrappers.go.golden new file mode 100644 index 0000000000..c9a99609f4 --- /dev/null +++ b/codegen/service/testdata/interceptors/mixed-result-streaming-interceptors_interceptor_wrappers.go.golden @@ -0,0 +1,104 @@ + + +// wrappedMethodServerStream is a server interceptor wrapper for the +// MethodServerStream stream. +type wrappedMethodServerStream struct { + ctx context.Context + sendWithContext func(context.Context, *events.Event) error + stream MethodServerStream +} + +// wrappedMethodClientStream is a client interceptor wrapper for the +// MethodClientStream stream. +type wrappedMethodClientStream struct { + ctx context.Context + recvWithContext func(context.Context) (*events.Event, error) + stream MethodClientStream +} + +// wrapMethodLogging applies the logging server interceptor to endpoints. +func wrapMethodLogging(endpoint goa.Endpoint, i ServerInterceptors) goa.Endpoint { + return func(ctx context.Context, req any) (any, error) { + stream := req.(*MethodEndpointInput).Stream + req.(*MethodEndpointInput).Stream = &wrappedMethodServerStream{ + ctx: ctx, + sendWithContext: func(ctx context.Context, req *events.Event) error { + info := &loggingMethodStreamingSendInfo{ + loggingMethodInfo: loggingMethodInfo{rawPayload: req}, + } + _, err := i.Logging(ctx, info, func(ctx context.Context, req any) (any, error) { + castReq, _ := req.(*events.Event) + return nil, stream.SendWithContext(ctx, castReq) + }) + return err + }, + stream: stream, + } + return endpoint(ctx, req) + } +} + +// wrapClientMethodLogging applies the logging client interceptor to endpoints. +func wrapClientMethodLogging(endpoint goa.Endpoint, i ClientInterceptors) goa.Endpoint { + return func(ctx context.Context, req any) (any, error) { + res, err := endpoint(ctx, req) + if err != nil { + return res, err + } + stream := res.(MethodClientStream) + return &wrappedMethodClientStream{ + ctx: ctx, + recvWithContext: func(ctx context.Context) (*events.Event, error) { + info := &loggingMethodStreamingRecvInfo{ + loggingMethodInfo: loggingMethodInfo{}, + } + res, err := i.Logging(ctx, info, func(ctx context.Context, _ any) (any, error) { + return stream.RecvWithContext(ctx) + }) + castRes, _ := res.(*events.Event) + return castRes, err + }, + stream: stream, + }, nil + } +} + +// Unwrap returns the underlying stream type. +func (w *wrappedMethodServerStream) Unwrap() any { + return w.stream +} + +// Send streams instances of "MethodServerStream" after executing the applied +// interceptor. +func (w *wrappedMethodServerStream) Send(v *events.Event) error { + return w.SendWithContext(w.ctx, v) +} + +// SendWithContext streams instances of "MethodServerStream" after executing +// the applied interceptor with context. +func (w *wrappedMethodServerStream) SendWithContext(ctx context.Context, v *events.Event) error { + if w.sendWithContext == nil { + return w.stream.SendWithContext(ctx, v) + } + return w.sendWithContext(ctx, v) +} + +// Close closes the stream. +func (w *wrappedMethodServerStream) Close() error { + return w.stream.Close() +} + +// Recv reads instances of "MethodClientStream" from the stream after executing +// the applied interceptor. +func (w *wrappedMethodClientStream) Recv() (*events.Event, error) { + return w.RecvWithContext(w.ctx) +} + +// RecvWithContext reads instances of "MethodClientStream" from the stream +// after executing the applied interceptor with context. +func (w *wrappedMethodClientStream) RecvWithContext(ctx context.Context) (*events.Event, error) { + if w.recvWithContext == nil { + return w.stream.RecvWithContext(ctx) + } + return w.recvWithContext(ctx) +} diff --git a/codegen/service/testdata/interceptors/mixed-result-streaming-interceptors_service_interceptors.go.golden b/codegen/service/testdata/interceptors/mixed-result-streaming-interceptors_service_interceptors.go.golden new file mode 100644 index 0000000000..17095e31fc --- /dev/null +++ b/codegen/service/testdata/interceptors/mixed-result-streaming-interceptors_service_interceptors.go.golden @@ -0,0 +1,98 @@ +// ServerInterceptors defines the interface for all server-side interceptors. +// Server interceptors execute after the request is decoded and before the +// payload is sent to the service. The implementation is responsible for calling +// next to complete the request. +type ServerInterceptors interface { + Logging(ctx context.Context, info LoggingInfo, next goa.Endpoint) (any, error) +} + +// Access interfaces for interceptor payloads and results +type ( + // LoggingInfo describes the service call currently passed to the interceptor. + LoggingInfo interface { + goa.InterceptorInfo + // ClientStreamingResult returns selected fields from the incoming stream result. + ClientStreamingResult(any) LoggingStreamingResult + // ServerStreamingResult returns selected fields from the outgoing stream result. + ServerStreamingResult() LoggingStreamingResult + } + + // LoggingStreamingResult provides type-safe access to the method streaming result. + // It allows reading and writing specific fields of the streaming result as defined + // in the design. + LoggingStreamingResult interface { + Message() string + } +) + +// Types used to provide information about each service call +type ( + loggingMethodInfo struct { + rawPayload any + } + loggingMethodStreamingSendInfo struct { + loggingMethodInfo + } + loggingMethodStreamingRecvInfo struct { + loggingMethodInfo + } + loggingMethodStreamingResult struct { + result *events.Event + } +) + +// WrapMethodEndpoint wraps the Method endpoint with the server-side +// interceptors defined in the design. +func WrapMethodEndpoint(endpoint goa.Endpoint, i ServerInterceptors) goa.Endpoint { + if i != nil { + endpoint = wrapMethodLogging(endpoint, i) + } + return endpoint +} + +// Methods that provide information about each service call + +// Service returns the service selected for this interceptor call. +func (info *loggingMethodInfo) Service() string { + return "MixedResultStreamingInterceptors" +} + +// Method returns the method selected for this interceptor call. +func (info *loggingMethodInfo) Method() string { + return "Method" +} + +// RawPayload returns the payload supplied for this interceptor call. +func (info *loggingMethodInfo) RawPayload() any { + return info.rawPayload +} + +// CallType reports that this is a stream send. +func (info *loggingMethodStreamingSendInfo) CallType() goa.InterceptorCallType { + return goa.InterceptorStreamingSend +} + +// CallType reports that this is a stream receive. +func (info *loggingMethodStreamingRecvInfo) CallType() goa.InterceptorCallType { + return goa.InterceptorStreamingRecv +} + +// ClientStreamingResult returns this method's incoming streaming result fields. +func (info *loggingMethodInfo) ClientStreamingResult(result any) LoggingStreamingResult { + return &loggingMethodStreamingResult{result: result.(*events.Event)} +} + +// ServerStreamingResult returns this method's outgoing streaming result fields. +func (info *loggingMethodInfo) ServerStreamingResult() LoggingStreamingResult { + return &loggingMethodStreamingResult{result: info.rawPayload.(*events.Event)} +} + +// Methods that read and write the selected payload and result fields + +func (r *loggingMethodStreamingResult) Message() string { + if r.result.Message == nil { + var zero string + return zero + } + return *r.result.Message +} diff --git a/codegen/service/testdata/interceptors/multiple-interceptors_client_interceptors.go.golden b/codegen/service/testdata/interceptors/multiple-interceptors_client_interceptors.go.golden index 449f55aa49..5aab4577c1 100644 --- a/codegen/service/testdata/interceptors/multiple-interceptors_client_interceptors.go.golden +++ b/codegen/service/testdata/interceptors/multiple-interceptors_client_interceptors.go.golden @@ -3,78 +3,86 @@ // is sent to the server. The implementation is responsible for calling next to // complete the request. type ClientInterceptors interface { - Test2(ctx context.Context, info *Test2Info, next goa.Endpoint) (any, error) - Test4(ctx context.Context, info *Test4Info, next goa.Endpoint) (any, error) + Test2(ctx context.Context, info Test2Info, next goa.Endpoint) (any, error) + Test4(ctx context.Context, info Test4Info, next goa.Endpoint) (any, error) } // Access interfaces for interceptor payloads and results type ( - // Test2Info provides metadata about the current interception. - // It includes service name, method name, and access to the endpoint. - Test2Info struct { - service string - method string - callType goa.InterceptorCallType + // Test2Info describes the service call currently passed to the interceptor. + Test2Info interface { + goa.InterceptorInfo + } + // Test4Info describes the service call currently passed to the interceptor. + Test4Info interface { + goa.InterceptorInfo + } +) + +// Types used to provide information about each service call +type ( + test2MethodInfo struct { rawPayload any } - // Test4Info provides metadata about the current interception. - // It includes service name, method name, and access to the endpoint. - Test4Info struct { - service string - method string - callType goa.InterceptorCallType + test2MethodClientUnaryInfo struct { + test2MethodInfo + } + test4MethodInfo struct { rawPayload any } + test4MethodClientUnaryInfo struct { + test4MethodInfo + } ) // WrapMethodClientEndpoint wraps the Method endpoint with the client // interceptors defined in the design. func WrapMethodClientEndpoint(endpoint goa.Endpoint, i ClientInterceptors) goa.Endpoint { if i != nil { - endpoint = wrapClientMethodtest2(endpoint, i) - endpoint = wrapClientMethodtest4(endpoint, i) + endpoint = wrapClientMethodTest2(endpoint, i) + endpoint = wrapClientMethodTest4(endpoint, i) } return endpoint } -// Public accessor methods for Info types - -// Service returns the name of the service handling the request. -func (info *Test2Info) Service() string { - return info.service -} +// Methods that provide information about each service call -// Method returns the name of the method handling the request. -func (info *Test2Info) Method() string { - return info.method +// Service returns the service selected for this interceptor call. +func (info *test2MethodInfo) Service() string { + return "MultipleInterceptorsService" } -// CallType returns the type of call the interceptor is handling. -func (info *Test2Info) CallType() goa.InterceptorCallType { - return info.callType +// Method returns the method selected for this interceptor call. +func (info *test2MethodInfo) Method() string { + return "Method" } -// RawPayload returns the raw payload of the request. -func (info *Test2Info) RawPayload() any { +// RawPayload returns the payload supplied for this interceptor call. +func (info *test2MethodInfo) RawPayload() any { return info.rawPayload } -// Service returns the name of the service handling the request. -func (info *Test4Info) Service() string { - return info.service +// CallType reports that this is a client endpoint call. +func (info *test2MethodClientUnaryInfo) CallType() goa.InterceptorCallType { + return goa.InterceptorUnary } -// Method returns the name of the method handling the request. -func (info *Test4Info) Method() string { - return info.method +// Service returns the service selected for this interceptor call. +func (info *test4MethodInfo) Service() string { + return "MultipleInterceptorsService" } -// CallType returns the type of call the interceptor is handling. -func (info *Test4Info) CallType() goa.InterceptorCallType { - return info.callType +// Method returns the method selected for this interceptor call. +func (info *test4MethodInfo) Method() string { + return "Method" } -// RawPayload returns the raw payload of the request. -func (info *Test4Info) RawPayload() any { +// RawPayload returns the payload supplied for this interceptor call. +func (info *test4MethodInfo) RawPayload() any { return info.rawPayload } + +// CallType reports that this is a client endpoint call. +func (info *test4MethodClientUnaryInfo) CallType() goa.InterceptorCallType { + return goa.InterceptorUnary +} diff --git a/codegen/service/testdata/interceptors/multiple-interceptors_interceptor_wrappers.go.golden b/codegen/service/testdata/interceptors/multiple-interceptors_interceptor_wrappers.go.golden index cfabf2905f..170ac4983d 100644 --- a/codegen/service/testdata/interceptors/multiple-interceptors_interceptor_wrappers.go.golden +++ b/codegen/service/testdata/interceptors/multiple-interceptors_interceptor_wrappers.go.golden @@ -1,52 +1,40 @@ -// wrapTestMethod applies the test server interceptor to endpoints. +// wrapMethodTest applies the test server interceptor to endpoints. func wrapMethodTest(endpoint goa.Endpoint, i ServerInterceptors) goa.Endpoint { return func(ctx context.Context, req any) (any, error) { - info := &TestInfo{ - service: "MultipleInterceptorsService", - method: "Method", - callType: goa.InterceptorUnary, - rawPayload: req, + info := &testMethodServerUnaryInfo{ + testMethodInfo: testMethodInfo{rawPayload: req}, } return i.Test(ctx, info, endpoint) } } -// wrapTest3Method applies the test3 server interceptor to endpoints. +// wrapMethodTest3 applies the test3 server interceptor to endpoints. func wrapMethodTest3(endpoint goa.Endpoint, i ServerInterceptors) goa.Endpoint { return func(ctx context.Context, req any) (any, error) { - info := &Test3Info{ - service: "MultipleInterceptorsService", - method: "Method", - callType: goa.InterceptorUnary, - rawPayload: req, + info := &test3MethodServerUnaryInfo{ + test3MethodInfo: test3MethodInfo{rawPayload: req}, } return i.Test3(ctx, info, endpoint) } } -// wrapClientTest2Method applies the test2 client interceptor to endpoints. +// wrapClientMethodTest2 applies the test2 client interceptor to endpoints. func wrapClientMethodTest2(endpoint goa.Endpoint, i ClientInterceptors) goa.Endpoint { return func(ctx context.Context, req any) (any, error) { - info := &Test2Info{ - service: "MultipleInterceptorsService", - method: "Method", - callType: goa.InterceptorUnary, - rawPayload: req, + info := &test2MethodClientUnaryInfo{ + test2MethodInfo: test2MethodInfo{rawPayload: req}, } return i.Test2(ctx, info, endpoint) } } -// wrapClientTest4Method applies the test4 client interceptor to endpoints. +// wrapClientMethodTest4 applies the test4 client interceptor to endpoints. func wrapClientMethodTest4(endpoint goa.Endpoint, i ClientInterceptors) goa.Endpoint { return func(ctx context.Context, req any) (any, error) { - info := &Test4Info{ - service: "MultipleInterceptorsService", - method: "Method", - callType: goa.InterceptorUnary, - rawPayload: req, + info := &test4MethodClientUnaryInfo{ + test4MethodInfo: test4MethodInfo{rawPayload: req}, } return i.Test4(ctx, info, endpoint) } diff --git a/codegen/service/testdata/interceptors/multiple-interceptors_service_interceptors.go.golden b/codegen/service/testdata/interceptors/multiple-interceptors_service_interceptors.go.golden index 46327a5a20..92996a5e4f 100644 --- a/codegen/service/testdata/interceptors/multiple-interceptors_service_interceptors.go.golden +++ b/codegen/service/testdata/interceptors/multiple-interceptors_service_interceptors.go.golden @@ -3,78 +3,86 @@ // payload is sent to the service. The implementation is responsible for calling // next to complete the request. type ServerInterceptors interface { - Test(ctx context.Context, info *TestInfo, next goa.Endpoint) (any, error) - Test3(ctx context.Context, info *Test3Info, next goa.Endpoint) (any, error) + Test(ctx context.Context, info TestInfo, next goa.Endpoint) (any, error) + Test3(ctx context.Context, info Test3Info, next goa.Endpoint) (any, error) } // Access interfaces for interceptor payloads and results type ( - // TestInfo provides metadata about the current interception. - // It includes service name, method name, and access to the endpoint. - TestInfo struct { - service string - method string - callType goa.InterceptorCallType + // TestInfo describes the service call currently passed to the interceptor. + TestInfo interface { + goa.InterceptorInfo + } + // Test3Info describes the service call currently passed to the interceptor. + Test3Info interface { + goa.InterceptorInfo + } +) + +// Types used to provide information about each service call +type ( + testMethodInfo struct { rawPayload any } - // Test3Info provides metadata about the current interception. - // It includes service name, method name, and access to the endpoint. - Test3Info struct { - service string - method string - callType goa.InterceptorCallType + testMethodServerUnaryInfo struct { + testMethodInfo + } + test3MethodInfo struct { rawPayload any } + test3MethodServerUnaryInfo struct { + test3MethodInfo + } ) // WrapMethodEndpoint wraps the Method endpoint with the server-side // interceptors defined in the design. func WrapMethodEndpoint(endpoint goa.Endpoint, i ServerInterceptors) goa.Endpoint { if i != nil { - endpoint = wrapMethodtest(endpoint, i) - endpoint = wrapMethodtest3(endpoint, i) + endpoint = wrapMethodTest(endpoint, i) + endpoint = wrapMethodTest3(endpoint, i) } return endpoint } -// Public accessor methods for Info types - -// Service returns the name of the service handling the request. -func (info *TestInfo) Service() string { - return info.service -} +// Methods that provide information about each service call -// Method returns the name of the method handling the request. -func (info *TestInfo) Method() string { - return info.method +// Service returns the service selected for this interceptor call. +func (info *testMethodInfo) Service() string { + return "MultipleInterceptorsService" } -// CallType returns the type of call the interceptor is handling. -func (info *TestInfo) CallType() goa.InterceptorCallType { - return info.callType +// Method returns the method selected for this interceptor call. +func (info *testMethodInfo) Method() string { + return "Method" } -// RawPayload returns the raw payload of the request. -func (info *TestInfo) RawPayload() any { +// RawPayload returns the payload supplied for this interceptor call. +func (info *testMethodInfo) RawPayload() any { return info.rawPayload } -// Service returns the name of the service handling the request. -func (info *Test3Info) Service() string { - return info.service +// CallType reports that this is a server endpoint call. +func (info *testMethodServerUnaryInfo) CallType() goa.InterceptorCallType { + return goa.InterceptorUnary } -// Method returns the name of the method handling the request. -func (info *Test3Info) Method() string { - return info.method +// Service returns the service selected for this interceptor call. +func (info *test3MethodInfo) Service() string { + return "MultipleInterceptorsService" } -// CallType returns the type of call the interceptor is handling. -func (info *Test3Info) CallType() goa.InterceptorCallType { - return info.callType +// Method returns the method selected for this interceptor call. +func (info *test3MethodInfo) Method() string { + return "Method" } -// RawPayload returns the raw payload of the request. -func (info *Test3Info) RawPayload() any { +// RawPayload returns the payload supplied for this interceptor call. +func (info *test3MethodInfo) RawPayload() any { return info.rawPayload } + +// CallType reports that this is a server endpoint call. +func (info *test3MethodServerUnaryInfo) CallType() goa.InterceptorCallType { + return goa.InterceptorUnary +} diff --git a/codegen/service/testdata/interceptors/single-api-server-interceptor_interceptor_wrappers.go.golden b/codegen/service/testdata/interceptors/single-api-server-interceptor_interceptor_wrappers.go.golden index 498cc404ca..07a8714869 100644 --- a/codegen/service/testdata/interceptors/single-api-server-interceptor_interceptor_wrappers.go.golden +++ b/codegen/service/testdata/interceptors/single-api-server-interceptor_interceptor_wrappers.go.golden @@ -1,26 +1,20 @@ -// wrapLoggingMethod applies the logging server interceptor to endpoints. +// wrapMethodLogging applies the logging server interceptor to endpoints. func wrapMethodLogging(endpoint goa.Endpoint, i ServerInterceptors) goa.Endpoint { return func(ctx context.Context, req any) (any, error) { - info := &LoggingInfo{ - service: "SingleAPIServerInterceptor", - method: "Method", - callType: goa.InterceptorUnary, - rawPayload: req, + info := &loggingMethodServerUnaryInfo{ + loggingMethodInfo: loggingMethodInfo{rawPayload: req}, } return i.Logging(ctx, info, endpoint) } } -// wrapLoggingMethod2 applies the logging server interceptor to endpoints. +// wrapMethod2Logging applies the logging server interceptor to endpoints. func wrapMethod2Logging(endpoint goa.Endpoint, i ServerInterceptors) goa.Endpoint { return func(ctx context.Context, req any) (any, error) { - info := &LoggingInfo{ - service: "SingleAPIServerInterceptor", - method: "Method2", - callType: goa.InterceptorUnary, - rawPayload: req, + info := &loggingMethod2ServerUnaryInfo{ + loggingMethod2Info: loggingMethod2Info{rawPayload: req}, } return i.Logging(ctx, info, endpoint) } diff --git a/codegen/service/testdata/interceptors/single-api-server-interceptor_service_interceptors.go.golden b/codegen/service/testdata/interceptors/single-api-server-interceptor_service_interceptors.go.golden index 4e28b05e7a..73c74d5c04 100644 --- a/codegen/service/testdata/interceptors/single-api-server-interceptor_service_interceptors.go.golden +++ b/codegen/service/testdata/interceptors/single-api-server-interceptor_service_interceptors.go.golden @@ -3,26 +3,38 @@ // payload is sent to the service. The implementation is responsible for calling // next to complete the request. type ServerInterceptors interface { - Logging(ctx context.Context, info *LoggingInfo, next goa.Endpoint) (any, error) + Logging(ctx context.Context, info LoggingInfo, next goa.Endpoint) (any, error) } // Access interfaces for interceptor payloads and results type ( - // LoggingInfo provides metadata about the current interception. - // It includes service name, method name, and access to the endpoint. - LoggingInfo struct { - service string - method string - callType goa.InterceptorCallType + // LoggingInfo describes the service call currently passed to the interceptor. + LoggingInfo interface { + goa.InterceptorInfo + } +) + +// Types used to provide information about each service call +type ( + loggingMethodInfo struct { rawPayload any } + loggingMethodServerUnaryInfo struct { + loggingMethodInfo + } + loggingMethod2Info struct { + rawPayload any + } + loggingMethod2ServerUnaryInfo struct { + loggingMethod2Info + } ) // WrapMethodEndpoint wraps the Method endpoint with the server-side // interceptors defined in the design. func WrapMethodEndpoint(endpoint goa.Endpoint, i ServerInterceptors) goa.Endpoint { if i != nil { - endpoint = wrapMethodlogging(endpoint, i) + endpoint = wrapMethodLogging(endpoint, i) } return endpoint } @@ -31,29 +43,49 @@ func WrapMethodEndpoint(endpoint goa.Endpoint, i ServerInterceptors) goa.Endpoin // interceptors defined in the design. func WrapMethod2Endpoint(endpoint goa.Endpoint, i ServerInterceptors) goa.Endpoint { if i != nil { - endpoint = wrapMethod2logging(endpoint, i) + endpoint = wrapMethod2Logging(endpoint, i) } return endpoint } -// Public accessor methods for Info types +// Methods that provide information about each service call -// Service returns the name of the service handling the request. -func (info *LoggingInfo) Service() string { - return info.service +// Service returns the service selected for this interceptor call. +func (info *loggingMethodInfo) Service() string { + return "SingleAPIServerInterceptor" } -// Method returns the name of the method handling the request. -func (info *LoggingInfo) Method() string { - return info.method +// Method returns the method selected for this interceptor call. +func (info *loggingMethodInfo) Method() string { + return "Method" } -// CallType returns the type of call the interceptor is handling. -func (info *LoggingInfo) CallType() goa.InterceptorCallType { - return info.callType +// RawPayload returns the payload supplied for this interceptor call. +func (info *loggingMethodInfo) RawPayload() any { + return info.rawPayload } -// RawPayload returns the raw payload of the request. -func (info *LoggingInfo) RawPayload() any { +// CallType reports that this is a server endpoint call. +func (info *loggingMethodServerUnaryInfo) CallType() goa.InterceptorCallType { + return goa.InterceptorUnary +} + +// Service returns the service selected for this interceptor call. +func (info *loggingMethod2Info) Service() string { + return "SingleAPIServerInterceptor" +} + +// Method returns the method selected for this interceptor call. +func (info *loggingMethod2Info) Method() string { + return "Method2" +} + +// RawPayload returns the payload supplied for this interceptor call. +func (info *loggingMethod2Info) RawPayload() any { return info.rawPayload } + +// CallType reports that this is a server endpoint call. +func (info *loggingMethod2ServerUnaryInfo) CallType() goa.InterceptorCallType { + return goa.InterceptorUnary +} diff --git a/codegen/service/testdata/interceptors/single-client-interceptor_client_interceptors.go.golden b/codegen/service/testdata/interceptors/single-client-interceptor_client_interceptors.go.golden index 8bc74da506..2b1dea0e78 100644 --- a/codegen/service/testdata/interceptors/single-client-interceptor_client_interceptors.go.golden +++ b/codegen/service/testdata/interceptors/single-client-interceptor_client_interceptors.go.golden @@ -3,48 +3,54 @@ // is sent to the server. The implementation is responsible for calling next to // complete the request. type ClientInterceptors interface { - Tracing(ctx context.Context, info *TracingInfo, next goa.Endpoint) (any, error) + Tracing(ctx context.Context, info TracingInfo, next goa.Endpoint) (any, error) } // Access interfaces for interceptor payloads and results type ( - // TracingInfo provides metadata about the current interception. - // It includes service name, method name, and access to the endpoint. - TracingInfo struct { - service string - method string - callType goa.InterceptorCallType + // TracingInfo describes the service call currently passed to the interceptor. + TracingInfo interface { + goa.InterceptorInfo + } +) + +// Types used to provide information about each service call +type ( + tracingMethodInfo struct { rawPayload any } + tracingMethodClientUnaryInfo struct { + tracingMethodInfo + } ) // WrapMethodClientEndpoint wraps the Method endpoint with the client // interceptors defined in the design. func WrapMethodClientEndpoint(endpoint goa.Endpoint, i ClientInterceptors) goa.Endpoint { if i != nil { - endpoint = wrapClientMethodtracing(endpoint, i) + endpoint = wrapClientMethodTracing(endpoint, i) } return endpoint } -// Public accessor methods for Info types +// Methods that provide information about each service call -// Service returns the name of the service handling the request. -func (info *TracingInfo) Service() string { - return info.service +// Service returns the service selected for this interceptor call. +func (info *tracingMethodInfo) Service() string { + return "SingleClientInterceptor" } -// Method returns the name of the method handling the request. -func (info *TracingInfo) Method() string { - return info.method +// Method returns the method selected for this interceptor call. +func (info *tracingMethodInfo) Method() string { + return "Method" } -// CallType returns the type of call the interceptor is handling. -func (info *TracingInfo) CallType() goa.InterceptorCallType { - return info.callType +// RawPayload returns the payload supplied for this interceptor call. +func (info *tracingMethodInfo) RawPayload() any { + return info.rawPayload } -// RawPayload returns the raw payload of the request. -func (info *TracingInfo) RawPayload() any { - return info.rawPayload +// CallType reports that this is a client endpoint call. +func (info *tracingMethodClientUnaryInfo) CallType() goa.InterceptorCallType { + return goa.InterceptorUnary } diff --git a/codegen/service/testdata/interceptors/single-client-interceptor_interceptor_wrappers.go.golden b/codegen/service/testdata/interceptors/single-client-interceptor_interceptor_wrappers.go.golden index b72e20a2e2..f62e358d9e 100644 --- a/codegen/service/testdata/interceptors/single-client-interceptor_interceptor_wrappers.go.golden +++ b/codegen/service/testdata/interceptors/single-client-interceptor_interceptor_wrappers.go.golden @@ -1,13 +1,10 @@ -// wrapClientTracingMethod applies the tracing client interceptor to endpoints. +// wrapClientMethodTracing applies the tracing client interceptor to endpoints. func wrapClientMethodTracing(endpoint goa.Endpoint, i ClientInterceptors) goa.Endpoint { return func(ctx context.Context, req any) (any, error) { - info := &TracingInfo{ - service: "SingleClientInterceptor", - method: "Method", - callType: goa.InterceptorUnary, - rawPayload: req, + info := &tracingMethodClientUnaryInfo{ + tracingMethodInfo: tracingMethodInfo{rawPayload: req}, } return i.Tracing(ctx, info, endpoint) } diff --git a/codegen/service/testdata/interceptors/single-method-server-interceptor_interceptor_wrappers.go.golden b/codegen/service/testdata/interceptors/single-method-server-interceptor_interceptor_wrappers.go.golden index 58c5f96d45..1d305437ed 100644 --- a/codegen/service/testdata/interceptors/single-method-server-interceptor_interceptor_wrappers.go.golden +++ b/codegen/service/testdata/interceptors/single-method-server-interceptor_interceptor_wrappers.go.golden @@ -1,13 +1,10 @@ -// wrapLoggingMethod applies the logging server interceptor to endpoints. +// wrapMethodLogging applies the logging server interceptor to endpoints. func wrapMethodLogging(endpoint goa.Endpoint, i ServerInterceptors) goa.Endpoint { return func(ctx context.Context, req any) (any, error) { - info := &LoggingInfo{ - service: "SingleMethodServerInterceptor", - method: "Method", - callType: goa.InterceptorUnary, - rawPayload: req, + info := &loggingMethodServerUnaryInfo{ + loggingMethodInfo: loggingMethodInfo{rawPayload: req}, } return i.Logging(ctx, info, endpoint) } diff --git a/codegen/service/testdata/interceptors/single-method-server-interceptor_service_interceptors.go.golden b/codegen/service/testdata/interceptors/single-method-server-interceptor_service_interceptors.go.golden index 73b5303882..f11ad7bb6c 100644 --- a/codegen/service/testdata/interceptors/single-method-server-interceptor_service_interceptors.go.golden +++ b/codegen/service/testdata/interceptors/single-method-server-interceptor_service_interceptors.go.golden @@ -3,48 +3,54 @@ // payload is sent to the service. The implementation is responsible for calling // next to complete the request. type ServerInterceptors interface { - Logging(ctx context.Context, info *LoggingInfo, next goa.Endpoint) (any, error) + Logging(ctx context.Context, info LoggingInfo, next goa.Endpoint) (any, error) } // Access interfaces for interceptor payloads and results type ( - // LoggingInfo provides metadata about the current interception. - // It includes service name, method name, and access to the endpoint. - LoggingInfo struct { - service string - method string - callType goa.InterceptorCallType + // LoggingInfo describes the service call currently passed to the interceptor. + LoggingInfo interface { + goa.InterceptorInfo + } +) + +// Types used to provide information about each service call +type ( + loggingMethodInfo struct { rawPayload any } + loggingMethodServerUnaryInfo struct { + loggingMethodInfo + } ) // WrapMethodEndpoint wraps the Method endpoint with the server-side // interceptors defined in the design. func WrapMethodEndpoint(endpoint goa.Endpoint, i ServerInterceptors) goa.Endpoint { if i != nil { - endpoint = wrapMethodlogging(endpoint, i) + endpoint = wrapMethodLogging(endpoint, i) } return endpoint } -// Public accessor methods for Info types +// Methods that provide information about each service call -// Service returns the name of the service handling the request. -func (info *LoggingInfo) Service() string { - return info.service +// Service returns the service selected for this interceptor call. +func (info *loggingMethodInfo) Service() string { + return "SingleMethodServerInterceptor" } -// Method returns the name of the method handling the request. -func (info *LoggingInfo) Method() string { - return info.method +// Method returns the method selected for this interceptor call. +func (info *loggingMethodInfo) Method() string { + return "Method" } -// CallType returns the type of call the interceptor is handling. -func (info *LoggingInfo) CallType() goa.InterceptorCallType { - return info.callType +// RawPayload returns the payload supplied for this interceptor call. +func (info *loggingMethodInfo) RawPayload() any { + return info.rawPayload } -// RawPayload returns the raw payload of the request. -func (info *LoggingInfo) RawPayload() any { - return info.rawPayload +// CallType reports that this is a server endpoint call. +func (info *loggingMethodServerUnaryInfo) CallType() goa.InterceptorCallType { + return goa.InterceptorUnary } diff --git a/codegen/service/testdata/interceptors/single-service-server-interceptor_interceptor_wrappers.go.golden b/codegen/service/testdata/interceptors/single-service-server-interceptor_interceptor_wrappers.go.golden index a73b41c30c..07a8714869 100644 --- a/codegen/service/testdata/interceptors/single-service-server-interceptor_interceptor_wrappers.go.golden +++ b/codegen/service/testdata/interceptors/single-service-server-interceptor_interceptor_wrappers.go.golden @@ -1,26 +1,20 @@ -// wrapLoggingMethod applies the logging server interceptor to endpoints. +// wrapMethodLogging applies the logging server interceptor to endpoints. func wrapMethodLogging(endpoint goa.Endpoint, i ServerInterceptors) goa.Endpoint { return func(ctx context.Context, req any) (any, error) { - info := &LoggingInfo{ - service: "SingleServerInterceptor", - method: "Method", - callType: goa.InterceptorUnary, - rawPayload: req, + info := &loggingMethodServerUnaryInfo{ + loggingMethodInfo: loggingMethodInfo{rawPayload: req}, } return i.Logging(ctx, info, endpoint) } } -// wrapLoggingMethod2 applies the logging server interceptor to endpoints. +// wrapMethod2Logging applies the logging server interceptor to endpoints. func wrapMethod2Logging(endpoint goa.Endpoint, i ServerInterceptors) goa.Endpoint { return func(ctx context.Context, req any) (any, error) { - info := &LoggingInfo{ - service: "SingleServerInterceptor", - method: "Method2", - callType: goa.InterceptorUnary, - rawPayload: req, + info := &loggingMethod2ServerUnaryInfo{ + loggingMethod2Info: loggingMethod2Info{rawPayload: req}, } return i.Logging(ctx, info, endpoint) } diff --git a/codegen/service/testdata/interceptors/single-service-server-interceptor_service_interceptors.go.golden b/codegen/service/testdata/interceptors/single-service-server-interceptor_service_interceptors.go.golden index 4e28b05e7a..4c1cdfd2cf 100644 --- a/codegen/service/testdata/interceptors/single-service-server-interceptor_service_interceptors.go.golden +++ b/codegen/service/testdata/interceptors/single-service-server-interceptor_service_interceptors.go.golden @@ -3,26 +3,38 @@ // payload is sent to the service. The implementation is responsible for calling // next to complete the request. type ServerInterceptors interface { - Logging(ctx context.Context, info *LoggingInfo, next goa.Endpoint) (any, error) + Logging(ctx context.Context, info LoggingInfo, next goa.Endpoint) (any, error) } // Access interfaces for interceptor payloads and results type ( - // LoggingInfo provides metadata about the current interception. - // It includes service name, method name, and access to the endpoint. - LoggingInfo struct { - service string - method string - callType goa.InterceptorCallType + // LoggingInfo describes the service call currently passed to the interceptor. + LoggingInfo interface { + goa.InterceptorInfo + } +) + +// Types used to provide information about each service call +type ( + loggingMethodInfo struct { rawPayload any } + loggingMethodServerUnaryInfo struct { + loggingMethodInfo + } + loggingMethod2Info struct { + rawPayload any + } + loggingMethod2ServerUnaryInfo struct { + loggingMethod2Info + } ) // WrapMethodEndpoint wraps the Method endpoint with the server-side // interceptors defined in the design. func WrapMethodEndpoint(endpoint goa.Endpoint, i ServerInterceptors) goa.Endpoint { if i != nil { - endpoint = wrapMethodlogging(endpoint, i) + endpoint = wrapMethodLogging(endpoint, i) } return endpoint } @@ -31,29 +43,49 @@ func WrapMethodEndpoint(endpoint goa.Endpoint, i ServerInterceptors) goa.Endpoin // interceptors defined in the design. func WrapMethod2Endpoint(endpoint goa.Endpoint, i ServerInterceptors) goa.Endpoint { if i != nil { - endpoint = wrapMethod2logging(endpoint, i) + endpoint = wrapMethod2Logging(endpoint, i) } return endpoint } -// Public accessor methods for Info types +// Methods that provide information about each service call -// Service returns the name of the service handling the request. -func (info *LoggingInfo) Service() string { - return info.service +// Service returns the service selected for this interceptor call. +func (info *loggingMethodInfo) Service() string { + return "SingleServerInterceptor" } -// Method returns the name of the method handling the request. -func (info *LoggingInfo) Method() string { - return info.method +// Method returns the method selected for this interceptor call. +func (info *loggingMethodInfo) Method() string { + return "Method" } -// CallType returns the type of call the interceptor is handling. -func (info *LoggingInfo) CallType() goa.InterceptorCallType { - return info.callType +// RawPayload returns the payload supplied for this interceptor call. +func (info *loggingMethodInfo) RawPayload() any { + return info.rawPayload } -// RawPayload returns the raw payload of the request. -func (info *LoggingInfo) RawPayload() any { +// CallType reports that this is a server endpoint call. +func (info *loggingMethodServerUnaryInfo) CallType() goa.InterceptorCallType { + return goa.InterceptorUnary +} + +// Service returns the service selected for this interceptor call. +func (info *loggingMethod2Info) Service() string { + return "SingleServerInterceptor" +} + +// Method returns the method selected for this interceptor call. +func (info *loggingMethod2Info) Method() string { + return "Method2" +} + +// RawPayload returns the payload supplied for this interceptor call. +func (info *loggingMethod2Info) RawPayload() any { return info.rawPayload } + +// CallType reports that this is a server endpoint call. +func (info *loggingMethod2ServerUnaryInfo) CallType() goa.InterceptorCallType { + return goa.InterceptorUnary +} diff --git a/codegen/service/testdata/interceptors/streaming-interceptors-with-read-payload-and-read-streaming-payload_client_interceptors.go.golden b/codegen/service/testdata/interceptors/streaming-interceptors-with-read-payload-and-read-streaming-payload_client_interceptors.go.golden index 46dd4655d2..7f47a35c66 100644 --- a/codegen/service/testdata/interceptors/streaming-interceptors-with-read-payload-and-read-streaming-payload_client_interceptors.go.golden +++ b/codegen/service/testdata/interceptors/streaming-interceptors-with-read-payload-and-read-streaming-payload_client_interceptors.go.golden @@ -3,14 +3,14 @@ // is sent to the server. The implementation is responsible for calling next to // complete the request. type ClientInterceptors interface { - Logging(ctx context.Context, info *LoggingInfo, next goa.Endpoint) (any, error) + Logging(ctx context.Context, info LoggingInfo, next goa.Endpoint) (any, error) } // WrapMethodClientEndpoint wraps the Method endpoint with the client // interceptors defined in the design. func WrapMethodClientEndpoint(endpoint goa.Endpoint, i ClientInterceptors) goa.Endpoint { if i != nil { - endpoint = wrapClientMethodlogging(endpoint, i) + endpoint = wrapClientMethodLogging(endpoint, i) } return endpoint } diff --git a/codegen/service/testdata/interceptors/streaming-interceptors-with-read-payload-and-read-streaming-payload_interceptor_wrappers.go.golden b/codegen/service/testdata/interceptors/streaming-interceptors-with-read-payload-and-read-streaming-payload_interceptor_wrappers.go.golden index fe7974931a..616be789c7 100644 --- a/codegen/service/testdata/interceptors/streaming-interceptors-with-read-payload-and-read-streaming-payload_interceptor_wrappers.go.golden +++ b/codegen/service/testdata/interceptors/streaming-interceptors-with-read-payload-and-read-streaming-payload_interceptor_wrappers.go.golden @@ -16,17 +16,15 @@ type wrappedMethodClientStream struct { stream MethodClientStream } -// wrapLoggingMethod applies the logging server interceptor to endpoints. +// wrapMethodLogging applies the logging server interceptor to endpoints. func wrapMethodLogging(endpoint goa.Endpoint, i ServerInterceptors) goa.Endpoint { return func(ctx context.Context, req any) (any, error) { stream := req.(*MethodEndpointInput).Stream req.(*MethodEndpointInput).Stream = &wrappedMethodServerStream{ ctx: ctx, recvWithContext: func(ctx context.Context) (*MethodStreamingPayload, error) { - info := &LoggingInfo{ - service: "StreamingInterceptorsWithReadPayloadAndReadStreamingPayload", - method: "Method", - callType: goa.InterceptorStreamingRecv, + info := &loggingMethodStreamingRecvInfo{ + loggingMethodInfo: loggingMethodInfo{}, } res, err := i.Logging(ctx, info, func(ctx context.Context, _ any) (any, error) { return stream.RecvWithContext(ctx) @@ -36,24 +34,18 @@ func wrapMethodLogging(endpoint goa.Endpoint, i ServerInterceptors) goa.Endpoint }, stream: stream, } - info := &LoggingInfo{ - service: "StreamingInterceptorsWithReadPayloadAndReadStreamingPayload", - method: "Method", - callType: goa.InterceptorUnary, - rawPayload: req, + info := &loggingMethodServerUnaryInfo{ + loggingMethodInfo: loggingMethodInfo{rawPayload: req}, } return i.Logging(ctx, info, endpoint) } } -// wrapClientLoggingMethod applies the logging client interceptor to endpoints. +// wrapClientMethodLogging applies the logging client interceptor to endpoints. func wrapClientMethodLogging(endpoint goa.Endpoint, i ClientInterceptors) goa.Endpoint { return func(ctx context.Context, req any) (any, error) { - info := &LoggingInfo{ - service: "StreamingInterceptorsWithReadPayloadAndReadStreamingPayload", - method: "Method", - callType: goa.InterceptorUnary, - rawPayload: req, + info := &loggingMethodClientUnaryInfo{ + loggingMethodInfo: loggingMethodInfo{rawPayload: req}, } res, err := i.Logging(ctx, info, endpoint) if err != nil { @@ -63,11 +55,8 @@ func wrapClientMethodLogging(endpoint goa.Endpoint, i ClientInterceptors) goa.En return &wrappedMethodClientStream{ ctx: ctx, sendWithContext: func(ctx context.Context, req *MethodStreamingPayload) error { - info := &LoggingInfo{ - service: "StreamingInterceptorsWithReadPayloadAndReadStreamingPayload", - method: "Method", - callType: goa.InterceptorStreamingSend, - rawPayload: req, + info := &loggingMethodStreamingSendInfo{ + loggingMethodInfo: loggingMethodInfo{rawPayload: req}, } _, err := i.Logging(ctx, info, func(ctx context.Context, req any) (any, error) { castReq, _ := req.(*MethodStreamingPayload) diff --git a/codegen/service/testdata/interceptors/streaming-interceptors-with-read-payload-and-read-streaming-payload_service_interceptors.go.golden b/codegen/service/testdata/interceptors/streaming-interceptors-with-read-payload-and-read-streaming-payload_service_interceptors.go.golden index 47a6372ee4..9dcee8c3a5 100644 --- a/codegen/service/testdata/interceptors/streaming-interceptors-with-read-payload-and-read-streaming-payload_service_interceptors.go.golden +++ b/codegen/service/testdata/interceptors/streaming-interceptors-with-read-payload-and-read-streaming-payload_service_interceptors.go.golden @@ -3,18 +3,20 @@ // payload is sent to the service. The implementation is responsible for calling // next to complete the request. type ServerInterceptors interface { - Logging(ctx context.Context, info *LoggingInfo, next goa.Endpoint) (any, error) + Logging(ctx context.Context, info LoggingInfo, next goa.Endpoint) (any, error) } // Access interfaces for interceptor payloads and results type ( - // LoggingInfo provides metadata about the current interception. - // It includes service name, method name, and access to the endpoint. - LoggingInfo struct { - service string - method string - callType goa.InterceptorCallType - rawPayload any + // LoggingInfo describes the service call currently passed to the interceptor. + LoggingInfo interface { + goa.InterceptorInfo + // Payload returns the selected fields from the method payload. + Payload() LoggingPayload + // ClientStreamingPayload returns selected fields from the outgoing stream payload. + ClientStreamingPayload() LoggingStreamingPayload + // ServerStreamingPayload returns selected fields from the incoming stream payload. + ServerStreamingPayload(any) LoggingStreamingPayload } // LoggingPayload provides type-safe access to the method payload. @@ -32,8 +34,23 @@ type ( } ) -// Private implementation types +// Types used to provide information about each service call type ( + loggingMethodInfo struct { + rawPayload any + } + loggingMethodServerUnaryInfo struct { + loggingMethodInfo + } + loggingMethodClientUnaryInfo struct { + loggingMethodInfo + } + loggingMethodStreamingSendInfo struct { + loggingMethodInfo + } + loggingMethodStreamingRecvInfo struct { + loggingMethodInfo + } loggingMethodPayload struct { payload *MethodPayload } @@ -46,54 +63,69 @@ type ( // interceptors defined in the design. func WrapMethodEndpoint(endpoint goa.Endpoint, i ServerInterceptors) goa.Endpoint { if i != nil { - endpoint = wrapMethodlogging(endpoint, i) + endpoint = wrapMethodLogging(endpoint, i) } return endpoint } -// Public accessor methods for Info types +// Methods that provide information about each service call -// Service returns the name of the service handling the request. -func (info *LoggingInfo) Service() string { - return info.service +// Service returns the service selected for this interceptor call. +func (info *loggingMethodInfo) Service() string { + return "StreamingInterceptorsWithReadPayloadAndReadStreamingPayload" } -// Method returns the name of the method handling the request. -func (info *LoggingInfo) Method() string { - return info.method +// Method returns the method selected for this interceptor call. +func (info *loggingMethodInfo) Method() string { + return "Method" } -// CallType returns the type of call the interceptor is handling. -func (info *LoggingInfo) CallType() goa.InterceptorCallType { - return info.callType +// RawPayload returns the payload supplied for this interceptor call. +func (info *loggingMethodInfo) RawPayload() any { + return info.rawPayload } -// RawPayload returns the raw payload of the request. -func (info *LoggingInfo) RawPayload() any { - return info.rawPayload +// CallType reports that this is a server endpoint call. +func (info *loggingMethodServerUnaryInfo) CallType() goa.InterceptorCallType { + return goa.InterceptorUnary } -// Payload returns a type-safe accessor for the method payload. -func (info *LoggingInfo) Payload() LoggingPayload { - switch pay := info.RawPayload().(type) { - case *MethodEndpointInput: - return &loggingMethodPayload{payload: pay.Payload} - default: - return &loggingMethodPayload{payload: pay.(*MethodPayload)} - } +// CallType reports that this is a client endpoint call. +func (info *loggingMethodClientUnaryInfo) CallType() goa.InterceptorCallType { + return goa.InterceptorUnary +} + +// CallType reports that this is a stream send. +func (info *loggingMethodStreamingSendInfo) CallType() goa.InterceptorCallType { + return goa.InterceptorStreamingSend +} + +// CallType reports that this is a stream receive. +func (info *loggingMethodStreamingRecvInfo) CallType() goa.InterceptorCallType { + return goa.InterceptorStreamingRecv +} + +// Payload returns this method's payload fields. +func (info *loggingMethodInfo) Payload() LoggingPayload { + return &loggingMethodPayload{payload: info.rawPayload.(*MethodPayload)} +} + +// Payload returns this server method's payload fields. +func (info *loggingMethodServerUnaryInfo) Payload() LoggingPayload { + return &loggingMethodPayload{payload: info.rawPayload.(*MethodEndpointInput).Payload} } -// ClientStreamingPayload returns a type-safe accessor for the method streaming payload for a client-side interceptor. -func (info *LoggingInfo) ClientStreamingPayload() LoggingStreamingPayload { - return &loggingMethodStreamingPayload{payload: info.RawPayload().(*MethodStreamingPayload)} +// ClientStreamingPayload returns this method's outgoing streaming payload fields. +func (info *loggingMethodInfo) ClientStreamingPayload() LoggingStreamingPayload { + return &loggingMethodStreamingPayload{payload: info.rawPayload.(*MethodStreamingPayload)} } -// ServerStreamingPayload returns a type-safe accessor for the method streaming payload for a server-side interceptor. -func (info *LoggingInfo) ServerStreamingPayload(pay any) LoggingStreamingPayload { - return &loggingMethodStreamingPayload{payload: pay.(*MethodStreamingPayload)} +// ServerStreamingPayload returns this method's incoming streaming payload fields. +func (info *loggingMethodInfo) ServerStreamingPayload(payload any) LoggingStreamingPayload { + return &loggingMethodStreamingPayload{payload: payload.(*MethodStreamingPayload)} } -// Private implementation methods +// Methods that read and write the selected payload and result fields func (p *loggingMethodPayload) Chunk() string { if p.payload.Chunk == nil { diff --git a/codegen/service/testdata/interceptors/streaming-interceptors-with-read-payload_interceptor_wrappers.go.golden b/codegen/service/testdata/interceptors/streaming-interceptors-with-read-payload_interceptor_wrappers.go.golden index b4f94826cd..1d305437ed 100644 --- a/codegen/service/testdata/interceptors/streaming-interceptors-with-read-payload_interceptor_wrappers.go.golden +++ b/codegen/service/testdata/interceptors/streaming-interceptors-with-read-payload_interceptor_wrappers.go.golden @@ -1,13 +1,10 @@ -// wrapLoggingMethod applies the logging server interceptor to endpoints. +// wrapMethodLogging applies the logging server interceptor to endpoints. func wrapMethodLogging(endpoint goa.Endpoint, i ServerInterceptors) goa.Endpoint { return func(ctx context.Context, req any) (any, error) { - info := &LoggingInfo{ - service: "StreamingInterceptorsWithReadPayload", - method: "Method", - callType: goa.InterceptorUnary, - rawPayload: req, + info := &loggingMethodServerUnaryInfo{ + loggingMethodInfo: loggingMethodInfo{rawPayload: req}, } return i.Logging(ctx, info, endpoint) } diff --git a/codegen/service/testdata/interceptors/streaming-interceptors-with-read-payload_service_interceptors.go.golden b/codegen/service/testdata/interceptors/streaming-interceptors-with-read-payload_service_interceptors.go.golden index 031813f303..70211c91ce 100644 --- a/codegen/service/testdata/interceptors/streaming-interceptors-with-read-payload_service_interceptors.go.golden +++ b/codegen/service/testdata/interceptors/streaming-interceptors-with-read-payload_service_interceptors.go.golden @@ -3,18 +3,16 @@ // payload is sent to the service. The implementation is responsible for calling // next to complete the request. type ServerInterceptors interface { - Logging(ctx context.Context, info *LoggingInfo, next goa.Endpoint) (any, error) + Logging(ctx context.Context, info LoggingInfo, next goa.Endpoint) (any, error) } // Access interfaces for interceptor payloads and results type ( - // LoggingInfo provides metadata about the current interception. - // It includes service name, method name, and access to the endpoint. - LoggingInfo struct { - service string - method string - callType goa.InterceptorCallType - rawPayload any + // LoggingInfo describes the service call currently passed to the interceptor. + LoggingInfo interface { + goa.InterceptorInfo + // Payload returns the selected fields from the method payload. + Payload() LoggingPayload } // LoggingPayload provides type-safe access to the method payload. @@ -25,8 +23,14 @@ type ( } ) -// Private implementation types +// Types used to provide information about each service call type ( + loggingMethodInfo struct { + rawPayload any + } + loggingMethodServerUnaryInfo struct { + loggingMethodInfo + } loggingMethodPayload struct { payload *MethodPayload } @@ -36,44 +40,44 @@ type ( // interceptors defined in the design. func WrapMethodEndpoint(endpoint goa.Endpoint, i ServerInterceptors) goa.Endpoint { if i != nil { - endpoint = wrapMethodlogging(endpoint, i) + endpoint = wrapMethodLogging(endpoint, i) } return endpoint } -// Public accessor methods for Info types +// Methods that provide information about each service call -// Service returns the name of the service handling the request. -func (info *LoggingInfo) Service() string { - return info.service +// Service returns the service selected for this interceptor call. +func (info *loggingMethodInfo) Service() string { + return "StreamingInterceptorsWithReadPayload" } -// Method returns the name of the method handling the request. -func (info *LoggingInfo) Method() string { - return info.method +// Method returns the method selected for this interceptor call. +func (info *loggingMethodInfo) Method() string { + return "Method" } -// CallType returns the type of call the interceptor is handling. -func (info *LoggingInfo) CallType() goa.InterceptorCallType { - return info.callType +// RawPayload returns the payload supplied for this interceptor call. +func (info *loggingMethodInfo) RawPayload() any { + return info.rawPayload } -// RawPayload returns the raw payload of the request. -func (info *LoggingInfo) RawPayload() any { - return info.rawPayload +// CallType reports that this is a server endpoint call. +func (info *loggingMethodServerUnaryInfo) CallType() goa.InterceptorCallType { + return goa.InterceptorUnary } -// Payload returns a type-safe accessor for the method payload. -func (info *LoggingInfo) Payload() LoggingPayload { - switch pay := info.RawPayload().(type) { - case *MethodEndpointInput: - return &loggingMethodPayload{payload: pay.Payload} - default: - return &loggingMethodPayload{payload: pay.(*MethodPayload)} - } +// Payload returns this method's payload fields. +func (info *loggingMethodInfo) Payload() LoggingPayload { + return &loggingMethodPayload{payload: info.rawPayload.(*MethodPayload)} +} + +// Payload returns this server method's payload fields. +func (info *loggingMethodServerUnaryInfo) Payload() LoggingPayload { + return &loggingMethodPayload{payload: info.rawPayload.(*MethodEndpointInput).Payload} } -// Private implementation methods +// Methods that read and write the selected payload and result fields func (p *loggingMethodPayload) Initial() string { if p.payload.Initial == nil { diff --git a/codegen/service/testdata/interceptors/streaming-interceptors-with-read-result_interceptor_wrappers.go.golden b/codegen/service/testdata/interceptors/streaming-interceptors-with-read-result_interceptor_wrappers.go.golden index b6e8f62cc2..1d305437ed 100644 --- a/codegen/service/testdata/interceptors/streaming-interceptors-with-read-result_interceptor_wrappers.go.golden +++ b/codegen/service/testdata/interceptors/streaming-interceptors-with-read-result_interceptor_wrappers.go.golden @@ -1,13 +1,10 @@ -// wrapLoggingMethod applies the logging server interceptor to endpoints. +// wrapMethodLogging applies the logging server interceptor to endpoints. func wrapMethodLogging(endpoint goa.Endpoint, i ServerInterceptors) goa.Endpoint { return func(ctx context.Context, req any) (any, error) { - info := &LoggingInfo{ - service: "StreamingInterceptorsWithReadResult", - method: "Method", - callType: goa.InterceptorUnary, - rawPayload: req, + info := &loggingMethodServerUnaryInfo{ + loggingMethodInfo: loggingMethodInfo{rawPayload: req}, } return i.Logging(ctx, info, endpoint) } diff --git a/codegen/service/testdata/interceptors/streaming-interceptors-with-read-result_service_interceptors.go.golden b/codegen/service/testdata/interceptors/streaming-interceptors-with-read-result_service_interceptors.go.golden index e1b86b82ab..489fbf0a77 100644 --- a/codegen/service/testdata/interceptors/streaming-interceptors-with-read-result_service_interceptors.go.golden +++ b/codegen/service/testdata/interceptors/streaming-interceptors-with-read-result_service_interceptors.go.golden @@ -3,18 +3,16 @@ // payload is sent to the service. The implementation is responsible for calling // next to complete the request. type ServerInterceptors interface { - Logging(ctx context.Context, info *LoggingInfo, next goa.Endpoint) (any, error) + Logging(ctx context.Context, info LoggingInfo, next goa.Endpoint) (any, error) } // Access interfaces for interceptor payloads and results type ( - // LoggingInfo provides metadata about the current interception. - // It includes service name, method name, and access to the endpoint. - LoggingInfo struct { - service string - method string - callType goa.InterceptorCallType - rawPayload any + // LoggingInfo describes the service call currently passed to the interceptor. + LoggingInfo interface { + goa.InterceptorInfo + // Result returns the selected fields from the method result. + Result(any) LoggingResult } // LoggingResult provides type-safe access to the method result. @@ -25,8 +23,14 @@ type ( } ) -// Private implementation types +// Types used to provide information about each service call type ( + loggingMethodInfo struct { + rawPayload any + } + loggingMethodServerUnaryInfo struct { + loggingMethodInfo + } loggingMethodResult struct { result *MethodResult } @@ -36,39 +40,39 @@ type ( // interceptors defined in the design. func WrapMethodEndpoint(endpoint goa.Endpoint, i ServerInterceptors) goa.Endpoint { if i != nil { - endpoint = wrapMethodlogging(endpoint, i) + endpoint = wrapMethodLogging(endpoint, i) } return endpoint } -// Public accessor methods for Info types +// Methods that provide information about each service call -// Service returns the name of the service handling the request. -func (info *LoggingInfo) Service() string { - return info.service +// Service returns the service selected for this interceptor call. +func (info *loggingMethodInfo) Service() string { + return "StreamingInterceptorsWithReadResult" } -// Method returns the name of the method handling the request. -func (info *LoggingInfo) Method() string { - return info.method +// Method returns the method selected for this interceptor call. +func (info *loggingMethodInfo) Method() string { + return "Method" } -// CallType returns the type of call the interceptor is handling. -func (info *LoggingInfo) CallType() goa.InterceptorCallType { - return info.callType +// RawPayload returns the payload supplied for this interceptor call. +func (info *loggingMethodInfo) RawPayload() any { + return info.rawPayload } -// RawPayload returns the raw payload of the request. -func (info *LoggingInfo) RawPayload() any { - return info.rawPayload +// CallType reports that this is a server endpoint call. +func (info *loggingMethodServerUnaryInfo) CallType() goa.InterceptorCallType { + return goa.InterceptorUnary } -// Result returns a type-safe accessor for the method result. -func (info *LoggingInfo) Result(res any) LoggingResult { +// Result returns this method's result fields. +func (info *loggingMethodInfo) Result(res any) LoggingResult { return &loggingMethodResult{result: res.(*MethodResult)} } -// Private implementation methods +// Methods that read and write the selected payload and result fields func (r *loggingMethodResult) Data() string { if r.result.Data == nil { diff --git a/codegen/service/testdata/interceptors/streaming-interceptors-with-read-streaming-result_client_interceptors.go.golden b/codegen/service/testdata/interceptors/streaming-interceptors-with-read-streaming-result_client_interceptors.go.golden index 46dd4655d2..7f47a35c66 100644 --- a/codegen/service/testdata/interceptors/streaming-interceptors-with-read-streaming-result_client_interceptors.go.golden +++ b/codegen/service/testdata/interceptors/streaming-interceptors-with-read-streaming-result_client_interceptors.go.golden @@ -3,14 +3,14 @@ // is sent to the server. The implementation is responsible for calling next to // complete the request. type ClientInterceptors interface { - Logging(ctx context.Context, info *LoggingInfo, next goa.Endpoint) (any, error) + Logging(ctx context.Context, info LoggingInfo, next goa.Endpoint) (any, error) } // WrapMethodClientEndpoint wraps the Method endpoint with the client // interceptors defined in the design. func WrapMethodClientEndpoint(endpoint goa.Endpoint, i ClientInterceptors) goa.Endpoint { if i != nil { - endpoint = wrapClientMethodlogging(endpoint, i) + endpoint = wrapClientMethodLogging(endpoint, i) } return endpoint } diff --git a/codegen/service/testdata/interceptors/streaming-interceptors-with-read-streaming-result_interceptor_wrappers.go.golden b/codegen/service/testdata/interceptors/streaming-interceptors-with-read-streaming-result_interceptor_wrappers.go.golden index ecb540af91..385cf93712 100644 --- a/codegen/service/testdata/interceptors/streaming-interceptors-with-read-streaming-result_interceptor_wrappers.go.golden +++ b/codegen/service/testdata/interceptors/streaming-interceptors-with-read-streaming-result_interceptor_wrappers.go.golden @@ -16,18 +16,15 @@ type wrappedMethodClientStream struct { stream MethodClientStream } -// wrapLoggingMethod applies the logging server interceptor to endpoints. +// wrapMethodLogging applies the logging server interceptor to endpoints. func wrapMethodLogging(endpoint goa.Endpoint, i ServerInterceptors) goa.Endpoint { return func(ctx context.Context, req any) (any, error) { stream := req.(*MethodEndpointInput).Stream req.(*MethodEndpointInput).Stream = &wrappedMethodServerStream{ ctx: ctx, sendWithContext: func(ctx context.Context, req *MethodResult) error { - info := &LoggingInfo{ - service: "StreamingInterceptorsWithReadStreamingResult", - method: "Method", - callType: goa.InterceptorStreamingSend, - rawPayload: req, + info := &loggingMethodStreamingSendInfo{ + loggingMethodInfo: loggingMethodInfo{rawPayload: req}, } _, err := i.Logging(ctx, info, func(ctx context.Context, req any) (any, error) { castReq, _ := req.(*MethodResult) @@ -41,7 +38,7 @@ func wrapMethodLogging(endpoint goa.Endpoint, i ServerInterceptors) goa.Endpoint } } -// wrapClientLoggingMethod applies the logging client interceptor to endpoints. +// wrapClientMethodLogging applies the logging client interceptor to endpoints. func wrapClientMethodLogging(endpoint goa.Endpoint, i ClientInterceptors) goa.Endpoint { return func(ctx context.Context, req any) (any, error) { res, err := endpoint(ctx, req) @@ -52,10 +49,8 @@ func wrapClientMethodLogging(endpoint goa.Endpoint, i ClientInterceptors) goa.En return &wrappedMethodClientStream{ ctx: ctx, recvWithContext: func(ctx context.Context) (*MethodResult, error) { - info := &LoggingInfo{ - service: "StreamingInterceptorsWithReadStreamingResult", - method: "Method", - callType: goa.InterceptorStreamingRecv, + info := &loggingMethodStreamingRecvInfo{ + loggingMethodInfo: loggingMethodInfo{}, } res, err := i.Logging(ctx, info, func(ctx context.Context, _ any) (any, error) { return stream.RecvWithContext(ctx) diff --git a/codegen/service/testdata/interceptors/streaming-interceptors-with-read-streaming-result_service_interceptors.go.golden b/codegen/service/testdata/interceptors/streaming-interceptors-with-read-streaming-result_service_interceptors.go.golden index af6d942fef..4b3dc469a5 100644 --- a/codegen/service/testdata/interceptors/streaming-interceptors-with-read-streaming-result_service_interceptors.go.golden +++ b/codegen/service/testdata/interceptors/streaming-interceptors-with-read-streaming-result_service_interceptors.go.golden @@ -3,18 +3,18 @@ // payload is sent to the service. The implementation is responsible for calling // next to complete the request. type ServerInterceptors interface { - Logging(ctx context.Context, info *LoggingInfo, next goa.Endpoint) (any, error) + Logging(ctx context.Context, info LoggingInfo, next goa.Endpoint) (any, error) } // Access interfaces for interceptor payloads and results type ( - // LoggingInfo provides metadata about the current interception. - // It includes service name, method name, and access to the endpoint. - LoggingInfo struct { - service string - method string - callType goa.InterceptorCallType - rawPayload any + // LoggingInfo describes the service call currently passed to the interceptor. + LoggingInfo interface { + goa.InterceptorInfo + // ClientStreamingResult returns selected fields from the incoming stream result. + ClientStreamingResult(any) LoggingStreamingResult + // ServerStreamingResult returns selected fields from the outgoing stream result. + ServerStreamingResult() LoggingStreamingResult } // LoggingStreamingResult provides type-safe access to the method streaming result. @@ -25,8 +25,17 @@ type ( } ) -// Private implementation types +// Types used to provide information about each service call type ( + loggingMethodInfo struct { + rawPayload any + } + loggingMethodStreamingSendInfo struct { + loggingMethodInfo + } + loggingMethodStreamingRecvInfo struct { + loggingMethodInfo + } loggingMethodStreamingResult struct { result *MethodResult } @@ -36,44 +45,49 @@ type ( // interceptors defined in the design. func WrapMethodEndpoint(endpoint goa.Endpoint, i ServerInterceptors) goa.Endpoint { if i != nil { - endpoint = wrapMethodlogging(endpoint, i) + endpoint = wrapMethodLogging(endpoint, i) } return endpoint } -// Public accessor methods for Info types +// Methods that provide information about each service call -// Service returns the name of the service handling the request. -func (info *LoggingInfo) Service() string { - return info.service +// Service returns the service selected for this interceptor call. +func (info *loggingMethodInfo) Service() string { + return "StreamingInterceptorsWithReadStreamingResult" } -// Method returns the name of the method handling the request. -func (info *LoggingInfo) Method() string { - return info.method +// Method returns the method selected for this interceptor call. +func (info *loggingMethodInfo) Method() string { + return "Method" } -// CallType returns the type of call the interceptor is handling. -func (info *LoggingInfo) CallType() goa.InterceptorCallType { - return info.callType +// RawPayload returns the payload supplied for this interceptor call. +func (info *loggingMethodInfo) RawPayload() any { + return info.rawPayload } -// RawPayload returns the raw payload of the request. -func (info *LoggingInfo) RawPayload() any { - return info.rawPayload +// CallType reports that this is a stream send. +func (info *loggingMethodStreamingSendInfo) CallType() goa.InterceptorCallType { + return goa.InterceptorStreamingSend +} + +// CallType reports that this is a stream receive. +func (info *loggingMethodStreamingRecvInfo) CallType() goa.InterceptorCallType { + return goa.InterceptorStreamingRecv } -// ClientStreamingResult returns a type-safe accessor for the method streaming result for a client-side interceptor. -func (info *LoggingInfo) ClientStreamingResult(res any) LoggingStreamingResult { - return &loggingMethodStreamingResult{result: res.(*MethodResult)} +// ClientStreamingResult returns this method's incoming streaming result fields. +func (info *loggingMethodInfo) ClientStreamingResult(result any) LoggingStreamingResult { + return &loggingMethodStreamingResult{result: result.(*MethodResult)} } -// ServerStreamingResult returns a type-safe accessor for the method streaming result for a server-side interceptor. -func (info *LoggingInfo) ServerStreamingResult() LoggingStreamingResult { - return &loggingMethodStreamingResult{result: info.RawPayload().(*MethodResult)} +// ServerStreamingResult returns this method's outgoing streaming result fields. +func (info *loggingMethodInfo) ServerStreamingResult() LoggingStreamingResult { + return &loggingMethodStreamingResult{result: info.rawPayload.(*MethodResult)} } -// Private implementation methods +// Methods that read and write the selected payload and result fields func (r *loggingMethodStreamingResult) Data() string { if r.result.Data == nil { diff --git a/codegen/service/testdata/interceptors/streaming-interceptors_client_interceptors.go.golden b/codegen/service/testdata/interceptors/streaming-interceptors_client_interceptors.go.golden index 46dd4655d2..7f47a35c66 100644 --- a/codegen/service/testdata/interceptors/streaming-interceptors_client_interceptors.go.golden +++ b/codegen/service/testdata/interceptors/streaming-interceptors_client_interceptors.go.golden @@ -3,14 +3,14 @@ // is sent to the server. The implementation is responsible for calling next to // complete the request. type ClientInterceptors interface { - Logging(ctx context.Context, info *LoggingInfo, next goa.Endpoint) (any, error) + Logging(ctx context.Context, info LoggingInfo, next goa.Endpoint) (any, error) } // WrapMethodClientEndpoint wraps the Method endpoint with the client // interceptors defined in the design. func WrapMethodClientEndpoint(endpoint goa.Endpoint, i ClientInterceptors) goa.Endpoint { if i != nil { - endpoint = wrapClientMethodlogging(endpoint, i) + endpoint = wrapClientMethodLogging(endpoint, i) } return endpoint } diff --git a/codegen/service/testdata/interceptors/streaming-interceptors_interceptor_wrappers.go.golden b/codegen/service/testdata/interceptors/streaming-interceptors_interceptor_wrappers.go.golden index dfd5cedeac..7ac5d5ff56 100644 --- a/codegen/service/testdata/interceptors/streaming-interceptors_interceptor_wrappers.go.golden +++ b/codegen/service/testdata/interceptors/streaming-interceptors_interceptor_wrappers.go.golden @@ -18,18 +18,15 @@ type wrappedMethodClientStream struct { stream MethodClientStream } -// wrapLoggingMethod applies the logging server interceptor to endpoints. +// wrapMethodLogging applies the logging server interceptor to endpoints. func wrapMethodLogging(endpoint goa.Endpoint, i ServerInterceptors) goa.Endpoint { return func(ctx context.Context, req any) (any, error) { stream := req.(*MethodEndpointInput).Stream req.(*MethodEndpointInput).Stream = &wrappedMethodServerStream{ ctx: ctx, sendWithContext: func(ctx context.Context, req *MethodResult) error { - info := &LoggingInfo{ - service: "StreamingInterceptors", - method: "Method", - callType: goa.InterceptorStreamingSend, - rawPayload: req, + info := &loggingMethodStreamingSendInfo{ + loggingMethodInfo: loggingMethodInfo{rawPayload: req}, } _, err := i.Logging(ctx, info, func(ctx context.Context, req any) (any, error) { castReq, _ := req.(*MethodResult) @@ -38,10 +35,8 @@ func wrapMethodLogging(endpoint goa.Endpoint, i ServerInterceptors) goa.Endpoint return err }, recvWithContext: func(ctx context.Context) (*MethodStreamingPayload, error) { - info := &LoggingInfo{ - service: "StreamingInterceptors", - method: "Method", - callType: goa.InterceptorStreamingRecv, + info := &loggingMethodStreamingRecvInfo{ + loggingMethodInfo: loggingMethodInfo{}, } res, err := i.Logging(ctx, info, func(ctx context.Context, _ any) (any, error) { return stream.RecvWithContext(ctx) @@ -55,7 +50,7 @@ func wrapMethodLogging(endpoint goa.Endpoint, i ServerInterceptors) goa.Endpoint } } -// wrapClientLoggingMethod applies the logging client interceptor to endpoints. +// wrapClientMethodLogging applies the logging client interceptor to endpoints. func wrapClientMethodLogging(endpoint goa.Endpoint, i ClientInterceptors) goa.Endpoint { return func(ctx context.Context, req any) (any, error) { res, err := endpoint(ctx, req) @@ -66,11 +61,8 @@ func wrapClientMethodLogging(endpoint goa.Endpoint, i ClientInterceptors) goa.En return &wrappedMethodClientStream{ ctx: ctx, sendWithContext: func(ctx context.Context, req *MethodStreamingPayload) error { - info := &LoggingInfo{ - service: "StreamingInterceptors", - method: "Method", - callType: goa.InterceptorStreamingSend, - rawPayload: req, + info := &loggingMethodStreamingSendInfo{ + loggingMethodInfo: loggingMethodInfo{rawPayload: req}, } _, err := i.Logging(ctx, info, func(ctx context.Context, req any) (any, error) { castReq, _ := req.(*MethodStreamingPayload) @@ -79,10 +71,8 @@ func wrapClientMethodLogging(endpoint goa.Endpoint, i ClientInterceptors) goa.En return err }, recvWithContext: func(ctx context.Context) (*MethodResult, error) { - info := &LoggingInfo{ - service: "StreamingInterceptors", - method: "Method", - callType: goa.InterceptorStreamingRecv, + info := &loggingMethodStreamingRecvInfo{ + loggingMethodInfo: loggingMethodInfo{}, } res, err := i.Logging(ctx, info, func(ctx context.Context, _ any) (any, error) { return stream.RecvWithContext(ctx) diff --git a/codegen/service/testdata/interceptors/streaming-interceptors_service_interceptors.go.golden b/codegen/service/testdata/interceptors/streaming-interceptors_service_interceptors.go.golden index da2e8aa555..3af899e9bb 100644 --- a/codegen/service/testdata/interceptors/streaming-interceptors_service_interceptors.go.golden +++ b/codegen/service/testdata/interceptors/streaming-interceptors_service_interceptors.go.golden @@ -3,18 +3,22 @@ // payload is sent to the service. The implementation is responsible for calling // next to complete the request. type ServerInterceptors interface { - Logging(ctx context.Context, info *LoggingInfo, next goa.Endpoint) (any, error) + Logging(ctx context.Context, info LoggingInfo, next goa.Endpoint) (any, error) } // Access interfaces for interceptor payloads and results type ( - // LoggingInfo provides metadata about the current interception. - // It includes service name, method name, and access to the endpoint. - LoggingInfo struct { - service string - method string - callType goa.InterceptorCallType - rawPayload any + // LoggingInfo describes the service call currently passed to the interceptor. + LoggingInfo interface { + goa.InterceptorInfo + // ClientStreamingPayload returns selected fields from the outgoing stream payload. + ClientStreamingPayload() LoggingStreamingPayload + // ServerStreamingPayload returns selected fields from the incoming stream payload. + ServerStreamingPayload(any) LoggingStreamingPayload + // ClientStreamingResult returns selected fields from the incoming stream result. + ClientStreamingResult(any) LoggingStreamingResult + // ServerStreamingResult returns selected fields from the outgoing stream result. + ServerStreamingResult() LoggingStreamingResult } // LoggingStreamingPayload provides type-safe access to the method streaming payload. @@ -34,8 +38,17 @@ type ( } ) -// Private implementation types +// Types used to provide information about each service call type ( + loggingMethodInfo struct { + rawPayload any + } + loggingMethodStreamingSendInfo struct { + loggingMethodInfo + } + loggingMethodStreamingRecvInfo struct { + loggingMethodInfo + } loggingMethodStreamingPayload struct { payload *MethodStreamingPayload } @@ -48,54 +61,59 @@ type ( // interceptors defined in the design. func WrapMethodEndpoint(endpoint goa.Endpoint, i ServerInterceptors) goa.Endpoint { if i != nil { - endpoint = wrapMethodlogging(endpoint, i) + endpoint = wrapMethodLogging(endpoint, i) } return endpoint } -// Public accessor methods for Info types +// Methods that provide information about each service call -// Service returns the name of the service handling the request. -func (info *LoggingInfo) Service() string { - return info.service +// Service returns the service selected for this interceptor call. +func (info *loggingMethodInfo) Service() string { + return "StreamingInterceptors" } -// Method returns the name of the method handling the request. -func (info *LoggingInfo) Method() string { - return info.method +// Method returns the method selected for this interceptor call. +func (info *loggingMethodInfo) Method() string { + return "Method" } -// CallType returns the type of call the interceptor is handling. -func (info *LoggingInfo) CallType() goa.InterceptorCallType { - return info.callType +// RawPayload returns the payload supplied for this interceptor call. +func (info *loggingMethodInfo) RawPayload() any { + return info.rawPayload } -// RawPayload returns the raw payload of the request. -func (info *LoggingInfo) RawPayload() any { - return info.rawPayload +// CallType reports that this is a stream send. +func (info *loggingMethodStreamingSendInfo) CallType() goa.InterceptorCallType { + return goa.InterceptorStreamingSend +} + +// CallType reports that this is a stream receive. +func (info *loggingMethodStreamingRecvInfo) CallType() goa.InterceptorCallType { + return goa.InterceptorStreamingRecv } -// ClientStreamingPayload returns a type-safe accessor for the method streaming payload for a client-side interceptor. -func (info *LoggingInfo) ClientStreamingPayload() LoggingStreamingPayload { - return &loggingMethodStreamingPayload{payload: info.RawPayload().(*MethodStreamingPayload)} +// ClientStreamingPayload returns this method's outgoing streaming payload fields. +func (info *loggingMethodInfo) ClientStreamingPayload() LoggingStreamingPayload { + return &loggingMethodStreamingPayload{payload: info.rawPayload.(*MethodStreamingPayload)} } -// ClientStreamingResult returns a type-safe accessor for the method streaming result for a client-side interceptor. -func (info *LoggingInfo) ClientStreamingResult(res any) LoggingStreamingResult { - return &loggingMethodStreamingResult{result: res.(*MethodResult)} +// ServerStreamingPayload returns this method's incoming streaming payload fields. +func (info *loggingMethodInfo) ServerStreamingPayload(payload any) LoggingStreamingPayload { + return &loggingMethodStreamingPayload{payload: payload.(*MethodStreamingPayload)} } -// ServerStreamingPayload returns a type-safe accessor for the method streaming payload for a server-side interceptor. -func (info *LoggingInfo) ServerStreamingPayload(pay any) LoggingStreamingPayload { - return &loggingMethodStreamingPayload{payload: pay.(*MethodStreamingPayload)} +// ClientStreamingResult returns this method's incoming streaming result fields. +func (info *loggingMethodInfo) ClientStreamingResult(result any) LoggingStreamingResult { + return &loggingMethodStreamingResult{result: result.(*MethodResult)} } -// ServerStreamingResult returns a type-safe accessor for the method streaming result for a server-side interceptor. -func (info *LoggingInfo) ServerStreamingResult() LoggingStreamingResult { - return &loggingMethodStreamingResult{result: info.RawPayload().(*MethodResult)} +// ServerStreamingResult returns this method's outgoing streaming result fields. +func (info *loggingMethodInfo) ServerStreamingResult() LoggingStreamingResult { + return &loggingMethodStreamingResult{result: info.rawPayload.(*MethodResult)} } -// Private implementation methods +// Methods that read and write the selected payload and result fields func (p *loggingMethodStreamingPayload) Chunk() string { if p.payload.Chunk == nil { diff --git a/codegen/service/testdata/interceptors_dsls.go b/codegen/service/testdata/interceptors_dsls.go index d901825e2c..f9a5d81f27 100644 --- a/codegen/service/testdata/interceptors_dsls.go +++ b/codegen/service/testdata/interceptors_dsls.go @@ -73,6 +73,25 @@ var SingleClientInterceptorDSL = func() { }) } +// LeadingInitialismInterceptorDSL defines an interceptor whose name starts with +// the common JWT initialism. +var LeadingInitialismInterceptorDSL = func() { + Interceptor("JWTAuth") + Service("LeadingInitialismInterceptor", func() { + ServerInterceptor("JWTAuth") + ClientInterceptor("JWTAuth") + Method("GetInfo", func() { + Payload(func() { + Attribute("id", Int) + }) + Result(func() { + Attribute("value", String) + }) + HTTP(func() { GET("/") }) + }) + }) +} + var MultipleInterceptorsDSL = func() { Interceptor("logging") Interceptor("tracing") @@ -112,6 +131,98 @@ var InterceptorWithReadPayloadDSL = func() { }) } +var InterceptorWithExternalReadPayloadDSL = func() { + var UUID = Type("UUID", String, func() { + Meta("struct:pkg:path", "types") + }) + Interceptor("authorization", func() { + ReadPayload(func() { + Attribute("org_id") + }) + }) + Service("InterceptorWithExternalReadPayload", func() { + ServerInterceptor("authorization") + Method("Method", func() { + Payload(func() { + Attribute("org_id", UUID) + Required("org_id") + }) + HTTP(func() { POST("/") }) + }) + }) +} + +var InterceptorWithExternalPayloadDSL = func() { + var Event = Type("Event", func() { + Attribute("runtime_session_id", String) + Required("runtime_session_id") + Meta("struct:pkg:path", "types") + }) + Interceptor("identify", func() { + ReadPayload(func() { + Attribute("runtime_session_id") + }) + }) + Service("InterceptorWithExternalPayload", func() { + ServerInterceptor("identify") + Method("Append", func() { + Payload(Event) + HTTP(func() { POST("/") }) + }) + }) +} + +var MixedInterceptorsWithExternalClientPayloadDSL = func() { + var UUID = Type("UUID", String, func() { + Meta("struct:pkg:path", "types") + }) + Interceptor("logging") + Interceptor("authorization", func() { + ReadPayload(func() { + Attribute("org_id") + }) + }) + Service("MixedInterceptorsWithExternalClientPayload", func() { + ServerInterceptor("logging") + ClientInterceptor("authorization") + Method("Method", func() { + Payload(func() { + Attribute("org_id", UUID) + Required("org_id") + }) + HTTP(func() { POST("/") }) + }) + }) +} + +var MergedInterceptorsWithExternalClientPayloadDSL = func() { + var Event = Type("Event", func() { + Attribute("runtime_session_id", String) + Required("runtime_session_id") + Meta("struct:pkg:path", "types") + }) + Interceptor("identify", func() { + ReadPayload(func() { + Attribute("runtime_session_id") + }) + }) + Service("MergedInterceptorsWithExternalClientPayload", func() { + Method("ServerMethod", func() { + ServerInterceptor("identify") + Payload(func() { + Attribute("runtime_session_id", String) + Required("runtime_session_id") + }) + HTTP(func() { POST("/server") }) + }) + Method("ClientMethod", func() { + ClientInterceptor("identify") + Payload(Event) + HTTP(func() { POST("/client") }) + }) + }) +} + var InterceptorWithWritePayloadDSL = func() { Interceptor("validation", func() { WritePayload(func() { @@ -285,6 +396,34 @@ var StreamingInterceptorsWithReadStreamingResultDSL = func() { }) } +var MixedResultStreamingInterceptorsDSL = func() { + Summary := Type("Summary", func() { + Meta("struct:pkg:path", "summary") + Field(1, "count", Int) + }) + Event := Type("Event", func() { + Meta("struct:pkg:path", "events") + Field(1, "message", String) + }) + Interceptor("logging", func() { + ReadStreamingResult(func() { + Attribute("message") + }) + }) + Service("MixedResultStreamingInterceptors", func() { + ServerInterceptor("logging") + ClientInterceptor("logging") + Method("Method", func() { + Result(Summary) + StreamingResult(Event) + HTTP(func() { + GET("/stream") + ServerSentEvents() + }) + }) + }) +} + var StreamingInterceptorsWithReadPayloadDSL = func() { Interceptor("logging", func() { ReadPayload(func() { diff --git a/codegen/service/testdata/nested-alpha/alpha.go b/codegen/service/testdata/nested-alpha/alpha.go new file mode 100644 index 0000000000..3e74569aa4 --- /dev/null +++ b/codegen/service/testdata/nested-alpha/alpha.go @@ -0,0 +1,7 @@ +// Package nestedalpha supplies a named reflected child type for conversion tests. +package nestedalpha + +// Child is the alpha branch embedded by the external envelope fixture. +type Child struct { + Value string +} diff --git a/codegen/service/testdata/nested-beta/beta.go b/codegen/service/testdata/nested-beta/beta.go new file mode 100644 index 0000000000..d07364725c --- /dev/null +++ b/codegen/service/testdata/nested-beta/beta.go @@ -0,0 +1,7 @@ +// Package nestedbeta supplies a same-named reflected child type from a different package. +package nestedbeta + +// Child is the beta branch embedded by the external envelope fixture. +type Child struct { + Value string +} diff --git a/codegen/service/testdata/nested-outer/outer.go b/codegen/service/testdata/nested-outer/outer.go new file mode 100644 index 0000000000..d814276d74 --- /dev/null +++ b/codegen/service/testdata/nested-outer/outer.go @@ -0,0 +1,17 @@ +// Package nestedouter supplies an external conversion shape whose child types +// come from two distinct Go packages. +package nestedouter + +import ( + unusedalpha "goa.design/goa/v3/codegen/service/testdata/a-nested-alpha" + nestedalpha "goa.design/goa/v3/codegen/service/testdata/nested-alpha" + nestedbeta "goa.design/goa/v3/codegen/service/testdata/nested-beta" +) + +// Envelope contains mapped same-named child types and one deliberately +// unmapped child whose package name collides with the mapped alpha package. +type Envelope struct { + Alpha *nestedalpha.Child + Beta *nestedbeta.Child + Unused *unusedalpha.Child +} diff --git a/codegen/service/testdata/security_dsls.go b/codegen/service/testdata/security_dsls.go index b35756a29f..2ad9224b7e 100644 --- a/codegen/service/testdata/security_dsls.go +++ b/codegen/service/testdata/security_dsls.go @@ -34,6 +34,8 @@ var OAuth2AuthorizationCode = OAuth2Security("authCode", func() { Scope("api:read", "Read access") }) +var SecurityCredential = Type("SecurityCredential", String) + var EndpointWithoutRequirementDSL = func() { Service("EndpointWithoutRequirement", func() { Method("Unsecure", func() { @@ -114,7 +116,8 @@ var EndpointWithRequiredScopesDSL = func() { Scope("api:write") }) Payload(func() { - Token("token", String) + Token("token", SecurityCredential) + Required("token") }) HTTP(func() { GET("/") @@ -131,8 +134,9 @@ var EndpointWithOptionalRequiredScopesDSL = func() { Scope("api:write") }) Payload(func() { - Username("user", String) - Password("pass", String) + Username("user", SecurityCredential) + Password("pass", SecurityCredential) + Required("user") }) HTTP(func() { GET("/") @@ -147,7 +151,8 @@ var EndpointWithAPIKeyOverrideDSL = func() { Method("SecureWithAPIKeyOverride", func() { Security(APIKeyAuth) Payload(func() { - APIKey("api_key", "key", String) + APIKey("api_key", "key", SecurityCredential) + Required("key") }) HTTP(func() { GET("/") @@ -163,7 +168,7 @@ var EndpointWithBearerDSL = func() { Scope("api:read") }) Payload(func() { - BearerToken("token", String) + BearerToken("token", SecurityCredential) }) HTTP(func() { GET("/") @@ -173,11 +178,12 @@ var EndpointWithBearerDSL = func() { } var EndpointWithOAuth2DSL = func() { + var accessToken = Type("AccessToken", String) Service("EndpointWithOAuth2", func() { Method("SecureWithOAuth2", func() { Security(OAuth2AuthorizationCode) Payload(func() { - AccessToken("token", String) + AccessToken("token", accessToken) }) HTTP(func() { GET("/") diff --git a/codegen/service/testdata/security_functions.go b/codegen/service/testdata/security_functions.go index 40b3f380dd..2db91fd95a 100644 --- a/codegen/service/testdata/security_functions.go +++ b/codegen/service/testdata/security_functions.go @@ -54,11 +54,7 @@ func NewSecureWithRequiredScopesEndpoint(s Service, authJWTFn security.AuthJWTFu Scopes: []string{"api:read", "api:write", "api:admin"}, RequiredScopes: []string{"api:read", "api:write"}, } - var token string - if p.Token != nil { - token = *p.Token - } - ctx, err = authJWTFn(ctx, token, &sc) + ctx, err = authJWTFn(ctx, string(p.Token), &sc) if err != nil { return nil, err } @@ -79,15 +75,11 @@ func NewSecureWithOptionalRequiredScopesEndpoint(s Service, authBasicFn security Scopes: []string{"api:read", "api:write", "api:admin"}, RequiredScopes: []string{"api:read", "api:write"}, } - var user string - if p.User != nil { - user = *p.User - } var pass string if p.Pass != nil { - pass = *p.Pass + pass = string(*p.Pass) } - ctx, err = authBasicFn(ctx, user, pass, &sc) + ctx, err = authBasicFn(ctx, string(p.User), pass, &sc) if err != nil { return nil, err } @@ -108,11 +100,7 @@ func NewSecureWithAPIKeyOverrideEndpoint(s Service, authAPIKeyFn security.AuthAP Scopes: []string{"api:read", "api:write", "api:admin"}, RequiredScopes: []string{}, } - var key string - if p.Key != nil { - key = *p.Key - } - ctx, err = authAPIKeyFn(ctx, key, &sc) + ctx, err = authAPIKeyFn(ctx, string(p.Key), &sc) if err != nil { return nil, err } @@ -134,7 +122,7 @@ func NewSecureWithBearerEndpoint(s Service, authBearerFn security.AuthBearerFunc } var token string if p.Token != nil { - token = *p.Token + token = string(*p.Token) } ctx, err = authBearerFn(ctx, token, &sc) if err != nil { @@ -166,7 +154,7 @@ func NewSecureWithOAuth2Endpoint(s Service, authOAuth2Fn security.AuthOAuth2Func } var token string if p.Token != nil { - token = *p.Token + token = string(*p.Token) } ctx, err = authOAuth2Fn(ctx, token, &sc) if err != nil { @@ -191,11 +179,11 @@ func NewEndpointWithSkipRequestBodyEncodeDecodeEndpoint(s Service, authBasicFn s } var user string if ep.Payload.User != nil { - user = *ep.Payload.User + user = string(*ep.Payload.User) } var pass string if ep.Payload.Pass != nil { - pass = *ep.Payload.Pass + pass = string(*ep.Payload.Pass) } ctx, err = authBasicFn(ctx, user, pass, &sc) if err != nil { diff --git a/codegen/service/testdata/service_dsls.go b/codegen/service/testdata/service_dsls.go index cd0bc6ff91..c130132d82 100644 --- a/codegen/service/testdata/service_dsls.go +++ b/codegen/service/testdata/service_dsls.go @@ -82,6 +82,16 @@ var MultipleMethodsDSL = func() { }) } +var RepeatedInlineErrorsDSL = func() { + Service("Secured", func() { + for _, method := range []string{"Read", "Write", "Delete"} { + Method(method, func() { + Error("invalid_scopes", String) + }) + } + }) +} + var UnionMethodDSL = func() { var AUnion = Type("AUnion", func() { OneOf("Values", func() { @@ -159,6 +169,56 @@ var PkgPathUnionDSL = func() { }) } +// PkgPathUnionNameScopeDSL exercises services that place separately named +// unions in relocated files that compile in one Go package. +var PkgPathUnionNameScopeDSL = func() { + var FirstValue = Type("FirstValue", func() { + Meta("struct:pkg:path", "types") + Meta("type:generate:force") + OneOf("Value", func() { + TypeName("FirstValueChoice") + Attribute("Bool", Boolean) + Attribute("Enum", String) + Attribute("Number", Float64) + }) + }) + var SecondValue = Type("SecondValue", func() { + Meta("struct:pkg:path", "types") + Meta("type:generate:force") + OneOf("Value", func() { + TypeName("SecondValueChoice") + Attribute("Bool", Boolean) + Attribute("Enum", String) + Attribute("Number", Float64) + }) + }) + var ThirdValue = Type("ThirdValue", func() { + Meta("struct:pkg:path", "types") + Meta("type:generate:force") + OneOf("Value", func() { + TypeName("ThirdValueChoice") + Attribute("Bool", Boolean) + Attribute("Enum", String) + Attribute("Number", Float64) + }) + }) + Service("FirstValueService", func() { + Method("Read", func() { + Payload(FirstValue) + }) + }) + Service("SecondValueService", func() { + Method("Read", func() { + Payload(SecondValue) + }) + }) + Service("ThirdValueService", func() { + Method("Read", func() { + Payload(ThirdValue) + }) + }) +} + // PkgPathUnionJSONFieldDSL tests OneOf branches declared with JSONField in a // struct:pkg:path type. var PkgPathUnionJSONFieldDSL = func() { @@ -284,6 +344,22 @@ var ServiceErrorDSL = func() { }) } +var APIErrorReferenceDSL = func() { + API("ErrorReference", func() { + Error("busy", func() { + Temporary() + Timeout() + Fault() + }) + }) + Service("ErrorReference", func() { + Error("busy") + Method("Run", func() { + Error("busy") + }) + }) +} + var CustomErrorsDSL = func() { var APayload = Type("APayload", func() { Attribute("IntField", Int) @@ -309,6 +385,20 @@ var CustomErrorsDSL = func() { }) } +var NestedCustomErrorDSL = func() { + var Detail = Type("Detail", func() { + Attribute("Message", String) + }) + var Failure = Type("Failure", func() { + Attribute("Detail", Detail) + }) + Service("NestedCustomError", func() { + Method("Fail", func() { + Error("failure", Failure) + }) + }) +} + var CustomErrorsCustomFieldDSL = func() { var Result = ResultType("application/vnd.goa.error", func() { ErrorName("error", String, func() { @@ -388,7 +478,7 @@ var WithExplicitAndDefaultViewsDSL = func() { Method("A", func() { Result(RTWithViews) }) - Method("A", func() { + Method("B", func() { Result(RTWithViews, func() { View("tiny") }) @@ -1039,6 +1129,35 @@ var PkgPathDupeDSL = func() { }) } +var PkgPathSharedRolesDSL = func() { + var Detail = Type("Detail", func() { + Attribute("Message", String) + Meta("struct:pkg:path", "shared") + }) + var Shared = Type("Shared", func() { + Attribute("IntField", Int) + Attribute("Detail", Detail) + Meta("struct:pkg:path", "shared") + Meta("type:generate:force") + }) + + Service("PkgPathSharedRoles", func() { + Method("Exchange", func() { + Payload(Shared) + StreamingPayload(Shared) + Result(Shared) + StreamingResult(Shared) + Error("shared_error", Shared) + }) + }) + + Service("PkgPathSharedError", func() { + Method("Fail", func() { + Error("shared_error", Shared) + }) + }) +} + var PkgPathPayloadAttributeDSL = func() { var Foo = Type("Foo", func() { Attribute("IntField", Int) diff --git a/codegen/service/testdata/views_code.go b/codegen/service/testdata/views_code.go index d4690ae3d2..2c5dca186e 100644 --- a/codegen/service/testdata/views_code.go +++ b/codegen/service/testdata/views_code.go @@ -229,12 +229,6 @@ func ValidateResultTypeViewTiny(result *ResultTypeView) (err error) { } return } - -// ValidateUserTypeView runs the validations defined on UserTypeView. -func ValidateUserTypeView(result *UserTypeView) (err error) { - - return -} ` const ResultWithResultTypeCode = `// RT is the viewed result type that is projected based on a view. @@ -328,11 +322,17 @@ func ValidateRT(result *RT) (err error) { // view. func ValidateRTView(result *RTView) (err error) { + if result.B == nil { + err = goa.MergeErrors(err, goa.MissingFieldError("b", "result")) + } if result.B != nil { if err2 := ValidateRT2ViewExtended(result.B); err2 != nil { err = goa.MergeErrors(err, err2) } } + if result.C == nil { + err = goa.MergeErrors(err, goa.MissingFieldError("c", "result")) + } if result.C != nil { if err2 := ValidateRT3View(result.C); err2 != nil { err = goa.MergeErrors(err, err2) @@ -345,11 +345,17 @@ func ValidateRTView(result *RTView) (err error) { // view. func ValidateRTViewTiny(result *RTView) (err error) { + if result.B == nil { + err = goa.MergeErrors(err, goa.MissingFieldError("b", "result")) + } if result.B != nil { if err2 := ValidateRT2ViewTiny(result.B); err2 != nil { err = goa.MergeErrors(err, err2) } } + if result.C == nil { + err = goa.MergeErrors(err, goa.MissingFieldError("c", "result")) + } if result.C != nil { if err2 := ValidateRT3View(result.C); err2 != nil { err = goa.MergeErrors(err, err2) @@ -391,12 +397,6 @@ func ValidateRT2ViewTiny(result *RT2View) (err error) { return } -// ValidateUserTypeView runs the validations defined on UserTypeView. -func ValidateUserTypeView(result *UserTypeView) (err error) { - - return -} - // ValidateRT3View runs the validations defined on RT3View using the "default" // view. func ValidateRT3View(result *RT3View) (err error) { @@ -460,6 +460,7 @@ func ValidateRT(result *RT) (err error) { // ValidateRTView runs the validations defined on RTView using the "default" // view. func ValidateRTView(result *RTView) (err error) { + if result.A == nil { err = goa.MergeErrors(err, goa.MissingFieldError("a", "result")) } @@ -474,6 +475,7 @@ func ValidateRTView(result *RTView) (err error) { // ValidateRTViewTiny runs the validations defined on RTView using the "tiny" // view. func ValidateRTViewTiny(result *RTView) (err error) { + if result.A == nil { err = goa.MergeErrors(err, goa.MissingFieldError("a", "result")) } @@ -646,6 +648,9 @@ func ValidateAnotherResult(result *AnotherResult) (err error) { // "default" view. func ValidateSomeRTView(result *SomeRTView) (err error) { + if result.A == nil { + err = goa.MergeErrors(err, goa.MissingFieldError("a", "result")) + } if result.A != nil { if err2 := ValidateSomeRTCollectionViewTiny(result.A); err2 != nil { err = goa.MergeErrors(err, err2) @@ -658,6 +663,9 @@ func ValidateSomeRTView(result *SomeRTView) (err error) { // "tiny" view. func ValidateSomeRTViewTiny(result *SomeRTView) (err error) { + if result.A == nil { + err = goa.MergeErrors(err, goa.MissingFieldError("a", "result")) + } if result.A != nil { if err2 := ValidateSomeRTCollectionView(result.A); err2 != nil { err = goa.MergeErrors(err, err2) @@ -692,6 +700,9 @@ func ValidateSomeRTCollectionViewTiny(result SomeRTCollectionView) (err error) { // using the "default" view. func ValidateAnotherResultView(result *AnotherResultView) (err error) { + if result.A == nil { + err = goa.MergeErrors(err, goa.MissingFieldError("a", "result")) + } if result.A != nil { if err2 := ValidateAnotherResultCollectionView(result.A); err2 != nil { err = goa.MergeErrors(err, err2) @@ -738,19 +749,11 @@ var ( func ValidateRT(result *RT) (err error) { switch result.View { case "default", "": - err = ValidateRTView(result.Projected) default: err = goa.InvalidEnumValueError("view", result.View, []any{"default"}) } return } - -// ValidateRTView runs the validations defined on RTView using the "default" -// view. -func ValidateRTView(result *RTView) (err error) { - - return -} ` const ResultWithEnumType = `// Result is the viewed result type that is projected based on a view. @@ -840,25 +843,11 @@ var ( func ValidateRT(result *RT) (err error) { switch result.View { case "default", "": - err = ValidateRTView(result.Projected) default: err = goa.InvalidEnumValueError("view", result.View, []any{"default"}) } return } - -// ValidateRTView runs the validations defined on RTView using the "default" -// view. -func ValidateRTView(result *RTView) (err error) { - - return -} - -// ValidateUserTypeView runs the validations defined on UserTypeView. -func ValidateUserTypeView(result *UserTypeView) (err error) { - - return -} ` -const ResultWithOneOfInResultTypeCode = "// OneOfResource is the viewed result type that is projected based on a view.\ntype OneOfResource struct {\n\t// Type to project\n\tProjected *OneOfResourceView\n\t// View to render\n\tView string\n}\n\n// OneOfResourceView is a type that runs validations on a projected type.\ntype OneOfResourceView struct {\n\t// Data (type depends on flag)\n\tData *OneOfValueView\n}\n\n// OneOfValueView is a type that runs validations on a projected type.\ntype OneOfValueView struct {\n\tFlag Flag\n}\n\n// FlagAstringView is a type that runs validations on a projected type.\ntype FlagAstringView string\n\n// FlagAintView is a type that runs validations on a projected type.\ntype FlagAintView int64\n\n// Flag is a sum-type union.\ntype Flag struct {\n\tkind FlagKind\n\tAstring FlagAstringView\n\tAint FlagAintView\n}\n\n// FlagKind enumerates the union variants for Flag.\ntype FlagKind string\n\nconst (\n\t// FlagKindAstring identifies the astring branch of the union.\n\tFlagKindAstring FlagKind = \"astring\"\n\t// FlagKindAint identifies the aint branch of the union.\n\tFlagKindAint FlagKind = \"aint\"\n)\n\n// Kind returns the discriminator value of the union.\nfunc (u Flag) Kind() FlagKind {\n\treturn u.kind\n}\n\n// NewFlagAstring constructs Flag with the astring branch set.\nfunc NewFlagAstring(v FlagAstringView) Flag {\n\treturn Flag{\n\t\tkind: FlagKindAstring,\n\t\tAstring: v,\n\t}\n}\n\n// AsAstring returns the value of the astring branch if set.\nfunc (u Flag) AsAstring() (_ FlagAstringView, ok bool) {\n\tif u.kind != FlagKindAstring {\n\t\treturn\n\t}\n\treturn u.Astring, true\n}\n\n// SetAstring sets the astring branch of the union.\nfunc (u *Flag) SetAstring(v FlagAstringView) {\n\tu.kind = FlagKindAstring\n\tu.Astring = v\n}\n\n// NewFlagAint constructs Flag with the aint branch set.\nfunc NewFlagAint(v FlagAintView) Flag {\n\treturn Flag{\n\t\tkind: FlagKindAint,\n\t\tAint: v,\n\t}\n}\n\n// AsAint returns the value of the aint branch if set.\nfunc (u Flag) AsAint() (_ FlagAintView, ok bool) {\n\tif u.kind != FlagKindAint {\n\t\treturn\n\t}\n\treturn u.Aint, true\n}\n\n// SetAint sets the aint branch of the union.\nfunc (u *Flag) SetAint(v FlagAintView) {\n\tu.kind = FlagKindAint\n\tu.Aint = v\n}\n\n// Validate ensures the union discriminant is valid.\nfunc (u Flag) Validate() error {\n\tswitch u.kind {\n\tcase \"\":\n\t\treturn goa.InvalidEnumValueError(\"type\", \"\", []any{\n\t\t\tstring(FlagKindAstring),\n\t\t\tstring(FlagKindAint),\n\t\t})\n\tcase FlagKindAstring:\n\t\treturn nil\n\tcase FlagKindAint:\n\t\treturn nil\n\tdefault:\n\t\treturn goa.InvalidEnumValueError(\"type\", u.kind, []any{\n\t\t\tstring(FlagKindAstring),\n\t\t\tstring(FlagKindAint),\n\t\t})\n\t}\n}\n\n// MarshalJSON marshals the union into the canonical {type,value} JSON shape.\nfunc (u Flag) MarshalJSON() ([]byte, error) {\n\tif err := u.Validate(); err != nil {\n\t\treturn nil, err\n\t}\n\tvar (\n\t\tvalue any\n\t)\n\tswitch u.kind {\n\tcase FlagKindAstring:\n\t\tvalue = u.Astring\n\tcase FlagKindAint:\n\t\tvalue = u.Aint\n\tdefault:\n\t\treturn nil, fmt.Errorf(\"unexpected Flag discriminant %q\", u.kind)\n\t}\n\treturn json.Marshal(struct {\n\t\tType string `json:\"type\"`\n\t\tValue any `json:\"value\"`\n\t}{\n\t\tType: string(u.kind),\n\t\tValue: value,\n\t})\n}\n\n// UnmarshalJSON unmarshals the union from the canonical {type,value} JSON shape.\nfunc (u *Flag) UnmarshalJSON(data []byte) error {\n\tvar raw struct {\n\t\tType string `json:\"type\"`\n\t\tValue json.RawMessage `json:\"value\"`\n\t}\n\tif err := json.Unmarshal(data, &raw); err != nil {\n\t\treturn err\n\t}\n\tif len(raw.Value) == 0 {\n\t\treturn goa.MissingFieldError(\"value\", \"Flag\")\n\t}\n\tif bytes.Equal(bytes.TrimSpace(raw.Value), []byte(\"null\")) {\n\t\treturn goa.InvalidFieldTypeError(\"value\", nil, \"non-null JSON value\")\n\t}\n\tswitch raw.Type {\n\tcase string(FlagKindAstring):\n\t\tvar v FlagAstringView\n\t\tif err := json.Unmarshal(raw.Value, &v); err != nil {\n\t\t\treturn err\n\t\t}\n\t\tu.kind = FlagKindAstring\n\t\tu.Astring = v\n\tcase string(FlagKindAint):\n\t\tvar v FlagAintView\n\t\tif err := json.Unmarshal(raw.Value, &v); err != nil {\n\t\t\treturn err\n\t\t}\n\t\tu.kind = FlagKindAint\n\t\tu.Aint = v\n\tdefault:\n\t\tif raw.Type == \"\" {\n\t\t\treturn goa.MissingFieldError(\"type\", \"Flag\")\n\t\t}\n\t\treturn goa.InvalidEnumValueError(\"type\", raw.Type, []any{\n\t\t\tstring(FlagKindAstring),\n\t\t\tstring(FlagKindAint),\n\t\t})\n\t}\n\treturn nil\n}\n\nvar (\n\t// OneOfResourceMap is a map indexing the attribute names of OneOfResource by\n\t// view name.\n\tOneOfResourceMap = map[string][]string{\n\t\t\"default\": {\n\t\t\t\"data\",\n\t\t},\n\t}\n)\n\n// ValidateOneOfResource runs the validations defined on the viewed result type\n// OneOfResource.\nfunc ValidateOneOfResource(result *OneOfResource) (err error) {\n\tswitch result.View {\n\tcase \"default\", \"\":\n\t\terr = ValidateOneOfResourceView(result.Projected)\n\tdefault:\n\t\terr = goa.InvalidEnumValueError(\"view\", result.View, []any{\"default\"})\n\t}\n\treturn\n}\n\n// ValidateOneOfResourceView runs the validations defined on OneOfResourceView\n// using the \"default\" view.\nfunc ValidateOneOfResourceView(result *OneOfResourceView) (err error) {\n\tif result.Data == nil {\n\t\terr = goa.MergeErrors(err, goa.MissingFieldError(\"data\", \"result\"))\n\t}\n\treturn\n}\n\n// ValidateOneOfValueView runs the validations defined on OneOfValueView.\nfunc ValidateOneOfValueView(result *OneOfValueView) (err error) {\n\n\treturn\n}\n\n// ValidateFlagAstringView runs the validations defined on FlagAstringView.\nfunc ValidateFlagAstringView(result FlagAstringView) (err error) {\n\n\treturn\n}\n\n// ValidateFlagAintView runs the validations defined on FlagAintView.\nfunc ValidateFlagAintView(result FlagAintView) (err error) {\n\n\treturn\n}\n" +const ResultWithOneOfInResultTypeCode = "// OneOfResource is the viewed result type that is projected based on a view.\ntype OneOfResource struct {\n\t// Type to project\n\tProjected *OneOfResourceView\n\t// View to render\n\tView string\n}\n\n// OneOfResourceView is a type that runs validations on a projected type.\ntype OneOfResourceView struct {\n\t// Data (type depends on flag)\n\tData *OneOfValueView\n}\n\n// OneOfValueView is a type that runs validations on a projected type.\ntype OneOfValueView struct {\n\tFlag Flag\n}\n\n// FlagAstringView is a type that runs validations on a projected type.\ntype FlagAstringView string\n\n// FlagAintView is a type that runs validations on a projected type.\ntype FlagAintView int64\n\n// Flag holds exactly one of its branch values.\ntype Flag struct {\n\tkind FlagKind\n\tastring FlagAstringView\n\taint FlagAintView\n}\n\n// FlagKind records which Flag branch is selected.\ntype FlagKind string\n\nconst (\n\t// FlagKindAstring identifies the astring branch.\n\tFlagKindAstring FlagKind = \"astring\"\n\t// FlagKindAint identifies the aint branch.\n\tFlagKindAint FlagKind = \"aint\"\n)\n\n// Kind returns the selected branch.\nfunc (u Flag) Kind() FlagKind {\n\treturn u.kind\n}\n\n// NewFlagAstring constructs Flag with the astring branch set.\nfunc NewFlagAstring(v FlagAstringView) Flag {\n\treturn Flag{\n\t\tkind: FlagKindAstring,\n\t\tastring: v,\n\t}\n}\n\n// AsAstring returns the value when the astring branch is selected.\nfunc (u Flag) AsAstring() (_ FlagAstringView, ok bool) {\n\tif u.kind != FlagKindAstring {\n\t\treturn\n\t}\n\treturn u.astring, true\n}\n\n// SetAstring selects the astring branch and stores v.\nfunc (u *Flag) SetAstring(v FlagAstringView) {\n\t*u = Flag{\n\t\tkind: FlagKindAstring,\n\t\tastring: v,\n\t}\n}\n\n// NewFlagAint constructs Flag with the aint branch set.\nfunc NewFlagAint(v FlagAintView) Flag {\n\treturn Flag{\n\t\tkind: FlagKindAint,\n\t\taint: v,\n\t}\n}\n\n// AsAint returns the value when the aint branch is selected.\nfunc (u Flag) AsAint() (_ FlagAintView, ok bool) {\n\tif u.kind != FlagKindAint {\n\t\treturn\n\t}\n\treturn u.aint, true\n}\n\n// SetAint selects the aint branch and stores v.\nfunc (u *Flag) SetAint(v FlagAintView) {\n\t*u = Flag{\n\t\tkind: FlagKindAint,\n\t\taint: v,\n\t}\n}\n\n// Validate ensures exactly one valid branch is selected.\nfunc (u Flag) Validate() error {\n\tswitch u.kind {\n\tcase \"\":\n\t\treturn goa.InvalidEnumValueError(\"type\", \"\", []any{\n\t\t\tstring(FlagKindAstring),\n\t\t\tstring(FlagKindAint),\n\t\t})\n\tcase FlagKindAstring:\n\t\treturn nil\n\tcase FlagKindAint:\n\t\treturn nil\n\tdefault:\n\t\treturn goa.InvalidEnumValueError(\"type\", u.kind, []any{\n\t\t\tstring(FlagKindAstring),\n\t\t\tstring(FlagKindAint),\n\t\t})\n\t}\n}\n\n// MarshalJSON marshals the union into the canonical {type,value} JSON shape.\nfunc (u Flag) MarshalJSON() ([]byte, error) {\n\tif err := u.Validate(); err != nil {\n\t\treturn nil, err\n\t}\n\tvar (\n\t\tvalue any\n\t)\n\tswitch u.kind {\n\tcase FlagKindAstring:\n\t\tvalue = u.astring\n\tcase FlagKindAint:\n\t\tvalue = u.aint\n\tdefault:\n\t\treturn nil, fmt.Errorf(\"unexpected Flag kind %q\", u.kind)\n\t}\n\treturn json.Marshal(struct {\n\t\tType string `json:\"type\"`\n\t\tValue any `json:\"value\"`\n\t}{\n\t\tType: string(u.kind),\n\t\tValue: value,\n\t})\n}\n\n// UnmarshalJSON unmarshals the union from the canonical {type,value} JSON shape.\nfunc (u *Flag) UnmarshalJSON(data []byte) error {\n\tvar raw struct {\n\t\tType string `json:\"type\"`\n\t\tValue json.RawMessage `json:\"value\"`\n\t}\n\tif err := json.Unmarshal(data, &raw); err != nil {\n\t\treturn err\n\t}\n\tif len(raw.Value) == 0 {\n\t\treturn goa.MissingFieldError(\"value\", \"Flag\")\n\t}\n\tif bytes.Equal(bytes.TrimSpace(raw.Value), []byte(\"null\")) {\n\t\treturn goa.InvalidFieldTypeError(\"value\", nil, \"non-null JSON value\")\n\t}\n\tswitch raw.Type {\n\tcase string(FlagKindAstring):\n\t\tvar v FlagAstringView\n\t\tif err := json.Unmarshal(raw.Value, &v); err != nil {\n\t\t\treturn err\n\t\t}\n\t\tu.SetAstring(v)\n\tcase string(FlagKindAint):\n\t\tvar v FlagAintView\n\t\tif err := json.Unmarshal(raw.Value, &v); err != nil {\n\t\t\treturn err\n\t\t}\n\t\tu.SetAint(v)\n\tdefault:\n\t\tif raw.Type == \"\" {\n\t\t\treturn goa.MissingFieldError(\"type\", \"Flag\")\n\t\t}\n\t\treturn goa.InvalidEnumValueError(\"type\", raw.Type, []any{\n\t\t\tstring(FlagKindAstring),\n\t\t\tstring(FlagKindAint),\n\t\t})\n\t}\n\treturn nil\n}\n\nvar (\n\t// OneOfResourceMap is a map indexing the attribute names of OneOfResource by\n\t// view name.\n\tOneOfResourceMap = map[string][]string{\n\t\t\"default\": {\n\t\t\t\"data\",\n\t\t},\n\t}\n)\n\n// ValidateOneOfResource runs the validations defined on the viewed result type\n// OneOfResource.\nfunc ValidateOneOfResource(result *OneOfResource) (err error) {\n\tswitch result.View {\n\tcase \"default\", \"\":\n\t\terr = ValidateOneOfResourceView(result.Projected)\n\tdefault:\n\t\terr = goa.InvalidEnumValueError(\"view\", result.View, []any{\"default\"})\n\t}\n\treturn\n}\n\n// ValidateOneOfResourceView runs the validations defined on OneOfResourceView\n// using the \"default\" view.\nfunc ValidateOneOfResourceView(result *OneOfResourceView) (err error) {\n\tif result.Data == nil {\n\t\terr = goa.MergeErrors(err, goa.MissingFieldError(\"data\", \"result\"))\n\t}\n\treturn\n}\n" diff --git a/codegen/service/testing.go b/codegen/service/testing.go index 4b8749f269..76b8f13393 100644 --- a/codegen/service/testing.go +++ b/codegen/service/testing.go @@ -1,3 +1,5 @@ +// This file evaluates isolated service DSL fixtures. Generation construction, +// rather than the fixture, performs the final raw-method normalization step. package service import ( @@ -5,7 +7,6 @@ import ( "github.com/stretchr/testify/require" - "goa.design/goa/v3/codegen" "goa.design/goa/v3/eval" "goa.design/goa/v3/expr" ) @@ -24,14 +25,12 @@ func initDSL(t *testing.T) *expr.RootExpr { return root } -// runDSL returns the DSL root resulting from running the given DSL. The root -// is normalized like the production Generate flow does before the generators -// read the design. +// runDSL evaluates the given DSL and returns its root. Test generation helpers +// normalize the root when they construct the generation. func runDSL(t *testing.T, dsl func()) *expr.RootExpr { root := initDSL(t) require.True(t, eval.Execute(dsl, nil)) require.NoError(t, eval.RunDSL()) - codegen.NormalizeRoot(root) return root } diff --git a/codegen/service/transform_helper_operation_contract_test.go b/codegen/service/transform_helper_operation_contract_test.go new file mode 100644 index 0000000000..02f6a18efa --- /dev/null +++ b/codegen/service/transform_helper_operation_contract_test.go @@ -0,0 +1,319 @@ +// This file verifies recursive transform calls retain the field-presence +// operation selected by each result view from planning through rendered code. +package service + +import ( + "testing" + + "github.com/stretchr/testify/require" + + "goa.design/goa/v3/codegen" + "goa.design/goa/v3/dsl" + "goa.design/goa/v3/expr" +) + +type retainedTransformOperation struct { + conversion *viewConversionFacts + init *InitData + helper codegen.TransformHelper + definition *codegen.TransformFunctionData +} + +// TestRecursiveTransformCallsRetainRequiredness checks that optional values +// are tested for nil before the same strict conversion used by required values. +func TestRecursiveTransformCallsRetainRequiredness(t *testing.T) { + forwardPlan := recursiveTransformPlan(t, false) + operations := retainedRecursiveTransformOperations(t, forwardPlan) + required := operations[expr.DefaultView] + optional := operations["optional"] + require.NotNil(t, required) + require.NotNil(t, optional) + + require.NotSame(t, required.definition.Declaration, optional.definition.Declaration) + require.NotContains(t, required.definition.Code, "if v == nil") + require.NotContains(t, optional.definition.Code, "if v == nil") + require.Contains(t, required.init.Code, required.definition.Declaration.Name()+"(") + require.Contains(t, optional.init.Code, "if res.OptionalNode != nil {") + require.Contains(t, optional.init.Code, optional.definition.Declaration.Name()+"(") + require.Same( + t, + required.helper.Declaration, + required.definition.Declaration, + ) + require.Same( + t, + optional.helper.Declaration, + optional.definition.Declaration, + ) + + reversePlan := recursiveTransformPlan(t, true) + reverseOperations := retainedRecursiveTransformOperations(t, reversePlan) + for _, view := range []string{expr.DefaultView, "optional"} { + require.Equal(t, operations[view].definition.Declaration.Name(), reverseOperations[view].definition.Declaration.Name()) + require.Equal(t, operations[view].definition.Code, reverseOperations[view].definition.Code) + } + + forwardFiles, err := Files(forwardPlan) + require.NoError(t, err) + reverseFiles, err := Files(reversePlan) + require.NoError(t, err) + + compileFiles := append([]*codegen.File(nil), forwardFiles...) + compileFiles = append(compileFiles, ExampleServiceFiles(forwardPlan)...) + compileGeneratedServiceFiles(t, compileFiles) + reverseCompileFiles := append([]*codegen.File(nil), reverseFiles...) + reverseCompileFiles = append(reverseCompileFiles, ExampleServiceFiles(reversePlan)...) + compileGeneratedServiceFiles(t, reverseCompileFiles) +} + +// TestRecursiveTransformCallsShareSiblingHelper checks that required and +// optional fields call one strict conversion while the optional call stays +// inside its nil check. +func TestRecursiveTransformCallsShareSiblingHelper(t *testing.T) { + root := codegen.RunDSL(t, func() { + node := dsl.Type("Node", func() { + dsl.Attribute("label", dsl.String) + dsl.Attribute("next", "Node") + dsl.Required("label") + }) + tree := dsl.ResultType("application/vnd.sibling-tree", func() { + dsl.TypeName("SiblingTree") + dsl.Attribute("left", node) + dsl.Attribute("right", node) + dsl.Required("left") + dsl.View(expr.DefaultView, func() { + dsl.Attribute("left") + dsl.Attribute("right") + }) + }) + dsl.Service("Trees", func() { + dsl.Method("Read", func() { + dsl.Result(tree) + }) + }) + }) + plan := retainedServicePlanForPackage(t, root) + facts := plan.facts.serviceByID["Trees"] + require.NotNil(t, facts) + projected := facts.projections[facts.methods[0]].types[0] + var conversion *viewConversionFacts + for _, candidate := range projected.conversions { + if !candidate.toResult && candidate.viewName == expr.DefaultView { + conversion = candidate + break + } + } + require.NotNil(t, conversion) + helpers := conversion.plan.Helpers() + require.Len(t, helpers, 2) + require.True(t, helpers[0].Required) + require.False(t, helpers[1].Required) + require.Same(t, helpers[0].Declaration, helpers[1].Declaration) + + serviceData := plan.Services().Get("Trees") + require.NotNil(t, serviceData) + var init *InitData + for _, projectedData := range serviceData.projectedTypes { + for _, candidate := range projectedData.Projections { + if candidate.Declaration == conversion.constructor { + init = candidate + break + } + } + } + require.NotNil(t, init) + require.Len(t, init.Helpers, 1) + require.Same(t, helpers[0].Declaration, init.Helpers[0].Declaration) + require.NotContains(t, init.Helpers[0].Code, "if v == nil") + require.Contains(t, init.Code, "vres.Left = "+helpers[0].Declaration.Name()+"(res.Left)") + require.Contains(t, init.Code, "if res.Right != nil {") + require.Contains(t, init.Code, "vres.Right = "+helpers[0].Declaration.Name()+"(res.Right)") + + files, err := Files(plan) + require.NoError(t, err) + files = append(files, ExampleServiceFiles(plan)...) + compileGeneratedServiceFiles(t, files) +} + +// TestTransformHelperNamesIgnoreSiblingFieldOrder verifies that the authored +// field path, not discovery order, decides which colliding helper name keeps +// its preferred spelling. +func TestTransformHelperNamesIgnoreSiblingFieldOrder(t *testing.T) { + declarations := func(reverse bool) map[string]string { + root := codegen.RunDSL(t, func() { + node := dsl.Type("Node", func() { + dsl.Attribute("label", dsl.String) + dsl.Attribute("next", "Node") + dsl.Required("label") + }) + tree := dsl.ResultType("application/vnd.stable-tree", func() { + dsl.TypeName("StableTree") + field := func(name string) { + dsl.Attribute(name, node, func() { + dsl.Meta("test:helper", name) + }) + } + if reverse { + field("right") + field("left") + } else { + field("left") + field("right") + } + dsl.View(expr.DefaultView, func() { + dsl.Attribute("left") + dsl.Attribute("right") + }) + }) + dsl.Service("Trees", func() { + dsl.Method("Read", func() { + dsl.Result(tree) + }) + }) + }) + plan := retainedServicePlanForPackage(t, root) + facts := plan.facts.serviceByID["Trees"] + projected := facts.projections[facts.methods[0]].types[0] + var conversion *viewConversionFacts + for _, candidate := range projected.conversions { + if !candidate.toResult && candidate.viewName == expr.DefaultView { + conversion = candidate + break + } + } + require.NotNil(t, conversion) + definitions := conversion.plan.HelperDefinitions() + require.Len(t, definitions, 2) + names := make(map[string]string, len(definitions)) + for _, definition := range definitions { + marker := definition.Source.Meta["test:helper"] + require.Len(t, marker, 1) + names[marker[0]] = definition.Declaration.Name() + } + return names + } + + require.Equal(t, declarations(false), declarations(true)) +} + +// recursiveTransformPlan builds equivalent result designs in either field and +// view order, then completes their retained service planning lifecycle. +func recursiveTransformPlan(t *testing.T, reverse bool) *Plan { + t.Helper() + root := codegen.RunDSL(t, func() { + node := dsl.Type("Node", func() { + dsl.Attribute("label", dsl.String) + dsl.Attribute("next", "Node") + dsl.Required("label") + }) + tree := dsl.ResultType("application/vnd.tree", func() { + dsl.TypeName("Tree") + requiredField := func() { + dsl.Attribute("required_node", node) + } + optionalField := func() { + dsl.Attribute("optional_node", node) + } + if reverse { + optionalField() + requiredField() + } else { + requiredField() + optionalField() + } + dsl.Required("required_node") + + requiredView := func() { + dsl.View(expr.DefaultView, func() { + dsl.Attribute("required_node") + }) + } + optionalView := func() { + dsl.View("optional", func() { + dsl.Attribute("optional_node") + }) + } + if reverse { + optionalView() + requiredView() + } else { + requiredView() + optionalView() + } + }) + dsl.Service("Trees", func() { + dsl.Method("Read", func() { + dsl.Result(tree) + }) + }) + }) + return retainedServicePlanForPackage(t, root) +} + +// retainedRecursiveTransformOperations returns the service-to-view helper +// operation, its call binding, and its rendered definition for each view. +func retainedRecursiveTransformOperations(t *testing.T, plan *Plan) map[string]*retainedTransformOperation { + t.Helper() + facts := plan.facts.serviceByID["Trees"] + require.NotNil(t, facts) + data := plan.Services().Get("Trees") + require.NotNil(t, data) + + var projectedFacts *projectedTypeFacts + for _, candidate := range facts.projections[facts.methods[0]].types { + if candidate.pair.source.Name() == "Tree" { + projectedFacts = candidate + break + } + } + require.NotNil(t, projectedFacts) + var projectedData *ProjectedTypeData + for _, candidate := range data.projectedTypes { + if candidate.Type.Origin() == projectedFacts.pair.projected.Origin() { + projectedData = candidate + break + } + } + require.NotNil(t, projectedData) + + operations := make(map[string]*retainedTransformOperation) + for _, conversion := range projectedFacts.conversions { + if conversion.toResult { + continue + } + helpers := conversion.plan.Helpers() + require.NotEmpty(t, helpers, conversion.viewName) + var selected codegen.TransformHelper + required := conversion.viewName == expr.DefaultView + for _, helper := range helpers { + if helper.Required == required { + selected = helper + break + } + } + require.NotNil(t, selected.Declaration, conversion.viewName) + var init *InitData + for _, candidate := range projectedData.Projections { + if candidate.Declaration == conversion.constructor { + init = candidate + break + } + } + require.NotNil(t, init, conversion.viewName) + var definition *codegen.TransformFunctionData + for _, helper := range init.Helpers { + if helper.ID == selected.ID { + definition = helper + break + } + } + require.NotNil(t, definition, conversion.viewName) + operations[conversion.viewName] = &retainedTransformOperation{ + conversion: conversion, + init: init, + helper: selected, + definition: definition, + } + } + return operations +} diff --git a/codegen/service/type_map_identity_contract_test.go b/codegen/service/type_map_identity_contract_test.go new file mode 100644 index 0000000000..2319387bda --- /dev/null +++ b/codegen/service/type_map_identity_contract_test.go @@ -0,0 +1,33 @@ +// This file verifies external type mappings follow the exact retained user +// type origin rather than a display name shared by unrelated declarations. +package service + +import ( + "testing" + + "github.com/stretchr/testify/require" + + "goa.design/goa/v3/expr" +) + +// TestTypeMapMatchesExactRetainedUserType catches a mapping selected only +// because an unrelated service type has the same display name. +func TestTypeMapMatchesExactRetainedUserType(t *testing.T) { + serviceType := &expr.UserTypeExpr{ + AttributeExpr: &expr.AttributeExpr{Type: expr.String}, + TypeName: "Shared", + UID: "service-shared", + } + mappedType := &expr.UserTypeExpr{ + AttributeExpr: &expr.AttributeExpr{Type: expr.String}, + TypeName: "Shared", + UID: "mapped-shared", + } + facts := &serviceFacts{ + reachableTypes: map[expr.UserType]struct{}{serviceType.Origin(): {}}, + } + + require.NotSame(t, serviceType.Origin(), mappedType.Origin()) + require.True(t, typeMapMatchesFacts(&expr.TypeMap{User: serviceType}, facts)) + require.False(t, typeMapMatchesFacts(&expr.TypeMap{User: mappedType}, facts)) +} diff --git a/codegen/service/type_plan.go b/codegen/service/type_plan.go new file mode 100644 index 0000000000..8b72b4e605 --- /dev/null +++ b/codegen/service/type_plan.go @@ -0,0 +1,587 @@ +// This file records the fields, pointers, tags, declarations, and unions +// needed to write generated service types. +package service + +import ( + "fmt" + "path" + "path/filepath" + + "goa.design/goa/v3/codegen" + "goa.design/goa/v3/expr" +) + +// planServiceTypeLayouts records every field, pointer, struct tag, package, and +// declaration needed to write service types after Generation.Freeze chooses +// every declaration and import name. +func planServiceTypeLayouts(facts *serviceFacts, rootTypes *rootTypeSet, generation *codegen.Generation) error { + binder := serviceGoTypeBinder(rootTypes, generation) + plan := func(attribute *expr.AttributeExpr, owner string, retainNamedValue bool) (*codegen.GoTypePlan, error) { + if attribute == nil { + return nil, nil + } + return codegen.PlanGoType(attribute, codegen.GoTypePlanOptions{ + Owner: owner, + RetainNamedValue: retainNamedValue, + Policy: codegen.GoLayoutPolicy{ + UseDefault: true, + SumType: true, + }, + Bind: binder, + }) + } + plannedTypes := make(map[*codegen.TypeDeclaration]struct{}, len(facts.userTypes)+len(facts.errorTypes)) + for _, userType := range append(append([]*userTypeFacts(nil), facts.userTypes...), facts.errorTypes...) { + if _, planned := plannedTypes[userType.declaration]; planned { + continue + } + plannedTypes[userType.declaration] = struct{}{} + layout, err := plan(userType.userType.Attribute(), userType.declaration.PackagePath(), false) + if err != nil { + return err + } + userType.layout = layout + } + for _, errorFacts := range facts.errorFacts { + layout, err := plan(errorFacts.attribute, facts.packagePath, false) + if err != nil { + return err + } + errorFacts.layout = layout + } + for _, method := range facts.orderedMethods { + for _, attribute := range []*methodAttributeFacts{ + method.payload, + method.streamingPayload, + method.result, + method.streamingResult, + } { + if attribute == nil { + continue + } + layout, err := plan(attribute.attribute, facts.packagePath, true) + if err != nil { + return err + } + attribute.layout = layout + if userType, ok := attribute.attribute.Type.(expr.UserType); ok { + _, attribute.normalized = generation.NormalizedMethodType(userType) + } + if layout.TypeDeclaration() != nil { + userType := attribute.attribute.Type.(expr.UserType) + definition, err := plan(userType.Attribute(), layout.Owner(), true) + if err != nil { + return err + } + attribute.definition = definition + } + } + for _, errorFacts := range method.errors { + layout, err := plan(errorFacts.attribute, facts.packagePath, false) + if err != nil { + return err + } + errorFacts.layout = layout + } + } + for _, interceptor := range append( + append([]*interceptorFacts(nil), facts.serverInterceptorFacts...), + facts.clientInterceptorFacts..., + ) { + if len(interceptor.methods) == 0 { + continue + } + method := interceptor.methods[0] + accesses := []struct { + selection *expr.AttributeExpr + parent *methodAttributeFacts + target *[]*interceptorAccessFacts + }{ + {interceptor.readPayload, method.payload, &interceptor.readPayloadFields}, + {interceptor.writePayload, method.payload, &interceptor.writePayloadFields}, + {interceptor.readResult, method.result, &interceptor.readResultFields}, + {interceptor.writeResult, method.result, &interceptor.writeResultFields}, + {interceptor.readStreamingPayload, method.streamingPayload, &interceptor.readStreamingPayloadFields}, + {interceptor.writeStreamingPayload, method.streamingPayload, &interceptor.writeStreamingPayloadFields}, + {interceptor.readStreamingResult, method.streamingResult, &interceptor.readStreamingResultFields}, + {interceptor.writeStreamingResult, method.streamingResult, &interceptor.writeStreamingResultFields}, + } + for _, access := range accesses { + planned, err := planInterceptorAccess(access.selection, access.parent, facts.packagePath, binder) + if err != nil { + return err + } + *access.target = planned + } + } + for _, union := range facts.unions { + if err := planUnionRenderFacts(union, binder, generation.Package(union.declaration.PackagePath())); err != nil { + return err + } + } + return nil +} + +// planUnionRenderFacts records every Goa OneOf branch, nil rule, and Go type +// before Generation.Freeze chooses declaration and import names. +func planUnionRenderFacts(facts *unionFacts, binder codegen.GoTypeBinder, generatedPackage *codegen.GeneratedPackage) error { + facts.identity = codegen.NewUnionDeclarationID(facts.attribute) + facts.typeKey = facts.union.GetTypeKey() + facts.valueKey = facts.union.GetValueKey() + facts.branches = make([]*unionBranchFacts, len(facts.union.Values)) + storageNames := codegen.NewNameScope() + // Reserve kind for the selector so a branch named kind uses another field. + storageNames.Unique("kind") + for index, branch := range facts.union.Values { + declaration, err := generatedPackage.UnionBranch(facts.attribute, branch.Name) + if err != nil { + return err + } + layout, err := codegen.PlanGoType(branch.Attribute, codegen.GoTypePlanOptions{ + Owner: generatedPackage.ImportPath(), + Policy: codegen.GoLayoutPolicy{ + UseDefault: true, + SumType: true, + }, + Bind: binder, + }) + if err != nil { + return err + } + primitiveAliasType, hasPrimitiveAlias := primitiveAliasGoType(branch.Attribute.Type) + _, isUserType := branch.Attribute.Type.(expr.UserType) + _, hasCustomImport := layout.Import() + facts.branches[index] = &unionBranchFacts{ + name: branch.Name, + fieldName: codegen.Goify(branch.Name, true), + storageName: storageNames.Unique(codegen.Goify(branch.Name, false)), + declaration: declaration, + layout: layout, + nilable: codegen.IsNilable(branch.Attribute.Type), + emitPrimitiveAlias: hasPrimitiveAlias && !isUserType && !hasCustomImport, + primitiveAliasType: primitiveAliasType, + } + } + return nil +} + +// planInterceptorAccess records the field names, pointer choices, and Go types +// exposed to an interceptor while the design expressions are available. +func planInterceptorAccess(selection *expr.AttributeExpr, parent *methodAttributeFacts, owner string, binder codegen.GoTypeBinder) ([]*interceptorAccessFacts, error) { + if selection == nil { + return nil, nil + } + object := expr.AsObject(selection.Type) + if object == nil { + return nil, fmt.Errorf("plan interceptor access: selection must be an object") + } + if len(*object) == 0 { + return nil, nil + } + result := make([]*interceptorAccessFacts, len(*object)) + for index, field := range *object { + attribute := parent.attribute.Find(field.Name) + if attribute == nil { + return nil, fmt.Errorf("plan interceptor access: attribute %q is not present in its method value", field.Name) + } + layout, err := codegen.PlanGoType(attribute, codegen.GoTypePlanOptions{ + Owner: owner, + Policy: codegen.GoLayoutPolicy{ + UseDefault: true, + SumType: true, + }, + Bind: binder, + }) + if err != nil { + return nil, err + } + result[index] = &interceptorAccessFacts{ + attribute: expr.DupAtt(attribute), + name: codegen.Goify(field.Name, true), + pointer: parent.attribute.IsPrimitivePointer(field.Name, true), + layout: layout, + } + } + return result, nil +} + +// serviceGoTypeBinder maps authored service types and compiler-created copies +// to the generated Go declarations selected during collection. +func serviceGoTypeBinder(rootTypes *rootTypeSet, generation *codegen.Generation) codegen.GoTypeBinder { + return func(request codegen.GoTypeBindingRequest) (codegen.GoTypeBinding, error) { + owner := request.InheritedOwner + if location := codegen.UserTypeLocation(request.Attribute.Type); location != nil { + owner = path.Join(generation.GenPkg(), location.RelImportPath) + } + generatedPackage := generation.Package(owner) + switch request.Kind { + case codegen.GoNamed: + userType := request.Attribute.Type.(expr.UserType) + declaration, err := generatedPackage.Type(rootTypes.canonical(userType)) + if err != nil { + return codegen.GoTypeBinding{}, err + } + return codegen.GoTypeBinding{Owner: owner, Type: declaration}, nil + case codegen.GoUnion: + declaration, err := generatedPackage.Union(request.Attribute) + if err != nil { + return codegen.GoTypeBinding{}, err + } + return codegen.GoTypeBinding{Owner: owner, Union: declaration}, nil + default: + return codegen.GoTypeBinding{}, fmt.Errorf("bind unsupported retained Go type kind %s", request.Kind) + } + } +} + +// collectServiceUnionFacts selects each service Goa OneOf type once in every +// package that writes it and records its generated declaration. +func collectServiceUnionFacts(facts *serviceFacts, rootTypes *rootTypeSet, generation *codegen.Generation) error { + seenTypes := make(map[plannedUserType]struct{}) + seenUnions := make(map[unionDataKey]struct{}) + collect := func(attribute *expr.AttributeExpr, location *codegen.Location) error { + return collectUnionFacts(attribute, facts.packagePath, location, rootTypes, generation, seenTypes, seenUnions, &facts.unions) + } + for _, userType := range facts.userTypes { + if err := collect(&expr.AttributeExpr{Type: userType.userType}, userType.location); err != nil { + return err + } + } + for _, method := range facts.methods { + attributes := []*expr.AttributeExpr{method.Payload, method.StreamingPayload, method.Result} + if method.HasMixedResults() { + attributes = append(attributes, method.StreamingResult) + } + for _, attribute := range attributes { + var location *codegen.Location + if attribute != nil { + location = codegen.UserTypeLocation(attribute.Type) + } + if err := collect(attribute, location); err != nil { + return err + } + } + for _, methodError := range method.Errors { + if err := collect(methodError.AttributeExpr, codegen.UserTypeLocation(methodError.Type)); err != nil { + return err + } + } + } + return nil +} + +// collectUnionFacts recursively records union declarations while keeping +// unlocated nested types in the package inherited from their enclosing type. +func collectUnionFacts(attribute *expr.AttributeExpr, servicePath string, location *codegen.Location, rootTypes *rootTypeSet, generation *codegen.Generation, seenTypes map[plannedUserType]struct{}, seenUnions map[unionDataKey]struct{}, unions *[]*unionFacts) error { + if attribute == nil || attribute.Type == expr.Empty { + return nil + } + recurse := func(attribute *expr.AttributeExpr, location *codegen.Location) error { + return collectUnionFacts(attribute, servicePath, location, rootTypes, generation, seenTypes, seenUnions, unions) + } + switch actual := attribute.Type.(type) { + case expr.UserType: + typeLocation := codegen.UserTypeLocation(actual) + if typeLocation == nil { + typeLocation = location + } + owner := generation.Package(generatedPackagePath(generation.GenPkg(), servicePath, typeLocation)) + key := plannedUserType{userType: rootTypes.canonical(actual), owner: owner} + if _, exists := seenTypes[key]; exists { + return nil + } + seenTypes[key] = struct{}{} + return recurse(actual.Attribute(), typeLocation) + case *expr.Object: + for _, field := range sortedNamedAttributes(*actual) { + if err := recurse(field.Attribute, location); err != nil { + return err + } + } + case *expr.Array: + return recurse(actual.ElemType, location) + case *expr.Map: + if err := recurse(actual.KeyType, location); err != nil { + return err + } + return recurse(actual.ElemType, location) + case *expr.Union: + packagePath := generatedPackagePath(generation.GenPkg(), servicePath, location) + identity := codegen.NewUnionDeclarationID(attribute) + key := unionDataKey{packagePath: packagePath, identity: identity} + if _, exists := seenUnions[key]; !exists { + declaration, err := generation.Package(packagePath).Union(attribute) + if err != nil { + return err + } + seenUnions[key] = struct{}{} + *unions = append(*unions, &unionFacts{ + attribute: attribute, + union: actual, + identity: identity, + typeKey: actual.GetTypeKey(), + valueKey: actual.GetValueKey(), + location: location, + declaration: declaration, + }) + } + for _, branch := range actual.Values { + if err := recurse(branch.Attribute, location); err != nil { + return err + } + } + } + return nil +} + +// typeMapMatchesFacts reports whether a user-supplied Go type mapping applies +// to a payload, result, error, stream value, or child type selected for this +// service. +func typeMapMatchesFacts(typeMap *expr.TypeMap, facts *serviceFacts) bool { + _, reachable := facts.reachableTypes[typeMap.User.Origin()] + return reachable +} + +// collectServiceTypeFacts selects the exact named types emitted for one +// service. Linking later formats these records without searching for the types +// again or deciding which generated package contains them. +func collectServiceTypeFacts(facts *serviceFacts, rootTypes []expr.UserType, canonical *rootTypeSet, generation *codegen.Generation) error { + seen := make(map[userTypeDataKey]struct{}) + selectedTypes := make(map[userTypeDataKey]*userTypeFacts) + appendTypes := func(selected []*userTypeFacts) { + for _, userType := range selected { + key := userTypeDataKey{origin: userType.userType.Origin(), declaration: userType.declaration} + selectedTypes[key] = userType + facts.userTypes = append(facts.userTypes, userType) + } + } + for _, method := range facts.methods { + attributes := []*expr.AttributeExpr{method.Payload, method.StreamingPayload, method.Result} + if method.HasMixedResults() { + attributes = append(attributes, method.StreamingResult) + } + for _, attribute := range attributes { + if attribute == nil { + continue + } + location := (*codegen.Location)(nil) + inner := attribute + if userType, ok := attribute.Type.(expr.UserType); ok { + location = codegen.UserTypeLocation(userType) + if _, normalized := generation.NormalizedMethodType(userType); normalized || location == nil { + inner = userType.Attribute() + } + } + selected, err := collectUserTypeFacts(inner, facts.packagePath, location, canonical, generation, seen) + if err != nil { + return err + } + appendTypes(selected) + } + } + for _, method := range facts.methods { + attributes := []*expr.AttributeExpr{method.Payload, method.StreamingPayload, method.Result} + if method.HasMixedResults() { + attributes = append(attributes, method.StreamingResult) + } + for _, attribute := range attributes { + if attribute == nil || attribute.Type == expr.Empty { + continue + } + if _, raw := attribute.Type.(*expr.Object); raw { + panic(fmt.Sprintf( + "service %q method %q declares a raw object type: codegen.NewGeneration must own the finalized design before generators read it", + facts.service.Name, method.Name)) + } + if userType, ok := attribute.Type.(expr.UserType); ok { + declaration, err := generation.Package(generatedPackagePath( + generation.GenPkg(), facts.packagePath, codegen.UserTypeLocation(userType), + )).Type(userType) + if err != nil { + return err + } + seen[userTypeDataKey{origin: userType.Origin(), declaration: declaration}] = struct{}{} + } + } + } + for _, userType := range rootTypes { + services, forced := userType.Attribute().Meta["type:generate:force"] + if !forced || len(services) > 0 && !containsString(services, facts.service.Name) { + continue + } + selected, err := collectUserTypeFacts( + &expr.AttributeExpr{Type: userType}, facts.packagePath, nil, canonical, generation, seen, + ) + if err != nil { + return err + } + appendTypes(selected) + } + errorTypesSeen := make(map[userTypeDataKey]struct{}) + recordError := func(serviceError *expr.ErrorExpr) error { + if expr.IsErrorResult(serviceError.Type) { + return nil + } + selected, err := collectUserTypeFacts( + serviceError.AttributeExpr, facts.packagePath, nil, canonical, generation, seen, + ) + if err != nil { + return err + } + appendTypes(selected) + userType := serviceError.Type.(expr.UserType) + errorType, key, err := makeUserTypeFacts(userType, facts.packagePath, nil, canonical, generation) + if err != nil { + return err + } + if _, recorded := errorTypesSeen[key]; recorded { + return nil + } + errorTypesSeen[key] = struct{}{} + if selected := selectedTypes[key]; selected != nil { + errorType = selected + } + facts.errorTypes = append(facts.errorTypes, errorType) + return nil + } + for _, serviceError := range facts.errors { + if err := recordError(serviceError); err != nil { + return err + } + } + for _, method := range facts.methods { + for _, methodError := range method.Errors { + if err := recordError(methodError); err != nil { + return err + } + } + } + for _, userType := range facts.userTypes { + facts.reachableTypes[userType.userType.Origin()] = struct{}{} + } + return nil +} + +// collectUserTypeFacts recursively selects named types while carrying the +// package location inherited from an enclosing generated type. +func collectUserTypeFacts(attribute *expr.AttributeExpr, servicePath string, location *codegen.Location, canonical *rootTypeSet, generation *codegen.Generation, seen map[userTypeDataKey]struct{}) ([]*userTypeFacts, error) { + if attribute == nil || attribute.Type == expr.Empty { + return nil, nil + } + collect := func(attribute *expr.AttributeExpr, location *codegen.Location) ([]*userTypeFacts, error) { + return collectUserTypeFacts(attribute, servicePath, location, canonical, generation, seen) + } + var result []*userTypeFacts + switch actual := attribute.Type.(type) { + case expr.UserType: + facts, key, err := makeUserTypeFacts(actual, servicePath, location, canonical, generation) + if err != nil { + return nil, err + } + if _, exists := seen[key]; exists { + return nil, nil + } + seen[key] = struct{}{} + result = append(result, facts) + nested, err := collect(actual.Attribute(), facts.location) + return append(result, nested...), err + case *expr.Object: + for _, field := range *actual { + selected, err := collect(field.Attribute, location) + if err != nil { + return nil, err + } + result = append(result, selected...) + } + case *expr.Array: + return collect(actual.ElemType, location) + case *expr.Map: + key, err := collect(actual.KeyType, location) + if err != nil { + return nil, err + } + value, err := collect(actual.ElemType, location) + return append(key, value...), err + case *expr.Union: + for _, branch := range actual.Values { + if userType, generated := generatedUnionBranch(branch, canonical); generated && location != nil { + selected, err := collect( + &expr.AttributeExpr{Type: userType}, + generatedUnionBranchLocation(userType, location), + ) + if err != nil { + return nil, err + } + result = append(result, selected...) + continue + } + selected, err := collect(branch.Attribute, location) + if err != nil { + return nil, err + } + result = append(result, selected...) + } + } + return result, nil +} + +// makeUserTypeFacts records one authored type and the generated declaration +// that every use of that type must share. +func makeUserTypeFacts(userType expr.UserType, servicePath string, location *codegen.Location, canonical *rootTypeSet, generation *codegen.Generation) (*userTypeFacts, userTypeDataKey, error) { + typeLocation := codegen.UserTypeLocation(userType) + if typeLocation == nil { + typeLocation = location + } + declaration, err := generation.Package( + generatedPackagePath(generation.GenPkg(), servicePath, typeLocation), + ).Type(canonical.canonical(userType)) + if err != nil { + return nil, userTypeDataKey{}, err + } + key := userTypeDataKey{origin: userType.Origin(), declaration: declaration} + return &userTypeFacts{ + userType: userType, + name: userType.Name(), + description: userType.Attribute().Description, + errorName: retainedErrorName(userType), + serviceError: expr.IsErrorResult(userType), + location: typeLocation, + declaration: declaration, + }, key, nil +} + +// generatedUnionBranchLocation gives a compiler-created OneOf branch one file +// regardless of which relocated type contains the union. +func generatedUnionBranchLocation(userType expr.UserType, location *codegen.Location) *codegen.Location { + return &codegen.Location{ + FilePath: filepath.Join(filepath.Dir(location.FilePath), codegen.SnakeCase(userType.Name())+".go"), + RelImportPath: location.RelImportPath, + } +} + +// This helper copies the Go expression returned by GoaErrorName while the +// design error metadata is still available. +func retainedErrorName(userType expr.UserType) string { + if object := expr.AsObject(userType); object != nil { + for _, field := range *object { + if _, ok := field.Attribute.Meta["struct:error:name"]; ok { + return fmt.Sprintf("e.%s", codegen.GoifyAtt(field.Attribute, field.Name, true)) + } + } + } + if value, ok := userType.Attribute().Meta["struct:error:name"]; ok { + return fmt.Sprintf("%q", value[0]) + } + return fmt.Sprintf("%q", userType.Name()) +} + +// containsString reports whether values contains target without introducing a +// second service-selection representation. +func containsString(values []string, target string) bool { + for _, value := range values { + if value == target { + return true + } + } + return false +} diff --git a/codegen/service/view_data.go b/codegen/service/view_data.go new file mode 100644 index 0000000000..09f1587d30 --- /dev/null +++ b/codegen/service/view_data.go @@ -0,0 +1,590 @@ +// This file builds generated view types, constructors, and validation +// functions from the data selected during planning. +package service + +import ( + "bytes" + "fmt" + + "goa.design/goa/v3/codegen" + "goa.design/goa/v3/expr" +) + +// projectTypePairs rewrites a copied result graph into pointer-backed view +// types and returns each generated declaration with its exact source. The +// source Origin makes independently rebuilt plan and render graphs select the +// same package record. +func projectTypePairs(projected, source *expr.AttributeExpr, seen map[expr.UserType]expr.UserType) []*projectedTypePair { + collect := func(projected, source *expr.AttributeExpr) []*projectedTypePair { + return projectTypePairs(projected, source, seen) + } + switch projectedType := projected.Type.(type) { + case expr.UserType: + sourceType := source.Type.(expr.UserType) + origin := sourceType.Origin() + if existing, ok := seen[origin]; ok { + if existing != nil { + projected.Type = existing + } + return nil + } + seen[origin] = nil + projectedType.Rename(projectedType.Name() + "View") + nested := collect(projectedType.Attribute(), sourceType.Attribute()) + seen[origin] = projectedType + return append([]*projectedTypePair{{ + source: sourceType, + projected: projectedType, + sourceAttribute: source, + projectedAttribute: projected, + }}, nested...) + case *expr.Array: + return collect(projectedType.ElemType, source.Type.(*expr.Array).ElemType) + case *expr.Map: + sourceMap := source.Type.(*expr.Map) + pairs := collect(projectedType.KeyType, sourceMap.KeyType) + return append(pairs, collect(projectedType.ElemType, sourceMap.ElemType)...) + case *expr.Object: + sourceObject := source.Type.(*expr.Object) + var pairs []*projectedTypePair + for _, field := range *projectedType { + pairs = append(pairs, collect(field.Attribute, sourceObject.Attribute(field.Name))...) + } + return pairs + case *expr.Union: + sourceUnion := source.Type.(*expr.Union) + var pairs []*projectedTypePair + for index, branch := range projectedType.Values { + pairs = append(pairs, collect(branch.Attribute, sourceUnion.Values[index].Attribute)...) + } + return pairs + default: + return nil + } +} + +// projectedResultRoot returns the root attribute used to build result types +// containing only the fields in each view of m.Result. Generation records +// which method wrappers Goa created, so authored types with matching text stay +// unchanged. +func projectedResultRoot(generation *codegen.Generation, m *expr.MethodExpr) (*expr.AttributeExpr, *expr.AttributeExpr) { + if ut, ok := m.Result.Type.(*expr.UserTypeExpr); ok { + if _, normalized := generation.NormalizedMethodType(ut); !normalized { + return expr.DupAtt(m.Result), m.Result + } + return expr.DupAtt(ut.Attribute()), ut.Attribute() + } + return expr.DupAtt(m.Result), m.Result +} + +// hasResultType returns true if the given attribute has a result type recursively. +func hasResultType(att *expr.AttributeExpr, seens ...map[expr.UserType]struct{}) bool { + if _, ok := att.Type.(*expr.ResultTypeExpr); ok { + return true + } + var seen map[expr.UserType]struct{} + if len(seens) > 0 { + seen = seens[0] + } else { + seen = make(map[expr.UserType]struct{}) + } + switch a := att.Type.(type) { + case expr.UserType: + origin := a.Origin() + if _, ok := seen[origin]; ok { + return false + } + seen[origin] = struct{}{} + return hasResultType(a.Attribute(), seen) + case *expr.Array: + return hasResultType(a.ElemType, seen) + case *expr.Map: + return hasResultType(a.KeyType, seen) || hasResultType(a.ElemType, seen) + case *expr.Object: + for _, nat := range *a { + if hasResultType(nat.Attribute, seen) { + return true + } + } + case *expr.Union: + for _, nat := range a.Values { + if hasResultType(nat.Attribute, seen) { + return true + } + } + } + return false +} + +// buildProjectedType returns render data for one view-specific declaration +// whose fields use pointers, plus conversions to and from the source service +// type. +func buildProjectedType(facts *projectedTypeFacts, serviceResolver, viewResolver *declarationResolver, declaration *codegen.TypeDeclaration, viewsPkg string) *ProjectedTypeData { + var ( + projections []*InitData + typeInits []*InitData + views []*ViewData + + varname = declaration.Name() + pt = facts.projectedType + ) + if facts.resultType { + typeInits = buildViewConversions(facts, serviceResolver, viewResolver, true) + projections = buildViewConversions(facts, serviceResolver, viewResolver, false) + serviceName := facts.source.Link( + serviceResolver.outputPath, + retainedTypeQualifier(serviceResolver.aliases, serviceResolver.outputPath), + ).Name() + views = buildViews(facts.views, serviceName, facts.mapDeclaration, facts.conversions) + } + validations := buildValidations(facts, viewResolver) + linked := facts.projected.Link(viewResolver.outputPath, retainedTypeQualifier(viewResolver.aliases, viewResolver.outputPath)) + definition := facts.definition.Link(viewResolver.outputPath, retainedTypeQualifier(viewResolver.aliases, viewResolver.outputPath)) + return &ProjectedTypeData{ + UserTypeData: &UserTypeData{ + Declaration: declaration, + Name: varname, + Description: fmt.Sprintf("%s is a type that runs validations on a projected type.", varname), + VarName: varname, + Def: definition.Def(), + Ref: linked.Ref(), + Type: pt, + }, + Projections: projections, + TypeInits: typeInits, + Validations: validations, + ViewsPkg: viewsPkg, + Views: views, + } +} + +// buildViews builds the view data for all the views in the given result type. +func buildViews(facts []*viewRenderFacts, typeName string, mapDeclaration *codegen.NameDeclaration, conversions []*viewConversionFacts) []*ViewData { + toProjected := make(map[string]*codegen.NameDeclaration) + toResult := make(map[string]*codegen.NameDeclaration) + for _, conversion := range conversions { + calls := toProjected + if conversion.toResult { + calls = toResult + } + calls[canonicalValidatorView(conversion.viewName)] = conversion.constructor + } + views := make([]*ViewData, len(facts)) + for i, view := range facts { + views[i] = &ViewData{ + Name: view.name, + Description: view.description, + Attributes: append([]string(nil), view.attributes...), + TypeVarName: typeName, + MapDeclaration: mapDeclaration, + ToProjected: toProjected[canonicalValidatorView(view.name)], + ToResult: toResult[canonicalValidatorView(view.name)], + } + } + return views +} + +// buildViewedResultType formats the viewed-result wrapper copied during +// planning and its constructors without rereading the mutable design +// expression. +func buildViewedResultType(facts *viewedResultFacts, viewspkg string, serviceResolver, viewResolver *declarationResolver, declaration *codegen.TypeDeclaration) *ViewedResultTypeData { + isarr := facts.isCollection + viewName := facts.viewName + views := buildViews(facts.views, declaration.Name(), facts.mapDeclaration, facts.conversions) + + // build validation data + qualifier := retainedTypeQualifier(serviceResolver.aliases, serviceResolver.outputPath) + serviceType := facts.source.layout.Link(serviceResolver.outputPath, qualifier) + resvar, serviceRef := declaration.Name(), serviceType.Ref() + projT := facts.wrapped + wrapperViewType := facts.wrappedLayout.Link(viewResolver.outputPath, qualifier) + resref := wrapperViewType.Ref() + if isarr { + resref = wrapperViewType.RefWithPointer(false) + } + validationCalls := make([]*ValidationCallData, len(facts.views)) + for index, view := range facts.views { + validationCalls[index] = newRetainedValidationCall(facts.validationCalls[index], view.name) + } + data := map[string]any{ + "ArgVar": "result", + "Source": "result", + "ValidationCalls": validationCalls, + "IsViewed": true, + } + buf := &bytes.Buffer{} + if err := validateTypeCodeTmpl.Execute(buf, data); err != nil { + panic(err) // bug + } + validatorDeclaration := facts.validator + name := validatorDeclaration.Name() + validate := &ValidateData{ + Declaration: validatorDeclaration, + Name: validatorDeclaration.Name(), + Description: fmt.Sprintf("%s runs the validations defined on the viewed result type %s.", name, resvar), + Ref: resref, + Validate: buf.String(), + Calls: validationCalls, + } + + // build constructor to initialize viewed result type from result type + wrapperServiceType := facts.wrappedLayout.Link(serviceResolver.outputPath, qualifier) + vresref := wrapperServiceType.Ref() + if isarr { + vresref = wrapperServiceType.RefWithPointer(false) + } + data = map[string]any{ + "ToViewed": true, + "ArgVar": "res", + "ReturnVar": "vres", + "Views": views, + "ReturnTypeRef": vresref, + "IsCollection": isarr, + "TargetType": wrapperServiceType.Name(), + } + buf = &bytes.Buffer{} + if err := initTypeCodeTmpl.Execute(buf, data); err != nil { + panic(err) // bug + } + name = facts.toViewed.Name() + init := &InitData{ + Declaration: facts.toViewed, + Name: facts.toViewed.Name(), + Description: fmt.Sprintf("%s initializes viewed result type %s from result type %s using the given view.", name, resvar, resvar), + Args: []*InitArgData{ + {Name: "res", Ref: serviceRef}, + {Name: "view", Ref: "string"}, + }, + ReturnTypeRef: vresref, + Code: buf.String(), + } + + // build constructor to initialize result type from viewed result type + resref = serviceRef + data = map[string]any{ + "ToResult": true, + "ArgVar": "vres", + "ReturnVar": "res", + "Views": views, + "ReturnTypeRef": resref, + } + buf = &bytes.Buffer{} + if err := initTypeCodeTmpl.Execute(buf, data); err != nil { + panic(err) // bug + } + name = facts.toResult.Name() + resinit := &InitData{ + Declaration: facts.toResult, + Name: facts.toResult.Name(), + Description: fmt.Sprintf("%s initializes result type %s from viewed result type %s.", name, resvar, resvar), + Args: []*InitArgData{{Name: "vres", Ref: vresref}}, + ReturnTypeRef: resref, + Code: buf.String(), + } + + return &ViewedResultTypeData{ + UserTypeData: &UserTypeData{ + Declaration: declaration, + Name: resvar, + Description: fmt.Sprintf("%s is the viewed result type that is projected based on a view.", resvar), + VarName: resvar, + Def: facts.wrappedDef.Link(viewResolver.outputPath, qualifier).Def(), + Ref: resref, + Type: projT, + }, + FullName: wrapperServiceType.Name(), + FullRef: vresref, + ResultInit: resinit, + Init: init, + Views: views, + Validate: validate, + IsCollection: isarr, + ViewName: viewName, + ViewsPkg: viewspkg, + } +} + +// wrapProjected builds a viewed result type with two fields: "projected" holds +// the supplied view-specific result, and "view" records the selected view name. +func wrapProjected(projected expr.UserType) expr.UserType { + rt := projected.(*expr.ResultTypeExpr) + pratt := &expr.NamedAttributeExpr{ + Name: "projected", + Attribute: &expr.AttributeExpr{Type: rt, Description: "Type to project"}, + } + prview := &expr.NamedAttributeExpr{ + Name: "view", + Attribute: &expr.AttributeExpr{Type: expr.String, Description: "View to render"}, + } + return &expr.ResultTypeExpr{ + UserTypeExpr: &expr.UserTypeExpr{ + AttributeExpr: &expr.AttributeExpr{ + Type: &expr.Object{pratt, prview}, + Validation: &expr.ValidationExpr{Required: []string{"projected", "view"}}, + }, + TypeName: rt.TypeName, + }, + Identifier: rt.Identifier, + Views: rt.Views, + } +} + +// buildViewConversions builds one constructor per view to convert between a +// complete service result and the result fields selected by that view. When +// toResult is true, each constructor rebuilds the service result. Otherwise it +// copies only the selected view fields from the service result. +func buildViewConversions(facts *projectedTypeFacts, serviceResolver, viewResolver *declarationResolver, toResult bool) []*InitData { + init := make([]*InitData, 0, len(facts.conversions)/2) + serviceType := facts.source.Link(serviceResolver.outputPath, retainedTypeQualifier(serviceResolver.aliases, serviceResolver.outputPath)) + serviceName, serviceRef := serviceType.Name(), serviceType.Ref() + projectedDeclaration := facts.declaration + projectedRef := facts.projected.Link(serviceResolver.outputPath, retainedTypeQualifier(serviceResolver.aliases, serviceResolver.outputPath)).Ref() + serviceViewResolver := viewResolver.withOutputPackage(serviceResolver.outputPath) + for _, conversion := range facts.conversions { + if conversion.toResult != toResult { + continue + } + viewedResolver := serviceViewResolver.bindDerived(conversion.contextType, conversion.contextIdentity) + if conversion.elementType != nil { + viewedResolver = viewedResolver.bindDerived(conversion.elementType, conversion.elementIdentity) + } + targetType := conversion.targetLayout.Link( + serviceResolver.outputPath, + retainedTypeQualifier(serviceResolver.aliases, serviceResolver.outputPath), + ).Name() + if toResult { + srcCtx := declarationContext(viewedResolver, true) + tgtCtx := declarationContext(serviceResolver, false) + resvar := serviceName + name := conversion.constructor.Name() + code, helpers := buildConstructorCode( + conversion, + "vres", + "res", + srcCtx, + tgtCtx, + targetType, + ) + init = append(init, &InitData{ + Declaration: conversion.constructor, + Name: conversion.constructor.Name(), + Description: fmt.Sprintf("%s converts projected type %s to service type %s.", name, resvar, resvar), + Args: []*InitArgData{{Name: "vres", Ref: projectedRef}}, + ReturnTypeRef: serviceRef, + Code: code, + Helpers: helpers, + }) + } else { + srcCtx := declarationContext(serviceResolver, false) + tgtCtx := declarationContext(viewedResolver, true) + tname := projectedDeclaration.Name() + name := conversion.constructor.Name() + code, helpers := buildConstructorCode( + conversion, + "res", + "vres", + srcCtx, + tgtCtx, + targetType, + ) + init = append(init, &InitData{ + Declaration: conversion.constructor, + Name: conversion.constructor.Name(), + Description: fmt.Sprintf("%s projects result type %s to projected type %s using the %q view.", name, serviceName, tname, conversion.viewName), + Args: []*InitArgData{{Name: "res", Ref: serviceRef}}, + ReturnTypeRef: projectedRef, + Code: code, + Helpers: helpers, + }) + } + } + return init +} + +// buildValidations builds the data required to validate result types containing +// only the fields in their selected views. +func buildValidations(projected *projectedTypeFacts, resolver *declarationResolver) []*ValidateData { + linkedType := projected.projected.Link(resolver.outputPath, retainedTypeQualifier(resolver.aliases, resolver.outputPath)) + tname := linkedType.Name() + var validations []*ValidateData + if projected.resultType { + // for result types we create a validation function containing view + // specific validation logic for each view + for _, facts := range projected.validations { + if !facts.needed { + continue + } + viewName := facts.viewName + data := map[string]any{ + "Projected": tname, + "ArgVar": "result", + "Source": "result", + "IsCollection": facts.collectionElem != nil, + } + declaration := facts.declaration + name := declaration.Name() + var calls []*ValidationCallData + + if facts.collectionElem != nil { + // dealing with an array type + data["Source"] = "item" + call := newRetainedValidationCall(facts.collectionCall, viewName) + data["ValidateCall"] = call + calls = append(calls, call) + } else { + fields := make([]*validationFieldData, 0, len(facts.fields)) + for _, field := range facts.fields { + if !field.required && field.call == nil { + continue + } + var call *ValidationCallData + if field.call != nil { + call = newRetainedValidationCall(field.call, field.view) + } + fields = append(fields, &validationFieldData{ + Name: field.name, + Call: call, + IsRequired: field.required, + }) + if call != nil { + calls = append(calls, call) + } + } + data["Validate"] = renderRetainedValidation(facts, resolver) + data["Fields"] = fields + } + + buf := &bytes.Buffer{} + if err := validateTypeCodeTmpl.Execute(buf, data); err != nil { + panic(err) // bug + } + + validations = append(validations, &ValidateData{ + Declaration: declaration, + Name: declaration.Name(), + Description: fmt.Sprintf("%s runs the validations defined on %s using the %q view.", name, tname, viewName), + Ref: linkedType.Ref(), + Validate: buf.String(), + Calls: calls, + }) + } + } else { + // for a user type or a result type with single view, we generate only one validation + // function containing the validation logic + facts := projected.validations[0] + if !facts.needed { + return nil + } + declaration := facts.declaration + name := declaration.Name() + validations = append(validations, &ValidateData{ + Declaration: declaration, + Name: declaration.Name(), + Description: fmt.Sprintf("%s runs the validations defined on %s.", name, tname), + Ref: linkedType.Ref(), + Validate: renderRetainedValidation(facts, resolver), + }) + } + return validations +} + +// This helper formats a saved validation plan using the completed declarations +// and import names from the views package. +func renderRetainedValidation(facts *validationFacts, resolver *declarationResolver) string { + linkedLayout := facts.layout.Link(resolver.outputPath, retainedTypeQualifier(resolver.aliases, resolver.outputPath)) + linked, err := facts.plan.Link(linkedLayout) + if err != nil { + panic(err) // bug + } + return linked.Render("result", "result") +} + +// This helper formats one nested validation call from the exact function +// declaration recorded during planning. +func newRetainedValidationCall(declaration *codegen.NameDeclaration, view string) *ValidationCallData { + return &ValidationCallData{ + Declaration: declaration, + View: view, + Default: canonicalValidatorView(view) == "", + } +} + +// buildConstructorCode builds code that copies fields between a complete +// service result and a result containing only one view's fields. +// +// sourceCtx and targetCtx provide the package names, pointer rules, and field +// names used to read the source value and write the target value. +// +// sourceVar and targetVar contains the variable name that holds the source and +// target data structures in the transformation code. +// +// view is used to generate the constructor function name. +func buildConstructorCode(facts *viewConversionFacts, sourceVar, targetVar string, sourceCtx, targetCtx *codegen.AttributeContext, targetType string) (string, []*codegen.TransformFunctionData) { + var ( + helpers []*codegen.TransformFunctionData + buf bytes.Buffer + ) + data := map[string]any{ + "ArgVar": sourceVar, + "ReturnVar": targetVar, + "IsCollection": facts.collection, + "TargetType": targetType, + } + + if facts.collection { + // result type collection + data["Init"] = facts.elementCall + if err := initTypeCodeTmpl.Execute(&buf, data); err != nil { + panic(err) // bug + } + return buf.String(), helpers + } + + data["Source"] = sourceVar + data["Target"] = targetVar + + if err := facts.plan.BindContexts(sourceCtx, targetCtx); err != nil { + panic(err) // bug + } + code, helpers, err := facts.plan.Render(sourceVar, targetVar, true) + if err != nil { + panic(err) // bug + } + data["Code"] = code + + fields := make([]*constructorFieldData, 0, len(facts.fields)) + for _, field := range facts.fields { + fields = append(fields, &constructorFieldData{ + VarName: codegen.Goify(field.name, true), + Declaration: field.call, + }) + } + data["Fields"] = fields + + if err := initTypeCodeTmpl.Execute(&buf, data); err != nil { + panic(err) // bug + } + return buf.String(), helpers +} + +// walkViewAttrs iterates through the attributes in att that are found in the +// given view and executes the walker function. +func walkViewAttrs(obj *expr.Object, view *expr.ViewExpr, walker func(name string, attr, vatt *expr.AttributeExpr)) { + for _, nat := range *expr.AsObject(view.Type) { + if attr := obj.Attribute(nat.Name); attr != nil { + walker(nat.Name, attr, nat.Attribute) + } + } +} + +// removeMeta removes the meta attributes from the given attribute. This is +// needed to make sure that any field name overriding is removed when +// generating protobuf types (as protogen itself won't honor these overrides). +func removeMeta(att *expr.AttributeExpr) { + if err := codegen.Walk(att, func(a *expr.AttributeExpr) error { + delete(a.Meta, "struct:pkg:path") + return nil + }); err != nil { + panic(err) // bug + } +} diff --git a/codegen/service/view_validation_plan.go b/codegen/service/view_validation_plan.go new file mode 100644 index 0000000000..0cd681ee8e --- /dev/null +++ b/codegen/service/view_validation_plan.go @@ -0,0 +1,253 @@ +// This file records validation for result types narrowed to their declared +// views. Each field check and child call uses the Go type and function +// declarations submitted for the generated views package. +package service + +import ( + "fmt" + + "goa.design/goa/v3/codegen" + "goa.design/goa/v3/expr" +) + +// viewValidationPolicy states that generated view fields use pointers, apply +// defaults, and represent Goa OneOf values with generated structs. Both type +// layout and validation use these same choices. +func viewValidationPolicy() codegen.GoLayoutPolicy { + return codegen.GoLayoutPolicy{ + Pointer: true, + UseDefault: true, + SumType: true, + } +} + +// planServiceValidations records every field check and child validation call +// written for service result views after all view type names are submitted. +func planServiceValidations(facts *serviceFacts, rootTypes *rootTypeSet, generation *codegen.Generation) error { + hasProjection := false + for _, method := range facts.orderedMethods { + hasProjection = hasProjection || method.projection != nil + } + if !hasProjection { + return nil + } + views := generation.Package(facts.viewsPath) + derived := make(map[expr.UserType]*codegen.TypeDeclaration) + wrappers := make(map[expr.UserType]*codegen.TypeDeclaration) + for _, method := range facts.orderedMethods { + if method.viewedResult != nil { + wrappers[method.viewedResult.wrapped] = method.viewedResult.declaration + } + if method.projection == nil { + continue + } + for _, projected := range method.projection.types { + declaration, err := views.DerivedType(codegen.NewProjectedTypeID(projected.pair.source)) + if err != nil { + return err + } + derived[projected.pair.projected.Origin()] = declaration + } + } + binder := func(request codegen.GoTypeBindingRequest) (codegen.GoTypeBinding, error) { + switch request.Kind { + case codegen.GoNamed: + userType := request.Attribute.Type.(expr.UserType) + declaration := wrappers[userType] + if declaration == nil { + declaration = derived[userType.Origin()] + } + if declaration == nil { + var err error + declaration, err = views.Type(userType) + if err != nil { + return codegen.GoTypeBinding{}, err + } + } + return codegen.GoTypeBinding{Owner: facts.viewsPath, Type: declaration}, nil + case codegen.GoUnion: + declaration, err := views.Union(request.Attribute) + if err != nil { + return codegen.GoTypeBinding{}, err + } + return codegen.GoTypeBinding{Owner: facts.viewsPath, Union: declaration}, nil + default: + return codegen.GoTypeBinding{}, fmt.Errorf("bind unsupported view validation type %s", request.Kind) + } + } + planLayout := func(attribute *expr.AttributeExpr, pointer bool) (*codegen.GoTypePlan, error) { + policy := viewValidationPolicy() + policy.Pointer = pointer + return codegen.PlanGoType(attribute, codegen.GoTypePlanOptions{ + Owner: facts.viewsPath, + Policy: policy, + Bind: binder, + }) + } + validator := func(request codegen.ValidatorBindingRequest) (*codegen.NameDeclaration, error) { + declaration := request.Layout.TypeDeclaration() + retained := facts.validators[validatorKey{ + declaration: declaration, + view: canonicalValidatorView(request.View), + }] + if retained == nil { + return nil, fmt.Errorf( + "validator for declaration %p and view %q was not retained", + declaration, + request.View, + ) + } + return retained, nil + } + validatorCall := func(attribute *expr.AttributeExpr, view string, required bool) (*codegen.NameDeclaration, error) { + layout, err := planLayout(attribute, true) + if err != nil { + return nil, err + } + request := codegen.ValidatorBindingRequest{ + Attribute: attribute, + Layout: layout, + View: view, + } + declaration := facts.validators[validatorKey{ + declaration: layout.TypeDeclaration(), + view: canonicalValidatorView(view), + }] + if declaration == nil && required { + return validator(request) + } + return declaration, nil + } + for _, method := range facts.orderedMethods { + if method.projection == nil { + continue + } + for _, projected := range method.projection.types { + projected.resultType = len(projected.views) > 0 + layout, err := planLayout(projected.pair.projectedAttribute, true) + if err != nil { + return err + } + definition, err := planLayout(projected.pair.projected.Attribute(), true) + if err != nil { + return err + } + source, err := codegen.PlanGoType(projected.pair.sourceAttribute, codegen.GoTypePlanOptions{ + Owner: facts.packagePath, + Policy: codegen.GoLayoutPolicy{ + UseDefault: true, + SumType: true, + }, + Bind: serviceGoTypeBinder(rootTypes, generation), + }) + if err != nil { + return err + } + projected.projected = layout + projected.definition = definition + projected.source = source + for _, conversion := range projected.conversions { + conversion.collection = expr.AsArray(conversion.target.Type) != nil + conversionBinder := binder + conversionOwner := facts.viewsPath + if conversion.toResult { + conversionBinder = serviceGoTypeBinder(rootTypes, generation) + conversionOwner = facts.packagePath + } else { + viewBinder := conversionBinder + targetType := conversion.target.Type.(expr.UserType) + conversionBinder = func(request codegen.GoTypeBindingRequest) (codegen.GoTypeBinding, error) { + if request.Kind == codegen.GoNamed && request.Attribute.Type == targetType { + return codegen.GoTypeBinding{Owner: facts.viewsPath, Type: projected.declaration}, nil + } + return viewBinder(request) + } + } + conversion.targetLayout, err = codegen.PlanGoType(conversion.target, codegen.GoTypePlanOptions{ + Owner: conversionOwner, + Policy: codegen.GoLayoutPolicy{ + UseDefault: true, + SumType: true, + }, + Bind: conversionBinder, + }) + if err != nil { + return err + } + context := conversion.target + if conversion.toResult { + context = conversion.source + } + conversion.contextType = context.Type.(expr.UserType) + conversion.contextIdentity = codegen.NewProjectedTypeID(projected.pair.source) + if projectedArray := expr.AsArray(projected.pair.projectedAttribute.Type); projectedArray != nil { + conversion.elementType = expr.AsArray(context.Type).ElemType.Type.(expr.UserType) + conversion.elementIdentity = codegen.NewProjectedTypeID( + expr.AsArray(projected.pair.sourceAttribute.Type).ElemType.Type.(expr.UserType), + ) + } + } + for _, validation := range projected.validations { + if !validation.needed { + continue + } + if validation.collectionElem != nil { + declaration, err := validatorCall(validation.collectionElem, validation.viewName, true) + if err != nil { + return err + } + validation.collectionCall = declaration + continue + } + layout, err := planLayout(validation.attribute, validation.pointer) + if err != nil { + return err + } + plan, err := codegen.NewValidationPlan( + validation.attribute, + layout, + codegen.ValidationPlanOptions{ + Required: true, + Alias: validation.alias, + Bind: validator, + }, + ) + if err != nil { + return err + } + validation.layout = layout + validation.plan = plan + for _, field := range validation.fields { + declaration, err := validatorCall(field.attribute, field.view, false) + if err != nil { + return err + } + field.call = declaration + } + } + } + viewed := method.viewedResult + if viewed == nil { + continue + } + wrapped, err := planLayout(&expr.AttributeExpr{Type: viewed.wrapped}, false) + if err != nil { + return err + } + if wrapped.TypeDeclaration() != viewed.declaration { + return fmt.Errorf("viewed result %q layout was bound to the wrong declaration", viewed.wrapped.Name()) + } + wrappedDef, err := planLayout(viewed.wrapped.Attribute(), false) + if err != nil { + return err + } + viewed.wrappedLayout = wrapped + viewed.wrappedDef = wrappedDef + } + for _, union := range facts.viewUnions { + if err := planUnionRenderFacts(union, binder, views); err != nil { + return err + } + } + return nil +} diff --git a/codegen/service/views.go b/codegen/service/views.go index 3c074edbb6..5fae321b39 100644 --- a/codegen/service/views.go +++ b/codegen/service/views.go @@ -1,61 +1,32 @@ +// This file renders result types containing only the fields in selected views, +// their viewed-result wrappers, and any unions those declarations require. package service import ( "path/filepath" - "sort" "goa.design/goa/v3/codegen" - "goa.design/goa/v3/expr" ) type viewedType struct { - // Name is the type name. - Name string + // Declaration is the exact package-level view map rendered for this type. + Declaration *codegen.NameDeclaration + // TypeName is the generated type whose fields the map indexes. + TypeName string // Views is the view data for all views defined in the type. Views []*ViewData } -// ViewsFile returns the views file for the given service which contains -// logic to render result types using the defined views. -func ViewsFile(_ string, service *expr.ServiceExpr, services *ServicesData) *codegen.File { - svc := services.Get(service.Name) +// viewsFile renders views from the service data copied into plan. +func viewsFile(plan *Plan, facts *serviceFacts) *codegen.File { + services := plan.Services() + svc := services.Get(facts.name) if len(svc.projectedTypes) == 0 { return nil } - // Collect union sum-type definitions for the views package. - // - // View-projected types cannot import the service package (which already - // depends on views), therefore unions must be generated in the views package - // when referenced by projected types. - unionByHash := make(map[string]*UnionTypeData) - seenUnions := make(map[string]struct{}) - viewLoc := &codegen.Location{RelImportPath: "views"} - for _, t := range svc.projectedTypes { - collectUnionTypes(&expr.AttributeExpr{Type: t.Type}, svc.ViewScope, viewLoc, unionByHash, seenUnions, true) - } - unions := make([]*UnionTypeData, 0, len(unionByHash)) - for _, u := range unionByHash { - unions = append(unions, u) - } - sort.Slice(unions, func(i, j int) bool { - return unions[i].Name < unions[j].Name - }) - path := filepath.Join(codegen.Gendir, svc.PathName, "views", "view.go") - imports := []*codegen.ImportSpec{ - codegen.GoaImport(""), - {Path: "unicode/utf8"}, - } - if len(unions) > 0 { - imports = append(imports, - codegen.SimpleImport("bytes"), - codegen.SimpleImport("encoding/json"), - codegen.SimpleImport("fmt"), - ) - } - header := codegen.Header(service.Name+" views", "views", - imports) + header := codegen.Header(facts.name+" views", "views", facts.imports.views.Imports()) sections := []*codegen.SectionTemplate{header} // type definitions @@ -73,7 +44,7 @@ func ViewsFile(_ string, service *expr.ServiceExpr, services *ServicesData) *cod Data: t.UserTypeData, }) } - for _, u := range unions { + for _, u := range svc.viewUnions { sections = append(sections, &codegen.SectionTemplate{ Name: "projected-union-type", Source: serviceTemplates.Read(unionTypeT), @@ -85,23 +56,31 @@ func ViewsFile(_ string, service *expr.ServiceExpr, services *ServicesData) *cod // rendered in the view as value. var ( rtdata []*viewedType - seen = make(map[string]struct{}) + seen = make(map[*codegen.NameDeclaration]struct{}) ) for _, t := range svc.viewedResultTypes { - name := t.Views[0].TypeVarName - if _, ok := seen[name]; !ok { - rtdata = append(rtdata, &viewedType{Name: name, Views: t.Views}) - seen[name] = struct{}{} + declaration := t.Views[0].MapDeclaration + if _, ok := seen[declaration]; !ok { + rtdata = append(rtdata, &viewedType{ + Declaration: declaration, + TypeName: t.Views[0].TypeVarName, + Views: t.Views, + }) + seen[declaration] = struct{}{} } } for _, t := range svc.projectedTypes { if len(t.Views) == 0 { continue } - name := t.Views[0].TypeVarName - if _, ok := seen[name]; !ok { - rtdata = append(rtdata, &viewedType{Name: name, Views: t.Views}) - seen[name] = struct{}{} + declaration := t.Views[0].MapDeclaration + if _, ok := seen[declaration]; !ok { + rtdata = append(rtdata, &viewedType{ + Declaration: declaration, + TypeName: t.Views[0].TypeVarName, + Views: t.Views, + }) + seen[declaration] = struct{}{} } } sections = append(sections, &codegen.SectionTemplate{ diff --git a/codegen/service/views_test.go b/codegen/service/views_test.go index 5a1b50bf8f..767b83f6f9 100644 --- a/codegen/service/views_test.go +++ b/codegen/service/views_test.go @@ -1,8 +1,16 @@ +// This file verifies generated view declarations, validators, and converters. package service import ( "bytes" + "flag" + "go/ast" "go/format" + "go/parser" + "go/token" + "os" + "slices" + "strconv" "strings" "testing" @@ -13,30 +21,34 @@ import ( "goa.design/goa/v3/codegen/service/testdata" ) +var updateViewGolden = flag.Bool("update-views", false, "update view code expectations") + func TestViews(t *testing.T) { cases := []struct { - Name string - DSL func() - Code string + Name string + Constant string + DSL func() + Code string }{ - {"result-with-multiple-views", testdata.ResultWithMultipleViewsDSL, testdata.ResultWithMultipleViewsCode}, - {"result-collection-multiple-views", testdata.ResultCollectionMultipleViewsDSL, testdata.ResultCollectionMultipleViewsCode}, - {"result-with-user-type", testdata.ResultWithUserTypeDSL, testdata.ResultWithUserTypeCode}, - {"result-with-result-type", testdata.ResultWithResultTypeDSL, testdata.ResultWithResultTypeCode}, - {"result-with-recursive-result-type", testdata.ResultWithRecursiveResultTypeDSL, testdata.ResultWithRecursiveResultTypeCode}, - {"result-type-with-custom-fields", testdata.ResultWithCustomFieldsDSL, testdata.ResultWithCustomFieldsCode}, - {"result-with-recursive-collection-of-result-type", testdata.ResultWithRecursiveCollectionOfResultTypeDSL, testdata.ResultWithRecursiveCollectionOfResultTypeCode}, - {"result-with-multiple-methods", testdata.ResultWithMultipleMethodsDSL, testdata.ResultWithMultipleMethodsCode}, - {"result-with-enum-type", testdata.ResultWithEnumTypeDSL, testdata.ResultWithEnumType}, - {"result-with-pkg-path", testdata.ResultWithPkgPathDSL, testdata.ResultWithPkgPathCode}, - {"result-with-oneof-in-result-type", testdata.ResultWithOneOfInResultTypeDSL, testdata.ResultWithOneOfInResultTypeCode}, + {"result-with-multiple-views", "ResultWithMultipleViewsCode", testdata.ResultWithMultipleViewsDSL, testdata.ResultWithMultipleViewsCode}, + {"result-collection-multiple-views", "ResultCollectionMultipleViewsCode", testdata.ResultCollectionMultipleViewsDSL, testdata.ResultCollectionMultipleViewsCode}, + {"result-with-user-type", "ResultWithUserTypeCode", testdata.ResultWithUserTypeDSL, testdata.ResultWithUserTypeCode}, + {"result-with-result-type", "ResultWithResultTypeCode", testdata.ResultWithResultTypeDSL, testdata.ResultWithResultTypeCode}, + {"result-with-recursive-result-type", "ResultWithRecursiveResultTypeCode", testdata.ResultWithRecursiveResultTypeDSL, testdata.ResultWithRecursiveResultTypeCode}, + {"result-type-with-custom-fields", "ResultWithCustomFieldsCode", testdata.ResultWithCustomFieldsDSL, testdata.ResultWithCustomFieldsCode}, + {"result-with-recursive-collection-of-result-type", "ResultWithRecursiveCollectionOfResultTypeCode", testdata.ResultWithRecursiveCollectionOfResultTypeDSL, testdata.ResultWithRecursiveCollectionOfResultTypeCode}, + {"result-with-multiple-methods", "ResultWithMultipleMethodsCode", testdata.ResultWithMultipleMethodsDSL, testdata.ResultWithMultipleMethodsCode}, + {"result-with-enum-type", "ResultWithEnumType", testdata.ResultWithEnumTypeDSL, testdata.ResultWithEnumType}, + {"result-with-pkg-path", "ResultWithPkgPathCode", testdata.ResultWithPkgPathDSL, testdata.ResultWithPkgPathCode}, + {"result-with-oneof-in-result-type", "ResultWithOneOfInResultTypeCode", testdata.ResultWithOneOfInResultTypeDSL, testdata.ResultWithOneOfInResultTypeCode}, } + updates := make(map[string]string, len(cases)) for _, c := range cases { t.Run(c.Name, func(t *testing.T) { root := codegen.RunDSL(t, c.DSL) - services := NewServicesData(root) + plan := mustServicePlan(t, root) require.Len(t, root.Services, 1) - fs := ViewsFile("goa.design/goa/example", root.Services[0], services) + fs := viewsFile(plan, plan.facts.services[0]) require.NotNil(t, fs) buf := new(bytes.Buffer) for _, s := range fs.SectionTemplates[1:] { @@ -46,7 +58,70 @@ func TestViews(t *testing.T) { require.NoError(t, err, buf.String()) code := string(bs) code = strings.ReplaceAll(code, "\r\n", "\n") + if *updateViewGolden { + updates[c.Constant] = code + return + } assert.Equal(t, c.Code, code) }) } + if *updateViewGolden { + updateViewCodeExpectations(t, updates) + } +} + +// updateViewCodeExpectations replaces only the named string literals and +// leaves the surrounding test fixtures unchanged. +func updateViewCodeExpectations(t *testing.T, updates map[string]string) { + t.Helper() + path := "testdata/views_code.go" + source, err := os.ReadFile(path) + require.NoError(t, err) + files := token.NewFileSet() + parsed, err := parser.ParseFile(files, path, source, 0) + require.NoError(t, err) + type replacement struct { + start int + end int + value string + } + var replacements []replacement + for _, declaration := range parsed.Decls { + generic, ok := declaration.(*ast.GenDecl) + if !ok || generic.Tok != token.CONST { + continue + } + for _, specification := range generic.Specs { + value := specification.(*ast.ValueSpec) + for index, name := range value.Names { + updated, exists := updates[name.Name] + if !exists { + continue + } + literal := value.Values[index].(*ast.BasicLit) + replacements = append(replacements, replacement{ + start: files.Position(literal.Pos()).Offset, + end: files.Position(literal.End()).Offset, + value: viewCodeLiteral(updated), + }) + } + } + } + require.Len(t, replacements, len(updates)) + slices.SortFunc(replacements, func(left, right replacement) int { + return right.start - left.start + }) + for _, replacement := range replacements { + source = append(source[:replacement.start], append([]byte(replacement.value), source[replacement.end:]...)...) + } + require.NoError(t, os.WriteFile(path, source, 0o644)) +} + +// viewCodeLiteral keeps readable raw strings unless generated Go tags require +// an interpreted string. +func viewCodeLiteral(source string) string { + if !strings.Contains(source, "`") { + return "`" + source + "`" + } + return strconv.Quote(source) } diff --git a/codegen/templates/transform_go_array.go.tpl b/codegen/templates/transform_go_array.go.tpl index 99119fd38a..a0c35ff259 100644 --- a/codegen/templates/transform_go_array.go.tpl +++ b/codegen/templates/transform_go_array.go.tpl @@ -1,12 +1,24 @@ -{{ .TargetVar }} {{ if .NewVar }}:={{ else }}={{ end }} make({{ if .TypeAliasName }}{{ .TypeAliasName }}{{ else }}[]{{ .ElemTypeRef }}{{ end }}, len({{ .SourceVar }})) +{{ .TargetVar }} {{ if .NewVar }}:={{ else }}={{ end }} make({{ if .TypeAliasName }}{{ .TypeAliasName }}{{ else }}[]{{ if .TargetElemPointer }}*{{ end }}{{ .ElemTypeRef }}{{ end }}, len({{ .SourceVar }})) for {{ .LoopVar }}, val := range {{ .SourceVar }} { -{{ if .IsStruct -}} +{{ if .SourceIsObject -}} if val == nil { {{ .TargetVar }}[{{ .LoopVar }}] = nil continue } - {{ .TargetVar }}[{{ .LoopVar }}] = {{ transformHelperName .SourceElem .TargetElem .TransformAttrs }}(val) +{{ end -}} +{{ if .TargetElemPointer -}} + var transformed {{ .ElemTypeRef }} +{{ if .UseHelper -}} + transformed = {{ transformHelperName .SourceElem .TargetElem .TransformAttrs }}({{ .SourceElement }}) +{{ else -}} + {{ transformAttribute .SourceElem .TargetElem .SourceElement "transformed" false .TransformAttrs -}} +{{ end -}} + {{ .TargetVar }}[{{ .LoopVar }}] = &transformed {{ else -}} - {{ transformAttribute .SourceElem .TargetElem "val" (printf "%s[%s]" .TargetVar .LoopVar) false .TransformAttrs -}} +{{ if .UseHelper -}} + {{ .TargetVar }}[{{ .LoopVar }}] = {{ transformHelperName .SourceElem .TargetElem .TransformAttrs }}({{ .SourceElement }}) +{{ else -}} + {{ transformAttribute .SourceElem .TargetElem .SourceElement (printf "%s[%s]" .TargetVar .LoopVar) false .TransformAttrs -}} +{{ end -}} {{ end -}} } diff --git a/codegen/templates/transform_go_map.go.tpl b/codegen/templates/transform_go_map.go.tpl index cc4715a029..ff646fd463 100644 --- a/codegen/templates/transform_go_map.go.tpl +++ b/codegen/templates/transform_go_map.go.tpl @@ -1,14 +1,16 @@ {{ .TargetVar }} {{ if .NewVar }}:={{ else }}={{ end }} make({{ if .TypeAliasName }}{{ .TypeAliasName }}{{ else }}map[{{ .KeyTypeRef }}]{{ .ElemTypeRef }}{{ end }}, len({{ .SourceVar }})) for key, val := range {{ .SourceVar }} { -{{ if .IsKeyStruct -}} - tk := {{ transformHelperName .SourceKey .TargetKey .TransformAttrs -}}(val) +{{ if .UseKeyHelper -}} + tk := {{ transformHelperName .SourceKey .TargetKey .TransformAttrs -}}(key) {{ else -}} {{ transformAttribute .SourceKey .TargetKey "key" "tk" true .TransformAttrs }}{{ end -}} -{{ if .IsElemStruct -}} +{{ if .ElemIsObject -}} if val == nil { {{ .TargetVar }}[tk] = nil continue } +{{ end -}} +{{ if .UseElemHelper -}} {{ .TargetVar }}[tk] = {{ transformHelperName .SourceElem .TargetElem .TransformAttrs -}}(val) {{ else -}} {{ transformAttribute .SourceElem .TargetElem "val" (printf "tv%s" .LoopVar) true .TransformAttrs -}} diff --git a/codegen/templates/transform_go_union.go.tpl b/codegen/templates/transform_go_union.go.tpl index 81d7bde5bb..1ca58a0131 100644 --- a/codegen/templates/transform_go_union.go.tpl +++ b/codegen/templates/transform_go_union.go.tpl @@ -4,20 +4,27 @@ switch string({{ .SourceVar }}.Kind()) { {{- range .Cases }} case {{ printf "%q" .CaseName }}: actual, _ := {{ $.SourceVar }}.As{{ .SourceFieldName }}() + {{- if .SourceNilable }} + var {{ $.TempVarName }} {{ .TargetCastType }} + if actual != nil { + {{- if .UseHelper }} + {{ $.TempVarName }} = {{ .HelperName }}(actual) + {{- else }} + {{ transformAttribute .SourceAttr .TargetAttr "actual" $.TempVarName false $.TransformAttrs -}} + {{- end }} + } + {{- else }} {{- if .UseHelper }} {{ $.TempVarName }} := {{ .HelperName }}(actual) {{- else }} {{ transformAttribute .SourceAttr .TargetAttr "actual" $.TempVarName true $.TransformAttrs -}} {{- end }} + {{- end }} {{- if $.NewVar }} var u {{ $.ValueTypeRef }} u.Set{{ .TargetFieldName }}(({{ .TargetCastType }})({{ $.TempVarName }})) - {{- if $.TargetIsPointer }} {{ $.TargetVar }} = &u {{- else }} - {{ $.TargetVar }} = u - {{- end }} - {{- else }} u := {{ $.TargetVar }} u.Set{{ .TargetFieldName }}(({{ .TargetCastType }})({{ $.TempVarName }})) {{ $.TargetVar }} = u diff --git a/codegen/templates/validation/array.go.tpl b/codegen/templates/validation/array.go.tpl index 2332fa52b1..a9672c3343 100644 --- a/codegen/templates/validation/array.go.tpl +++ b/codegen/templates/validation/array.go.tpl @@ -1,10 +1,11 @@ for _, e := range {{ .target }} { -{{- if .nonNullableElems }} +{{- if .checkNilElements }} if e == nil { - err = goa.MergeErrors(err, goa.MissingFieldError("{{ .context }}", "[*]")) + err = {{ .goa }}.MergeErrors(err, {{ .goa }}.MissingFieldError({{ validationPath .context }}, "[*]")) } {{- end }} {{- if .validation }} {{ .validation }} {{- end }} -} \ No newline at end of file +} +{{- "" -}} diff --git a/codegen/templates/validation/enum.go.tpl b/codegen/templates/validation/enum.go.tpl index 4238f7691c..426d2a6186 100644 --- a/codegen/templates/validation/enum.go.tpl +++ b/codegen/templates/validation/enum.go.tpl @@ -1,8 +1,8 @@ {{ if .isPointer }}if {{ .target }} != nil { {{ end -}} if !({{ oneof .targetVal .values }}) { - err = goa.MergeErrors(err, goa.InvalidEnumValueError({{ printf "%q" .context }}, {{ .targetVal }}, {{ slice .values }})) + err = {{ .goa }}.MergeErrors(err, {{ .goa }}.InvalidEnumValueError({{ validationPath .context }}, {{ .targetVal }}, {{ slice .values }})) } {{- if .isPointer }} } -{{- end }} \ No newline at end of file +{{- end }} diff --git a/codegen/templates/validation/excl_min_max.go.tpl b/codegen/templates/validation/excl_min_max.go.tpl index 67d19a2852..ac8b5ac6d8 100644 --- a/codegen/templates/validation/excl_min_max.go.tpl +++ b/codegen/templates/validation/excl_min_max.go.tpl @@ -1,8 +1,8 @@ {{ if .isPointer }}if {{ .target }} != nil { {{ end -}} if {{ .targetVal }} {{ if .isExclMin }}<={{ else }}>={{ end }} {{ if .isExclMin }}{{ .exclMin }}{{ else }}{{ .exclMax }}{{ end }} { - err = goa.MergeErrors(err, goa.InvalidRangeError({{ printf "%q" .context }}, {{ .targetVal }}, {{ if .isExclMin }}{{ .exclMin }}, true{{ else }}{{ .exclMax }}, false{{ end }})) + err = {{ .goa }}.MergeErrors(err, {{ .goa }}.InvalidRangeError({{ validationPath .context }}, {{ .targetVal }}, {{ if .isExclMin }}{{ .exclMin }}, true{{ else }}{{ .exclMax }}, false{{ end }})) } {{- if .isPointer }} } -{{- end }} \ No newline at end of file +{{- end }} diff --git a/codegen/templates/validation/format.go.tpl b/codegen/templates/validation/format.go.tpl index da2999f03b..c31ca71f91 100644 --- a/codegen/templates/validation/format.go.tpl +++ b/codegen/templates/validation/format.go.tpl @@ -1,6 +1,6 @@ {{ if .isPointer }}if {{ .target }} != nil { {{ end -}} - err = goa.MergeErrors(err, goa.ValidateFormat({{ printf "%q" .context }}, {{ .targetVal}}, {{ constant .format }})) + err = {{ .goa }}.MergeErrors(err, {{ .goa }}.ValidateFormat({{ validationPath .context }}, {{ .targetVal}}, {{ constant .format }})) {{- if .isPointer }} } -{{- end }} \ No newline at end of file +{{- end }} diff --git a/codegen/templates/validation/length.go.tpl b/codegen/templates/validation/length.go.tpl index 69b41dc487..80feb15fc9 100644 --- a/codegen/templates/validation/length.go.tpl +++ b/codegen/templates/validation/length.go.tpl @@ -2,8 +2,8 @@ {{ if and .isPointer .string -}} if {{ .target }} != nil { {{ end -}} -if {{ if .string }}utf8.RuneCountInString({{ $target }}){{ else }}len({{ $target }}){{ end }} {{ if .isMinLength }}<{{ else }}>{{ end }} {{ if .isMinLength }}{{ .minLength }}{{ else }}{{ .maxLength }}{{ end }} { - err = goa.MergeErrors(err, goa.InvalidLengthError({{ printf "%q" .context }}, {{ $target }}, {{ if .string }}utf8.RuneCountInString({{ $target }}){{ else }}len({{ $target }}){{ end }}, {{ if .isMinLength }}{{ .minLength }}, true{{ else }}{{ .maxLength }}, false{{ end }})) +if {{ if .string }}{{ .utf8 }}.RuneCountInString({{ $target }}){{ else }}len({{ $target }}){{ end }} {{ if .isMinLength }}<{{ else }}>{{ end }} {{ if .isMinLength }}{{ .minLength }}{{ else }}{{ .maxLength }}{{ end }} { + err = {{ .goa }}.MergeErrors(err, {{ .goa }}.InvalidLengthError({{ validationPath .context }}, {{ $target }}, {{ if .string }}{{ .utf8 }}.RuneCountInString({{ $target }}){{ else }}len({{ $target }}){{ end }}, {{ if .isMinLength }}{{ .minLength }}, true{{ else }}{{ .maxLength }}, false{{ end }})) }{{- if and .isPointer .string }} } -{{- end }} \ No newline at end of file +{{- end }} diff --git a/codegen/templates/validation/min_max.go.tpl b/codegen/templates/validation/min_max.go.tpl index 44fef2c234..51d938a0af 100644 --- a/codegen/templates/validation/min_max.go.tpl +++ b/codegen/templates/validation/min_max.go.tpl @@ -1,8 +1,8 @@ {{ if .isPointer -}}if {{ .target }} != nil { {{ end -}} if {{ .targetVal }} {{ if .isMin }}<{{ else }}>{{ end }} {{ if .isMin }}{{ .min }}{{ else }}{{ .max }}{{ end }} { - err = goa.MergeErrors(err, goa.InvalidRangeError({{ printf "%q" .context }}, {{ .targetVal }}, {{ if .isMin }}{{ .min }}, true{{ else }}{{ .max }}, false{{ end }})) + err = {{ .goa }}.MergeErrors(err, {{ .goa }}.InvalidRangeError({{ validationPath .context }}, {{ .targetVal }}, {{ if .isMin }}{{ .min }}, true{{ else }}{{ .max }}, false{{ end }})) } {{- if .isPointer }} } -{{- end }} \ No newline at end of file +{{- end }} diff --git a/codegen/templates/validation/pattern.go.tpl b/codegen/templates/validation/pattern.go.tpl index 4841eff60d..c5e0671bf8 100644 --- a/codegen/templates/validation/pattern.go.tpl +++ b/codegen/templates/validation/pattern.go.tpl @@ -1,6 +1,6 @@ {{ if .isPointer }}if {{ .target }} != nil { {{ end -}} - err = goa.MergeErrors(err, goa.ValidatePattern({{ printf "%q" .context }}, {{ .targetVal }}, {{ printf "%q" .pattern }})) + err = {{ .goa }}.MergeErrors(err, {{ .goa }}.ValidatePattern({{ validationPath .context }}, {{ .targetVal }}, {{ printf "%q" .pattern }})) {{- if .isPointer }} } -{{- end }} \ No newline at end of file +{{- end }} diff --git a/codegen/templates/validation/required.go.tpl b/codegen/templates/validation/required.go.tpl index afc2a1054b..4138d7ae61 100644 --- a/codegen/templates/validation/required.go.tpl +++ b/codegen/templates/validation/required.go.tpl @@ -1,9 +1,9 @@ -{{- if and (isUnion .reqAtt) (isAttributeScope .attCtx.Scope) (not (isUnionPointer .attCtx true)) }} +{{- if and (isUnion .reqAtt) (isSumType .attCtx.Scope) (not (isUnionPointer .attCtx true)) }} if {{ $.target }}.{{ .attCtx.Scope.Field $.reqAtt .req true }}.Kind() == "" { - err = goa.MergeErrors(err, goa.MissingFieldError("{{ .req }}", {{ printf "%q" $.context }})) + err = {{ $.goa }}.MergeErrors(err, {{ $.goa }}.MissingFieldError("{{ .req }}", {{ validationPath $.context }})) } {{- else }} if {{ $.target }}.{{ .attCtx.Scope.Field $.reqAtt .req true }} == nil { - err = goa.MergeErrors(err, goa.MissingFieldError("{{ .req }}", {{ printf "%q" $.context }})) + err = {{ $.goa }}.MergeErrors(err, {{ $.goa }}.MissingFieldError("{{ .req }}", {{ validationPath $.context }})) } {{- end }} diff --git a/codegen/templates/validation/union.go.tpl b/codegen/templates/validation/union.go.tpl index 9460731efe..1ad02e44c5 100644 --- a/codegen/templates/validation/union.go.tpl +++ b/codegen/templates/validation/union.go.tpl @@ -1,6 +1,18 @@ -switch v := {{ .target }}.(type) { -{{- range $i, $val := .values }} - case {{ index $.types $i }}: - {{ $val }} +switch v := {{ .Target }}.(type) { +{{- range .Cases }} + case {{ .Type }}: + {{- if $.Protobuf }} + if v == nil { + err = {{ $.Goa }}.MergeErrors(err, {{ $.Goa }}.MissingFieldError({{ printf "%q" .Name }}, {{ validationPath $.Context }})) + break + } + {{- if .PayloadRequiresPresence }} + if v.{{ .Field }} == nil { + err = {{ $.Goa }}.MergeErrors(err, {{ $.Goa }}.MissingFieldError({{ printf "%q" .Name }}, {{ validationPath $.Context }})) + break + } + {{- end }} + {{- end }} + {{ .Validation }} {{ end -}} -} \ No newline at end of file +} diff --git a/codegen/templates/validation/user.go.tpl b/codegen/templates/validation/user.go.tpl index cee4c6c8f6..5bddddcf43 100644 --- a/codegen/templates/validation/user.go.tpl +++ b/codegen/templates/validation/user.go.tpl @@ -1,3 +1,4 @@ -if err2 := Validate{{ .name }}({{ .target }}); err2 != nil { - err = goa.MergeErrors(err, err2) -} \ No newline at end of file +if err2 := {{ .call }}; err2 != nil { + err = {{ .goa }}.MergeErrors(err, err2) +} +{{- "" -}} diff --git a/codegen/testdata/golden/go_transform_source-target-type-use-default_defaults-to-defaults-types.go.golden b/codegen/testdata/golden/go_transform_source-target-type-use-default_defaults-to-defaults-types.go.golden index 0e2e496ccd..50bef0f9da 100644 --- a/codegen/testdata/golden/go_transform_source-target-type-use-default_defaults-to-defaults-types.go.golden +++ b/codegen/testdata/golden/go_transform_source-target-type-use-default_defaults-to-defaults-types.go.golden @@ -17,8 +17,7 @@ func transform() { } } { - var zero json.RawMessage - if target.RawJSON == zero { + if target.RawJSON == nil { target.RawJSON = json.RawMessage{0x66, 0x6f, 0x6f} } } @@ -29,14 +28,12 @@ func transform() { } } { - var zero []byte - if target.Bytes == zero { + if target.Bytes == nil { target.Bytes = []byte{0x66, 0x6f, 0x6f, 0x62, 0x61, 0x72} } } { - var zero any - if target.Any == zero { + if target.Any == nil { target.Any = "something" } } diff --git a/codegen/testdata/golden/go_transform_union_UnionSomeType to UnionSomeType2.go.golden b/codegen/testdata/golden/go_transform_union_UnionSomeType to UnionSomeType2.go.golden index 30b0772b94..1ce022fd9c 100644 --- a/codegen/testdata/golden/go_transform_union_UnionSomeType to UnionSomeType2.go.golden +++ b/codegen/testdata/golden/go_transform_union_UnionSomeType to UnionSomeType2.go.golden @@ -3,7 +3,10 @@ func transform() { switch string(source.Kind()) { case "SomeType": actual, _ := source.AsSomeType() - obj := transformSomeTypeToSomeType(actual) + var obj *SomeType + if actual != nil { + obj = transformSomeTypeToSomeType(actual) + } var u UnionSomeType2 u.SetSomeType((*SomeType)(obj)) target = &u diff --git a/codegen/testdata/golden/go_transform_union_nil_branch.go.golden b/codegen/testdata/golden/go_transform_union_nil_branch.go.golden new file mode 100644 index 0000000000..c84416a8a9 --- /dev/null +++ b/codegen/testdata/golden/go_transform_union_nil_branch.go.golden @@ -0,0 +1,78 @@ +func transform() { + var target *State + switch string(source.Kind()) { + case "details": + actual, _ := source.AsDetails() + var obj *Details + if actual != nil { + obj = transformDetailsToDetails(actual) + } + var u State + u.SetDetails((*Details)(obj)) + target = &u + case "empty": + actual, _ := source.AsEmpty() + var obj *Empty + if actual != nil { + obj = transformEmptyToEmpty(actual) + } + var u State + u.SetEmpty((*Empty)(obj)) + target = &u + case "aliases": + actual, _ := source.AsAliases() + var obj []string + if actual != nil { + obj = make([]string, len(actual)) + for i, val := range actual { + obj[i] = val + } + + } + var u State + u.SetAliases(([]string)(obj)) + target = &u + case "labels": + actual, _ := source.AsLabels() + var obj map[string]string + if actual != nil { + obj = make(map[string]string, len(actual)) + for key, val := range actual { + tk := key + tv := val + obj[tk] = tv + } + + } + var u State + u.SetLabels((map[string]string)(obj)) + target = &u + case "blob": + actual, _ := source.AsBlob() + var obj []byte + if actual != nil { + obj = actual + + } + var u State + u.SetBlob(([]byte)(obj)) + target = &u + case "anything": + actual, _ := source.AsAnything() + var obj any + if actual != nil { + obj = actual + + } + var u State + u.SetAnything((any)(obj)) + target = &u + case "name": + actual, _ := source.AsName() + obj := actual + + var u State + u.SetName((string)(obj)) + target = &u + } +} diff --git a/codegen/testdata/golden/validation_alias-type.go.golden b/codegen/testdata/golden/validation_alias-type.go.golden index 4e19459d5d..7eff466ad9 100644 --- a/codegen/testdata/golden/validation_alias-type.go.golden +++ b/codegen/testdata/golden/validation_alias-type.go.golden @@ -8,13 +8,9 @@ func Validate() (err error) { } if target.Alias != nil { err = goa.MergeErrors(err, goa.ValidatePattern("target.alias", string(*target.Alias), "^[A-z].*[a-z]$")) - } - if target.Alias != nil { if utf8.RuneCountInString(string(*target.Alias)) < 1 { err = goa.MergeErrors(err, goa.InvalidLengthError("target.alias", string(*target.Alias), utf8.RuneCountInString(string(*target.Alias)), 1, true)) } - } - if target.Alias != nil { if utf8.RuneCountInString(string(*target.Alias)) > 10 { err = goa.MergeErrors(err, goa.InvalidLengthError("target.alias", string(*target.Alias), utf8.RuneCountInString(string(*target.Alias)), 10, false)) } diff --git a/codegen/testdata/golden/validation_chain-holder-pointer.go.golden b/codegen/testdata/golden/validation_chain-holder-pointer.go.golden index ff87e2cfd9..fef37a7d31 100644 --- a/codegen/testdata/golden/validation_chain-holder-pointer.go.golden +++ b/codegen/testdata/golden/validation_chain-holder-pointer.go.golden @@ -6,11 +6,7 @@ func Validate() (err error) { if !(string(*target.ReqMid) == "ab" || string(*target.ReqMid) == "abc" || string(*target.ReqMid) == "abcd") { err = goa.MergeErrors(err, goa.InvalidEnumValueError("target.req_mid", string(*target.ReqMid), []any{"ab", "abc", "abcd"})) } - } - if target.ReqMid != nil { err = goa.MergeErrors(err, goa.ValidatePattern("target.req_mid", string(*target.ReqMid), "^[a-z]+$")) - } - if target.ReqMid != nil { if utf8.RuneCountInString(string(*target.ReqMid)) < 2 { err = goa.MergeErrors(err, goa.InvalidLengthError("target.req_mid", string(*target.ReqMid), utf8.RuneCountInString(string(*target.ReqMid)), 2, true)) } @@ -19,11 +15,7 @@ func Validate() (err error) { if !(string(*target.Mid) == "ab" || string(*target.Mid) == "abc" || string(*target.Mid) == "abcd") { err = goa.MergeErrors(err, goa.InvalidEnumValueError("target.mid", string(*target.Mid), []any{"ab", "abc", "abcd"})) } - } - if target.Mid != nil { err = goa.MergeErrors(err, goa.ValidatePattern("target.mid", string(*target.Mid), "^[a-z]+$")) - } - if target.Mid != nil { if utf8.RuneCountInString(string(*target.Mid)) < 2 { err = goa.MergeErrors(err, goa.InvalidLengthError("target.mid", string(*target.Mid), utf8.RuneCountInString(string(*target.Mid)), 2, true)) } @@ -32,11 +24,7 @@ func Validate() (err error) { if !(string(*target.Pass) == "ab" || string(*target.Pass) == "abc" || string(*target.Pass) == "abcd") { err = goa.MergeErrors(err, goa.InvalidEnumValueError("target.pass", string(*target.Pass), []any{"ab", "abc", "abcd"})) } - } - if target.Pass != nil { err = goa.MergeErrors(err, goa.ValidatePattern("target.pass", string(*target.Pass), "^[a-z]+$")) - } - if target.Pass != nil { if utf8.RuneCountInString(string(*target.Pass)) < 2 { err = goa.MergeErrors(err, goa.InvalidLengthError("target.pass", string(*target.Pass), utf8.RuneCountInString(string(*target.Pass)), 2, true)) } diff --git a/codegen/testdata/golden/validation_chain-holder-required.go.golden b/codegen/testdata/golden/validation_chain-holder-required.go.golden index b3c3da78cc..690def43b6 100644 --- a/codegen/testdata/golden/validation_chain-holder-required.go.golden +++ b/codegen/testdata/golden/validation_chain-holder-required.go.golden @@ -10,11 +10,7 @@ func Validate() (err error) { if !(string(*target.Mid) == "ab" || string(*target.Mid) == "abc" || string(*target.Mid) == "abcd") { err = goa.MergeErrors(err, goa.InvalidEnumValueError("target.mid", string(*target.Mid), []any{"ab", "abc", "abcd"})) } - } - if target.Mid != nil { err = goa.MergeErrors(err, goa.ValidatePattern("target.mid", string(*target.Mid), "^[a-z]+$")) - } - if target.Mid != nil { if utf8.RuneCountInString(string(*target.Mid)) < 2 { err = goa.MergeErrors(err, goa.InvalidLengthError("target.mid", string(*target.Mid), utf8.RuneCountInString(string(*target.Mid)), 2, true)) } @@ -23,11 +19,7 @@ func Validate() (err error) { if !(string(*target.Pass) == "ab" || string(*target.Pass) == "abc" || string(*target.Pass) == "abcd") { err = goa.MergeErrors(err, goa.InvalidEnumValueError("target.pass", string(*target.Pass), []any{"ab", "abc", "abcd"})) } - } - if target.Pass != nil { err = goa.MergeErrors(err, goa.ValidatePattern("target.pass", string(*target.Pass), "^[a-z]+$")) - } - if target.Pass != nil { if utf8.RuneCountInString(string(*target.Pass)) < 2 { err = goa.MergeErrors(err, goa.InvalidLengthError("target.pass", string(*target.Pass), utf8.RuneCountInString(string(*target.Pass)), 2, true)) } diff --git a/codegen/testdata/golden/validation_float-pointer.go.golden b/codegen/testdata/golden/validation_float-pointer.go.golden index 51de19e361..f69e7157ec 100644 --- a/codegen/testdata/golden/validation_float-pointer.go.golden +++ b/codegen/testdata/golden/validation_float-pointer.go.golden @@ -21,10 +21,8 @@ func Validate() (err error) { if *target.ExclusiveFloat64 <= 1 { err = goa.MergeErrors(err, goa.InvalidRangeError("target.exclusive_float64", *target.ExclusiveFloat64, 1, true)) } - } - if target.ExclusiveFloat64 != nil { - if *target.ExclusiveFloat64 <= 1 { - err = goa.MergeErrors(err, goa.InvalidRangeError("target.exclusive_float64", *target.ExclusiveFloat64, 1, true)) + if *target.ExclusiveFloat64 >= 100.1 { + err = goa.MergeErrors(err, goa.InvalidRangeError("target.exclusive_float64", *target.ExclusiveFloat64, 100.1, false)) } } } diff --git a/codegen/testdata/golden/validation_float-required.go.golden b/codegen/testdata/golden/validation_float-required.go.golden index 11a198a4eb..e74c9ac4bd 100644 --- a/codegen/testdata/golden/validation_float-required.go.golden +++ b/codegen/testdata/golden/validation_float-required.go.golden @@ -16,10 +16,8 @@ func Validate() (err error) { if *target.ExclusiveFloat64 <= 1 { err = goa.MergeErrors(err, goa.InvalidRangeError("target.exclusive_float64", *target.ExclusiveFloat64, 1, true)) } - } - if target.ExclusiveFloat64 != nil { - if *target.ExclusiveFloat64 <= 1 { - err = goa.MergeErrors(err, goa.InvalidRangeError("target.exclusive_float64", *target.ExclusiveFloat64, 1, true)) + if *target.ExclusiveFloat64 >= 100.1 { + err = goa.MergeErrors(err, goa.InvalidRangeError("target.exclusive_float64", *target.ExclusiveFloat64, 100.1, false)) } } } diff --git a/codegen/testdata/golden/validation_float-use-default.go.golden b/codegen/testdata/golden/validation_float-use-default.go.golden index 92efc1c091..f14511e34f 100644 --- a/codegen/testdata/golden/validation_float-use-default.go.golden +++ b/codegen/testdata/golden/validation_float-use-default.go.golden @@ -14,10 +14,8 @@ func Validate() (err error) { if *target.ExclusiveFloat64 <= 1 { err = goa.MergeErrors(err, goa.InvalidRangeError("target.exclusive_float64", *target.ExclusiveFloat64, 1, true)) } - } - if target.ExclusiveFloat64 != nil { - if *target.ExclusiveFloat64 <= 1 { - err = goa.MergeErrors(err, goa.InvalidRangeError("target.exclusive_float64", *target.ExclusiveFloat64, 1, true)) + if *target.ExclusiveFloat64 >= 100.1 { + err = goa.MergeErrors(err, goa.InvalidRangeError("target.exclusive_float64", *target.ExclusiveFloat64, 100.1, false)) } } } diff --git a/codegen/testdata/golden/validation_integer-pointer.go.golden b/codegen/testdata/golden/validation_integer-pointer.go.golden index 2386735ad4..28c328f6f7 100644 --- a/codegen/testdata/golden/validation_integer-pointer.go.golden +++ b/codegen/testdata/golden/validation_integer-pointer.go.golden @@ -21,10 +21,8 @@ func Validate() (err error) { if *target.ExclusiveInteger <= 1 { err = goa.MergeErrors(err, goa.InvalidRangeError("target.exclusive_integer", *target.ExclusiveInteger, 1, true)) } - } - if target.ExclusiveInteger != nil { - if *target.ExclusiveInteger <= 1 { - err = goa.MergeErrors(err, goa.InvalidRangeError("target.exclusive_integer", *target.ExclusiveInteger, 1, true)) + if *target.ExclusiveInteger >= 100 { + err = goa.MergeErrors(err, goa.InvalidRangeError("target.exclusive_integer", *target.ExclusiveInteger, 100, false)) } } } diff --git a/codegen/testdata/golden/validation_integer-required.go.golden b/codegen/testdata/golden/validation_integer-required.go.golden index 84a979e80b..160130d2de 100644 --- a/codegen/testdata/golden/validation_integer-required.go.golden +++ b/codegen/testdata/golden/validation_integer-required.go.golden @@ -16,10 +16,8 @@ func Validate() (err error) { if *target.ExclusiveInteger <= 1 { err = goa.MergeErrors(err, goa.InvalidRangeError("target.exclusive_integer", *target.ExclusiveInteger, 1, true)) } - } - if target.ExclusiveInteger != nil { - if *target.ExclusiveInteger <= 1 { - err = goa.MergeErrors(err, goa.InvalidRangeError("target.exclusive_integer", *target.ExclusiveInteger, 1, true)) + if *target.ExclusiveInteger >= 100 { + err = goa.MergeErrors(err, goa.InvalidRangeError("target.exclusive_integer", *target.ExclusiveInteger, 100, false)) } } } diff --git a/codegen/testdata/golden/validation_integer-use-default.go.golden b/codegen/testdata/golden/validation_integer-use-default.go.golden index 9bc2be4599..34fd59d095 100644 --- a/codegen/testdata/golden/validation_integer-use-default.go.golden +++ b/codegen/testdata/golden/validation_integer-use-default.go.golden @@ -14,10 +14,8 @@ func Validate() (err error) { if *target.ExclusiveInteger <= 1 { err = goa.MergeErrors(err, goa.InvalidRangeError("target.exclusive_integer", *target.ExclusiveInteger, 1, true)) } - } - if target.ExclusiveInteger != nil { - if *target.ExclusiveInteger <= 1 { - err = goa.MergeErrors(err, goa.InvalidRangeError("target.exclusive_integer", *target.ExclusiveInteger, 1, true)) + if *target.ExclusiveInteger >= 100 { + err = goa.MergeErrors(err, goa.InvalidRangeError("target.exclusive_integer", *target.ExclusiveInteger, 100, false)) } } } diff --git a/codegen/testdata/golden/validation_string-pointer.go.golden b/codegen/testdata/golden/validation_string-pointer.go.golden index 51b19890fd..8127794309 100644 --- a/codegen/testdata/golden/validation_string-pointer.go.golden +++ b/codegen/testdata/golden/validation_string-pointer.go.golden @@ -4,13 +4,9 @@ func Validate() (err error) { } if target.RequiredString != nil { err = goa.MergeErrors(err, goa.ValidatePattern("target.required_string", *target.RequiredString, "^[A-z].*[a-z]$")) - } - if target.RequiredString != nil { if utf8.RuneCountInString(*target.RequiredString) < 1 { err = goa.MergeErrors(err, goa.InvalidLengthError("target.required_string", *target.RequiredString, utf8.RuneCountInString(*target.RequiredString), 1, true)) } - } - if target.RequiredString != nil { if utf8.RuneCountInString(*target.RequiredString) > 10 { err = goa.MergeErrors(err, goa.InvalidLengthError("target.required_string", *target.RequiredString, utf8.RuneCountInString(*target.RequiredString), 10, false)) } diff --git a/codegen/testdata/types_dsl.go b/codegen/testdata/types_dsl.go index 6669b530ed..6905e57ee5 100644 --- a/codegen/testdata/types_dsl.go +++ b/codegen/testdata/types_dsl.go @@ -196,7 +196,7 @@ var TestTypesDSL = func() { Default(100) }) Attribute("raw_json", func() { - Meta("struct:field:type", "json.RawMessage", "json") + Meta("struct:field:type", "json.RawMessage", "encoding/json", "json") Default(json.RawMessage("foo")) }) Attribute("required_int", Int, func() { diff --git a/codegen/testing.go b/codegen/testing.go index 67c1b8ee8b..b35632728b 100644 --- a/codegen/testing.go +++ b/codegen/testing.go @@ -1,3 +1,5 @@ +// This file evaluates isolated Goa designs and renders sections for codegen +// tests without performing generation-owned normalization ahead of the test. package codegen import ( @@ -24,9 +26,6 @@ func RunDSL(t *testing.T, dsl func()) *expr.RootExpr { expr.Root.API.Servers = []*expr.ServerExpr{expr.Root.API.DefaultServer()} require.True(t, eval.Execute(dsl, nil), eval.Context.Error()) require.NoError(t, eval.RunDSL()) - // Apply the sanctioned post-finalization rewrite the production Generate - // flow runs before the generators read the design. - NormalizeRoot(expr.Root) return expr.Root } diff --git a/codegen/transform_helper_registry.go b/codegen/transform_helper_registry.go new file mode 100644 index 0000000000..7c63aa4927 --- /dev/null +++ b/codegen/transform_helper_registry.go @@ -0,0 +1,581 @@ +// This file finds conversion functions that can share one declaration in a +// generated Go package. It compares the saved Go types, conversion rules, and +// child function calls before final package names are chosen. +package codegen + +import ( + "fmt" + "reflect" + "slices" + "strings" + + "goa.design/goa/v3/expr" +) + +type ( + // TransformHelperRegistry collects every conversion plan that writes helper + // functions to one generated Go package. Finalize groups only helpers with + // the same generated parameter type, result type, conversion rules, and + // ordered child calls. + TransformHelperRegistry struct { + candidates []*transformHelperCandidate + finalized bool + } + + // TransformHelperGroup describes helper occurrences that can use one Go + // function declaration. Definition and Order return facts from the same + // occurrence; Bind assigns the supplied declaration to every occurrence in + // the group. + TransformHelperGroup struct { + definition TransformHelperDefinition + order PackageNameOrder + candidates []*transformHelperCandidate + } + + // TransformHelperOrder returns the package name order for one helper at its + // exact field or collection location. The caller supplies the stable identity + // of the conversion that contains the helper. + TransformHelperOrder func(TransformHelperDefinitionLocation) PackageNameOrder + + // transformHelperCandidate stores one planned function call and the Go types + // selected at that call's field or collection position. + transformHelperCandidate struct { + index int + plan *TransformPlan + helper TransformHelper + definition TransformHelperDefinition + order PackageNameOrder + sourceLayout *GoTypePlan + targetLayout *GoTypePlan + children []*transformHelperCandidate + } + + // transformGoLayoutPair stops comparison when two recursive generated types + // refer back to layouts that were already compared. + transformGoLayoutPair struct { + left *GoTypePlan + right *GoTypePlan + } + + // transformSemanticAttributePair stops comparison when two recursive Goa + // types refer back to fields that were already compared. + transformSemanticAttributePair struct { + left *expr.AttributeExpr + right *expr.AttributeExpr + } +) + +// NewTransformHelperRegistry creates an empty package-level helper registry. +func NewTransformHelperRegistry() *TransformHelperRegistry { + return &TransformHelperRegistry{} +} + +// Collect adds every helper occurrence in plan. sourceLayout and targetLayout +// must describe the complete source and target values supplied to plan and +// retain the fields beneath named types. order must combine the conversion's +// stable identity with the location supplied to it. +func (r *TransformHelperRegistry) Collect(plan *TransformPlan, sourceLayout, targetLayout *GoTypePlan, order TransformHelperOrder) error { + if r.finalized { + return fmt.Errorf("transform helper registry is already finalized") + } + if plan == nil { + return fmt.Errorf("transform helper plan must not be nil") + } + if sourceLayout == nil || targetLayout == nil { + return fmt.Errorf("transform helper layouts must not be nil") + } + if order == nil { + return fmt.Errorf("transform helper order must not be nil") + } + sourceLayout, targetLayout, err := transformRootLayouts(plan, sourceLayout, targetLayout) + if err != nil { + return err + } + definitions := make(map[int]TransformHelperDefinition, len(plan.helpers)) + for _, definition := range plan.definitions { + for _, index := range definition.helpers { + definitions[index] = definition + } + } + for _, helper := range plan.helpers { + definition, ok := definitions[helper.ID.index] + if !ok { + return fmt.Errorf("transform helper occurrence %d has no definition", helper.Occurrence) + } + source, err := transformLayoutAtLocation(sourceLayout, plan.rootSource, helper.location) + if err != nil { + return fmt.Errorf("find source layout for transform helper occurrence %d: %w", helper.Occurrence, err) + } + target, err := transformLayoutAtLocation(targetLayout, plan.rootTarget, helper.location) + if err != nil { + return fmt.Errorf("find target layout for transform helper occurrence %d: %w", helper.Occurrence, err) + } + candidateOrder := order(helper.location) + if err := validatePackageNameOrder(candidateOrder); err != nil { + return fmt.Errorf("order transform helper occurrence %d: %w", helper.Occurrence, err) + } + r.candidates = append(r.candidates, &transformHelperCandidate{ + index: len(r.candidates), + plan: plan, + helper: helper, + definition: definition, + order: candidateOrder, + sourceLayout: source, + targetLayout: target, + }) + } + return nil +} + +// transformRootLayouts selects the generated wrapper field that the transform +// reads or writes before it calls any nested conversion functions. +func transformRootLayouts(plan *TransformPlan, sourceLayout, targetLayout *GoTypePlan) (*GoTypePlan, *GoTypePlan, error) { + if plan.rootWrap == nil { + return sourceLayout, targetLayout, nil + } + if plan.rootWrap.WrapTarget { + selected, err := transformRootWrapperField(targetLayout, plan.target, plan.rootTarget, plan.rootWrap.FieldName) + if err != nil { + return nil, nil, fmt.Errorf("select target root wrapper: %w", err) + } + return sourceLayout, selected, nil + } + selected, err := transformRootWrapperField(sourceLayout, plan.source, plan.rootSource, plan.rootWrap.FieldName) + if err != nil { + return nil, nil, fmt.Errorf("select source root wrapper: %w", err) + } + return selected, targetLayout, nil +} + +// transformRootWrapperField returns the generated field chosen by the saved +// wrapper instruction. The generated field and design field must describe the +// same value that the transform planned to read or write. +func transformRootWrapperField(layout *GoTypePlan, wrapper, selected *expr.AttributeExpr, fieldName string) (*GoTypePlan, error) { + layout, wrapper = transformLayoutValue(layout, wrapper) + object := expr.AsObject(wrapper.Type) + if object == nil || layout.kind != GoStruct { + return nil, fmt.Errorf("wrapper does not select a generated struct") + } + if len(layout.fields) != len(*object) { + return nil, fmt.Errorf("wrapper has %d design fields and %d generated fields", len(*object), len(layout.fields)) + } + var match *GoTypePlan + for index, field := range layout.fields { + if field.fieldNameUpper != fieldName { + continue + } + if match != nil { + return nil, fmt.Errorf("wrapper field %q is ambiguous", fieldName) + } + if (*object)[index].Attribute != selected { + return nil, fmt.Errorf("wrapper field %q does not hold the selected value", fieldName) + } + match = field + } + if match == nil { + return nil, fmt.Errorf("wrapper field %q is missing", fieldName) + } + return match, nil +} + +// Finalize groups all collected helpers. It may be called once, after every +// transform plan for the generated package has been collected and before the +// package chooses final declaration names. +func (r *TransformHelperRegistry) Finalize() ([]*TransformHelperGroup, error) { + if r.finalized { + return nil, fmt.Errorf("transform helper registry is already finalized") + } + r.finalized = true + byID := make(map[TransformHelperID]*transformHelperCandidate, len(r.candidates)) + for _, candidate := range r.candidates { + byID[candidate.helper.ID] = candidate + } + for _, candidate := range r.candidates { + operation := candidate.plan.operations[candidate.helper.ID.index+1] + candidate.children = make([]*transformHelperCandidate, len(operation.calls)) + for index, call := range operation.calls { + child := byID[call.helper] + if child == nil { + return nil, fmt.Errorf("transform helper occurrence %d calls an uncollected helper", candidate.helper.Occurrence) + } + candidate.children[index] = child + } + } + + classes := make([]int, len(r.candidates)) + for { + next := transformHelperClasses(r.candidates, classes) + if slices.Equal(classes, next) { + classes = next + break + } + classes = next + } + groups := make([]*TransformHelperGroup, 0) + for index, class := range classes { + for len(groups) <= class { + groups = append(groups, &TransformHelperGroup{}) + } + group := groups[class] + candidate := r.candidates[index] + if len(group.candidates) == 0 || comparePackageNameOrders(candidate.order, group.order) < 0 { + group.definition = candidate.definition + group.definition.Location = candidate.helper.location + group.order = candidate.order + } + group.candidates = append(group.candidates, candidate) + } + return groups, nil +} + +// Order returns the stable package name order from the same helper occurrence +// returned by Definition. +func (g *TransformHelperGroup) Order() PackageNameOrder { + return g.order +} + +// Definition returns a detached description used to choose the group's Go +// function name. Changing the returned attributes does not change the plans. +func (g *TransformHelperGroup) Definition() TransformHelperDefinition { + definition := g.definition + sourceCopier := expr.NewAttributeGraphCopier() + targetCopier := expr.NewAttributeGraphCopier() + definition.Source = sourceCopier.Copy(definition.Source) + definition.Target = targetCopier.Copy(definition.Target) + definition.helpers = nil + return definition +} + +// Bind assigns declaration to every helper occurrence in the group. It checks +// the complete group before changing any plan, so an error leaves all helpers +// unchanged. +func (g *TransformHelperGroup) Bind(declaration *NameDeclaration) error { + if declaration == nil { + return fmt.Errorf("transform helper group declaration must not be nil") + } + if declaration.Kind() != NameFunction { + return fmt.Errorf("transform helper group declaration must be a function, got %s", declaration.Kind()) + } + for _, candidate := range g.candidates { + bound := candidate.plan.helpers[candidate.helper.ID.index].Declaration + if bound != nil && bound != declaration { + return fmt.Errorf("transform helper occurrence %d already has a different declaration", candidate.helper.Occurrence) + } + } + for _, candidate := range g.candidates { + candidate.plan.helpers[candidate.helper.ID.index].Declaration = declaration + candidate.plan.refreshTransformDefinitionDeclaration(candidate.definition.ID) + } + return nil +} + +// refreshTransformDefinitionDeclaration records a declaration only when every +// occurrence in the plan-local definition uses that same declaration. +func (p *TransformPlan) refreshTransformDefinitionDeclaration(id TransformHelperDefinitionID) { + definition := &p.definitions[id.index] + declaration := p.helpers[definition.helpers[0]].Declaration + for _, index := range definition.helpers[1:] { + if p.helpers[index].Declaration != declaration { + definition.Declaration = nil + return + } + } + definition.Declaration = declaration +} + +// transformHelperClasses assigns a group from the function's own conversion +// rules and the groups assigned to its child calls on the previous pass. +func transformHelperClasses(candidates []*transformHelperCandidate, previous []int) []int { + classes := make([]int, len(candidates)) + representatives := make([]int, 0, len(candidates)) + for index, candidate := range candidates { + class := -1 + for candidateClass, representative := range representatives { + if transformHelperCandidatesEqual(candidate, candidates[representative], previous) { + class = candidateClass + break + } + } + if class < 0 { + class = len(representatives) + representatives = append(representatives, index) + } + classes[index] = class + } + return classes +} + +// transformHelperCandidatesEqual compares the facts that determine one +// function body, then compares its child calls using the previous pass. +func transformHelperCandidatesEqual(left, right *transformHelperCandidate, previous []int) bool { + if left.plan.program != right.plan.program { + return false + } + if left.plan.program != nil && !transformProgramHelpersEqual(left, right) { + return false + } + if !transformGoLayoutsEqual(left.sourceLayout, right.sourceLayout, true, make(map[transformGoLayoutPair]struct{})) { + return false + } + if !transformGoLayoutsEqual(left.targetLayout, right.targetLayout, true, make(map[transformGoLayoutPair]struct{})) { + return false + } + if !transformSemanticAttributesEqual(left.helper.Source, right.helper.Source, make(map[transformSemanticAttributePair]struct{})) { + return false + } + if !transformSemanticAttributesEqual(left.helper.Target, right.helper.Target, make(map[transformSemanticAttributePair]struct{})) { + return false + } + if len(left.children) != len(right.children) { + return false + } + for index := range left.children { + if previous[left.children[index].index] != previous[right.children[index].index] { + return false + } + } + return true +} + +// transformProgramHelpersEqual asks one shared hook program whether helpers +// from separate plans have the same conversion inputs. The remaining registry +// comparison still checks their generated Go layouts and ordered child calls. +func transformProgramHelpersEqual(left, right *transformHelperCandidate) bool { + hooks := left.plan.program.hooks + if hooks.SameHelperDefinition == nil { + return transformAttributesEqual(left.definition.Source, right.definition.Source, make(map[transformAttributePair]struct{})) && + transformAttributesEqual(left.definition.Target, right.definition.Target, make(map[transformAttributePair]struct{})) + } + return hooks.SameHelperDefinition(left.definition.Source, left.definition.Target, right.definition.Source, right.definition.Target) +} + +// transformGoLayoutsEqual compares every generated type fact read by a +// conversion. The root field name and enclosing field pointer are excluded +// because a helper receives the field value as its own function parameter. +func transformGoLayoutsEqual(left, right *GoTypePlan, root bool, seen map[transformGoLayoutPair]struct{}) bool { + if left == nil || right == nil { + return left == right + } + pair := transformGoLayoutPair{left: left, right: right} + if _, ok := seen[pair]; ok { + return true + } + seen[pair] = struct{}{} + if left.kind != right.kind || left.owner != right.owner || left.policy != right.policy || + left.referencePointer != right.referencePointer || + left.referenceNilable != right.referenceNilable || left.primitive != right.primitive || + left.directImport != right.directImport || left.hasDirectImport != right.hasDirectImport || + left.customQualifier != right.customQualifier || left.declaration != right.declaration || + left.fixedName != right.fixedName || len(left.fields) != len(right.fields) || + len(left.branches) != len(right.branches) { + return false + } + if !root && (left.fieldNameUpper != right.fieldNameUpper || left.fieldNameLower != right.fieldNameLower || + left.fieldPointer != right.fieldPointer || left.definitionPointer != right.definitionPointer || left.tag != right.tag) { + return false + } + if !transformGoLayoutsEqual(left.key, right.key, false, seen) || + !transformGoLayoutsEqual(left.element, right.element, false, seen) || + !transformGoLayoutsEqual(left.value, right.value, false, seen) { + return false + } + for index := range left.fields { + if !transformGoLayoutsEqual(left.fields[index], right.fields[index], false, seen) { + return false + } + } + for index := range left.branches { + if !transformGoLayoutsEqual(left.branches[index], right.branches[index], false, seen) { + return false + } + } + return true +} + +// transformSemanticAttributesEqual compares the Goa fields read by the normal +// conversion writers. The saved Go declarations identify named types, so +// separately copied Goa types do not differ by copy address. +func transformSemanticAttributesEqual(left, right *expr.AttributeExpr, seen map[transformSemanticAttributePair]struct{}) bool { + if left == nil || right == nil { + return left == right + } + pair := transformSemanticAttributePair{left: left, right: right} + if _, ok := seen[pair]; ok { + return true + } + seen[pair] = struct{}{} + if !reflect.DeepEqual(left.DefaultValue, right.DefaultValue) { + return false + } + leftObject := expr.AsObject(left.Type) + rightObject := expr.AsObject(right.Type) + if (leftObject == nil) != (rightObject == nil) { + return false + } + if leftObject != nil { + leftMapped := expr.NewMappedAttributeExpr(left) + rightMapped := expr.NewMappedAttributeExpr(right) + if len(*leftObject) != len(*rightObject) { + return false + } + for _, field := range *leftObject { + name := strings.SplitN(field.Name, ":", 2)[0] + if leftMapped.IsRequired(name) != rightMapped.IsRequired(name) { + return false + } + } + } + return transformSemanticDataTypesEqual(left.Type, right.Type, seen) +} + +// transformSemanticDataTypesEqual compares field matching, required fields, +// collection rules, union branches, and nested defaults without comparing Goa +// value addresses. +func transformSemanticDataTypesEqual(left, right expr.DataType, seen map[transformSemanticAttributePair]struct{}) bool { + if left == nil || right == nil { + return left == right + } + if reflect.TypeOf(left) != reflect.TypeOf(right) { + return false + } + switch left := left.(type) { + case expr.Primitive: + return left == right.(expr.Primitive) + case *expr.Array: + right := right.(*expr.Array) + return left.NonNullableElems == right.NonNullableElems && + transformSemanticAttributesEqual(left.ElemType, right.ElemType, seen) + case *expr.Object: + right := right.(*expr.Object) + if len(*left) != len(*right) { + return false + } + for index, field := range *left { + other := (*right)[index] + if field.Name != other.Name || !transformSemanticAttributesEqual(field.Attribute, other.Attribute, seen) { + return false + } + } + return true + case *expr.Map: + right := right.(*expr.Map) + return transformSemanticAttributesEqual(left.KeyType, right.KeyType, seen) && + transformSemanticAttributesEqual(left.ElemType, right.ElemType, seen) + case *expr.Union: + right := right.(*expr.Union) + if left.TypeKey != right.TypeKey || left.ValueKey != right.ValueKey || len(left.Values) != len(right.Values) { + return false + } + for index, branch := range left.Values { + other := right.Values[index] + if branch.Name != other.Name || !transformSemanticAttributesEqual(branch.Attribute, other.Attribute, seen) { + return false + } + } + return true + case expr.UserType: + return transformSemanticAttributesEqual(left.Attribute(), right.(expr.UserType).Attribute(), seen) + default: + panic(fmt.Sprintf("cannot compare transform type %T", left)) + } +} + +// transformLayoutAtLocation follows the authored field, collection, and union +// path saved by TransformPlan and returns the generated layout at that point. +func transformLayoutAtLocation(layout *GoTypePlan, attribute *expr.AttributeExpr, location TransformHelperDefinitionLocation) (*GoTypePlan, error) { + remaining := location.encoded + for len(remaining) > 0 { + kind := remaining[0] + remaining = remaining[1:] + var name strings.Builder + for { + if len(remaining) < 2 { + return nil, fmt.Errorf("invalid transform helper location") + } + if remaining[0] == 0 { + if remaining[1] == 0xff { + name.WriteByte(0) + remaining = remaining[2:] + continue + } + if remaining[1] == 0 { + remaining = remaining[2:] + break + } + } + name.WriteByte(remaining[0]) + remaining = remaining[1:] + } + layout, attribute = transformLayoutValue(layout, attribute) + switch kind { + case transformObjectFieldLocation: + object := expr.AsObject(attribute.Type) + if object == nil || layout.kind != GoStruct { + return nil, fmt.Errorf("object field %q does not select a generated struct", name.String()) + } + found := false + for index, field := range *object { + if strings.SplitN(field.Name, ":", 2)[0] == name.String() { + layout = layout.fields[index] + attribute = field.Attribute + found = true + break + } + } + if !found { + return nil, fmt.Errorf("object field %q is missing", name.String()) + } + case transformArrayElementLocation: + array := expr.AsArray(attribute.Type) + if array == nil || layout.element == nil { + return nil, fmt.Errorf("array element does not select a generated element") + } + layout, attribute = layout.element, array.ElemType + case transformMapKeyLocation, transformMapValueLocation: + mapped := expr.AsMap(attribute.Type) + if mapped == nil { + return nil, fmt.Errorf("map component does not select a generated map") + } + if kind == transformMapKeyLocation { + layout, attribute = layout.key, mapped.KeyType + } else { + layout, attribute = layout.element, mapped.ElemType + } + case transformUnionBranchLocation: + union := expr.AsUnion(attribute.Type) + if union == nil || layout.kind != GoUnion { + return nil, fmt.Errorf("union branch %q does not select a generated union", name.String()) + } + found := false + for index, branch := range union.Values { + if branch.Name == name.String() { + layout = layout.branches[index] + attribute = branch.Attribute + found = true + break + } + } + if !found { + return nil, fmt.Errorf("union branch %q is missing", name.String()) + } + default: + return nil, fmt.Errorf("unknown transform helper location step %d", kind) + } + } + return layout, nil +} + +// transformLayoutValue enters every consecutive named type while keeping the +// outer named layout available as the helper parameter or result type. +func transformLayoutValue(layout *GoTypePlan, attribute *expr.AttributeExpr) (*GoTypePlan, *expr.AttributeExpr) { + for { + userType, ok := attribute.Type.(expr.UserType) + if !ok || userType == expr.Empty { + return layout, attribute + } + attribute = userType.Attribute() + if layout.kind == GoNamed && layout.value != nil { + layout = layout.value + } + } +} diff --git a/codegen/transformer.go b/codegen/transformer.go index 35ef51ec2f..effcdba736 100644 --- a/codegen/transformer.go +++ b/codegen/transformer.go @@ -1,7 +1,10 @@ +// This file defines the naming and pointer contracts shared by Go +// transformation and validation generators. package codegen import ( "fmt" + "strings" "goa.design/goa/v3/expr" ) @@ -20,6 +23,17 @@ type ( // attribute and field name. If firstUpper is true then the field name // first letter is capitalized. Field(att *expr.AttributeExpr, name string, firstUpper bool) string + // Package returns the qualifier used to reference att from the current + // generated file, or the empty string for a same-package declaration. + Package(att *expr.AttributeExpr) string + // Enter returns the resolver for the package containing att and declarations + // nested in it. + Enter(att *expr.AttributeExpr) Attributor + // IsSumType reports whether unions use Goa's generated sum-type layout. + IsSumType() bool + // ValidatorCall returns the complete call that validates target as att. + // path is the generated expression used as the start of nested error paths. + ValidatorCall(att *expr.AttributeExpr, view, target, path string) string } // AttributeContext contains properties which impacts the code generating @@ -33,6 +47,10 @@ type ( // (proto) uses non-pointers to hold required attributes and // therefore do not need to be validated. IgnoreRequired bool + // IgnoreRequiredCollections suppresses presence checks for arrays and maps + // in transports such as protobuf that cannot distinguish empty from absent. + // Length and element validations still apply. + IgnoreRequiredCollections bool // UseDefault if true indicates that the attribute uses non-pointers for // primitive types if they have default value. If false, the attribute with // primitive types are non-pointers if they are required, otherwise they @@ -40,17 +58,14 @@ type ( UseDefault bool // Scope is the attribute scope. Scope Attributor - // DefaultPkg is the default package name where the attribute - // type is found. it can be overridden via struct:pkg:path meta. - DefaultPkg string - // SamePackageConversion if true indicates that this context is being used - // for conversion code generation within the same package as the types. - SamePackageConversion bool // UnionPointer if true indicates that optional sum-type union fields use // pointers to preserve transport-level presence. Required union fields also // use pointers when Pointer is true. Service types leave this false because // the empty union discriminator represents omission after decoding. UnionPointer bool + // ArrayElementPointer keeps primitive array elements as pointers when + // generated validation must distinguish null from the primitive zero value. + ArrayElementPointer bool } // AttributeScope contains the scope of an attribute. It implements the @@ -58,6 +73,15 @@ type ( AttributeScope struct { // scope is the name scope for the attribute. scope *NameScope + // pkg is the default generated Go package qualifier. + pkg string + } + + // goTypeLayoutAttributor uses names retained in one linked Go type. The + // wrapped resolver supplies operations that the type plan does not record. + goTypeLayoutAttributor struct { + Attributor + layout LinkedGoType } // TransformAttrs are the attributes that help in the transformation. @@ -69,7 +93,80 @@ type ( // Hooks are optional generator specific extension points // consulted by the transform engine. Nil selects the engine // defaults. - Hooks *TransformHooks + Hooks *TransformHooks + helpers map[TransformHelperID]TransformHelper + calls *transformCallCursor + locals *NameScope + collectionDepth int + unionDepth int + } + + // TransformHelperID selects one recursive function in a TransformPlan. Its + // fields are private so callers cannot rebuild it from a generated name. + TransformHelperID struct { + plan *TransformPlan + index int + } + + // TransformHelperDefinitionID selects one function body in a TransformPlan. + // Its fields are private so callers cannot rebuild it from type names. + TransformHelperDefinitionID struct { + plan *TransformPlan + index int + } + + // TransformHelperDefinitionLocation orders function bodies by the authored + // field or collection position where they are used. Its representation is + // private and is never used as a generated Go name. + TransformHelperDefinitionLocation struct { + encoded string + } + + // TransformHelperDefinition describes one function body shared by equivalent + // helper calls in a TransformPlan. Requiredness belongs to each call and does + // not create a different definition. + TransformHelperDefinition struct { + // ID selects this function body in its TransformPlan. + ID TransformHelperDefinitionID + // Source describes the source attribute converted by the function. + // HelperDefinitions returns a detached copy, so changing it does not affect + // Render. + Source *expr.AttributeExpr + // Target describes the target attribute produced by the function. + // HelperDefinitions returns a detached copy, so changing it does not affect + // Render. + Target *expr.AttributeExpr + // Location provides a stable order for this function body. + Location TransformHelperDefinitionLocation + // Declaration is populated when BindHelperDefinition assigns the + // package-level function name. It remains nil when callers bind only the + // individual helper occurrences. + Declaration *NameDeclaration + helpers []int + } + + // TransformHelper describes one generated function that converts a nested or + // recursive value. The same chosen function name is used at every call and at + // its definition. + TransformHelper struct { + // ID selects this function in its TransformPlan. + ID TransformHelperID + // Source describes the source attribute selected for this function. + // Helpers returns a detached copy, so changing it does not affect Render. + Source *expr.AttributeExpr + // Target describes the target attribute selected for this function. + // Helpers returns a detached copy, so changing it does not affect Render. + Target *expr.AttributeExpr + // Required reports whether the caller reaches this function through a + // required value. Optional callers check for nil before calling it. + Required bool + // Occurrence is the one-based position of this helper operation in the + // transform plan's stable traversal. + Occurrence int + // Declaration holds the package-level function name chosen before source + // is written. Render returns an error when it is missing. + Declaration *NameDeclaration + location TransformHelperDefinitionLocation } // TransformFunctionData describes a helper function used to transform @@ -90,11 +187,106 @@ type ( // } // TransformFunctionData struct { - Name string - ParamTypeRef string + // ID selects the recursive function rendered by this value. + ID TransformHelperID + // Declaration is the package-level function chosen before writing code. + // It is nil when GoTransformWithAttrs created this value while writing + // code. + Declaration *NameDeclaration + // Name is the final helper name kept for existing plugins. + // + // Deprecated: Use Declaration.Name() after planning. + Name string + // ParamTypeRef is the generated Go reference to the helper parameter type. + ParamTypeRef string + // ResultTypeRef is the generated Go reference to the helper result type. ResultTypeRef string - Code string + // Code is the helper body. + Code string + } + + // TransformProgram owns one immutable, non-nil set of conversion hooks. + // Plans made by the same program may share a generated helper when their + // complete Go types, conversion rules, and child calls match. Built-in + // conversion plans do not need a TransformProgram. + TransformProgram struct { + hooks *TransformHooks + } + + // TransformPlan owns copied source and target expressions plus every + // recursive function needed to convert between them. Create a plan, inspect + // the detached helper descriptions from Helpers and declare their names, bind + // those declarations and the completed type resolvers, then call Render. + // A helper ID remains bound to this plan, but changing a description returned + // by Helpers cannot change the private expressions Render uses. Render caches + // each exact argument set, so repeated calls return the first generated code. + TransformPlan struct { + source *expr.AttributeExpr + target *expr.AttributeExpr + rootSource *expr.AttributeExpr + rootTarget *expr.AttributeExpr + rootWrap *WrapDirective + sourceBaseline *expr.AttributeExpr + targetBaseline *expr.AttributeExpr + sourceCopier *expr.AttributeGraphCopier + targetCopier *expr.AttributeGraphCopier + prefix string + program *TransformProgram + hooks *TransformHooks + sourceCtx *AttributeContext + targetCtx *AttributeContext + helpers []TransformHelper + definitions []TransformHelperDefinition + operations []*transformOperation + renders map[transformRenderRequest]transformRenderResult + } + + // transformRenderRequest identifies one Render invocation. Repeating the + // same invocation returns its first result instead of invoking hooks again. + transformRenderRequest struct { + sourceVar string + targetVar string + newVar bool + } + + // transformRenderResult is the private immutable cache for one Render call. + transformRenderResult struct { + code string + helpers []*TransformFunctionData + err error + } + + // transformPair holds the exact copied source and target types whose fields + // are currently being visited. + transformPair struct { + source expr.DataType + target expr.DataType } + + // transformOperation stores the recursive calls made by the top-level + // conversion or one function body, in call order. + transformOperation struct { + calls []transformCall + } + + // transformCall selects the recursive function used by one call. + transformCall struct { + helper TransformHelperID + } + + // transformCallCursor counts the planned calls used by one render. + transformCallCursor struct { + calls []transformCall + next int + } +) + +const ( + transformObjectFieldLocation byte = iota + 1 + transformArrayElementLocation + transformMapKeyLocation + transformMapValueLocation + transformUnionBranchLocation ) // NewAttributeContext initializes an attribute context. @@ -103,21 +295,54 @@ func NewAttributeContext(pointer, reqIgnore, useDefault bool, pkg string, scope Pointer: pointer, IgnoreRequired: reqIgnore, UseDefault: useDefault, - Scope: NewAttributeScope(scope), - DefaultPkg: pkg, + Scope: newAttributeScope(scope, pkg), } } -// NewAttributeContextForConversion initializes an attribute context for same-package conversion. -func NewAttributeContextForConversion(pointer, reqIgnore, useDefault bool, pkg string, scope *NameScope) *AttributeContext { - ctx := NewAttributeContext(pointer, reqIgnore, useDefault, pkg, scope) - ctx.SamePackageConversion = true - return ctx -} - // NewAttributeScope initializes an attribute scope. func NewAttributeScope(scope *NameScope) *AttributeScope { - return &AttributeScope{scope: scope} + return newAttributeScope(scope, "") +} + +// Compare returns -1, 0, or 1 when this authored location sorts before, at, +// or after other. +func (l TransformHelperDefinitionLocation) Compare(other TransformHelperDefinitionLocation) int { + return strings.Compare(l.encoded, other.encoded) +} + +// EnterCollection returns the loop variable for the current array and a copy +// used to render values nested inside that array. +func (a *TransformAttrs) EnterCollection() (string, *TransformAttrs) { + child := *a + child.collectionDepth++ + return string(rune('i' + a.collectionDepth)), &child +} + +// EnterLocalBlock returns a copy whose temporary names are limited to one +// generated Go block. expressions and names are the values that code in that +// block must continue to reference after declaring a temporary. +func (a *TransformAttrs) EnterLocalBlock(expressionsAndNames ...string) (*TransformAttrs, error) { + child := *a + var err error + child.locals, err = newTransformLocalScope(expressionsAndNames...) + if err != nil { + return nil, err + } + return &child, nil +} + +// enterLocalBlock returns a copy that can declare names visible only inside a +// generated Go block. Names already visible outside the block remain reserved. +func (a *TransformAttrs) enterLocalBlock() *TransformAttrs { + child := *a + child.locals = a.locals.Fork() + return &child +} + +// uniqueLocal reserves the preferred local name in the current generated Go +// block and returns the spelling that does not conflict with visible names. +func (a *TransformAttrs) uniqueLocal(preferred string) string { + return a.locals.Unique(preferred) } // IsCompatible returns an error if a and b are not both objects, both arrays, @@ -158,13 +383,19 @@ func IsCompatible(a, b expr.DataType, actx, bctx string) error { return nil } -// AppendHelpers takes care of only appending helper functions from newH that -// are not already in oldH. +// AppendHelpers appends functions from newH that oldH does not already contain. +// Planned functions are the same when they use the same package declaration. +// Older functions without a declaration are the same when their names match. +// It panics when one declaration or released name has different parameter, +// result, or body text because one Go function cannot implement both values. func AppendHelpers(oldH, newH []*TransformFunctionData) []*TransformFunctionData { for _, h := range newH { found := false for _, h2 := range oldH { - if h.Name == h2.Name { + if sameTransformHelper(h, h2) { + if !transformFunctionDefinitionsEqual(h, h2) { + panic(fmt.Sprintf("transform helper %q has different definitions", h.Name)) + } found = true break } @@ -176,28 +407,41 @@ func AppendHelpers(oldH, newH []*TransformFunctionData) []*TransformFunctionData return oldH } +// sameTransformHelper compares the chosen package declarations when both +// helpers have one. Values created while writing code have no declaration and +// keep using their generated names. +func sameTransformHelper(left, right *TransformFunctionData) bool { + if left.Declaration != nil && right.Declaration != nil { + return left.Declaration == right.Declaration + } + if left.Declaration != nil || right.Declaration != nil { + return false + } + return left.Name == right.Name +} + // MapDepth returns the level of nested maps. For unnested maps, it returns 0. func MapDepth(m *expr.Map) int { return mapDepth(m.ElemType.Type, 0) } -func mapDepth(dt expr.DataType, depth int, seen ...map[string]struct{}) int { +func mapDepth(dt expr.DataType, depth int, seen ...map[expr.DataType]struct{}) int { if mp := expr.AsMap(dt); mp != nil { depth++ depth = mapDepth(mp.ElemType.Type, depth, seen...) } else if ar := expr.AsArray(dt); ar != nil { depth = mapDepth(ar.ElemType.Type, depth, seen...) } else if mo := expr.AsObject(dt); mo != nil { - var s map[string]struct{} + var s map[expr.DataType]struct{} if len(seen) > 0 { s = seen[0] } else { - s = make(map[string]struct{}) + s = make(map[expr.DataType]struct{}) seen = append(seen, s) } - key := dt.Name() + key := dt if u, ok := dt.(expr.UserType); ok { - key = u.ID() + key = u.Origin() } if _, ok := s[key]; ok { return depth @@ -221,8 +465,11 @@ func mapDepth(dt expr.DataType, depth int, seen ...map[string]struct{}) int { // IsPrimitivePointer returns true if the attribute with the given name is a // primitive pointer in the given parent attribute. func (a *AttributeContext) IsPrimitivePointer(name string, att *expr.AttributeExpr) bool { - if at := att.Find(name); at != nil && (at.Type == expr.Any || at.Type == expr.Bytes) { - return false + if at := att.Find(name); at != nil && expr.IsPrimitive(at.Type) { + kind := unalias(at.Type).Kind() + if kind == expr.AnyKind || kind == expr.BytesKind { + return false + } } if a.Pointer { return true @@ -237,7 +484,7 @@ func (a *AttributeContext) IsFieldPointer(name string, att *expr.AttributeExpr) if expr.IsUnion(field.Type) { return a.IsUnionPointer(att.IsRequired(name)) } - if _, ok := a.Scope.(*AttributeScope); !ok { + if !a.Scope.IsSumType() { return expr.IsPrimitive(field.Type) && a.IsPrimitivePointer(name, att) } return goFieldIsPointer(att, name, a.Pointer, a.UseDefault) @@ -249,39 +496,72 @@ func (a *AttributeContext) IsUnionPointer(required bool) bool { return a.UnionPointer && (!required || a.Pointer) } +// IsArrayElementPointer reports whether primitive elements in array use +// pointers so generated validation can reject null before conversion. +func (a *AttributeContext) IsArrayElementPointer(array *expr.Array) bool { + return arrayElementIsPointer(array, a.ArrayElementPointer) +} + +// LayoutPolicy returns the exact pointer and default rules used by this +// generated value. +func (a *AttributeContext) LayoutPolicy() GoLayoutPolicy { + return GoLayoutPolicy{ + Pointer: a.Pointer, + IgnoreRequired: a.IgnoreRequired, + IgnoreRequiredCollections: a.IgnoreRequiredCollections, + UseDefault: a.UseDefault, + UnionPointer: a.UnionPointer, + ArrayElementPointer: a.ArrayElementPointer, + SumType: a.Scope.IsSumType(), + } +} + // Pkg returns the package name of the given type. func (a *AttributeContext) Pkg(att *expr.AttributeExpr) string { - if att == nil { - return a.DefaultPkg + return a.Scope.Package(att) +} + +// Enter returns a copy whose attributor owns att and unlocated declarations +// nested inside it. +func (a *AttributeContext) Enter(att *expr.AttributeExpr) *AttributeContext { + entered := a.Dup() + entered.Scope = a.Scope.Enter(att) + return entered +} + +// WithGoTypeLayout returns a copy that uses the final generated type, field, +// and package names retained in layout. +func (a *AttributeContext) WithGoTypeLayout(layout LinkedGoType) (*AttributeContext, error) { + if a == nil { + return nil, fmt.Errorf("attach Go type layout: attribute context must not be nil") } - if loc := UserTypeLocation(att.Type); loc != nil { - pkg := loc.PackageName() - // If this is same-package conversion and the type's package matches - // the context's default package, return empty string to avoid qualification - if a.SamePackageConversion && pkg == a.DefaultPkg { - return "" - } - return pkg + if a.Scope == nil { + return nil, fmt.Errorf("attach Go type layout: attribute resolver must not be nil") } - if expr.AsUnion(att.Type) != nil { - if a.SamePackageConversion { - return "" - } - return a.DefaultPkg + if layout.plan == nil { + return nil, fmt.Errorf("attach Go type layout: linked Go type must not be empty") + } + if layout.plan.policy != a.LayoutPolicy() { + return nil, fmt.Errorf("attach Go type layout: pointer and default rules do not match the attribute context") } - return a.DefaultPkg + linked := a.Dup() + linked.Scope = &goTypeLayoutAttributor{ + Attributor: a.Scope, + layout: layout, + } + return linked, nil } // Dup creates a shallow copy of the AttributeContext. func (a *AttributeContext) Dup() *AttributeContext { return &AttributeContext{ - Pointer: a.Pointer, - IgnoreRequired: a.IgnoreRequired, - UseDefault: a.UseDefault, - Scope: a.Scope, - DefaultPkg: a.DefaultPkg, - SamePackageConversion: a.SamePackageConversion, - UnionPointer: a.UnionPointer, + Pointer: a.Pointer, + IgnoreRequired: a.IgnoreRequired, + IgnoreRequiredCollections: a.IgnoreRequiredCollections, + UseDefault: a.UseDefault, + Scope: a.Scope, + UnionPointer: a.UnionPointer, + ArrayElementPointer: a.ArrayElementPointer, } } @@ -314,6 +594,49 @@ func (a *AttributeScope) Ref(att *expr.AttributeExpr, pkg string) string { return a.scope.GoFullTypeRef(att, pkg) } +// GoTypeLayout records the exact names from this scope with the pointer policy +// selected by the caller. +func (a *AttributeScope) GoTypeLayout(attribute *expr.AttributeExpr, policy GoLayoutPolicy) (LinkedGoType, error) { + return planGoTypeWithAttributor(attribute, policy, a) +} + +// Package returns the qualifier selected by att's explicit type location or +// the scope's default package. +func (a *AttributeScope) Package(att *expr.AttributeExpr) string { + if att == nil { + return a.pkg + } + if loc := UserTypeLocation(att.Type); loc != nil { + return a.scope.generatedImportName(loc.RelImportPath, loc.PackageName()) + } + return a.pkg +} + +// ValidatorCall returns a call to the validation function selected from the +// generated type and view names. +func (a *AttributeScope) ValidatorCall(att *expr.AttributeExpr, view, target, _ string) string { + name := "Validate" + a.Name(att, "", false, true) + Goify(view, true) + return fmt.Sprintf("%s(%s)", name, target) +} + +// Enter returns a scope whose default qualifier follows att's explicit type +// location. The underlying name scope remains unchanged. +func (a *AttributeScope) Enter(att *expr.AttributeExpr) Attributor { + if loc := UserTypeLocation(att.Type); loc != nil { + pkg := a.scope.generatedImportName(loc.RelImportPath, loc.PackageName()) + if pkg != a.pkg { + return newAttributeScope(a.scope, pkg) + } + } + return a +} + +// IsSumType reports that AttributeScope renders unions using Goa's generated +// sum-type structs. +func (*AttributeScope) IsSumType() bool { + return true +} + // Field returns a valid Go struct field name. func (*AttributeScope) Field(att *expr.AttributeExpr, name string, firstUpper bool) string { return GoifyAtt(att, name, firstUpper) @@ -323,3 +646,115 @@ func (*AttributeScope) Field(att *expr.AttributeExpr, name string, firstUpper bo func (a *AttributeScope) Scope() *NameScope { return a.scope } + +// Name returns the final planned type name when layout contains attribute. +func (a *goTypeLayoutAttributor) Name(attribute *expr.AttributeExpr, pkg string, pointer, useDefault bool) string { + return a.mustFind(attribute).Name() +} + +// Ref returns the final planned type reference when layout contains attribute. +func (a *goTypeLayoutAttributor) Ref(attribute *expr.AttributeExpr, pkg string) string { + return a.mustFind(attribute).Ref() +} + +// Field returns the final planned field name when layout contains attribute. +func (a *goTypeLayoutAttributor) Field(attribute *expr.AttributeExpr, name string, firstUpper bool) string { + expected := a.Attributor.Field(attribute, name, firstUpper) + for _, layout := range a.findAll(attribute) { + if layout.Field(firstUpper) == expected { + return expected + } + } + panic(fmt.Sprintf("Go type layout does not contain field %q", name)) // bug +} + +// Package returns the final package qualifier when layout contains attribute. +func (a *goTypeLayoutAttributor) Package(attribute *expr.AttributeExpr) string { + if attribute == nil { + return a.Attributor.Package(nil) + } + return a.mustFind(attribute).Package() +} + +// Enter narrows the retained type when attribute identifies one planned child. +func (a *goTypeLayoutAttributor) Enter(attribute *expr.AttributeExpr) Attributor { + return &goTypeLayoutAttributor{ + Attributor: a.Attributor.Enter(attribute), + layout: a.mustFind(attribute), + } +} + +// GoTypeLayout returns the retained type when it contains attribute. +func (a *goTypeLayoutAttributor) GoTypeLayout(attribute *expr.AttributeExpr, policy GoLayoutPolicy) (LinkedGoType, error) { + if layout, ok := a.find(attribute); ok { + if layout.plan.policy != policy { + return LinkedGoType{}, fmt.Errorf("resolve Go type layout: pointer and default rules do not match the retained type") + } + return layout, nil + } + return LinkedGoType{}, fmt.Errorf("resolve Go type layout: retained type does not contain %q", attribute.Type.Name()) +} + +// OneofWrapper asks the wrapped transport resolver for its protobuf wrapper. +func (a *goTypeLayoutAttributor) OneofWrapper(attribute *expr.AttributeExpr) string { + resolver, ok := a.Attributor.(interface { + OneofWrapper(*expr.AttributeExpr) string + }) + if !ok { + panic("Go type layout resolver cannot resolve a protobuf oneof wrapper") // bug + } + return resolver.OneofWrapper(attribute) +} + +// UnionConstructor asks the wrapped resolver for the generated branch constructor. +func (a *goTypeLayoutAttributor) UnionConstructor(attribute *expr.AttributeExpr, branch string) (string, error) { + resolver, ok := a.Attributor.(interface { + UnionConstructor(*expr.AttributeExpr, string) (string, error) + }) + if !ok { + return "", fmt.Errorf("Go type layout resolver cannot resolve a OneOf constructor") + } + return resolver.UnionConstructor(attribute, branch) +} + +// find returns one retained type for attribute and rejects different planned +// references for the same expression. +func (a *goTypeLayoutAttributor) find(attribute *expr.AttributeExpr) (LinkedGoType, bool) { + layouts := a.findAll(attribute) + if len(layouts) == 0 { + return LinkedGoType{}, false + } + selected := layouts[0] + for _, layout := range layouts[1:] { + if layout.Name() != selected.Name() || layout.Ref() != selected.Ref() || layout.Package() != selected.Package() { + panic(fmt.Sprintf("Go type layout has different references for %q", attribute.Type.Name())) // bug + } + } + return selected, true +} + +// mustFind returns the retained type for attribute or stops generation because +// a linked context must contain every type requested from it. +func (a *goTypeLayoutAttributor) mustFind(attribute *expr.AttributeExpr) LinkedGoType { + layout, ok := a.find(attribute) + if !ok { + panic(fmt.Sprintf("Go type layout does not contain %q", attribute.Type.Name())) // bug + } + return layout +} + +// findAll returns every retained occurrence of attribute below the current type. +func (a *goTypeLayoutAttributor) findAll(attribute *expr.AttributeExpr) []LinkedGoType { + plans := a.layout.plan.PlansForOccurrence(attribute) + layouts := make([]LinkedGoType, len(plans)) + for index, plan := range plans { + layouts[index] = a.layout.Enter(plan) + } + return layouts +} + +// newAttributeScope builds an attribute scope with explicit package +// qualification behavior. +func newAttributeScope(scope *NameScope, pkg string) *AttributeScope { + return &AttributeScope{scope: scope, pkg: pkg} +} diff --git a/codegen/transformer_test.go b/codegen/transformer_test.go index 1a40acf303..1f4530a372 100644 --- a/codegen/transformer_test.go +++ b/codegen/transformer_test.go @@ -1,12 +1,91 @@ +// This file verifies shared transform rules that are independent of a +// transport generator. package codegen import ( "testing" + "github.com/stretchr/testify/assert" + "goa.design/goa/v3/expr" ) +func TestAppendHelpersUsesDeclarationIdentity(t *testing.T) { + firstPlan := &TransformPlan{} + secondPlan := &TransformPlan{} + shared := NewExactName(NameFunction, "sharedHelper") + separate := NewExactName(NameFunction, "separateHelper") + old := []*TransformFunctionData{ + {ID: TransformHelperID{plan: firstPlan}, Declaration: shared, Name: "sharedHelper"}, + {ID: TransformHelperID{plan: firstPlan, index: 1}, Declaration: separate, Name: "separateHelper"}, + {Name: "legacyOne"}, + } + added := []*TransformFunctionData{ + {ID: TransformHelperID{plan: secondPlan}, Declaration: shared, Name: "sharedHelper"}, + {Name: "sharedHelper"}, + {Name: "legacyOne"}, + {Name: "legacyTwo"}, + } + + got := AppendHelpers(old, added) + + if assert.Len(t, got, 5) { + assert.Same(t, shared, got[0].Declaration) + assert.Same(t, separate, got[1].Declaration) + assert.Nil(t, got[2].Declaration) + assert.Nil(t, got[3].Declaration) + assert.Nil(t, got[4].Declaration) + assert.Equal(t, "sharedHelper", got[3].Name) + assert.Equal(t, "legacyTwo", got[4].Name) + } +} + +func TestAppendHelpersRejectsConflictingLegacyDefinitions(t *testing.T) { + tests := map[string]*TransformFunctionData{ + "parameter type": { + Name: "transformValue", + ParamTypeRef: "OtherSource", + ResultTypeRef: "Target", + Code: "return value", + }, + "result type": { + Name: "transformValue", + ParamTypeRef: "Source", + ResultTypeRef: "OtherTarget", + Code: "return value", + }, + "body": { + Name: "transformValue", + ParamTypeRef: "Source", + ResultTypeRef: "Target", + Code: "return other", + }, + } + for name, added := range tests { + t.Run(name, func(t *testing.T) { + old := []*TransformFunctionData{{ + Name: "transformValue", + ParamTypeRef: "Source", + ResultTypeRef: "Target", + Code: "return value", + }} + assert.PanicsWithValue(t, "transform helper \"transformValue\" has different definitions", func() { + AppendHelpers(old, []*TransformFunctionData{added}) + }) + }) + } +} + func TestIsPrimitivePointer(t *testing.T) { + primitiveAlias := func(name string, primitive expr.Primitive) expr.DataType { + return &expr.UserTypeExpr{ + TypeName: name, + UID: name, + AttributeExpr: &expr.AttributeExpr{ + Type: primitive, + }, + } + } newObj := func(fieldName string, fieldType expr.DataType, req bool) *expr.AttributeExpr { attr := &expr.AttributeExpr{ Type: &expr.Object{ @@ -53,6 +132,20 @@ func TestIsPrimitivePointer(t *testing.T) { Name: "foo", Expected: true, }, + { + Test: "pointer context with bytes alias", + Context: &AttributeContext{Pointer: true}, + Attr: newObj("foo", primitiveAlias("BytesAlias", expr.Bytes), true), + Name: "foo", + Expected: false, + }, + { + Test: "pointer context with any alias", + Context: &AttributeContext{Pointer: true}, + Attr: newObj("foo", primitiveAlias("AnyAlias", expr.Any), true), + Name: "foo", + Expected: false, + }, { Test: "ignore required context with pointer attribute", Context: &AttributeContext{IgnoreRequired: true}, diff --git a/codegen/types.go b/codegen/types.go index 3e2e8dbb79..38f7bdd904 100644 --- a/codegen/types.go +++ b/codegen/types.go @@ -43,11 +43,19 @@ func GoNativeTypeName(t expr.DataType) string { // IsNilable reports whether the Go type generated for t can be nil. func IsNilable(t expr.DataType) bool { + underlying := unalias(t) return expr.IsObject(t) || expr.IsArray(t) || expr.IsMap(t) || - t.Kind() == expr.BytesKind || - t.Kind() == expr.AnyKind + underlying.Kind() == expr.BytesKind || + underlying.Kind() == expr.AnyKind +} + +// arrayElementIsPointer reports whether validation needs a pointer to tell a +// null element apart from the element type's zero value. +func arrayElementIsPointer(array *expr.Array, enabled bool) bool { + return enabled && array.NonNullableElems && expr.IsPrimitive(array.ElemType.Type) && + !IsNilable(array.ElemType.Type) } // goFieldIsPointer reports whether a field in a generated Goa service struct diff --git a/codegen/union.go b/codegen/union.go new file mode 100644 index 0000000000..1f30b1347a --- /dev/null +++ b/codegen/union.go @@ -0,0 +1,148 @@ +// This file builds a repeatable key from every detail that changes a generated +// union's Go or JSON definition. +package codegen + +import ( + "strconv" + "strings" + + "goa.design/goa/v3/expr" +) + +type ( + // UnionDeclarationID identifies one authored OneOf declaration and the Go + // definition emitted for the current copy. Copies of the same authored + // attribute share an identity, while separate OneOf declarations do not. + UnionDeclarationID struct { + authored *expr.AttributeExpr + definition UnionTypeID + } + + // UnionTypeID identifies the Go and JSON definition emitted for a union. + // It is distinct from expr.Union.Hash, which describes design compatibility. + UnionTypeID string +) + +// NewUnionDeclarationID returns the identity of the OneOf stored in attribute. +// Transport copies keep the authored attribute, so they find the same +// declaration when their emitted definitions also match. +func NewUnionDeclarationID(attribute *expr.AttributeExpr) UnionDeclarationID { + union, ok := attribute.Type.(*expr.Union) + if !ok { + panic("union declaration identity requires a OneOf attribute") + } + return UnionDeclarationID{ + authored: attribute.AuthoredAttribute(), + definition: NewUnionTypeID(union), + } +} + +// NewUnionTypeID returns a repeatable key for union's generated Go and JSON +// definitions. The key includes the effective JSON envelope keys and every +// detail that changes a generated Go branch type: package location, field type +// metadata, and whether the value may be nil. +func NewUnionTypeID(union *expr.Union) UnionTypeID { + var key strings.Builder + writeUnionTypeID( + &key, + union, + make(map[*expr.Object]int), + make(map[*expr.Union]int), + make(map[expr.UserType]int), + ) + return UnionTypeID(key.String()) +} + +// writeUnionTypeID appends one union definition using length-prefixed values +// so different inputs cannot produce an ambiguous concatenation. +func writeUnionTypeID(key *strings.Builder, union *expr.Union, objects map[*expr.Object]int, unions map[*expr.Union]int, userTypes map[expr.UserType]int) { + if index, ok := unions[union]; ok { + writeUnionIDPart(key, "union-ref") + writeUnionIDPart(key, strconv.Itoa(index)) + return + } + unions[union] = len(unions) + defer delete(unions, union) + writeUnionIDPart(key, "union") + writeUnionIDPart(key, union.TypeName) + writeUnionIDPart(key, union.GetTypeKey()) + writeUnionIDPart(key, union.GetValueKey()) + for _, value := range union.Values { + writeUnionIDPart(key, value.Name) + writeUnionAttributeID(key, value.Attribute, objects, unions, userTypes) + } +} + +// writeUnionAttributeID appends every attribute detail that changes generated +// Go code. +func writeUnionAttributeID(key *strings.Builder, att *expr.AttributeExpr, objects map[*expr.Object]int, unions map[*expr.Union]int, userTypes map[expr.UserType]int) { + writeUnionIDPart(key, strconv.FormatBool(IsNilable(att.Type))) + if metaType, ok := att.Meta["struct:field:type"]; ok { + writeUnionIDPart(key, "meta-type") + for _, value := range metaType { + writeUnionIDPart(key, value) + } + } + switch actual := att.Type.(type) { + case expr.Primitive: + writeUnionIDPart(key, "primitive") + writeUnionIDPart(key, GoNativeTypeName(actual)) + case expr.UserType: + writeUnionIDPart(key, "user") + writeUnionIDPart(key, Goify(actual.Name(), true)) + if loc := UserTypeLocation(actual); loc != nil { + writeUnionIDPart(key, loc.RelImportPath) + } else { + writeUnionIDPart(key, "") + } + origin := actual.Origin() + if index, ok := userTypes[origin]; ok { + writeUnionIDPart(key, "user-ref") + writeUnionIDPart(key, strconv.Itoa(index)) + return + } + userTypes[origin] = len(userTypes) + defer delete(userTypes, origin) + writeUnionAttributeID(key, actual.Attribute(), objects, unions, userTypes) + case *expr.Array: + writeUnionIDPart(key, "array") + writeUnionAttributeID(key, actual.ElemType, objects, unions, userTypes) + case *expr.Map: + writeUnionIDPart(key, "map") + writeUnionAttributeID(key, actual.KeyType, objects, unions, userTypes) + writeUnionAttributeID(key, actual.ElemType, objects, unions, userTypes) + case *expr.Object: + writeUnionObjectID(key, att, actual, objects, unions, userTypes) + case *expr.Union: + writeUnionTypeID(key, actual, objects, unions, userTypes) + case expr.CompositeExpr: + writeUnionAttributeID(key, actual.Attribute(), objects, unions, userTypes) + default: + panic("unknown union branch data type") + } +} + +// writeUnionObjectID appends the inline Go struct emitted for an object. +func writeUnionObjectID(key *strings.Builder, parent *expr.AttributeExpr, object *expr.Object, objects map[*expr.Object]int, unions map[*expr.Union]int, userTypes map[expr.UserType]int) { + if index, ok := objects[object]; ok { + writeUnionIDPart(key, "object-ref") + writeUnionIDPart(key, strconv.Itoa(index)) + return + } + objects[object] = len(objects) + defer delete(objects, object) + writeUnionIDPart(key, "object") + for _, field := range *object { + writeUnionIDPart(key, GoifyAtt(field.Attribute, field.Name, true)) + writeUnionIDPart(key, AttributeTagsWithName(parent, field.Name, field.Attribute)) + writeUnionIDPart(key, strconv.FormatBool(goFieldIsPointer(parent, field.Name, false, true))) + writeUnionAttributeID(key, field.Attribute, objects, unions, userTypes) + } +} + +// writeUnionIDPart appends one unambiguous string component to key. +func writeUnionIDPart(key *strings.Builder, value string) { + key.WriteString(strconv.Itoa(len(value))) + key.WriteByte(':') + key.WriteString(value) +} diff --git a/codegen/validation.go b/codegen/validation.go index 384bdf15fd..24e390e186 100644 --- a/codegen/validation.go +++ b/codegen/validation.go @@ -1,15 +1,67 @@ +// This file generates functions that check service values and values sent over +// HTTP, gRPC, and JSON-RPC. Each function uses the Go names already chosen for +// its package. package codegen import ( "bytes" - "errors" "fmt" + "strconv" "strings" "text/template" "goa.design/goa/v3/expr" ) +type ( + // nilUserTypeValidationAttributor reports generated user types whose + // validator defines what nil means. Other user-type validators are called + // only for a present value. + nilUserTypeValidationAttributor interface { + ValidationAcceptsNil(*expr.AttributeExpr) bool + } + + // unionValidationCase describes one possible union branch in generated + // validation code. + unionValidationCase struct { + // Type is the generated Go type for the branch. + Type string + // Field is the field which stores the branch value. + Field string + // Name is the branch name shown in validation errors. + Name string + // PayloadRequiresPresence is true when selecting this branch also + // requires a non-nil value. + PayloadRequiresPresence bool + // Validation checks the value stored by this branch. + Validation string + } + + // unionValidationData contains the information needed to write one union + // check. + unionValidationData struct { + // Target is the generated union value being checked. + Target string + // Context identifies the union in validation errors. + Context validationPath + // Protobuf is true when each selected branch is stored in its own generated + // protobuf struct. + Protobuf bool + // Cases lists every branch accepted by the union. + Cases []unionValidationCase + // Goa is the generated import name of Goa's error package. + Goa string + } + + // validationPath stores an error path while Goa writes validation source. + // variable is true when root names a parameter in the generated function. + validationPath struct { + root string + suffix string + variable bool + } +) + var ( enumValT *template.Template formatValT *template.Template @@ -27,21 +79,21 @@ var ( func init() { fm := template.FuncMap{ - "slice": toSlice, - "oneof": oneof, - "constant": constant, + "slice": toSlice, + "oneof": oneof, + "constant": constant, + "validationPath": renderValidationPath, "isUnion": func(att *expr.AttributeExpr) bool { if att == nil { return false } return expr.IsUnion(att.Type) }, - "isAttributeScope": func(scope Attributor) bool { + "isSumType": func(scope Attributor) bool { if scope == nil { return false } - _, ok := scope.(*AttributeScope) - return ok + return scope.IsSumType() }, "isUnionPointer": func(ctx *AttributeContext, required bool) bool { return ctx.IsUnionPointer(required) @@ -67,7 +119,7 @@ func init() { // // See ValidationCode for a description of the arguments. func AttributeValidationCode(att *expr.AttributeExpr, put expr.UserType, attCtx *AttributeContext, req, alias bool, target, attName string) string { - return recurseValidationCode(att, put, attCtx, req, alias, false, target, attName, nil).String() + return recurseValidationCode(att, put, attCtx, req, alias, false, target, literalValidationPath(attName), nil).String() } // ValidationCode produces Go code that runs the validations defined in the @@ -91,12 +143,26 @@ func AttributeValidationCode(att *expr.AttributeExpr, put expr.UserType, attCtx // // context is used to produce helpful messages in case of error. func ValidationCode(att *expr.AttributeExpr, put expr.UserType, attCtx *AttributeContext, req, alias, view bool, target string) string { - return recurseValidationCode(att, put, attCtx, req, alias, view, target, target, nil).String() + return recurseValidationCode(att, put, attCtx, req, alias, view, target, literalValidationPath(target), nil).String() +} + +// ValidationCodeWithPathParameter produces validation code whose error paths +// begin with the string held by pathParameter. target and pathParameter are Go +// expressions. +func ValidationCodeWithPathParameter(att *expr.AttributeExpr, put expr.UserType, attCtx *AttributeContext, req, alias, view bool, target, pathParameter string) string { + return recurseValidationCode(att, put, attCtx, req, alias, view, target, parameterValidationPath(pathParameter), nil).String() +} + +func recurseValidationCode(att *expr.AttributeExpr, put expr.UserType, attCtx *AttributeContext, req, alias, view bool, target string, context validationPath, seen map[expr.UserType]*bytes.Buffer) *bytes.Buffer { + return renderValidationCode(att, put, attCtx, req, alias, view, target, context, seen, true) } -func recurseValidationCode(att *expr.AttributeExpr, put expr.UserType, attCtx *AttributeContext, req, alias, view bool, target, context string, seen map[string]*bytes.Buffer) *bytes.Buffer { +// renderValidationCode writes one validation tree. localGuards reports whether +// local rule templates must check a pointer before reading it. Nested fields +// disable those checks when validateAttribute wraps the whole field once. +func renderValidationCode(att *expr.AttributeExpr, put expr.UserType, attCtx *AttributeContext, req, alias, view bool, target string, context validationPath, seen map[expr.UserType]*bytes.Buffer, localGuards bool) *bytes.Buffer { if seen == nil { - seen = make(map[string]*bytes.Buffer) + seen = make(map[expr.UserType]*bytes.Buffer) } var ( buf = new(bytes.Buffer) @@ -109,10 +175,11 @@ func recurseValidationCode(att *expr.AttributeExpr, put expr.UserType, attCtx *A // so alias types shouldn't use the recursion guard. Only non-alias user // types need cycle protection. if isUT && !alias { - if buf, ok := seen[ut.ID()]; ok { + origin := ut.Origin() + if buf, ok := seen[origin]; ok { return buf } - seen[ut.ID()] = buf + seen[origin] = buf } newline := func() { @@ -124,7 +191,7 @@ func recurseValidationCode(att *expr.AttributeExpr, put expr.UserType, attCtx *A } // Write validations on attribute if any. - validation := validationCode(att, attCtx, req, alias, target, context) + validation := validationCode(att, attCtx, req, alias, target, context, localGuards) if validation != "" { buf.WriteString(validation) first = false @@ -138,7 +205,7 @@ func recurseValidationCode(att *expr.AttributeExpr, put expr.UserType, attCtx *A } for _, nat := range *(expr.AsObject(att.Type)) { tgt := fmt.Sprintf("%s.%s", target, attCtx.Scope.Field(nat.Attribute, nat.Name, true)) - ctx := fmt.Sprintf("%s.%s", context, nat.Name) + ctx := context.child("." + nat.Name) val := validateAttribute(attCtx, nat.Attribute, put, tgt, ctx, att.IsRequired(nat.Name), view, seen) if val != "" { newline() @@ -149,19 +216,21 @@ func recurseValidationCode(att *expr.AttributeExpr, put expr.UserType, attCtx *A arr := expr.AsArray(att.Type) elem := arr.ElemType ctx := attCtx - if ctx.Pointer && expr.IsPrimitive(elem.Type) { - // Array elements of primitive type are never pointers + if expr.IsPrimitive(elem.Type) { ctx = attCtx.Dup() - ctx.Pointer = false + ctx.Pointer = attCtx.IsArrayElementPointer(arr) } - val := validateAttribute(ctx, elem, put, "e", context+"[*]", true, view, seen) - if val != "" || arr.NonNullableElems { + val := validateAttribute(ctx, elem, put, "e", context.child("[*]"), true, view, seen) + nonNullableElems := arr.NonNullableElems && + (IsNilable(elem.Type) || attCtx.IsArrayElementPointer(arr)) + if val != "" || nonNullableElems { newline() data := map[string]any{ "target": target, "validation": val, - "nonNullableElems": arr.NonNullableElems, + "checkNilElements": nonNullableElems, "context": context, + "goa": "goa", } if err := arrayValT.Execute(buf, data); err != nil { panic(err) // bug @@ -171,11 +240,11 @@ func recurseValidationCode(att *expr.AttributeExpr, put expr.UserType, attCtx *A m := expr.AsMap(att.Type) ctx := attCtx.Dup() ctx.Pointer = false - keyVal := validateAttribute(ctx, m.KeyType, put, "k", context+".key", true, view, seen) + keyVal := validateAttribute(ctx, m.KeyType, put, "k", context.child(".key"), true, view, seen) if keyVal != "" { keyVal = "\n" + keyVal } - valueVal := validateAttribute(ctx, m.ElemType, put, "v", context+"[key]", true, view, seen) + valueVal := validateAttribute(ctx, m.ElemType, put, "v", context.child("[key]"), true, view, seen) if valueVal != "" { valueVal = "\n" + valueVal } @@ -188,7 +257,7 @@ func recurseValidationCode(att *expr.AttributeExpr, put expr.UserType, attCtx *A } case expr.IsUnion(att.Type): u := expr.AsUnion(att.Type) - if _, ok := attCtx.Scope.(*AttributeScope); ok { + if attCtx.Scope.IsSumType() { cases := make([]map[string]any, 0, len(u.Values)) for _, v := range u.Values { // Sum-type unions (struct-based, with Kind/AsX accessors) store each @@ -198,7 +267,7 @@ func recurseValidationCode(att *expr.AttributeExpr, put expr.UserType, attCtx *A // only keep pointer semantics when both layers use pointers. unionCtx := attCtx.Dup() unionCtx.Pointer = unionCtx.Pointer && expr.IsObject(v.Attribute.Type) - val := validateAttribute(unionCtx, v.Attribute, put, "actual", context+".value", true, view, seen) + val := validateAttribute(unionCtx, v.Attribute, put, "actual", context.child(".value"), true, view, seen) if val == "" { continue } @@ -213,6 +282,7 @@ func recurseValidationCode(att *expr.AttributeExpr, put expr.UserType, attCtx *A data := map[string]any{ "target": target, "cases": cases, + "goa": "goa", } if err := unionSumValT.Execute(buf, data); err != nil { panic(err) // bug @@ -222,35 +292,49 @@ func recurseValidationCode(att *expr.AttributeExpr, put expr.UserType, attCtx *A } // Validate unions represented as interfaces (e.g., protobuf oneof wrappers). - var vals []string - var types []string + var cases []unionValidationCase for _, v := range u.Values { vatt := v.Attribute if view { // Union values in views are never pointers - they are concrete typed values unionCtx := attCtx.Dup() unionCtx.Pointer = false - val := validateAttribute(unionCtx, vatt, put, "v", context+".value", true, view, seen) + val := validateAttribute(unionCtx, vatt, put, "v", context.child(".value"), true, view, seen) if val != "" { - types = append(types, attCtx.Scope.Ref(vatt, attCtx.DefaultPkg)) - vals = append(vals, val) + cases = append(cases, unionValidationCase{ + Type: attCtx.Scope.Ref(vatt, attCtx.Pkg(vatt)), + Validation: val, + }) } } else { fieldName := attCtx.Scope.Field(vatt, v.Name, true) - val := validateAttribute(attCtx, vatt, put, "v."+fieldName, context+".value", true, view, seen) - if val != "" { - tref := attCtx.Scope.Ref(&expr.AttributeExpr{Type: put}, attCtx.DefaultPkg) - types = append(types, tref+"_"+fieldName) - vals = append(vals, val) + branchCtx := attCtx + if expr.IsPrimitive(vatt.Type) { + // A union wrapper stores its scalar directly. The wrapper + // itself records whether that branch was selected. + branchCtx = attCtx.Dup() + branchCtx.Pointer = false } + val := validateAttribute(branchCtx, vatt, put, "v."+fieldName, context.child(".value"), true, view, seen) + parent := &expr.AttributeExpr{Type: put} + tref := attCtx.Scope.Ref(parent, attCtx.Pkg(parent)) + cases = append(cases, unionValidationCase{ + Type: tref + "_" + fieldName, + Field: fieldName, + Name: v.Name, + PayloadRequiresPresence: protobufUnionPayloadRequiresPresence(vatt), + Validation: val, + }) } } - if len(vals) > 0 { + if len(cases) > 0 { newline() - data := map[string]any{ - "target": target, - "types": types, - "values": vals, + data := unionValidationData{ + Target: target, + Context: context, + Protobuf: !view, + Cases: cases, + Goa: "goa", } if err := unionValT.Execute(buf, data); err != nil { panic(err) // bug @@ -261,38 +345,29 @@ func recurseValidationCode(att *expr.AttributeExpr, put expr.UserType, attCtx *A return buf } -func validateAttribute(ctx *AttributeContext, att *expr.AttributeExpr, put expr.UserType, target, context string, req, view bool, seen map[string]*bytes.Buffer) string { +// protobufUnionPayloadRequiresPresence reports whether selecting a protobuf +// union branch requires a non-nil value. Messages, byte slices, and Any values +// may be nil in Go, so their generated checks must reject nil explicitly. +func protobufUnionPayloadRequiresPresence(att *expr.AttributeExpr) bool { + kind := unalias(att.Type).Kind() + return !expr.IsPrimitive(att.Type) || kind == expr.BytesKind || kind == expr.AnyKind +} + +func validateAttribute(ctx *AttributeContext, att *expr.AttributeExpr, put expr.UserType, target string, context validationPath, req, view bool, seen map[expr.UserType]*bytes.Buffer) string { ut, isUT := att.Type.(expr.UserType) if !isUT { - code := recurseValidationCode(att, put, ctx, req, false, view, target, context, seen).String() + guard := validationAttributeNeedsNilGuard(att, ctx, req) + code := renderValidationCode(att, put, ctx, req, false, view, target, context, seen, !guard).String() if code == "" { return "" } if expr.IsArray(att.Type) || expr.IsMap(att.Type) { return code } - if expr.IsUnion(att.Type) { - _, sumType := ctx.Scope.(*AttributeScope) - if sumType { - if !ctx.IsUnionPointer(req) { - return code - } - } else if req { - return code - } - cond := fmt.Sprintf("if %s != nil {\n", target) - if strings.HasPrefix(code, cond) { - return code - } - return fmt.Sprintf("%s%s\n}", cond, code) - } - if !ctx.Pointer && (req || (att.DefaultValue != nil && ctx.UseDefault)) { + if !guard { return code } cond := fmt.Sprintf("if %s != nil {\n", target) - if strings.HasPrefix(code, cond) { - return code - } return fmt.Sprintf("%s%s\n}", cond, code) } // Alias user types: validate underlying attribute with alias flag so that @@ -303,36 +378,47 @@ func validateAttribute(ctx *AttributeContext, att *expr.AttributeExpr, put expr. // validating alias user types against their underlying base. Passing // the original attribute with alias=true ensures validations operate // on the correct value type without dropping field defaults. - code := recurseValidationCode(att, put, ctx, req, true, view, target, context, seen).String() + guard := validationAttributeNeedsNilGuard(att, ctx, req) + code := renderValidationCode(att, put, ctx, req, true, view, target, context, seen, !guard).String() if code == "" { return "" } - // For optional pointer fields, wrap validation code in nil check - if !ctx.Pointer && (req || (att.DefaultValue != nil && ctx.UseDefault)) { + if !guard { return code } cond := fmt.Sprintf("if %s != nil {\n", target) - if strings.HasPrefix(code, cond) { - return code - } return fmt.Sprintf("%s%s\n}", cond, code) } if !hasValidations(ctx, ut) { return "" } var buf bytes.Buffer - name := ctx.Scope.Name(att, "", ctx.Pointer, ctx.UseDefault) - // Use the scoped type name directly to preserve identifiers such as - // protocol buffer-reserved names that include a trailing underscore - // (e.g., Message_). Applying Goify here would drop underscores and - // cause mismatches between function declarations and call sites. - data := map[string]any{"name": name, "target": target} + call := ctx.Scope.ValidatorCall(att, "", target, renderValidationPath(context)) + data := map[string]any{"call": call, "goa": "goa"} if err := userValT.Execute(&buf, data); err != nil { panic(err) // bug } + if resolver, ok := ctx.Scope.(nilUserTypeValidationAttributor); ok && resolver.ValidationAcceptsNil(att) { + return buf.String() + } return fmt.Sprintf("if %s != nil {\n\t%s\n}", target, buf.String()) } +// validationAttributeNeedsNilGuard reports whether a nested value may be nil +// in the generated Go layout and must be checked before any validation uses it. +func validationAttributeNeedsNilGuard(att *expr.AttributeExpr, ctx *AttributeContext, required bool) bool { + if expr.IsArray(att.Type) || expr.IsMap(att.Type) { + return false + } + if expr.IsUnion(att.Type) { + if ctx.Scope.IsSumType() { + return ctx.IsUnionPointer(required) + } + return !required + } + return ctx.Pointer || !required && (att.DefaultValue == nil || !ctx.UseDefault) +} + // validationCode produces Go code that runs the validations that effectively // apply to the given attribute - see expr.EffectiveValidation - if any // against the content of the variable named target. The generated code @@ -353,7 +439,7 @@ func validateAttribute(ctx *AttributeContext, att *expr.AttributeExpr, put expr. // target is the variable name against which the validation code is generated // // context is used to produce helpful messages in case of error. -func validationCode(att *expr.AttributeExpr, attCtx *AttributeContext, req, alias bool, target, context string) string { +func validationCode(att *expr.AttributeExpr, attCtx *AttributeContext, req, alias bool, target string, context validationPath, localGuards bool) string { validation := expr.EffectiveValidation(att) if validation == nil { return "" @@ -378,10 +464,12 @@ func validationCode(att *expr.AttributeExpr, attCtx *AttributeContext, req, alia data := map[string]any{ "attribute": att, "attCtx": attCtx, - "isPointer": isPointer, + "isPointer": isPointer && localGuards, "context": context, "target": target, "targetVal": tval, + "goa": "goa", + "utf8": "utf8", "string": kind == expr.StringKind, "array": expr.IsArray(att.Type), "map": expr.IsMap(att.Type), @@ -434,7 +522,7 @@ func validationCode(att *expr.AttributeExpr, attCtx *AttributeContext, req, alia } if exclMax := validation.ExclusiveMaximum; exclMax != nil { data["exclMax"] = *exclMax - data["isExclMax"] = true + data["isExclMin"] = false if val := runTemplate(exclMinMaxValT, data); val != "" { res = append(res, val) } @@ -462,7 +550,7 @@ func validationCode(att *expr.AttributeExpr, attCtx *AttributeContext, req, alia res = append(res, val) } } - reqs := generatedRequiredValidation(att, validation, attCtx) + reqs := generatedRequiredValidationNames(att, validation, attCtx.LayoutPolicy()) obj := expr.AsObject(att.Type) for _, r := range reqs { reqAtt := obj.Attribute(r) @@ -473,53 +561,40 @@ func validationCode(att *expr.AttributeExpr, attCtx *AttributeContext, req, alia return strings.Join(res, "\n") } -// hasValidations returns true if a UserType contains validations. It is a -// pure predicate: it never mutates the design expression tree. -func hasValidations(attCtx *AttributeContext, ut expr.UserType) bool { - // We need to check empirically whether there are validations to be - // generated. We can't call recurseValidationCode() to avoid infinite - // recursions, but we can use validationCode() for the local (non-recursive) - // attribute-level checks — it is the source of truth for whether a given - // attribute produces any validation output, including any skips (e.g. - // format checks on struct:field:type attributes). For nested user types - // and required-field checks we keep the structural walk. - res := false - done := errors.New("done") - Walk(ut.Attribute(), func(a *expr.AttributeExpr) error { // nolint: errcheck - // validationCode computes the validation that effectively applies - // to a - including user type alias chain validations - and returns - // the empty string when there is nothing to validate. - if validationCode(a, attCtx, true, false, "x", "x") != "" { - res = true - return done - } - return nil - }) - return res +// literalValidationPath folds a complete error path into a quoted Go string +// while Goa is generating source. +func literalValidationPath(root string) validationPath { + return validationPath{root: root} } -// There is a case where there is validation but no actual validation code: if -// the validation is a required validation that applies to attributes that -// cannot be nil i.e. primitive types. val is the validation that effectively -// applies to att as computed by expr.EffectiveValidation. -func generatedRequiredValidation(att *expr.AttributeExpr, val *expr.ValidationExpr, attCtx *AttributeContext) (res []string) { - obj := expr.AsObject(att.Type) - for _, req := range val.Required { - reqAtt := obj.Attribute(req) - if reqAtt == nil { - continue - } - if !attCtx.Pointer && expr.IsPrimitive(reqAtt.Type) && - reqAtt.Type.Kind() != expr.BytesKind && - reqAtt.Type.Kind() != expr.AnyKind { - continue - } - if attCtx.IgnoreRequired && expr.IsPrimitive(reqAtt.Type) { - continue - } - res = append(res, req) +// parameterValidationPath writes an error path relative to the string held by +// a generated validator parameter. +func parameterValidationPath(parameter string) validationPath { + return validationPath{root: parameter, variable: true} +} + +// child returns the context used for a field or collection value below c. +func (p validationPath) child(prefix string) validationPath { + p.suffix += prefix + return p +} + +// renderValidationPath returns the Go expression passed to a generated +// validation error. +func renderValidationPath(path validationPath) string { + if !path.variable { + return strconv.Quote(path.root + path.suffix) + } + if path.suffix == "" { + return path.root } - return + return path.root + " + " + strconv.Quote(path.suffix) +} + +// hasValidations reports whether validating ut can write any code with the Go +// layout described by attCtx. +func hasValidations(attCtx *AttributeContext, ut expr.UserType) bool { + return NeedsValidation(ut.Attribute(), attCtx.LayoutPolicy()) } // toSlice returns Go code that represents the given slice. diff --git a/codegen/validation_plan.go b/codegen/validation_plan.go new file mode 100644 index 0000000000..3d5460b162 --- /dev/null +++ b/codegen/validation_plan.go @@ -0,0 +1,983 @@ +// This file records every validation check and generated function call before +// Goa chooses Go names. It later writes those checks using the stored field +// shapes, rules, and chosen function names. +package codegen + +import ( + "bytes" + "fmt" + "path" + "strings" + "text/template" + + "goa.design/goa/v3/expr" +) + +type ( + // ValidatorBindingRequest describes one validation call for a nested user + // type. Attribute is available only while planning. Layout supplies its + // generated package and chosen declaration. + ValidatorBindingRequest struct { + // Attribute is the nested user type whose validation call is being prepared. + Attribute *expr.AttributeExpr + // Layout is the planned Go layout for Attribute. + Layout *GoTypePlan + // View selects which result fields the nested validator checks. Service + // types and view-specific result copies use the default view, represented by + // the empty string. + View string + } + + // ValidatorDeclarationBinder returns the package-level validation function + // chosen before Goa starts writing files. + ValidatorDeclarationBinder func(ValidatorBindingRequest) (*NameDeclaration, error) + + // ValidationPlanOptions configures one root validation operation. + ValidationPlanOptions struct { + // Required reports whether the root value is required. + Required bool + // Alias validates the root as the underlying value of a user-type alias. + Alias bool + // Bind resolves every nested non-alias user validation call. + Bind ValidatorDeclarationBinder + } + + // ValidationPlan stores the copied checks for one service or view value, + // including calls to validators for nested fields. It keeps expression + // pointers only to recognize the attribute supplied by the caller. + ValidationPlan struct { + layout *GoTypePlan + root *validationPlanNode + declarations []*NameDeclaration + } + + // LinkedValidationPlan renders a ValidationPlan after Goa has chosen all + // generated function names and package aliases. + LinkedValidationPlan struct { + plan *ValidationPlan + layout LinkedGoType + } + + // validationPlanNode stores checks for one value and for its fields, + // collection entries, or union branches. + validationPlanNode struct { + occurrence *expr.AttributeExpr + layout *GoTypePlan + rules validationRulePlan + guard bool + call *validatorCallPlan + fields []validationFieldPlan + array *validationArrayPlan + mapValue *validationMapPlan + union *validationUnionPlan + } + + // validationRulePlan stores local effective validation values in template + // execution order. + validationRulePlan struct { + values []any + format string + pattern string + exclusiveMinimum *float64 + minimum *float64 + exclusiveMaximum *float64 + maximum *float64 + minLength *int + maxLength *int + required []validationRequiredPlan + pointer bool + dereference bool + aliasCast string + stringValue bool + arrayValue bool + mapValue bool + } + + // validationRequiredPlan stores one generated required-field check. + validationRequiredPlan struct { + name string + fieldName string + unionKind bool + } + + // validationFieldPlan stores one object child and its context path segment. + validationFieldPlan struct { + name string + node *validationPlanNode + } + + // validationArrayPlan stores one element operation and presence policy. + validationArrayPlan struct { + element *validationPlanNode + checkNilElements bool + } + + // validationMapPlan stores map key and value validation operations. + validationMapPlan struct { + key *validationPlanNode + value *validationPlanNode + } + + // validationUnionPlan stores generated sum-type branch operations. + validationUnionPlan struct { + cases []validationUnionCasePlan + } + + // validationUnionCasePlan stores one sum-type accessor and branch program. + validationUnionCasePlan struct { + typeTag string + fieldName string + node *validationPlanNode + } + + // validatorCallPlan stores one exact nested validator declaration. + validatorCallPlan struct { + declaration *NameDeclaration + } + + // validationPlanner performs all expression reads while validation checks and + // function calls are copied into a plan. + validationPlanner struct { + bind ValidatorDeclarationBinder + declarations []*NameDeclaration + } +) + +// NewValidationPlan records every check needed for attribute before Goa chooses +// the final generated names. layout must describe the same attribute and the +// requested service or view representation. +func NewValidationPlan(attribute *expr.AttributeExpr, layout *GoTypePlan, options ValidationPlanOptions) (*ValidationPlan, error) { + if attribute == nil { + return nil, fmt.Errorf("plan validation: attribute must not be nil") + } + if layout == nil { + return nil, fmt.Errorf("plan validation: Go type layout must not be nil") + } + if !layout.MatchesOccurrence(attribute) { + return nil, fmt.Errorf("plan validation: Go type layout does not match the root attribute occurrence") + } + if !layout.Policy().SumType { + return nil, fmt.Errorf("plan validation: service/view validation requires a sum-type Go layout") + } + planner := validationPlanner{bind: options.Bind} + root, err := planner.plan(attribute, layout, options.Required, options.Alias, false, "root") + if err != nil { + return nil, err + } + return &ValidationPlan{ + layout: layout, + root: root, + declarations: planner.declarations, + }, nil +} + +// NeedsValidation reports whether Goa would generate at least one validation +// check for attribute with the given Go field layout. +func NeedsValidation(attribute *expr.AttributeExpr, policy GoLayoutPolicy) bool { + return attributeNeedsValidation(attribute, policy, make(map[expr.UserType]struct{})) +} + +// ValidationRuntimeImports returns the Goa and standard library packages used +// by validation code for attribute. Calls to validators in other generated +// packages are recorded by ValidationPlan.ImportPreferences instead. +func ValidationRuntimeImports(attribute *expr.AttributeExpr, policy GoLayoutPolicy) []GoTypeImport { + if !NeedsValidation(attribute, policy) { + return nil + } + var imports []GoTypeImport + if validationBodyUsesUTF8(attribute, false, false, make(map[expr.UserType]struct{})) { + imports = append(imports, GoTypeImport{Path: "unicode/utf8"}) + } + imports = append(imports, GoTypeImport{Name: "goa", Path: GoaImport("").Path}) + return imports +} + +// ValidatorDeclarations returns the exact nested validator declarations in +// stable call order. Repeated calls deliberately repeat the same pointer. +func (p *ValidationPlan) ValidatorDeclarations() []*NameDeclaration { + return append([]*NameDeclaration(nil), p.declarations...) +} + +// ImportPreferences returns each package needed by the stored validation +// checks. Goa and standard library packages keep the names used by the +// templates. A package containing another generated validator includes the +// name Goa should try first. +func (p *ValidationPlan) ImportPreferences() []GoTypeImport { + seen := make(map[string]struct{}) + var imports []GoTypeImport + add := func(goImport GoTypeImport) { + if _, exists := seen[goImport.Path]; exists { + return + } + seen[goImport.Path] = struct{}{} + imports = append(imports, goImport) + } + if p.root.usesUTF8() { + add(GoTypeImport{Path: "unicode/utf8"}) + } + if !p.root.empty() { + goa := GoaImport("") + add(GoTypeImport{Name: goa.Name, Path: goa.Path}) + } + for _, declaration := range p.declarations { + owner := declaration.packagePath() + if owner == p.layout.Owner() { + continue + } + add(GoTypeImport{ + Name: strings.ToLower(Goify(path.Base(owner), false)), + Path: owner, + }) + } + return imports +} + +// Link joins p with the Go types and package aliases that Goa chose for the +// generated file. +func (p *ValidationPlan) Link(layout LinkedGoType) (LinkedValidationPlan, error) { + if layout.plan != p.layout { + return LinkedValidationPlan{}, fmt.Errorf("link validation: linked Go type does not belong to this validation plan") + } + return LinkedValidationPlan{plan: p, layout: layout}, nil +} + +// Render returns validation code for target. context is the root name included +// in validation errors and may differ from the Go target expression. +func (p LinkedValidationPlan) Render(target, context string) string { + return p.renderNode(p.plan.root, target, context) +} + +// Imports returns each external validation package once, using its final alias. +// Imports already supplied by the linked Go layout are not +// repeated unless validation calls require them too. +func (p LinkedValidationPlan) Imports() []GoTypeImport { + preferences := p.plan.ImportPreferences() + if len(preferences) == 0 { + return nil + } + imports := make([]GoTypeImport, len(preferences)) + for index, preference := range preferences { + imports[index] = GoTypeImport{ + Name: p.layout.qualify(preference.Path), + Path: preference.Path, + } + } + return imports +} + +// plan copies one recursive operation. nested distinguishes a user-type field +// call from a root definition whose anonymous layout is expanded in place. +func (p *validationPlanner) plan(attribute *expr.AttributeExpr, layout *GoTypePlan, required, alias, nested bool, path string) (*validationPlanNode, error) { + if !layout.MatchesOccurrence(attribute) { + return nil, fmt.Errorf("plan validation for %s: Go type layout occurrence does not match", path) + } + policy := layout.Policy() + if userType, named := attribute.Type.(expr.UserType); named && !alias && nested { + if !userTypeNeedsValidation(userType, policy, make(map[expr.UserType]struct{})) { + return &validationPlanNode{occurrence: attribute, layout: layout}, nil + } + if p.bind == nil { + return nil, fmt.Errorf("plan validation for %s: validator binder must not be nil", path) + } + declaration, err := p.bind(ValidatorBindingRequest{ + Attribute: attribute, + Layout: layout, + View: "", + }) + if err != nil { + return nil, fmt.Errorf("plan validation for %s: %w", path, err) + } + if declaration == nil { + return nil, fmt.Errorf("plan validation for %s: validator declaration must not be nil", path) + } + if declaration.owner == nil { + return nil, fmt.Errorf("plan validation for %s: validator declaration is not owned", path) + } + if declaration.packagePath() != layout.Owner() { + return nil, fmt.Errorf( + "plan validation for %s: validator owner %q does not match layout owner %q", + path, declaration.packagePath(), layout.Owner(), + ) + } + p.declarations = append(p.declarations, declaration) + return &validationPlanNode{ + occurrence: attribute, + layout: layout, + call: &validatorCallPlan{declaration: declaration}, + }, nil + } + + node := &validationPlanNode{ + occurrence: attribute, + layout: layout, + rules: planValidationRules(attribute, layout, required, alias), + } + switch { + case expr.IsObject(attribute.Type): + object := expr.AsObject(attribute.Type) + fields := layout.Fields() + if len(fields) != len(*object) { + return nil, fmt.Errorf("plan validation for %s: object layout has %d fields, expected %d", path, len(fields), len(*object)) + } + node.fields = make([]validationFieldPlan, 0, len(fields)) + for index, field := range *object { + child, err := p.plan( + field.Attribute, + fields[index], + attribute.IsRequired(field.Name), + expr.IsAlias(field.Attribute.Type), + true, + fmt.Sprintf("field %q", field.Name), + ) + if err != nil { + return nil, err + } + if child.empty() { + continue + } + node.fields = append(node.fields, validationFieldPlan{name: field.Name, node: child}) + } + case expr.IsArray(attribute.Type): + array := expr.AsArray(attribute.Type) + childLayout := layout.Elem() + if childLayout == nil { + return nil, fmt.Errorf("plan validation for %s: array layout has no element", path) + } + childPolicy := policy + if expr.IsPrimitive(array.ElemType.Type) { + childPolicy.Pointer = childLayout.definitionPointer + } + childLayout = childLayout.withPolicy(childPolicy) + child, err := p.plan(array.ElemType, childLayout, true, expr.IsAlias(array.ElemType.Type), true, path+"[*]") + if err != nil { + return nil, err + } + checkNilElements := array.NonNullableElems && + (childLayout.definitionPointer || IsNilable(array.ElemType.Type)) + if !child.empty() || checkNilElements { + node.array = &validationArrayPlan{ + element: child, + checkNilElements: checkNilElements, + } + } + case expr.IsMap(attribute.Type): + mapping := expr.AsMap(attribute.Type) + childPolicy := policy + childPolicy.Pointer = false + keyLayout := layout.Key() + valueLayout := layout.Elem() + if keyLayout == nil || valueLayout == nil { + return nil, fmt.Errorf("plan validation for %s: map layout is incomplete", path) + } + key, err := p.plan(mapping.KeyType, keyLayout.withPolicy(childPolicy), true, expr.IsAlias(mapping.KeyType.Type), true, path+".key") + if err != nil { + return nil, err + } + value, err := p.plan(mapping.ElemType, valueLayout.withPolicy(childPolicy), true, expr.IsAlias(mapping.ElemType.Type), true, path+"[key]") + if err != nil { + return nil, err + } + if !key.empty() || !value.empty() { + node.mapValue = &validationMapPlan{key: key, value: value} + } + case expr.IsUnion(attribute.Type): + union := expr.AsUnion(attribute.Type) + branches := layout.Branches() + if len(branches) != len(union.Values) { + return nil, fmt.Errorf("plan validation for %s: union layout has %d branches, expected %d", path, len(branches), len(union.Values)) + } + var cases []validationUnionCasePlan + for index, branch := range union.Values { + branchPolicy := policy + branchPolicy.Pointer = branchPolicy.Pointer && expr.IsObject(branch.Attribute.Type) + child, err := p.plan( + branch.Attribute, + branches[index].withPolicy(branchPolicy), + true, + expr.IsAlias(branch.Attribute.Type), + true, + fmt.Sprintf("union branch %q", branch.Name), + ) + if err != nil { + return nil, err + } + if child.empty() { + continue + } + cases = append(cases, validationUnionCasePlan{ + typeTag: branch.Name, + fieldName: Goify(branch.Name, true), + node: child, + }) + } + if len(cases) > 0 { + node.union = &validationUnionPlan{cases: cases} + } + } + if nested && !node.empty() { + node.guard = validationNeedsNilGuard(attribute, required, policy) + } + return node, nil +} + +// planValidationRules copies every local effective validation rule. +func planValidationRules(attribute *expr.AttributeExpr, layout *GoTypePlan, required, alias bool) validationRulePlan { + validation := expr.EffectiveValidation(attribute) + if validation == nil { + return validationRulePlan{} + } + policy := layout.Policy() + unaliased := unalias(attribute.Type) + pointer := policy.Pointer || !required && (attribute.DefaultValue == nil || !policy.UseDefault) + rules := validationRulePlan{ + format: string(validation.Format), + pattern: validation.Pattern, + exclusiveMinimum: copyValidationFloat(validation.ExclusiveMinimum), + minimum: copyValidationFloat(validation.Minimum), + exclusiveMaximum: copyValidationFloat(validation.ExclusiveMaximum), + maximum: copyValidationFloat(validation.Maximum), + minLength: copyValidationInt(validation.MinLength), + maxLength: copyValidationInt(validation.MaxLength), + pointer: pointer, + dereference: pointer && expr.IsPrimitive(attribute.Type) && + unaliased.Kind() != expr.BytesKind && unaliased.Kind() != expr.AnyKind, + stringValue: unaliased.Kind() == expr.StringKind, + arrayValue: expr.IsArray(attribute.Type), + mapValue: expr.IsMap(attribute.Type), + } + if validation.Values != nil { + rules.values = make([]any, len(validation.Values)) + for index, value := range validation.Values { + rules.values[index] = copyValidationValue(value) + } + } + if custom, _ := GetMetaType(attribute); custom != "" { + rules.format = "" + } + if alias { + rules.aliasCast = unaliased.Name() + } + object := expr.AsObject(attribute.Type) + fields := layout.Fields() + for _, name := range generatedRequiredValidationNames(attribute, validation, policy) { + var fieldName string + for index, field := range *object { + if field.Name == name { + fieldName = fields[index].FieldName(true) + break + } + } + requiredAttribute := object.Attribute(name) + rules.required = append(rules.required, validationRequiredPlan{ + name: name, + fieldName: fieldName, + unionKind: expr.IsUnion(requiredAttribute.Type) && + policy.SumType && !(policy.UnionPointer && policy.Pointer), + }) + } + return rules +} + +// generatedRequiredValidationNames retains required checks emitted for policy. +func generatedRequiredValidationNames(attribute *expr.AttributeExpr, validation *expr.ValidationExpr, policy GoLayoutPolicy) []string { + object := expr.AsObject(attribute.Type) + var names []string + for _, name := range validation.Required { + required := object.Attribute(name) + if required == nil { + continue + } + if !policy.Pointer && expr.IsPrimitive(required.Type) && + required.Type.Kind() != expr.BytesKind && required.Type.Kind() != expr.AnyKind { + continue + } + if policy.IgnoreRequired && expr.IsPrimitive(required.Type) { + continue + } + if policy.IgnoreRequiredCollections && (expr.IsArray(required.Type) || expr.IsMap(required.Type)) { + continue + } + names = append(names, name) + } + return names +} + +// validationNeedsNilGuard reports whether generated checks must first verify +// that the value is not nil. +func validationNeedsNilGuard(attribute *expr.AttributeExpr, required bool, policy GoLayoutPolicy) bool { + if expr.IsArray(attribute.Type) || expr.IsMap(attribute.Type) { + return false + } + if expr.IsUnion(attribute.Type) { + return policy.UnionPointer && (!required || policy.Pointer) + } + return policy.Pointer || !required && (attribute.DefaultValue == nil || !policy.UseDefault) +} + +// userTypeNeedsValidation reports whether a user-defined type or any value +// inside it needs a generated check. seen stops recursive types. +func userTypeNeedsValidation(userType expr.UserType, policy GoLayoutPolicy, seen map[expr.UserType]struct{}) bool { + origin := userType.Origin() + if _, exists := seen[origin]; exists { + return false + } + seen[origin] = struct{}{} + defer delete(seen, origin) + return attributeNeedsValidation(userType.Attribute(), policy, seen) +} + +// attributeNeedsValidation reports whether Goa would generate a check for the +// attribute or a value inside it. +func attributeNeedsValidation(attribute *expr.AttributeExpr, policy GoLayoutPolicy, seen map[expr.UserType]struct{}) bool { + validation := expr.EffectiveValidation(attribute) + if validation != nil { + if len(validation.Values) > 0 || validation.Pattern != "" || + validation.ExclusiveMinimum != nil || validation.Minimum != nil || + validation.ExclusiveMaximum != nil || validation.Maximum != nil || + validation.MinLength != nil || validation.MaxLength != nil { + return true + } + if validation.Format != "" { + if custom, _ := GetMetaType(attribute); custom == "" { + return true + } + } + if len(generatedRequiredValidationNames(attribute, validation, policy)) > 0 { + return true + } + } + switch { + case expr.IsObject(attribute.Type): + for _, field := range *expr.AsObject(attribute.Type) { + if nested, ok := field.Attribute.Type.(expr.UserType); ok && !expr.IsAlias(nested) { + if userTypeNeedsValidation(nested, policy, seen) { + return true + } + continue + } + if attributeNeedsValidation(field.Attribute, policy, seen) { + return true + } + } + case expr.IsArray(attribute.Type): + array := expr.AsArray(attribute.Type) + if array.NonNullableElems && + (IsNilable(array.ElemType.Type) || arrayElementIsPointer(array, policy.ArrayElementPointer)) { + return true + } + return attributeNeedsValidation(array.ElemType, policy, seen) + case expr.IsMap(attribute.Type): + mapping := expr.AsMap(attribute.Type) + mapPolicy := policy + mapPolicy.Pointer = false + return attributeNeedsValidation(mapping.KeyType, mapPolicy, seen) || + attributeNeedsValidation(mapping.ElemType, mapPolicy, seen) + case expr.IsUnion(attribute.Type): + for _, branch := range expr.AsUnion(attribute.Type).Values { + branchPolicy := policy + branchPolicy.Pointer = policy.Pointer && expr.IsObject(branch.Attribute.Type) + if nested, ok := branch.Attribute.Type.(expr.UserType); ok && !expr.IsAlias(nested) { + if userTypeNeedsValidation(nested, branchPolicy, seen) { + return true + } + continue + } + if attributeNeedsValidation(branch.Attribute, branchPolicy, seen) { + return true + } + } + } + return false +} + +// validationBodyUsesUTF8 reports whether one emitted validator counts string +// characters. A nested named object has its own validator, so the caller does +// not reserve packages used only by that separate function. +func validationBodyUsesUTF8(attribute *expr.AttributeExpr, nested, alias bool, seen map[expr.UserType]struct{}) bool { + if attribute == nil || attribute.Type == nil || attribute.Type == expr.Empty { + return false + } + validation := expr.EffectiveValidation(attribute) + if isStringDataType(attribute.Type) && validation != nil && + (validation.MinLength != nil || validation.MaxLength != nil) { + return true + } + switch actual := attribute.Type.(type) { + case expr.UserType: + if nested && !alias { + return false + } + origin := actual.Origin() + if _, exists := seen[origin]; exists { + return false + } + seen[origin] = struct{}{} + defer delete(seen, origin) + return validationBodyUsesUTF8(actual.Attribute(), nested, alias, seen) + case *expr.Object: + for _, field := range *actual { + if validationBodyUsesUTF8(field.Attribute, true, expr.IsAlias(field.Attribute.Type), seen) { + return true + } + } + case *expr.Array: + return validationBodyUsesUTF8(actual.ElemType, true, expr.IsAlias(actual.ElemType.Type), seen) + case *expr.Map: + return validationBodyUsesUTF8(actual.KeyType, true, expr.IsAlias(actual.KeyType.Type), seen) || + validationBodyUsesUTF8(actual.ElemType, true, expr.IsAlias(actual.ElemType.Type), seen) + case *expr.Union: + for _, branch := range actual.Values { + if validationBodyUsesUTF8(branch.Attribute, true, expr.IsAlias(branch.Attribute.Type), seen) { + return true + } + } + } + return false +} + +// isStringDataType reports whether dataType is a string or a named string. +func isStringDataType(dataType expr.DataType) bool { + for { + switch actual := dataType.(type) { + case expr.Primitive: + return actual == expr.String + case expr.UserType: + dataType = actual.Attribute().Type + default: + return false + } + } +} + +// renderNode writes the Go checks for node and its children without reading the +// original design expression. +func (p LinkedValidationPlan) renderNode(node *validationPlanNode, target, context string) string { + if node.call != nil { + name := p.validatorName(node.call.declaration) + var buffer bytes.Buffer + if err := userValT.Execute(&buffer, map[string]any{ + "call": fmt.Sprintf("%s(%s)", name, target), + "goa": p.goaPackage(), + }); err != nil { + panic(err) + } + return fmt.Sprintf("if %s != nil {\n\t%s\n}", target, buffer.String()) + } + var sections []string + if local := p.renderValidationRules(node.rules, target, context, !node.guard); local != "" { + sections = append(sections, local) + } + for _, field := range node.fields { + validation := p.renderNode( + field.node, + target+"."+field.node.layout.FieldName(true), + context+"."+field.name, + ) + if validation != "" { + sections = append(sections, validation) + } + } + if node.array != nil { + validation := p.renderNode(node.array.element, "e", context+"[*]") + var buffer bytes.Buffer + if err := arrayValT.Execute(&buffer, map[string]any{ + "target": target, + "validation": validation, + "checkNilElements": node.array.checkNilElements, + "context": literalValidationPath(context), + "goa": p.goaPackage(), + }); err != nil { + panic(err) + } + sections = append(sections, buffer.String()) + } + if node.mapValue != nil { + keyValidation := p.renderNode(node.mapValue.key, "k", context+".key") + if keyValidation != "" { + keyValidation = "\n" + keyValidation + } + valueValidation := p.renderNode(node.mapValue.value, "v", context+"[key]") + if valueValidation != "" { + valueValidation = "\n" + valueValidation + } + var buffer bytes.Buffer + if err := mapValT.Execute(&buffer, map[string]any{ + "target": target, + "keyValidation": keyValidation, + "valueValidation": valueValidation, + }); err != nil { + panic(err) + } + sections = append(sections, buffer.String()) + } + if node.union != nil { + cases := make([]map[string]any, len(node.union.cases)) + for index, unionCase := range node.union.cases { + cases[index] = map[string]any{ + "typeTag": unionCase.typeTag, + "fieldName": unionCase.fieldName, + "validation": p.renderNode(unionCase.node, "actual", context+".value"), + } + } + var buffer bytes.Buffer + if err := unionSumValT.Execute(&buffer, map[string]any{"target": target, "cases": cases}); err != nil { + panic(err) + } + sections = append(sections, buffer.String()) + } + code := strings.Join(sections, "\n") + if node.guard && code != "" { + condition := fmt.Sprintf("if %s != nil {\n", target) + code = condition + code + "\n}" + } + return code +} + +// renderValidationRules renders copied local rules through the shared +// validation templates using the package names assigned to the linked file. +func (p LinkedValidationPlan) renderValidationRules(rules validationRulePlan, target, context string, localGuards bool) string { + targetValue := target + if rules.dereference { + targetValue = "*" + targetValue + } + if rules.aliasCast != "" { + targetValue = fmt.Sprintf("%s(%s)", rules.aliasCast, targetValue) + } + utf8Package := "" + if rules.stringValue && (rules.minLength != nil || rules.maxLength != nil) { + utf8Package = p.utf8Package() + } + data := map[string]any{ + "isPointer": rules.pointer && localGuards, + "context": literalValidationPath(context), + "target": target, + "targetVal": targetValue, + "goa": p.goaPackage(), + "utf8": utf8Package, + "string": rules.stringValue, + "array": rules.arrayValue, + "map": rules.mapValue, + } + var rendered []string + if rules.values != nil { + data["values"] = rules.values + rendered = appendValidationTemplate(rendered, enumValT, data) + } + if rules.format != "" { + data["format"] = rules.format + rendered = appendValidationTemplate(rendered, formatValT, data) + } + if rules.pattern != "" { + data["pattern"] = rules.pattern + rendered = appendValidationTemplate(rendered, patternValT, data) + } + if rules.exclusiveMinimum != nil { + data["exclMin"] = *rules.exclusiveMinimum + data["isExclMin"] = true + rendered = appendValidationTemplate(rendered, exclMinMaxValT, data) + } + if rules.minimum != nil { + data["min"] = *rules.minimum + data["isMin"] = true + rendered = appendValidationTemplate(rendered, minMaxValT, data) + } + if rules.exclusiveMaximum != nil { + data["exclMax"] = *rules.exclusiveMaximum + data["isExclMin"] = false + rendered = appendValidationTemplate(rendered, exclMinMaxValT, data) + } + if rules.maximum != nil { + data["max"] = *rules.maximum + data["isMin"] = false + rendered = appendValidationTemplate(rendered, minMaxValT, data) + } + if rules.minLength != nil { + data["minLength"] = rules.minLength + data["isMinLength"] = true + delete(data, "maxLength") + rendered = appendValidationTemplate(rendered, lengthValT, data) + } + if rules.maxLength != nil { + data["maxLength"] = rules.maxLength + data["isMinLength"] = false + delete(data, "minLength") + rendered = appendValidationTemplate(rendered, lengthValT, data) + } + for _, required := range rules.required { + if required.unionKind { + rendered = append(rendered, fmt.Sprintf( + "if %s.%s.Kind() == \"\" {\n err = %s.MergeErrors(err, %s.MissingFieldError(%q, %q))\n}", + target, required.fieldName, p.goaPackage(), p.goaPackage(), required.name, context, + )) + continue + } + rendered = append(rendered, fmt.Sprintf( + "if %s.%s == nil {\n err = %s.MergeErrors(err, %s.MissingFieldError(%q, %q))\n}", + target, required.fieldName, p.goaPackage(), p.goaPackage(), required.name, context, + )) + } + return strings.Join(rendered, "\n") +} + +// appendValidationTemplate executes one shared local validation template. +func appendValidationTemplate(rendered []string, validationTemplate *template.Template, data map[string]any) []string { + var buffer bytes.Buffer + if err := validationTemplate.Execute(&buffer, data); err != nil { + panic(err) + } + if validation := strings.Trim(buffer.String(), "\n"); validation != "" { + return append(rendered, validation) + } + return rendered +} + +// validatorName qualifies one exact validator declaration for the linked file. +func (p LinkedValidationPlan) validatorName(declaration *NameDeclaration) string { + name := declaration.Name() + owner := declaration.packagePath() + if owner == p.layout.outputPath { + return name + } + return p.layout.qualify(owner) + "." + name +} + +// goaPackage returns the final name of Goa's generated-error package. +func (p LinkedValidationPlan) goaPackage() string { + return p.layout.qualify(GoaImport("").Path) +} + +// utf8Package returns the final name of the standard UTF-8 package. +func (p LinkedValidationPlan) utf8Package() string { + return p.layout.qualify("unicode/utf8") +} + +// empty reports whether node emits any validation code. +func (n *validationPlanNode) empty() bool { + return n.call == nil && n.rules.empty() && len(n.fields) == 0 && + n.array == nil && n.mapValue == nil && n.union == nil +} + +// usesUTF8 reports whether this validation tree counts runes in a string. +func (n *validationPlanNode) usesUTF8() bool { + if n.rules.stringValue && (n.rules.minLength != nil || n.rules.maxLength != nil) { + return true + } + for _, field := range n.fields { + if field.node.usesUTF8() { + return true + } + } + if n.array != nil && n.array.element.usesUTF8() { + return true + } + if n.mapValue != nil && (n.mapValue.key.usesUTF8() || n.mapValue.value.usesUTF8()) { + return true + } + if n.union != nil { + for _, unionCase := range n.union.cases { + if unionCase.node.usesUTF8() { + return true + } + } + } + return false +} + +// empty reports whether these rules would write no Go checks. +func (p validationRulePlan) empty() bool { + return p.values == nil && p.format == "" && p.pattern == "" && + p.exclusiveMinimum == nil && p.minimum == nil && + p.exclusiveMaximum == nil && p.maximum == nil && + p.minLength == nil && p.maxLength == nil && len(p.required) == 0 +} + +// withPolicy copies the prepared type and changes only the rules used to write +// its Go value. It does not modify the original type description. +func (p *GoTypePlan) withPolicy(policy GoLayoutPolicy) *GoTypePlan { + clone := *p + clone.policy = policy + if p.key != nil { + clone.key = p.key.withPolicy(policy) + } + if p.element != nil { + clone.element = p.element.withPolicy(policy) + } + if len(p.fields) > 0 { + clone.fields = make([]*GoTypePlan, len(p.fields)) + for index, field := range p.fields { + clone.fields[index] = field.withPolicy(policy) + } + } + if len(p.branches) > 0 { + clone.branches = make([]*GoTypePlan, len(p.branches)) + for index, branch := range p.branches { + clone.branches[index] = branch.withPolicy(policy) + } + } + return &clone +} + +// copyValidationFloat copies one optional scalar rule value. +func copyValidationFloat(value *float64) *float64 { + if value == nil { + return nil + } + copy := *value + return © +} + +// copyValidationInt copies one optional length rule value. +func copyValidationInt(value *int) *int { + if value == nil { + return nil + } + copy := *value + return © +} + +// copyValidationValue detaches the mutable collection shapes accepted by Goa +// enum validations. Primitive values are immutable and remain shared. +func copyValidationValue(value any) any { + switch actual := value.(type) { + case expr.Val: + copied := make(expr.Val, len(actual)) + for name, child := range actual { + copied[name] = copyValidationValue(child) + } + return copied + case expr.ArrayVal: + copied := make(expr.ArrayVal, len(actual)) + for index, child := range actual { + copied[index] = copyValidationValue(child) + } + return copied + case expr.MapVal: + copied := make(expr.MapVal, len(actual)) + for key, child := range actual { + copied[copyValidationValue(key)] = copyValidationValue(child) + } + return copied + case []any: + copied := make([]any, len(actual)) + for index, child := range actual { + copied[index] = copyValidationValue(child) + } + return copied + case []byte: + return append([]byte(nil), actual...) + case map[string]any: + copied := make(map[string]any, len(actual)) + for name, child := range actual { + copied[name] = copyValidationValue(child) + } + return copied + case map[any]any: + copied := make(map[any]any, len(actual)) + for key, child := range actual { + copied[copyValidationValue(key)] = copyValidationValue(child) + } + return copied + default: + return actual + } +} diff --git a/codegen/validation_plan_test.go b/codegen/validation_plan_test.go new file mode 100644 index 0000000000..748d184282 --- /dev/null +++ b/codegen/validation_plan_test.go @@ -0,0 +1,589 @@ +// This file verifies that validation planning preserves service and view +// output without reading expressions after package names are fixed. +package codegen + +import ( + "fmt" + "strings" + "testing" + + "github.com/stretchr/testify/require" + + "goa.design/goa/v3/expr" +) + +// TestValidationPlanPreservesRulesPathsAndRequiredness compares copied rule +// rendering with the existing service/view validation generator. +func TestValidationPlanPreservesRulesPathsAndRequiredness(t *testing.T) { + minimum := 2.0 + minLength := 3 + attribute := &expr.AttributeExpr{ + Type: &expr.Object{ + {Name: "name", Attribute: &expr.AttributeExpr{ + Type: expr.String, + Validation: &expr.ValidationExpr{ + Pattern: "^[a-z]+$", + MinLength: &minLength, + }, + }}, + {Name: "count", Attribute: &expr.AttributeExpr{ + Type: expr.Int, + Validation: &expr.ValidationExpr{Minimum: &minimum}, + }}, + {Name: "nested", Attribute: &expr.AttributeExpr{Type: &expr.Object{}}}, + }, + Validation: &expr.ValidationExpr{Required: []string{"name", "nested"}}, + } + policy := GoLayoutPolicy{Pointer: true, UseDefault: true, SumType: true} + legacyContext := NewAttributeContext( + policy.Pointer, + policy.IgnoreRequired, + policy.UseDefault, + "", + NewNameScope(), + ) + legacyContext.UnionPointer = policy.UnionPointer + want := ValidationCode(attribute, nil, legacyContext, true, false, true, "target") + + layout, err := PlanGoType(attribute, GoTypePlanOptions{ + Owner: "generated.local/gen/service", + Policy: policy, + }) + require.NoError(t, err) + plan, err := NewValidationPlan(attribute, layout, ValidationPlanOptions{Required: true}) + require.NoError(t, err) + + attribute.Validation = nil + attribute.Type = expr.String + minimum = 99 + minLength = 99 + + linked, err := plan.Link(layout.Link("generated.local/gen/service", validationPlanTestQualifier)) + require.NoError(t, err) + require.Equal(t, want, linked.Render("target", "target")) + require.Equal(t, []GoTypeImport{ + {Name: "utf8", Path: "unicode/utf8"}, + {Name: "goa", Path: "goa.design/goa/v3/pkg"}, + }, linked.Imports()) +} + +// TestValidationPlanImportsOnlyUsedRuntimePackages checks that standalone +// validation users receive the packages named directly by rendered checks. +func TestValidationPlanImportsOnlyUsedRuntimePackages(t *testing.T) { + for _, test := range []struct { + name string + validation *expr.ValidationExpr + wantPreferences []GoTypeImport + wantImports []GoTypeImport + }{ + { + name: "no checks", + validation: nil, + }, + { + name: "pattern", + validation: &expr.ValidationExpr{Pattern: "^[a-z]+$"}, + wantPreferences: []GoTypeImport{ + {Name: "goa", Path: "goa.design/goa/v3/pkg"}, + }, + wantImports: []GoTypeImport{ + {Name: "goa", Path: "goa.design/goa/v3/pkg"}, + }, + }, + { + name: "string length", + validation: func() *expr.ValidationExpr { + minimum := 2 + return &expr.ValidationExpr{MinLength: &minimum} + }(), + wantPreferences: []GoTypeImport{ + {Path: "unicode/utf8"}, + {Name: "goa", Path: "goa.design/goa/v3/pkg"}, + }, + wantImports: []GoTypeImport{ + {Name: "utf8", Path: "unicode/utf8"}, + {Name: "goa", Path: "goa.design/goa/v3/pkg"}, + }, + }, + } { + t.Run(test.name, func(t *testing.T) { + attribute := &expr.AttributeExpr{Type: expr.String, Validation: test.validation} + policy := GoLayoutPolicy{UseDefault: true, SumType: true} + layout, err := PlanGoType(attribute, GoTypePlanOptions{ + Owner: "generated.local/gen/service", + Policy: policy, + }) + require.NoError(t, err) + plan, err := NewValidationPlan(attribute, layout, ValidationPlanOptions{Required: true}) + require.NoError(t, err) + require.Equal(t, test.wantPreferences, plan.ImportPreferences()) + require.Equal(t, test.wantPreferences, ValidationRuntimeImports(attribute, policy)) + linked, err := plan.Link(layout.Link(layout.Owner(), validationPlanTestQualifier)) + require.NoError(t, err) + require.Equal(t, test.wantImports, linked.Imports()) + }) + } +} + +// TestValidationRuntimeImportsStopAtNestedValidators verifies that a parent +// file does not reserve packages used only inside a named child validator. +func TestValidationRuntimeImportsStopAtNestedValidators(t *testing.T) { + minimum := 1 + child := &expr.UserTypeExpr{ + TypeName: "Child", + AttributeExpr: &expr.AttributeExpr{Type: &expr.Object{ + {Name: "name", Attribute: &expr.AttributeExpr{ + Type: expr.String, + Validation: &expr.ValidationExpr{MinLength: &minimum}, + }}, + }}, + } + parent := &expr.AttributeExpr{Type: &expr.Object{ + {Name: "child", Attribute: &expr.AttributeExpr{Type: child}}, + }} + policy := GoLayoutPolicy{UseDefault: true, SumType: true} + + require.Equal(t, []GoTypeImport{{Name: "goa", Path: GoaImport("").Path}}, ValidationRuntimeImports(parent, policy)) +} + +// TestValidationPlanImportPreferencesIncludeExternalValidators checks that +// planning includes only packages containing validation functions that the +// generated checks call. +func TestValidationPlanImportPreferencesIncludeExternalValidators(t *testing.T) { + const ( + owner = "generated.local/gen/service" + childOwner = "generated.local/gen/shared" + ) + minimum := 1.0 + child := goTypeTestUserType("Child", &expr.Object{ + {Name: "count", Attribute: &expr.AttributeExpr{ + Type: expr.Int, + Validation: &expr.ValidationExpr{Minimum: &minimum}, + }}, + }) + generation, err := NewGeneration("generated.local/gen", nil) + require.NoError(t, err) + childDeclaration := declareGoTypeTestUserType(t, generation, childOwner, child) + validator := NewExactName(NameFunction, "ValidateChild") + require.NoError(t, generation.Package(childOwner).DeclareName(validator)) + attribute := &expr.AttributeExpr{Type: &expr.Object{ + {Name: "first", Attribute: &expr.AttributeExpr{Type: child}}, + {Name: "second", Attribute: &expr.AttributeExpr{Type: child}}, + }} + layout, err := PlanGoType(attribute, GoTypePlanOptions{ + Owner: owner, + Policy: GoLayoutPolicy{UseDefault: true, SumType: true}, + Bind: goTypeTestBinder(map[expr.DataType]GoTypeBinding{ + child: {Owner: childOwner, Type: childDeclaration}, + }), + }) + require.NoError(t, err) + plan, err := NewValidationPlan(attribute, layout, ValidationPlanOptions{ + Required: true, + Bind: func(ValidatorBindingRequest) (*NameDeclaration, error) { + return validator, nil + }, + }) + require.NoError(t, err) + + require.Equal(t, []GoTypeImport{ + {Name: "goa", Path: "goa.design/goa/v3/pkg"}, + {Name: "shared", Path: childOwner}, + }, plan.ImportPreferences()) +} + +// TestValidationPlanUsesFinalRuntimeImportNames proves rendered checks and +// reported imports use the same collision-safe package names. +func TestValidationPlanUsesFinalRuntimeImportNames(t *testing.T) { + minimum := 2 + attribute := &expr.AttributeExpr{ + Type: expr.String, + Validation: &expr.ValidationExpr{MinLength: &minimum}, + } + layout, err := PlanGoType(attribute, GoTypePlanOptions{ + Owner: "generated.local/gen/service", + Policy: GoLayoutPolicy{UseDefault: true, SumType: true}, + }) + require.NoError(t, err) + plan, err := NewValidationPlan(attribute, layout, ValidationPlanOptions{Required: true}) + require.NoError(t, err) + linked, err := plan.Link(layout.Link(layout.Owner(), func(importPath string) string { + switch importPath { + case "goa.design/goa/v3/pkg": + return "goa2" + case "unicode/utf8": + return "utf82" + default: + t.Fatalf("unexpected validation import %q", importPath) + return "" + } + })) + require.NoError(t, err) + + require.Equal(t, []GoTypeImport{ + {Name: "utf82", Path: "unicode/utf8"}, + {Name: "goa2", Path: "goa.design/goa/v3/pkg"}, + }, linked.Imports()) + code := linked.Render("target", "target") + require.Contains(t, code, "utf82.RuneCountInString") + require.Contains(t, code, "goa2.MergeErrors") +} + +// TestValidationPlanSharesOptionalFieldGuard verifies that copied validation +// rules use the one nil check selected for their containing field. +func TestValidationPlanSharesOptionalFieldGuard(t *testing.T) { + minLength := 2 + attribute := &expr.AttributeExpr{Type: &expr.Object{ + {Name: "name", Attribute: &expr.AttributeExpr{ + Type: expr.String, + Validation: &expr.ValidationExpr{ + Pattern: "^[a-z]+$", + MinLength: &minLength, + }, + }}, + }} + policy := GoLayoutPolicy{UseDefault: true, SumType: true} + layout, err := PlanGoType(attribute, GoTypePlanOptions{ + Owner: "generated.local/gen/service", + Policy: policy, + }) + require.NoError(t, err) + plan, err := NewValidationPlan(attribute, layout, ValidationPlanOptions{Required: true}) + require.NoError(t, err) + linked, err := plan.Link(layout.Link(layout.Owner(), validationPlanTestQualifier)) + require.NoError(t, err) + + code := linked.Render("target", "target") + require.Equal(t, 1, strings.Count(code, "if target.Name != nil")) + require.Contains(t, code, "goa.ValidatePattern") + require.Contains(t, code, "goa.InvalidLengthError") +} + +// TestValidationPlanCopiesEnumValues verifies that accepted mutable enum +// values cannot change a validation program after planning. +func TestValidationPlanCopiesEnumValues(t *testing.T) { + bytesValue := []byte{1, 2} + arrayValue := []any{ + bytesValue, + map[string]any{"nested": []any{"kept"}}, + } + mapValue := map[string]any{"array": arrayValue} + attribute := &expr.AttributeExpr{ + Type: expr.Any, + Validation: &expr.ValidationExpr{Values: []any{ + bytesValue, + arrayValue, + mapValue, + }}, + } + layout, err := PlanGoType(attribute, GoTypePlanOptions{ + Owner: "generated.local/gen/service", + Policy: GoLayoutPolicy{UseDefault: true, SumType: true}, + }) + require.NoError(t, err) + plan, err := NewValidationPlan(attribute, layout, ValidationPlanOptions{Required: true}) + require.NoError(t, err) + + bytesValue[0] = 9 + arrayValue[1].(map[string]any)["nested"].([]any)[0] = "changed" + mapValue["added"] = true + attribute.Validation.Values[0] = "replaced" + + require.Equal(t, []any{ + []byte{1, 2}, + []any{ + []byte{1, 2}, + map[string]any{"nested": []any{"kept"}}, + }, + map[string]any{"array": []any{ + []byte{1, 2}, + map[string]any{"nested": []any{"kept"}}, + }}, + }, plan.root.rules.values) +} + +// TestNeedsValidation reports whether the validation renderer can write code +// for local rules, nested rules, and values with no rules. +func TestNeedsValidation(t *testing.T) { + minimum := 1.0 + child := goTypeTestUserType("Child", &expr.Object{ + {Name: "count", Attribute: &expr.AttributeExpr{ + Type: expr.Int, + Validation: &expr.ValidationExpr{Minimum: &minimum}, + }}, + }) + tests := []struct { + name string + attribute *expr.AttributeExpr + want bool + }{ + { + name: "local rule", + attribute: &expr.AttributeExpr{ + Type: expr.String, + Validation: &expr.ValidationExpr{Pattern: ".+"}, + }, + want: true, + }, + { + name: "nested rule", + attribute: &expr.AttributeExpr{Type: &expr.Object{ + {Name: "child", Attribute: &expr.AttributeExpr{Type: child}}, + }}, + want: true, + }, + { + name: "no rules", + attribute: &expr.AttributeExpr{Type: &expr.Object{{Name: "name", Attribute: &expr.AttributeExpr{Type: expr.String}}}}, + }, + } + policy := GoLayoutPolicy{Pointer: true, UseDefault: true, SumType: true} + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + require.Equal(t, test.want, NeedsValidation(test.attribute, policy)) + }) + } +} + +// TestValidationPlanChecksOnlyRepresentableNullElements verifies that a null +// check follows the generated element type instead of the raw DSL flag. +func TestValidationPlanChecksOnlyRepresentableNullElements(t *testing.T) { + array := &expr.Array{ + ElemType: &expr.AttributeExpr{ + Type: expr.String, + Validation: &expr.ValidationExpr{Pattern: "[a-z]+"}, + }, + NonNullableElems: true, + } + attribute := &expr.AttributeExpr{Type: array} + tests := []struct { + name string + jsonBody bool + wantCheck bool + }{ + {name: "service values"}, + {name: "JSON input pointers", jsonBody: true, wantCheck: true}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + policy := GoLayoutPolicy{ + UseDefault: true, + SumType: true, + ArrayElementPointer: test.jsonBody, + } + layout, err := PlanGoType(attribute, GoTypePlanOptions{ + Owner: "generated.local/gen/service", + Policy: policy, + }) + require.NoError(t, err) + plan, err := NewValidationPlan(attribute, layout, ValidationPlanOptions{Required: true}) + require.NoError(t, err) + linked, err := plan.Link(layout.Link(layout.Owner(), validationPlanTestQualifier)) + require.NoError(t, err) + code := linked.Render("target", "target") + require.Equal(t, test.wantCheck, strings.Contains(code, "e == nil")) + if test.jsonBody { + require.Contains(t, code, "goa.ValidatePattern(\"target[*]\", *e, \"[a-z]+\")") + } else { + require.Contains(t, code, "goa.ValidatePattern(\"target[*]\", e, \"[a-z]+\")") + } + require.True(t, NeedsValidation(attribute, policy)) + }) + } + + objectArray := &expr.AttributeExpr{Type: &expr.Array{ + ElemType: &expr.AttributeExpr{Type: &expr.Object{}}, + NonNullableElems: true, + }} + policy := GoLayoutPolicy{UseDefault: true, SumType: true} + layout, err := PlanGoType(objectArray, GoTypePlanOptions{ + Owner: "generated.local/gen/service", + Policy: policy, + }) + require.NoError(t, err) + plan, err := NewValidationPlan(objectArray, layout, ValidationPlanOptions{Required: true}) + require.NoError(t, err) + linked, err := plan.Link(layout.Link(layout.Owner(), validationPlanTestQualifier)) + require.NoError(t, err) + require.Contains(t, linked.Render("target", "target"), "e == nil") + require.True(t, NeedsValidation(objectArray, policy)) +} + +// TestNeedsValidationChecksEverySiblingCopy verifies that one unconstrained +// copy of a type does not hide rules on another copy of the same type. +func TestNeedsValidationChecksEverySiblingCopy(t *testing.T) { + minLength := 2 + child := goTypeTestUserType("Child", &expr.Object{ + {Name: "value", Attribute: &expr.AttributeExpr{Type: expr.String}}, + }) + unvalidated := expr.DupAtt(&expr.AttributeExpr{Type: child}) + validated := expr.DupAtt(&expr.AttributeExpr{Type: child}) + expr.AsObject(validated.Type.(expr.UserType).Attribute().Type).Attribute("value").Validation = + &expr.ValidationExpr{MinLength: &minLength} + + tests := []struct { + name string + fields *expr.Object + }{ + { + name: "unvalidated copy first", + fields: &expr.Object{ + {Name: "first", Attribute: unvalidated}, + {Name: "second", Attribute: validated}, + }, + }, + { + name: "validated copy first", + fields: &expr.Object{ + {Name: "first", Attribute: validated}, + {Name: "second", Attribute: unvalidated}, + }, + }, + } + policy := GoLayoutPolicy{Pointer: true, UseDefault: true, SumType: true} + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + attribute := &expr.AttributeExpr{Type: test.fields} + require.True(t, NeedsValidation(attribute, policy)) + }) + } +} + +// TestValidationPlanPreservesContainersUnionsAndValidatorBindings verifies all +// recursive service/view shapes retain exact nested validator declarations. +func TestValidationPlanPreservesContainersUnionsAndValidatorBindings(t *testing.T) { + const owner = "generated.local/gen/service" + minLength := 1 + minimum := 4.0 + child := goTypeTestUserType("Child", &expr.Object{ + {Name: "name", Attribute: &expr.AttributeExpr{ + Type: expr.String, + Validation: &expr.ValidationExpr{MinLength: &minLength}, + }}, + }) + union := &expr.Union{ + TypeName: "Choice", + Values: []*expr.NamedAttributeExpr{ + {Name: "label", Attribute: &expr.AttributeExpr{ + Type: expr.String, + Validation: &expr.ValidationExpr{Pattern: ".+"}, + }}, + {Name: "child", Attribute: &expr.AttributeExpr{Type: child}}, + }, + } + mapKey := &expr.AttributeExpr{ + Type: expr.String, + Validation: &expr.ValidationExpr{MinLength: &minLength}, + } + mapValue := &expr.AttributeExpr{ + Type: expr.Int, + Validation: &expr.ValidationExpr{Minimum: &minimum}, + } + attribute := &expr.AttributeExpr{Type: &expr.Object{ + {Name: "children", Attribute: &expr.AttributeExpr{Type: &expr.Array{ + ElemType: &expr.AttributeExpr{Type: child}, + }}}, + {Name: "labels", Attribute: &expr.AttributeExpr{Type: &expr.Map{ + KeyType: mapKey, + ElemType: mapValue, + }}}, + {Name: "choice", Attribute: &expr.AttributeExpr{Type: union}}, + }} + policy := GoLayoutPolicy{Pointer: true, UseDefault: true, SumType: true} + legacyContext := NewAttributeContext( + policy.Pointer, + policy.IgnoreRequired, + policy.UseDefault, + "", + NewNameScope(), + ) + legacyContext.UnionPointer = policy.UnionPointer + want := ValidationCode(attribute, nil, legacyContext, true, false, true, "target") + + generation, err := NewGeneration("generated.local/gen", nil) + require.NoError(t, err) + childDeclaration := declareGoTypeTestUserType(t, generation, owner, child) + unionDeclaration := declareGoTypeTestUnion(t, generation, owner, union) + generatedPackage := generation.Package(owner) + validator := NewExactName(NameFunction, "ValidateChild") + require.NoError(t, generatedPackage.DeclareName(validator)) + + layout, err := PlanGoType(attribute, GoTypePlanOptions{ + Owner: owner, + Policy: policy, + Bind: goTypeTestBinder(map[expr.DataType]GoTypeBinding{ + child: {Owner: owner, Type: childDeclaration}, + union: {Owner: owner, Union: unionDeclaration}, + }), + }) + require.NoError(t, err) + plan, err := NewValidationPlan(attribute, layout, ValidationPlanOptions{ + Required: true, + Bind: func(request ValidatorBindingRequest) (*NameDeclaration, error) { + require.Same(t, child, request.Attribute.Type) + require.Equal(t, owner, request.Layout.Owner()) + require.Empty(t, request.View) + return validator, nil + }, + }) + require.NoError(t, err) + require.Equal(t, []*NameDeclaration{validator, validator}, plan.ValidatorDeclarations()) + + mapKey.Validation = nil + mapValue.Validation = nil + union.Values = nil + child.SetAttribute(&expr.AttributeExpr{Type: expr.String}) + attribute.Type = expr.String + + require.NoError(t, generation.Freeze()) + linked, err := plan.Link(layout.Link(owner, validationPlanTestQualifier)) + require.NoError(t, err) + require.Equal(t, want, linked.Render("target", "target")) + require.Equal(t, []GoTypeImport{ + {Name: "utf8", Path: "unicode/utf8"}, + {Name: "goa", Path: "goa.design/goa/v3/pkg"}, + }, linked.Imports()) +} + +// TestValidationPlanRejectsUnboundNestedValidator verifies planning never +// falls back to reconstructing a validator name from a user type. +func TestValidationPlanRejectsUnboundNestedValidator(t *testing.T) { + minLength := 1 + child := goTypeTestUserType("Child", &expr.Object{ + {Name: "name", Attribute: &expr.AttributeExpr{ + Type: expr.String, + Validation: &expr.ValidationExpr{MinLength: &minLength}, + }}, + }) + attribute := &expr.AttributeExpr{Type: &expr.Object{ + {Name: "child", Attribute: &expr.AttributeExpr{Type: child}}, + }} + generation, err := NewGeneration("generated.local/gen", nil) + require.NoError(t, err) + declaration := declareGoTypeTestUserType(t, generation, "generated.local/gen/service", child) + layout, err := PlanGoType(attribute, GoTypePlanOptions{ + Owner: "generated.local/gen/service", + Policy: GoLayoutPolicy{Pointer: true, UseDefault: true, SumType: true}, + Bind: goTypeTestBinder(map[expr.DataType]GoTypeBinding{ + child: {Owner: "generated.local/gen/service", Type: declaration}, + }), + }) + require.NoError(t, err) + + _, err = NewValidationPlan(attribute, layout, ValidationPlanOptions{Required: true}) + require.EqualError(t, err, "plan validation for field \"child\": validator binder must not be nil") +} + +// validationPlanTestQualifier resolves the focused generated package aliases. +func validationPlanTestQualifier(importPath string) string { + switch importPath { + case "generated.local/gen/service": + return "service" + case "goa.design/goa/v3/pkg": + return "goa" + case "unicode/utf8": + return "utf8" + default: + panic(fmt.Sprintf("unexpected validation import %q", importPath)) + } +} diff --git a/codegen/validation_protobuf_union_test.go b/codegen/validation_protobuf_union_test.go new file mode 100644 index 0000000000..cdfcfdc119 --- /dev/null +++ b/codegen/validation_protobuf_union_test.go @@ -0,0 +1,103 @@ +// This file checks the validation generated for protobuf OneOf values. +// It rejects a missing selected branch and a selected branch whose value is nil. +package codegen + +import ( + "fmt" + "testing" + + "github.com/stretchr/testify/require" + + d "goa.design/goa/v3/dsl" + "goa.design/goa/v3/expr" +) + +type ( + // protobufUnionTestScope returns the Go names used by the generated struct for + // each selected branch. This lets the test run without generating a service. + protobufUnionTestScope struct { + scope *NameScope + } +) + +func TestProtobufUnionValidationRequiresCompleteSelectedBranch(t *testing.T) { + root := RunDSL(t, protobufUnionValidationDSL) + message := root.UserType("Message") + ctx := NewAttributeContext(true, false, false, "pb", NewNameScope()) + ctx.Scope = &protobufUnionTestScope{scope: NewNameScope()} + + generated := AttributeValidationCode(message.Attribute(), message, ctx, true, false, "message", "message") + + require.Contains(t, generated, `goa.MissingFieldError("choice", "message")`) + require.Contains(t, generated, `goa.MissingFieldError("detail", "message.choice")`) + require.Contains(t, generated, `goa.MissingFieldError("inactive", "message.choice")`) + require.Contains(t, generated, `goa.MissingFieldError("blob", "message.choice")`) + require.Contains(t, generated, `goa.MissingFieldError("metadata", "message.choice")`) + require.Contains(t, generated, "if v == nil {") + require.Contains(t, generated, "if v.Detail == nil {") + require.Contains(t, generated, "if v.Inactive == nil {") + require.Contains(t, generated, "if v.Metadata == nil {") + require.Contains(t, generated, "if v.Blob == nil {") + require.NotContains(t, generated, "if v.Token == nil {") + require.NotContains(t, generated, "if v.Number != nil {") + require.NotContains(t, generated, "if v.Token != nil {") +} + +func (s *protobufUnionTestScope) Name(att *expr.AttributeExpr, pkg string, _, _ bool) string { + name := Goify(att.Type.Name(), true) + if pkg != "" { + return pkg + "." + name + } + return name +} + +func (s *protobufUnionTestScope) Ref(att *expr.AttributeExpr, pkg string) string { + return "*" + s.Name(att, pkg, false, false) +} + +func (*protobufUnionTestScope) Field(_ *expr.AttributeExpr, name string, firstUpper bool) string { + return Goify(name, firstUpper) +} + +func (*protobufUnionTestScope) Package(*expr.AttributeExpr) string { + return "pb" +} + +func (s *protobufUnionTestScope) Enter(*expr.AttributeExpr) Attributor { + return s +} + +func (*protobufUnionTestScope) IsSumType() bool { + return false +} + +func (s *protobufUnionTestScope) ValidatorCall(att *expr.AttributeExpr, view, target, _ string) string { + name := "Validate" + s.Name(att, "", false, false) + Goify(view, true) + return fmt.Sprintf("%s(%s)", name, target) +} + +func (s *protobufUnionTestScope) Scope() *NameScope { + return s.scope +} + +// protobufUnionValidationDSL creates OneOf branches stored as pointers, +// scalars, and byte slices. +func protobufUnionValidationDSL() { + token := d.Type("Token", d.String) + detail := d.Type("Detail", func() { + d.Attribute("label", d.String) + d.Required("label") + }) + inactive := d.Type("Inactive", func() {}) + d.Type("Message", func() { + d.OneOf("choice", func() { + d.Attribute("number", d.Int, func() { d.Minimum(1) }) + d.Attribute("detail", detail) + d.Attribute("inactive", inactive) + d.Attribute("blob", d.Bytes) + d.Attribute("token", token) + d.Attribute("metadata", d.Any) + }) + d.Required("choice") + }) +} diff --git a/codegen/validation_test.go b/codegen/validation_test.go index 0c373a3e7e..77c1cc44ea 100644 --- a/codegen/validation_test.go +++ b/codegen/validation_test.go @@ -1,6 +1,9 @@ +// This file verifies generated validation code for nested attributes, user +// types, unions, and declaration origins. package codegen import ( + "bytes" "strings" "testing" @@ -131,6 +134,138 @@ func TestRecursiveValidationWithCycleGuard(t *testing.T) { } } +// TestRecursiveValidationDistinguishesEqualUIDOrigins verifies that unrelated +// user types with the same semantic UID retain their distinct validation +// shapes when reached in one recursive validation pass. +func TestRecursiveValidationDistinguishesEqualUIDOrigins(t *testing.T) { + minLength := 3 + minimum := 5.0 + first := &expr.UserTypeExpr{ + AttributeExpr: &expr.AttributeExpr{Type: &expr.Object{ + &expr.NamedAttributeExpr{ + Name: "code", + Attribute: &expr.AttributeExpr{ + Type: expr.String, + Validation: &expr.ValidationExpr{MinLength: &minLength}, + }, + }, + }}, + TypeName: "First", + UID: "shared", + } + second := &expr.UserTypeExpr{ + AttributeExpr: &expr.AttributeExpr{Type: &expr.Object{ + &expr.NamedAttributeExpr{ + Name: "count", + Attribute: &expr.AttributeExpr{ + Type: expr.Int, + Validation: &expr.ValidationExpr{Minimum: &minimum}, + }, + }, + }}, + TypeName: "Second", + UID: "shared", + } + ctx := NewAttributeContext(false, false, false, "", NewNameScope()) + seen := make(map[expr.UserType]*bytes.Buffer) + firstCode := recurseValidationCode(&expr.AttributeExpr{Type: first}, nil, ctx, true, false, false, "first", literalValidationPath("first"), seen).String() + secondCode := recurseValidationCode(&expr.AttributeExpr{Type: second}, nil, ctx, true, false, false, "second", literalValidationPath("second"), seen).String() + + require.Contains(t, firstCode, "first.Code") + require.Contains(t, firstCode, "InvalidLengthError") + require.Contains(t, secondCode, "second.Count") + require.Contains(t, secondCode, "InvalidRangeError") + require.Len(t, seen, 2) +} + +// TestValidationPathsAreSpecializedBeforeRendering verifies that fixed roots +// become string literals while reusable validators receive only their caller's +// path as a runtime value. +func TestValidationPathsAreSpecializedBeforeRendering(t *testing.T) { + minLength := 1 + pattern := "^[a-z]+$" + attribute := &expr.AttributeExpr{Type: &expr.Object{ + { + Name: "nested", + Attribute: &expr.AttributeExpr{Type: &expr.Object{ + { + Name: "value", + Attribute: &expr.AttributeExpr{ + Type: expr.String, + Validation: &expr.ValidationExpr{MinLength: &minLength}, + }, + }, + }}, + }, + { + Name: "items", + Attribute: &expr.AttributeExpr{Type: &expr.Array{ + ElemType: &expr.AttributeExpr{ + Type: expr.String, + Validation: &expr.ValidationExpr{Pattern: pattern}, + }, + }}, + }, + { + Name: "values", + Attribute: &expr.AttributeExpr{Type: &expr.Map{ + KeyType: &expr.AttributeExpr{Type: expr.String}, + ElemType: &expr.AttributeExpr{ + Type: expr.String, + Validation: &expr.ValidationExpr{Pattern: pattern}, + }, + }}, + }, + }} + context := NewAttributeContext(true, false, false, "", NewNameScope()) + + direct := ValidationCode(attribute, nil, context, true, false, false, "body") + direct = FormatTestCode(t, "package foo\nfunc validate() (err error) {\n"+direct+"\nreturn\n}") + require.Contains(t, direct, `"body.nested.value"`) + require.Contains(t, direct, `"body.items[*]"`) + require.Contains(t, direct, `"body.values[key]"`) + require.NotContains(t, direct, "path+") + + nested := ValidationCodeWithPathParameter(attribute, nil, context, true, false, false, "body", "path") + nested = FormatTestCode(t, "package foo\nfunc validate(path string) (err error) {\n"+nested+"\nreturn\n}") + require.Contains(t, nested, `path+".nested.value"`) + require.Contains(t, nested, `path+".items[*]"`) + require.Contains(t, nested, `path+".values[key]"`) + require.NotContains(t, nested, "fmt.Sprintf") +} + +// TestValidationCodeSharesOptionalFieldGuard verifies that the direct +// validation renderer checks one optional primitive once before all its rules. +func TestValidationCodeSharesOptionalFieldGuard(t *testing.T) { + minLength := 2 + attribute := &expr.AttributeExpr{Type: &expr.Object{ + {Name: "name", Attribute: &expr.AttributeExpr{ + Type: expr.String, + Validation: &expr.ValidationExpr{ + Pattern: "^[a-z]+$", + MinLength: &minLength, + }, + }}, + }} + context := NewAttributeContext(false, false, true, "", NewNameScope()) + + code := ValidationCode(attribute, nil, context, true, false, false, "target") + require.Equal(t, 1, strings.Count(code, "if target.Name != nil")) + require.Contains(t, code, "goa.ValidatePattern") + require.Contains(t, code, "goa.InvalidLengthError") + + root := &expr.AttributeExpr{ + Type: expr.String, + Validation: &expr.ValidationExpr{ + Pattern: "^[a-z]+$", + MinLength: &minLength, + }, + } + rootCode := ValidationCode(root, nil, context, false, false, false, "target") + require.Equal(t, 2, strings.Count(rootCode, "if target != nil")) + require.Contains(t, rootCode, "*target") +} + // TestMultipleAliasTypesInSameStruct tests that multiple fields with the same // alias type can be validated independently. Previously, the recursion guard // would incorrectly block validation of the second field. @@ -219,6 +354,39 @@ func TestValidationPredicatesPure(t *testing.T) { assertValidationsUnchanged(t, before) } +// TestValidationCodeUsesBothExclusiveBounds verifies that generating a lower +// exclusive bound cannot leave the upper bound on the lower-bound template +// branch. +func TestValidationCodeUsesBothExclusiveBounds(t *testing.T) { + exclusiveMinimum := 1.0 + exclusiveMaximum := 10.0 + attribute := &expr.AttributeExpr{ + Type: expr.Float64, + Validation: &expr.ValidationExpr{ + ExclusiveMinimum: &exclusiveMinimum, + ExclusiveMaximum: &exclusiveMaximum, + }, + } + context := NewAttributeContext(false, false, true, "", NewNameScope()) + legacy := ValidationCode(attribute, nil, context, true, false, true, "target") + require.Contains(t, legacy, "target <= 1") + require.Contains(t, legacy, "target >= 10") + + layout, err := PlanGoType(attribute, GoTypePlanOptions{ + Owner: "generated.local/gen/service", + Policy: GoLayoutPolicy{UseDefault: true, SumType: true}, + }) + require.NoError(t, err) + plan, err := NewValidationPlan(attribute, layout, ValidationPlanOptions{Required: true}) + require.NoError(t, err) + linked, err := plan.Link(layout.Link("generated.local/gen/service", func(importPath string) string { + require.Equal(t, "goa.design/goa/v3/pkg", importPath) + return "goa" + })) + require.NoError(t, err) + require.Equal(t, legacy, linked.Render("target", "target")) +} + // validationSnapshot captures the identity and deep value of an attribute // validation so mutations can be detected after running codegen. type validationSnapshot struct { diff --git a/codegen/validation_union_context_test.go b/codegen/validation_union_context_test.go index 4f17d58eda..749bbe3124 100644 --- a/codegen/validation_union_context_test.go +++ b/codegen/validation_union_context_test.go @@ -11,6 +11,14 @@ import ( "goa.design/goa/v3/expr" ) +type ( + // sumTypeTestScope reports that a union is stored directly as a Go value. The + // test checks that generated validation uses this information. + sumTypeTestScope struct { + Attributor + } +) + func TestUnionValidationPreservesValueContextForRequiredOnlyObjectBranches(t *testing.T) { root := RunDSL(t, requiredObjectUnionDSL) scope := NewNameScope() @@ -71,6 +79,32 @@ func TestUnionValidationUsesGeneratedFieldRepresentation(t *testing.T) { require.Contains(t, marshalCode, "if target.Optional != nil {") } +func TestUnionValidationUsesCustomSumTypeResolver(t *testing.T) { + union := &expr.Union{ + TypeName: "Scope", + Values: []*expr.NamedAttributeExpr{ + {Name: "name", Attribute: &expr.AttributeExpr{Type: expr.String}}, + }, + } + attribute := &expr.AttributeExpr{ + Type: &expr.Object{ + {Name: "scope", Attribute: &expr.AttributeExpr{Type: union}}, + }, + Validation: &expr.ValidationExpr{Required: []string{"scope"}}, + } + context := NewAttributeContext(false, false, true, "", NewNameScope()) + context.Scope = &sumTypeTestScope{Attributor: context.Scope} + + generated := ValidationCode(attribute, nil, context, true, false, false, "target") + + require.Contains(t, generated, `if target.Scope.Kind() == "" {`) + require.NotContains(t, generated, "if target.Scope == nil {") +} + +func (*sumTypeTestScope) IsSumType() bool { + return true +} + // requiredObjectUnionDSL defines a OneOf with required-only object branches so // validation generation can distinguish pointer and value contexts. func requiredObjectUnionDSL() { diff --git a/codegen/walk.go b/codegen/walk.go index a2192912d4..1e64d3e351 100644 --- a/codegen/walk.go +++ b/codegen/walk.go @@ -1,3 +1,5 @@ +// Attribute walkers visit Goa design types once per source declaration while +// preserving the concrete dynamic type supplied to callbacks. package codegen import "goa.design/goa/v3/expr" @@ -10,13 +12,13 @@ type MappedAttributeWalker func(name, elem string, required bool, a *expr.Attrib // Walk traverses the data structure recursively and calls the given function // once on each attribute starting with a. func Walk(a *expr.AttributeExpr, walker func(*expr.AttributeExpr) error) error { - return walk(a, walker, make(map[string]bool)) + return walk(a, walker, make(map[expr.UserType]struct{})) } // WalkType traverses the data structure recursively and calls the given function // once on each attribute starting with the user type attribute. func WalkType(u expr.UserType, walker func(*expr.AttributeExpr) error) error { - return walk(u.Attribute(), walker, map[string]bool{u.ID(): true}) + return walk(u.Attribute(), walker, map[expr.UserType]struct{}{u.Origin(): {}}) } // WalkMappedAttr iterates over the mapped attributes. It calls the given @@ -35,15 +37,16 @@ func WalkMappedAttr(ma *expr.MappedAttributeExpr, it MappedAttributeWalker) erro // Recursive implementation of the Walk methods. Takes care of avoiding infinite // recursions by keeping track of types that have already been walked. -func walk(at *expr.AttributeExpr, walker func(*expr.AttributeExpr) error, seen map[string]bool) error { +func walk(at *expr.AttributeExpr, walker func(*expr.AttributeExpr) error, seen map[expr.UserType]struct{}) error { if err := walker(at); err != nil { return err } walkUt := func(ut expr.UserType) error { - if _, ok := seen[ut.ID()]; ok { + origin := ut.Origin() + if _, ok := seen[origin]; ok { return nil } - seen[ut.ID()] = true + seen[origin] = struct{}{} return walk(ut.Attribute(), walker, seen) } switch actual := at.Type.(type) { @@ -71,7 +74,7 @@ func walk(at *expr.AttributeExpr, walker func(*expr.AttributeExpr) error, seen m case *expr.UserTypeExpr: return walkUt(actual) case *expr.ResultTypeExpr: - return walkUt(actual.UserTypeExpr) + return walkUt(actual) default: panic("unknown attribute type") // bug } diff --git a/codegen/walk_test.go b/codegen/walk_test.go new file mode 100644 index 0000000000..e71e66ff6b --- /dev/null +++ b/codegen/walk_test.go @@ -0,0 +1,88 @@ +// This file verifies that attribute traversal distinguishes unrelated design +// declarations while terminating when a declaration refers to itself. +package codegen + +import ( + "testing" + + "github.com/stretchr/testify/require" + + "goa.design/goa/v3/expr" +) + +func TestWalkDistinguishesEqualUIDOrigins(t *testing.T) { + firstLeaf := &expr.AttributeExpr{Type: expr.String} + secondLeaf := &expr.AttributeExpr{Type: expr.Int} + first := &expr.UserTypeExpr{ + AttributeExpr: &expr.AttributeExpr{Type: &expr.Object{ + {Name: "first", Attribute: firstLeaf}, + }}, + TypeName: "First", + UID: "shared", + } + second := &expr.UserTypeExpr{ + AttributeExpr: &expr.AttributeExpr{Type: &expr.Object{ + {Name: "second", Attribute: secondLeaf}, + }}, + TypeName: "Second", + UID: "shared", + } + root := &expr.AttributeExpr{Type: &expr.Object{ + {Name: "first", Attribute: &expr.AttributeExpr{Type: first}}, + {Name: "second", Attribute: &expr.AttributeExpr{Type: second}}, + }} + + visited := make(map[*expr.AttributeExpr]bool) + require.NoError(t, Walk(root, func(att *expr.AttributeExpr) error { + visited[att] = true + return nil + })) + require.True(t, visited[firstLeaf]) + require.True(t, visited[secondLeaf]) +} + +func TestWalkPreservesDynamicResultTypeOrigin(t *testing.T) { + baseLeaf := &expr.AttributeExpr{Type: expr.String} + base := &expr.UserTypeExpr{ + AttributeExpr: &expr.AttributeExpr{Type: &expr.Object{ + {Name: "base", Attribute: baseLeaf}, + }}, + TypeName: "Base", + } + resultLeaf := &expr.AttributeExpr{Type: expr.Int} + embedded := base.Dup(&expr.AttributeExpr{Type: &expr.Object{ + {Name: "result", Attribute: resultLeaf}, + }}).(*expr.UserTypeExpr) + result := &expr.ResultTypeExpr{ + UserTypeExpr: embedded, + Identifier: "application/vnd.result", + } + root := &expr.AttributeExpr{Type: &expr.Object{ + {Name: "base", Attribute: &expr.AttributeExpr{Type: base}}, + {Name: "result", Attribute: &expr.AttributeExpr{Type: result}}, + }} + + visited := make(map[*expr.AttributeExpr]bool) + require.NoError(t, Walk(root, func(att *expr.AttributeExpr) error { + visited[att] = true + return nil + })) + require.True(t, visited[baseLeaf]) + require.True(t, visited[resultLeaf]) +} + +func TestWalkTypeTerminatesRecursiveCopy(t *testing.T) { + recursive := &expr.UserTypeExpr{TypeName: "Recursive", UID: "recursive"} + object := &expr.Object{} + recursive.AttributeExpr = &expr.AttributeExpr{Type: object} + self := &expr.AttributeExpr{Type: recursive} + object.Set("self", self) + copy := expr.Dup(recursive).(expr.UserType) + + visits := 0 + require.NoError(t, WalkType(copy, func(*expr.AttributeExpr) error { + visits++ + return nil + })) + require.Equal(t, 2, visits) +} diff --git a/docs/superpowers/plans/2026-08-20-generated-package-ownership.md b/docs/superpowers/plans/2026-08-20-generated-package-ownership.md new file mode 100644 index 0000000000..0dddaa1abf --- /dev/null +++ b/docs/superpowers/plans/2026-08-20-generated-package-ownership.md @@ -0,0 +1,844 @@ +# Generated Package Ownership Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Make every generated package-level declaration and every reference use one name collected once, frozen once, and retained through rendering. + +**Architecture:** One generation run instantiates fresh core and plugin objects, permits root mutation only during preparation, and builds one typed `generator.Plan`. Service, HTTP, JSON-RPC, gRPC, OpenAPI, example, and goa-ai plans collect package-owned `NameDeclaration` records, freeze them, link those records once into complete immutable render models, and render without rebuilding analysis or allocating a name. + +**Tech Stack:** Go 1.25, Goa evaluation and code generation, Protocol Buffers and `protoc-gen-go`, goa-ai plugins, `testify/require` + +**Spec:** `codegen/ARCHITECTURE.md` + +## Global Constraints + +- Never edit generated output; regenerate it from the owning design. +- Preparation is the last phase allowed to mutate an expression root. +- One run owns one immutable root snapshot, one `codegen.Generation`, and one typed `generator.Plan`. +- Every package-level type, function, constant, and variable has a package-owned `NameDeclaration` before freeze. +- Exact symbols reject normalized collisions; preferred symbols receive deterministic suffixes from stable typed ordering. +- `NameDeclaration.Name()` panics before freeze and is stable after freeze. +- Linking resolves retained facts through frozen declarations exactly once; it cannot collect another declaration or import. +- Render accepts retained typed plans. It does not accept roots, a generated module path, or callbacks that reconstruct analysis. +- Complete import path is the only import identity. Different package identities that normalize to one output import path or directory are rejected. +- Recursion uses `UserType.Origin()` only for cycle detection. Emitted declarations use complete typed declaration identities. +- Keep `expr.Union.Hash()` unchanged. +- Protoc-generated Go names come from one explicit, versioned naming contract that covers complete declaration families. +- Plugins consume the exact core service plan. Do not add `PlanKey`, plan registries, generic plan bags, reconstruction, decorated string keys, or process-global run state. +- `SectionTemplate.Name` is diagnostic metadata, not declaration identity. +- Never add fallbacks or compatibility modes. Migrate all in-tree callers and delete replaced APIs. +- Every exported construct needs GoDoc; every non-trivial file needs a concrete purpose and invariant header. + +--- + +## Completed foundation + +The following tasks are complete against their reviewed contracts. Their tests, +typed identities, import-path ownership, and transport correctness remain +required. The retained-plan audit supersedes their transitional callback and +reconstruction APIs; “complete” here records delivered history, not approval to +preserve those APIs. + +### Task 1: Executable failure contracts — complete + +**Commits:** `0103f6ab`, `dac45fe7` + +- [x] Added a real two-service generated-module test with relocated nested unions and HTTP/gRPC compilation. +- [x] Added exact relocated-name collision coverage. +- [x] Added the same-label file-section regression later resolved by Task 6. + +### Task 2: Generation-owned type catalog — complete + +**Commits:** `15f86ce7`, `7353f34b`, `8bda1ae4` + +- [x] Added one package catalog per generated import path. +- [x] Reserved exact user declarations before deterministic union allocation. +- [x] Froze package scopes and rejected planning through render-only accessors. + +### Task 3: Typed union identity and initial lifecycle — complete + +**Commits:** `4957ddef`, `a120d8ea` + +- [x] Added `UnionTypeID` without changing expression hash semantics. +- [x] Established prepare, plan, freeze, and render ordering. +- [x] Proved one generation reaches core and plugin callbacks. + +The retained-plan audit replaces `Genfunc`, callback plugin instances, and +render-time reconstruction introduced during this transition. + +### Task 4: Package-owned service declarations — complete + +**Commits:** `81b8bc37`, `4ca1f70c`, `5469ad1e`, `14b1a3c4`, `1bf62f51`, `839791a6` + +- [x] Added typed authored origin, derived method/view identities, complete union families, full-path import aliases, and cross-root package emission. +- [x] Made declaration records immutable and deterministic. +- [x] Bound service and views references to frozen package records. +- [x] Rejected unregistered roots and immutable-generation mutation. + +The retained-plan audit expands this ownership from selected type families to +every service package-level symbol and replaces `Plan` plus +`NewServicesData` re-analysis with one retained `service.Plan`. + +### Task 5: Transport declaration ownership — complete + +**Commits:** `595f50ad`, `8dc8ea8e`, `c6a0e1e0`, `393e9102`, `ddfc0472` + +- [x] Routed HTTP, gRPC, and JSON-RPC service references through exact frozen service declarations. +- [x] Added HTTP/JSON-RPC and protobuf wire catalogs, independent transform ownership, native gRPC metadata, generated import planning, and effective inherited-error validation. +- [x] Distinguished cycle identity from transport declaration identity and compiled the integrated generated modules. + +The retained-plan audit keeps these semantics and converts the render-time +catalog construction into retained HTTP, JSON-RPC, protobuf, and gRPC plans. + +--- + +## Remaining implementation + +### Task 6: Common declarations and fresh run lifecycle + +**Files:** +- Modify: `codegen/generation.go` +- Modify: `codegen/generated_types.go` +- Modify: `codegen/generated_types_test.go` +- Modify: `codegen/import_aliases.go` +- Modify: `codegen/normalize.go` +- Delete: `codegen/plugin.go` +- Delete: `codegen/plugin_test.go` +- Modify: `codegen/generator/generate.go` +- Replace: `codegen/generator/generators.go` +- Create: `codegen/generator/plan.go` +- Create: `codegen/generator/plugin.go` +- Create: `codegen/generator/plugin_test.go` +- Modify: `codegen/generator/generation_test.go` +- Modify: `codegen/generator/purity_test.go` +- Modify: `codegen/generator/generate_merge_test.go` +- Modify: `codegen/generator/service_union_package_scope_test.go` +- Modify: `codegen/generator/generate_http_union_shape_integration_test.go` +- Modify: `codegen/generator/generate_union_merge_integration_test.go` +- Modify: `codegen/walk.go` +- Modify: `codegen/import.go` +- Modify: `codegen/validation.go` +- Modify: `codegen/example/plan.go` +- Modify: `codegen/example/example_client.go` +- Modify: `codegen/example/example_server.go` + +**Interfaces:** +- Produces: package-owned `NameDeclaration` for type/function/constant/variable symbols +- Produces: `generator.Plugin`, `PluginFactory`, fresh core factories, and private-field `generator.Plan` +- Preserves: `Generation`, import-path bindings, `TypeDeclaration`, `UnionDeclaration`, and typed declaration identities + +- [x] **Step 1: Add declaration and lifecycle RED tests** + +Add table-driven tests proving one package namespace catches cross-kind +collisions, exact names reject, preferred names suffix in stable typed order, +`Name()` panics before freeze, and every existing type/union record returns its +contained canonical name record. Add canonical output-path tests where two +different package identities normalize to one import path or directory. + +Add repeated and concurrent generator tests. Register a factory whose plugin +keeps per-run counters, run generation twice and in parallel, and prove each run +starts at zero and receives only its own roots, plan, and files. Attempt root +mutation after preparation and require rejection or a purity failure at the +owning boundary. + +Run: + +```bash +go test ./codegen ./codegen/generator \ + -run 'TestNameDeclaration|TestGeneratedOutputPath|TestPluginFactory|TestConcurrentGeneration|TestPreparedRoots' \ + -count=1 +``` + +Expected: FAIL because names are still type-family-specific, plugins are +registered as callback instances in `codegen`, and `Generators` is mutable +process-global run state. + +- [x] **Step 2: Implement the common declaration owner** + +Add private preferred/final state and a package-level symbol kind to +`NameDeclaration`. Make exact and preferred declaration APIs return the same +record on idempotent typed identity and reject one identity binding to two +records. Allocate exact records first and preferred records in stable typed +order during `Generation.Freeze`. + +Embed or reference `NameDeclaration` from existing type, union, union branch, +imported toolchain, and later subsystem records. Remove duplicate name fields +as each owner migrates. Canonicalize output paths during collection and reject +different package owners that converge after normalization. + +- [x] **Step 3: Move orchestration and plugin registration into generator** + +Implement the approved public surface: + +```go +type Plugin struct { + Prepare PrepareFunc + Plan func(*Plan) error + Generate func(*Plan, []*codegen.File) ([]*codegen.File, error) +} + +type PluginFactory func() Plugin + +func RegisterPlugin(name, command string, factory PluginFactory) +func RegisterPluginFirst(name, command string, factory PluginFactory) +func RegisterPluginLast(name, command string, factory PluginFactory) + +func (p *Plan) Generation() *codegen.Generation +``` + +Store immutable factory descriptors and instantiate fresh plugins and core +generators before each run. Make `Generation` construction the final +preparation operation: normalize raw method objects there, snapshot the design +immediately afterward, and reject every later mutation. Delete `Genfunc`, the public +replaceable `Generators` variable, `renderOnly`, and the callback registry in +`codegen/plugin.go`. Tests install an isolated registry or command factory +through a private test seam, not a mutable production global. + +- [x] **Step 4: Finish mechanical identity and example cleanup** + +Audit every cycle-only walk and key it by `UserType.Origin()`. Keep semantic +`ID()` only where it identifies a named user type or a public semantic +identifier. Give every generated example a kind-tagged identity derived from +its exact owning expression: user type, method payload/result/error, HTTP +request/success/error body, object member, array element, map key/value, or +union branch. Reject unanchored draws and remove delimiter-joined paths, +caller-supplied response ordinals, and shared sequential collection streams. +Give independently mapped HTTP and JSON-RPC body types distinct stable semantic +IDs derived from their exact typed body owners so the recursive example cache +cannot return one transport's body for the other. Preserve authored type IDs +and expression hash behavior. +Remove render-time example scopes that own package-level names; leave local +argument and field scopes local. Add focused cross-kind, delimiter, response +reordering, dual-transport order, repeated-analysis, and concurrent-run +counterexamples. + +- [x] **Step 5: Verify and commit Task 6** + +Run: + +```bash +go fmt ./... +go test ./codegen ./codegen/generator -count=1 +go test ./... -run '^$' +git diff --check +``` + +All commands pass. +Commit the common owner and fresh-run lifecycle together because retained plans +depend on both contracts. + +### Task 7: Retained service plan and complete core symbols + +**Files:** +- Replace: `codegen/service/generated_package.go` +- Replace: `codegen/service/service_data.go` +- Modify: `codegen/service/service.go` +- Modify: `codegen/service/client.go` +- Modify: `codegen/service/endpoint.go` +- Modify: `codegen/service/views.go` +- Modify: `codegen/service/convert.go` +- Modify: `codegen/validation.go` +- Modify: `codegen/service/example_svc.go` +- Modify: `codegen/service/declaration_resolver.go` +- Modify: service headers/import builders that emit package symbols +- Modify: `codegen/generator/plan.go` +- Modify: `codegen/generator/service.go` +- Test: `codegen/service/*_test.go` +- Test: `codegen/generator/service_union_package_scope_test.go` + +**Interfaces:** +- Consumes: Task 6 `Generation`, `NameDeclaration`, and prepared root snapshot +- Produces: `service.NewPlans(generation, inputs...) ([]*service.Plan, error)` +- Produces: `service.NewPlan(root, generation, examples) (*service.Plan, error)` +- Produces: `generator.Plan.Service(root) *service.Plan` +- Produces: one post-freeze `service.Plan.Link()` operation before rendering +- Produces: service render functions that accept retained plans only + +- [x] **Step 1: Inventory and test every service package-level symbol** + +Build a table from templates and render data covering service and views types, +method wrappers, union families, endpoint constructors, clients, +errors, validators, conversions, interceptors, stream interfaces and helpers, +view constructors, and package variables. For each family, add a collision +fixture against a type and another generated function or constant. Assert the +declaration and every call site share the same `NameDeclaration` pointer, then +compile the generated service and views packages. + +Run: + +```bash +go test ./codegen/service ./codegen/generator \ + -run 'TestServicePlan|TestServicePackageDeclarations|TestRelocatedUnionPackageNamesCompile' \ + -count=1 +``` + +Expected: FAIL where `NewServicesData` and private render scopes still allocate +package-level endpoint, constructor, validator, conversion, or stream names. + +- [x] **Step 2: Build and retain the complete service-plan batch** + +Replace the declaration-only `service.Plan` function and render-time +`NewServicesData` reconstruction with one run-wide constructor: + +```go +func NewPlans(generation *codegen.Generation, inputs ...PlanInput) ([]*Plan, error) +``` + +The constructor requires every Goa root owned by the generation exactly once. +It collects every root-local design fact, package import, output owner, and +package-level declaration, then assigns shared conversion methods and relocated +files across the complete run without reading provisional names. Structurally +equivalent compiler copies may share one declaration; candidates with different +retained Go layouts or union branch facts are rejected before freeze. Exact +duplicate external conversions across roots are rejected rather than receiving +an artificial numeric suffix. `NewPlan` remains only as the strict single-root +form and rejects a multi-root generation. After Generation freezes, `Plan.Link` +resolves the frozen records into the immutable render model without another +declaration traversal. `generator.Plan` stores the exact result by root and +returns it through `Service(root)`; unknown roots fail fast. + +- [x] **Step 3: Render only the retained plan** + +Change service, views, client, endpoint, conversion, validation, interceptor, +and starter implementation renderers to accept `*service.Plan` or typed values +owned by that plan. Remove root, generation, generated module path, and mutable +scope parameters that permit re-analysis or redirected output. Keep lexical +scopes only for locals, parameters, fields, and methods. + +Delete `NewServicesData`, the old `ServicesData` reconstruction constructor, +duplicate planning traversals, and any record that carries a second final name. + +- [x] **Step 4: Prove aggregation, order independence, and purity** + +Generate two roots contributing to one relocated package, reverse root and +service traversal, and assert byte-identical declarations. Mutate the source +service, method, type-location, field, validation, and conversion expressions +after planning, then assert core service output is byte-identical wherever the +core service plan owns those facts. Preserve distinct transport validation as +a valid counterexample: HTTP and gRPC own those validation programs when the +shared service declaration's Go layout is unchanged. Render twice without new +catalog entries and compile service, views, and example implementation packages. + +- [x] **Step 5: Verify and commit Task 7** + +Run: + +```bash +go fmt ./... +go test ./expr ./dsl ./codegen ./codegen/service ./codegen/generator -count=1 +go test ./... -run '^$' +git diff --check +``` + +All commands must pass. + +### Task 8: Retained HTTP and JSON-RPC plans + +**Files:** +- Replace: `http/codegen/plan.go` +- Replace: `http/codegen/service_data.go` +- Replace: `http/codegen/wire_catalog.go` +- Modify: `http/codegen/client.go` +- Modify: `http/codegen/server.go` +- Modify: `http/codegen/websocket.go` +- Modify: `http/codegen/sse.go` +- Modify: `http/codegen/sse_client.go` +- Modify: `http/codegen/types.go` +- Modify: `http/codegen/client_cli.go` +- Modify: `http/codegen/example_cli.go` +- Modify: `http/codegen/example_server.go` +- Replace: `jsonrpc/codegen/plan.go` +- Modify: `jsonrpc/codegen/client.go` +- Modify: `jsonrpc/codegen/server.go` +- Modify: `jsonrpc/codegen/websocket_client.go` +- Modify: `jsonrpc/codegen/websocket_server.go` +- Modify: `jsonrpc/codegen/example_server.go` +- Modify: `codegen/generator/plan.go` +- Modify: `codegen/generator/transport.go` +- Test: `http/codegen/plan_test.go` +- Test: `http/codegen/wire_catalog_test.go` +- Test: `http/codegen/service_data_purity_test.go` +- Test: `http/codegen/streaming_test.go` +- Test: `jsonrpc/codegen/plan_test.go` +- Test: `jsonrpc/codegen/kitchen_sink_test.go` +- Test: `jsonrpc/codegen/sse_integration_test.go` +- Test: `codegen/generator/service_union_package_scope_test.go` + +**Interfaces:** +- Consumes: exact retained `*service.Plan` +- Produces: typed retained HTTP and JSON-RPC plans with complete package declarations +- Preserves: independent wire/service transform ownership and detached HTTP bodies + +**Progress ledger (2026-08-21):** + +- Task 7 now reserves service-view imports only in the HTTP, JSON-RPC, and gRPC + files whose rendered sections reference them. The focused generated-module + proof covers viewed and ordinary services, unary and streaming gRPC, HTTP + SSE and WebSocket, and JSON-RPC unary code. The complete variable-view + transport behavior remains Task 8 work rather than an import-planning + exception. +- JSON-RPC unary responses currently discard the selected view: the server + always renders the first retained response-body variant and sends no view, + while the client tries to read `goa-view` from the HTTP response header. + Task 8 must make the selected representation explicit and reconstruct the + same view-specific body on both sides. +- JSON-RPC SSE and WebSocket clients currently decode `params` or `result` + bytes directly into the service result. This is not a valid shortcut. For + example, the generated Feed response body maps the wire property + `event_id` to `EventID`, but the service result has no JSON tag; direct + decoding silently leaves `EventID` unset. Task 8 must decode the retained + transport body, run its generated constructor and validation, then return + the canonical service result. + +- [x] **Step 1: Add complete HTTP/JSON-RPC declaration REDs** + +Inventory request, response, WebSocket, SSE, error, union, constructor, +validator, codec, stream, client, server, CLI, and example package symbols. +Create collisions between wire types and validators/constructors, between +request and response policy for one origin, and between HTTP and JSON-RPC +sections sharing an output package. Require stable names under reversed +endpoint order and compile the full generated module. + +Add two-view streaming-result contracts for HTTP SSE, JSON-RPC SSE, and +JSON-RPC WebSocket. Prove each method/request stream implements `SetView`, +retains its own view, projects through the canonical service constructor, and +selects the response-body declaration for that exact view. Use two concurrent +requests on one JSON-RPC WebSocket connection as the counterexample: a view +stored on the connection is invalid because one request may select `summary` +while another selects `detailed`. + +Cover the direct JSON-RPC `StreamHandler` API separately. For a method whose +view is not fixed by the design, each `SendNotification` and +`SendResponse` call must carry its own view; it must not inherit a +connection-global or latest value. Fixed-view methods remain specialized and +do not expose a redundant selector. Add client runtime proofs with nested and +transport-mapped fields so SSE and WebSocket receivers cannot pass by decoding +view-specific wire JSON directly into the service result. Include a required +snake-case field such as `event_id`: decoding it into a service field named +`EventID` must fail the test unless the generated transport-body constructor +performs the mapping. + +- [x] **Step 2: Build retained HTTP plans from exact service plans** + +Make HTTP `NewPlan` consume the prepared root's HTTP expressions and exact +`*service.Plan`. Collect detached client and server wire models, union families, +validators, helpers, imports, and file membership once. Move every current +`NewServicesData` and `wire_catalog` allocation into this constructor. Store +canonical service and wire declaration records in transform data. + +Retain one method/request-scoped view value for variable-view SSE and +WebSocket streams. The stream's `SetView` updates that value; send operations +use it to select both the service projection and the already-retained +view-specific response body. Never place mutable view selection on a shared +connection. + +- [x] **Step 3: Make JSON-RPC retain the HTTP plan it shares** + +Build one typed JSON-RPC plan that points at the exact HTTP plan used for HTTP +codecs and body files, then collects JSON-RPC-only declarations. Do not invoke +HTTP planning or analysis again. Make JSON-RPC render functions accept this +plan and delete their root/service reconstruction paths. + +Define one explicit viewed-stream wire contract shared by JSON-RPC SSE and +WebSocket. Every viewed streamed message carries the selected view together +with its view-specific body. Generated clients must select the matching +retained body decoder, reconstruct the projected value, validate the viewed +result, and return the canonical service result. Do not decode a projected +wire body directly into the service result, infer a default for a variable-view +method, or ask callers to construct generated views-package values. + +Apply the same representation contract to unary JSON-RPC. A variable-view +success response must carry the selected view with its view-specific body; +the server cannot choose the first body variant and the client cannot recover +the view from an unset HTTP header. Fixed-view unary methods remain fully +specialized and need no runtime discriminator. + +- [x] **Step 4: Remove context-dependent helper naming** + +Validators, constructors, conversions, stream helpers, and codecs must read +their `NameDeclaration`; call-site traversal selects a record but cannot name +it. Keep local field and variable scopes. Prove request/response and +WebSocket/SSE transforms enter service and wire owners independently. + +- [x] **Step 5: Verify and commit Task 8** + +Run: + +```bash +go fmt ./... +go test ./codegen/service ./http/codegen/... ./jsonrpc/codegen/... ./codegen/generator -count=1 +go test ./... -run '^$' +git diff --check +``` + +All commands must pass. + +### Task 9: Versioned protobuf descriptor plan and retained gRPC plan + +**Files:** +- Replace: `grpc/codegen/plan.go` +- Replace: `grpc/codegen/service_data.go` +- Replace: `grpc/codegen/protobuf_catalog.go` +- Modify: `grpc/codegen/protobuf.go` +- Modify: `grpc/codegen/proto.go` +- Modify: `grpc/codegen/proto_hooks.go` +- Modify: `grpc/codegen/types.go` +- Modify: `grpc/codegen/client.go` +- Modify: `grpc/codegen/server.go` +- Modify: `grpc/codegen/client_cli.go` +- Modify: `grpc/codegen/example_cli.go` +- Modify: `grpc/codegen/example_server.go` +- Create: `grpc/codegen/protoc_names.go` +- Create: `grpc/codegen/protoc_names_test.go` +- Modify: `codegen/generator/plan.go` +- Modify: `codegen/generator/transport.go` +- Test: `grpc/codegen/plan_test.go` +- Test: `grpc/codegen/proto_test.go` +- Test: `grpc/codegen/protobuf_test.go` +- Test: `grpc/codegen/protobuf_transform_test.go` +- Test: `grpc/codegen/service_data_traversal_test.go` +- Test: `grpc/codegen/service_metadata_reference_test.go` +- Test: `grpc/codegen/streaming_test.go` +- Test: `codegen/generator/service_union_package_scope_test.go` + +**Interfaces:** +- Consumes: exact retained `*service.Plan` +- Produces: retained protobuf descriptor plans and retained gRPC plans +- Produces: one explicit supported protoc/protoc-gen-go naming version and complete Go declaration families + +- [ ] **Step 1: Capture the real protoc naming contract as RED tests** + +Create descriptor fixtures for acronym and digit names, reserved words, nested +messages, enums, oneofs, services, streams, and explicit preferred names. Run +the supported real `protoc` and `protoc-gen-go` toolchain in a temporary module, +then compare every Goa-predicted package-level symbol with generated Go source. +Include message, enum/value, oneof interface/wrapper, client/server, and support +families. Add a test that rejects an unknown naming-version selector. + +Run: + +```bash +go test ./grpc/codegen -run 'TestProtocNameVersion|TestProtocDeclarationFamilies' -count=1 +``` + +Expected: FAIL because protoc naming is currently approximated across helpers +and the catalog does not retain complete versioned families. + +- [ ] **Step 2: Build one retained descriptor plan per protobuf package** + +Represent `.proto` declarations and protoc-generated Go declarations as +separate typed records. Give each family canonical `NameDeclaration` records +for every Go symbol Goa references. Identity includes complete emitted schema, +ordered fields/oneofs, field numbers, validation, defaults, source provenance, +and role where these facts change output; explicit protobuf names remain +preferences. + +Put the supported toolchain naming algorithm behind one explicit versioned +implementation. Delete scattered protoc CamelCase, oneof-wrapper, and service +name reconstruction after their callers consume family records. + +- [ ] **Step 3: Build and render one retained gRPC plan** + +Make gRPC `NewPlan` consume the exact `*service.Plan` and retained protobuf +descriptor plans. Collect messages, validators, conversions, native metadata, +streams, clients, servers, CLI, examples, imports, and output files before +freeze. Render `.proto` and Go files from the same records. + +- [ ] **Step 4: Make validators and transforms context-independent** + +Store the exact message, wrapper, validator, and conversion declarations in +render data. A transform context may select source and target records but may +not calculate their names. Add equal semantic ID/different origin cases, +same-origin/different role cases, reversed endpoint order, and one type reused +across unary and streaming roles. Compile and round-trip native metadata. + +- [ ] **Step 5: Verify and commit Task 9** + +Run: + +```bash +go fmt ./... +go test ./codegen/service ./grpc/codegen/... ./codegen/generator -count=1 +go test ./... -run '^$' +git diff --check +``` + +All commands must pass. + +### Task 10: OpenAPI, examples, and selective lifecycle integration + +**Files:** +- Modify: `codegen/generator/plan.go` +- Replace: `codegen/generator/openapi.go` +- Replace: `codegen/generator/example.go` +- Modify: `codegen/example/plan.go` +- Modify: `codegen/example/example_client.go` +- Modify: `codegen/example/example_server.go` +- Modify: `http/codegen/openapi.go` +- Modify: `http/codegen/openapi/v2/builder.go` +- Modify: `http/codegen/openapi/v2/files.go` +- Modify: `http/codegen/openapi/v2/openapi.go` +- Modify: `http/codegen/openapi/v3/builder.go` +- Modify: `http/codegen/openapi/v3/example.go` +- Modify: `http/codegen/openapi/v3/files.go` +- Modify: `http/codegen/openapi/v3/openapi.go` +- Modify: `codegen/generator/generation_test.go` +- Modify: `codegen/generator/purity_test.go` + +**Interfaces:** +- Consumes: retained service and selected transport plans +- Produces: retained OpenAPI and example plans +- Produces: one core command plan with no render-time root or generation reconstruction + +- [x] **Step 1: Add selective-command and plan-identity REDs** + +For `gen`, `example`, and focused test commands, assert each selected subsystem +is planned once, each renderer receives the exact retained pointer, unselected +subsystems allocate nothing, and the prepared root remains unchanged after the +plan boundary. Cover OpenAPI-only semantic example IDs separately from Go +declaration identity. + +- [x] **Step 2: Retain OpenAPI and example analysis** + +Build typed OpenAPI plans from prepared expressions and typed example plans +from exact service/transport plans. The example plan owns its server +composition data; delete the process-global `codegen/example.Servers` map. +Retain one private JSON Schema registry per OpenAPI plan; delete the exported +mutable `Definitions` map and process-global definition-name state. The +returned specification owns its definition map and schema values, so a later +build cannot mutate it. Use the typed example identities established in Task +6. Collect every example and CLI package-level constructor, variable, and +helper through the owning package catalog. + +- [x] **Step 3: Make the core plan the only command execution model** + +Have command factories construct one private-field `generator.Plan` containing +the exact selected subsystem plans. Core render dispatch reads those fields; +it does not call `NewPlan`, `NewServicesData`, `Generation.Roots`, or accept a +second generated module path. Remove all remaining generator adapters and +callback-shaped lifecycle tests. + +- [x] **Step 4: Prove purity, selection, repeated runs, and compilation** + +Run each command twice and concurrently with different roots. Assert byte- +identical output per input, no cross-run state, no late declarations, and no +unselected files. Build disjoint example servers and OpenAPI specifications +sequentially and behind a start barrier; assert no server or schema from one +design appears in the other and the first returned result remains unchanged +after the second build. Run both concurrency tests with the race detector. +Compile full HTTP/gRPC/JSON-RPC examples and validate both OpenAPI versions. + +- [x] **Step 5: Verify and commit Task 10** + +Run: + +```bash +go fmt ./... +go test ./codegen/... ./http/codegen/... ./grpc/codegen/... ./jsonrpc/codegen/... \ + -skip '^TestMergeFilesPreservesSameLabelSections$' -count=1 +go test ./... -run '^$' +git diff --check +``` + +The skipped merge regression remains Task 12 work. + +### Task 11: Goa-ai retained plans and plugin migration + +**Files:** +- Modify: `/Users/raphael/src/goa-ai/codegen/agent/init.go` +- Modify: `/Users/raphael/src/goa-ai/codegen/agent/data.go` +- Modify: `/Users/raphael/src/goa-ai/codegen/agent/generate.go` +- Modify: `/Users/raphael/src/goa-ai/codegen/agent/generate_toolset_specs.go` +- Modify: `/Users/raphael/src/goa-ai/codegen/agent/generate_agent_files.go` +- Modify: `/Users/raphael/src/goa-ai/codegen/agent/specs_builder_build.go` +- Modify: `/Users/raphael/src/goa-ai/codegen/agent/specs_builder_helpers.go` +- Modify: `/Users/raphael/src/goa-ai/codegen/agent/specs_builder_materialize.go` +- Modify: `/Users/raphael/src/goa-ai/codegen/agent/specs_builder_misc.go` +- Modify: `/Users/raphael/src/goa-ai/codegen/agent/specs_builder_type_info.go` +- Modify: `/Users/raphael/src/goa-ai/codegen/agent/specs_builder_types.go` +- Modify: `/Users/raphael/src/goa-ai/codegen/agent/specs_builder_unions.go` +- Modify: `/Users/raphael/src/goa-ai/codegen/ir/build.go` +- Modify: `/Users/raphael/src/goa-ai/codegen/mcp/init.go` +- Modify: `/Users/raphael/src/goa-ai/codegen/mcp/generate.go` +- Modify: `/Users/raphael/src/goa-ai/eval/codegen/codegen.go` +- Test: `/Users/raphael/src/goa-ai/codegen/agent/generate_test.go` +- Test: `/Users/raphael/src/goa-ai/codegen/agent/specs_builder_internal_test.go` +- Test: `/Users/raphael/src/goa-ai/codegen/agent/uniontest/union_names_test.go` +- Test: `/Users/raphael/src/goa-ai/codegen/agent/tests/golden_deep_nested_validations_test.go` +- Test: `/Users/raphael/src/goa-ai/codegen/mcp/contract_test.go` +- Test: `/Users/raphael/src/goa-ai/codegen/mcp/state_test.go` +- Test: `/Users/raphael/src/goa-ai/eval/codegen/codegen_test.go` + +**Interfaces:** +- Consumes: generator plugin factories and exact `generator.Plan.Service(root)` +- Produces: retained agent specification, MCP, and eval plans +- Removes: temporary-root rendering, repeated service/spec reconstruction, shared spec scopes, and string union companion keys + +- [ ] **Step 1: Add plugin and specification REDs** + +Add a repeated/concurrent run test in one process, an MCP service whose package +overlaps a core service package, and an AURA-shaped large tool-spec package +with colliding validator/constructor preferences. Assert public tool specs and +HTTP transport specs own independent package plans and natural names. Assert +MCP render consumes the exact core service-plan pointer created after prepare. + +- [ ] **Step 2: Attach all generated expressions during prepare** + +Register fresh agent, MCP, and eval plugin factories. MCP prepare creates and +validates its service/types/JSON-RPC expressions and attaches them to a +canonical registered root before normalization and core planning. Do not create +a render-time temporary root or a second core service plan. + +- [ ] **Step 3: Build one retained goa-ai plan per output package** + +Build agent IR and tool specification data once during plugin planning. Split +public-spec and transport-spec package owners. Retain typed declaration records +for every generated type, validator, constructor, tool variable, and union +family. Delete repeated `NewServicesData`/IR/spec builders, +`UnionTypeHash`-based companion keys, shared `NameScope` use across packages, +and any emitted-name reconstruction. + +- [ ] **Step 4: Render through exact core and plugin plans** + +Agent, MCP, and eval render callbacks accept `*generator.Plan` and their +factory-owned retained plugin plan. MCP consumes `Plan.Service(root)` and emits +only plugin-owned adapters or modifications; core service/JSON-RPC plans emit +the attached service declarations once. No plugin accesses a plan registry or +looks up a “latest” analysis. + +- [ ] **Step 5: Verify and commit Goa-ai** + +Use a disposable module replacement or the repository's established local Goa +development workflow without committing an unrelated replacement. Run: + +```bash +go fmt ./... +go test ./codegen/... ./eval/codegen/... -count=1 +go test ./... -run '^$' +git diff --check +``` + +Generate and compile the AURA-shaped goa-ai fixture. Record the Goa commit it +requires, commit the goa-ai changes separately, and open or update the goa-ai +pull request. + +### Task 12: Lossless merge, full regeneration, review, and publication + +**Files:** +- Modify: `codegen/generator/generate.go` +- Modify: `codegen/generator/generate_merge_test.go` +- Regenerate: `/Users/raphael/src/aura/gen` only through AURA generation scripts +- Update: Goa and goa-ai pull request descriptions + +**Interfaces:** +- Consumes: complete retained plans and package-owned declaration deduplication +- Produces: lossless same-path assembly and fully verified Goa, goa-ai, and AURA branches + +- [x] **Step 1: Make same-path file assembly lossless** + +Merge compatible headers and imports, then append every non-header section in +producer order. Never deduplicate by `SectionTemplate.Name`. Require all +same-path contributors to name the same canonical package identity; package +planning already owns declaration reuse and collision rejection. + +Run: + +```bash +go test ./codegen/generator -run TestMergeFilesPreservesSameLabelSections -count=1 +``` + +This was completed with Task 6 because lossless file assembly is part of the +common run lifecycle. Both same-label bodies are preserved, all contributor +finalizers run in order, and incompatible headers or output paths fail before +rendering. + +- [ ] **Step 2: Delete every superseded mechanism** + +Require these production searches to return no hits: + +```bash +rg -n 'Genfunc|renderOnly|NewServicesData|NewServicesDataForRoots|PlanKey|UnionTypeHash|unionRegistryKey|unionCompanionKey|userTypePkgs' \ + --glob '*.go' +rg -n 'var Generators|codegen\.RegisterPlugin|RunPluginsPlan|RunPluginsPrepare' \ + --glob '*.go' +``` + +Inspect every surviving `NewNameScope` in service, transport, example, and +goa-ai code. Retain it only when it owns lexical local names; no package-level +declaration or import may depend on it. Search every render function for root, +generated module path, and `Generation` inputs and remove remaining analysis +or output redirection. + +- [ ] **Step 3: Verify Goa completely** + +Run: + +```bash +go fmt ./... +go test ./... -count=1 +make lint +git diff --check +``` + +Expected: all pass with no skipped regression. + +- [ ] **Step 4: Verify goa-ai completely** + +Run in `/Users/raphael/src/goa-ai` against the final local Goa commit: + +```bash +go fmt ./... +go test ./... -count=1 +git diff --check +``` + +Expected: all pass. + +- [ ] **Step 5: Regenerate and verify AURA from scratch** + +Run in `/Users/raphael/src/aura`: + +```bash +./scripts/gen goa +./scripts/gen +cd gen && go test ./... -count=1 +``` + +Do not patch anything under `gen/`. Each generation command deletes and +recreates its owned output. Review the generated diff for unexpected public +name changes, then run the relevant AURA service/eval suites identified by +`docs/TROUBLESHOOT.md` and the original production-task reproduction. + +- [ ] **Step 6: Run independent whole-branch reviews** + +Review Goa and goa-ai against `codegen/ARCHITECTURE.md`. Require explicit +checks for every package-level symbol, exact/preferred collision policy, +output-path normalization, retained plan identity, protoc family/version +accuracy, repeated/concurrent runs, plugin root ownership, dead APIs, and +lossless merging. Fix every confirmed finding and repeat full verification. + +- [ ] **Step 7: Publish clear pull requests** + +Update the Goa PR in plain language: describe the invalid AURA validation +function reference, why separate analyses disagreed, the one-plan/one-name +rule, breaking plugin API, protoc proof, generated-source effects, and exact +verification commands. Update or create the goa-ai PR with its prepare-time MCP +attachment and retained spec-plan changes. Address every applicable GitHub +Copilot review comment before merge, push only verified commits, and keep both +PRs draft until dependent verification is green. + +## Final completion proof + +The work is complete only when all of these statements are true: + +- one fresh factory instance owns each core generator and plugin in each run; +- roots never change after preparation; +- one typed core plan retains exact typed subsystem plans; +- every emitted package-level symbol has one frozen `NameDeclaration`; +- every declaration and reference consumes that same record; +- no renderer reconstructs service, wire, protobuf, OpenAPI, example, or plugin analysis; +- protobuf Go names match the explicit supported real toolchain family; +- repeated and concurrent runs are isolated; +- same-path file contributions are lossless; +- Goa, goa-ai, and freshly regenerated AURA all compile and pass their tests; and +- independent review finds no registry, fallback, compatibility path, duplicate owner, or dead transitional API. diff --git a/dsl/api.go b/dsl/api.go index 9a47d14414..6dd9c9084a 100644 --- a/dsl/api.go +++ b/dsl/api.go @@ -1,3 +1,5 @@ +// This file defines the API-level DSL, including immutable configuration for +// the example streams created separately by each generation run. package dsl import ( @@ -157,8 +159,9 @@ func License(fn func()) { // // Randomizer must appear in an API expression. // -// Randomizer takes a single argument which is an implementation of -// expr.Randomizer. +// Randomizer takes a single argument which is an immutable +// expr.RandomizerFactory. The factory creates a fresh value stream for each +// code generation run. // // The default randomizer uses the API name as the seed, to get consistent // random examples. @@ -166,7 +169,7 @@ func License(fn func()) { // Example: // // var _ = API("divider", func() { -// Randomizer(expr.NewFakerRandomizer("different seed")) +// Randomizer(expr.NewFakerRandomizerFactory("different seed")) // }) // // There's also a deterministic randomizer which will only generate one example @@ -175,11 +178,15 @@ func License(fn func()) { // Example: // // var _ = API("divider", func() { -// Randomizer(expr.NewDeterministicRandomizer()) +// Randomizer(expr.NewDeterministicRandomizerFactory()) // }) -func Randomizer(randomizer expr.Randomizer) { +func Randomizer(factory expr.RandomizerFactory) { if s, ok := eval.Current().(*expr.APIExpr); ok { - s.ExampleGenerator = &expr.ExampleGenerator{Randomizer: randomizer} + if factory == nil { + eval.ReportError("Randomizer requires a non-nil randomizer factory") + return + } + s.RandomizerFactory = factory return } eval.IncompatibleDSL() diff --git a/dsl/attribute.go b/dsl/attribute.go index 0b8b21787b..dad555c338 100644 --- a/dsl/attribute.go +++ b/dsl/attribute.go @@ -138,7 +138,7 @@ func Attribute(name string, args ...any) { var attr *expr.AttributeExpr { if ref := parent.Find(name); ref != nil { - attr = expr.DupAtt(ref) + attr = expr.DupAttForDSL(ref) } dataType, description, fn := parseAttributeArgs(attr, args...) @@ -170,7 +170,7 @@ func Attribute(name string, args ...any) { } union := parent.Type.(*expr.Union) if _, ok := attr.Type.(expr.UserType); !ok { - att := expr.DupAtt(attr) + att := expr.DupAttForDSL(attr) attr.Type = &expr.UserTypeExpr{AttributeExpr: att, TypeName: union.TypeName + expr.Title(name)} } union.Values = append(union.Values, &expr.NamedAttributeExpr{Name: name, Attribute: attr}) @@ -302,9 +302,8 @@ func Default(def any) { eval.IncompatibleDSL() return } - if a.Type != nil && !a.Type.IsCompatible(def) { - eval.ReportError("default value %#v is incompatible with attribute of type %s", - def, expr.QualifiedTypeName(a.Type)) + if def == nil { + eval.ReportError("default value must not be nil") return } a.SetDefault(def) diff --git a/dsl/collection_of_test.go b/dsl/collection_of_test.go new file mode 100644 index 0000000000..20f01db673 --- /dev/null +++ b/dsl/collection_of_test.go @@ -0,0 +1,41 @@ +package dsl + +import ( + "testing" + + "github.com/stretchr/testify/assert" + + "goa.design/goa/v3/expr" +) + +// TestCollectionOfReusesGeneratedType verifies that CollectionOf reuses the +// generated collection type when it is called more than once for the same +// result type. The lookup is made with a canonical identifier, so a result type +// whose identifier carries a suffix such as "+json" must still match. Creating a +// second type makes two declarations ask for one generated Go name. +func TestCollectionOfReusesGeneratedType(t *testing.T) { + var first, second *expr.ResultTypeExpr + expr.RunDSL(t, func() { + rt := ResultType("application/vnd.item+json", func() { + Attributes(func() { + Attribute("id", expr.Int) + Required("id") + }) + }) + Service("svc", func() { + Method("first", func() { + first = CollectionOf(rt) + Result(first) + HTTP(func() { GET("/first") }) + }) + Method("second", func() { + second = CollectionOf(rt) + Result(second) + HTTP(func() { GET("/second") }) + }) + }) + }) + + assert.Same(t, first, second) + assert.Len(t, *expr.GeneratedResultTypes, 1) +} diff --git a/dsl/description.go b/dsl/description.go index c402211440..2ac7702d84 100644 --- a/dsl/description.go +++ b/dsl/description.go @@ -44,10 +44,14 @@ func Description(d string) { e.Description = d case *expr.HTTPResponseExpr: e.Description = d + case *expr.HTTPErrorExpr: + e.Response.Description = d case *expr.HTTPFileServerExpr: e.Description = d case *expr.GRPCResponseExpr: e.Description = d + case *expr.GRPCErrorExpr: + e.Response.Description = d case *expr.InterceptorExpr: e.Description = d case expr.DescriptionHolder: diff --git a/dsl/error.go b/dsl/error.go index 35bad61525..305d09ec67 100644 --- a/dsl/error.go +++ b/dsl/error.go @@ -1,3 +1,6 @@ +// This file defines service and method error DSL functions. Error declarations +// select service value contracts; transport mappings separately choose how +// those values are encoded by HTTP or gRPC. package dsl import ( @@ -62,6 +65,13 @@ const ( // the service methods) or Method expressions. Error may also appear under the API // expression to create reusable error definitions. // +// Error with only a name refers to an error with that name in the parent scope +// when one exists. A service may refer to an API error, and a method may refer +// to a service or API error. The reference uses the parent's complete error +// definition, including its type, validation, default values, description, and +// Temporary, Timeout, and Fault settings. Supplying any additional argument +// defines a separate error in the current scope instead. +// // See Attribute for details on the Error arguments. // // Example: @@ -84,7 +94,24 @@ const ( // }) // }) func Error(name string, args ...any) { + current := eval.Current() if len(args) == 0 { + switch actual := current.(type) { + case *expr.ServiceExpr: + if inherited := expr.Root.Error(name); inherited != nil { + actual.Errors = append(actual.Errors, inherited) + return + } + case *expr.MethodExpr: + inherited := actual.Service.Error(name) + if inherited == nil { + inherited = expr.Root.Error(name) + } + if inherited != nil { + actual.Errors = append(actual.Errors, inherited) + return + } + } args = []any{expr.ErrorResult} } dt, desc, fn := parseAttributeArgs(nil, args...) @@ -99,7 +126,7 @@ func Error(name string, args ...any) { att.Type = expr.ErrorResult } erro := &expr.ErrorExpr{AttributeExpr: att, Name: name} - switch actual := eval.Current().(type) { + switch actual := current.(type) { case *expr.APIExpr: expr.Root.Errors = append(expr.Root.Errors, erro) case *expr.ServiceExpr: diff --git a/dsl/grpc.go b/dsl/grpc.go index e494b4458d..03ea2df48b 100644 --- a/dsl/grpc.go +++ b/dsl/grpc.go @@ -1,3 +1,5 @@ +// This file defines the gRPC transport DSL for endpoint messages, metadata, +// status responses, streaming behavior, and protobuf field mappings. package dsl import ( @@ -92,7 +94,13 @@ const ( func GRPC(fn func()) { switch actual := eval.Current().(type) { case *expr.APIExpr: - eval.Execute(fn, expr.Root.API.GRPC) + previous := actual.GRPC.DSLFunc + actual.GRPC.DSLFunc = func() { + if previous != nil { + previous() + } + fn() + } case *expr.ServiceExpr: res := expr.Root.API.GRPC.ServiceFor(actual) res.DSLFunc = fn @@ -251,7 +259,10 @@ func Message(fn func()) { // typed stream frame rather than being rewritten into metadata. // // Metadata takes one argument of function type which lists the attributes -// that must be set in the request metadata instead of the message. +// that must be set in the request metadata instead of the message. Each +// selected attribute must have an effective primitive type or an array whose +// elements have an effective primitive type. Named aliases are accepted and +// converted to the native metadata value by generated client and server code. // If Metadata is set in the gRPC endpoint expression, it inherits the // attribute properties (description, type, meta, validations etc.) from the // method payload. @@ -302,6 +313,9 @@ func Metadata(fn func()) { // // Trailers takes one argument of function type which lists the attributes // that must be set in the trailer response metadata instead of the message. +// Each selected attribute must have an effective primitive type or an array +// whose elements have an effective primitive type. Named aliases are accepted +// and converted to the native metadata value by generated code. // If Trailers is set in the gRPC response expression, it inherits the // attribute properties (description, type, meta, validations etc.) from the // method result. @@ -339,6 +353,11 @@ func Trailers(fn func()) { if eval.Execute(fn, attr) { e.Trailers = expr.NewMappedAttributeExpr(attr) } + case *expr.GRPCErrorExpr: + attr := &expr.AttributeExpr{} + if eval.Execute(fn, attr) { + e.Response.Trailers = expr.NewMappedAttributeExpr(attr) + } default: eval.IncompatibleDSL() } diff --git a/dsl/headers.go b/dsl/headers.go index 8828cd6586..ff90da39ae 100644 --- a/dsl/headers.go +++ b/dsl/headers.go @@ -1,3 +1,5 @@ +// This file defines HTTP request and response header DSL and the shared entry +// point used to select gRPC response metadata fields. package dsl import ( @@ -9,7 +11,9 @@ import ( // When used in a HTTP expression, it groups a set of Header expressions and // makes it possible to list required headers using the Required function. // When used in a GRPC response expression, it defines the headers to be sent -// in the response metadata. +// in the response metadata. A gRPC response header must have an effective +// primitive type or an array whose elements have an effective primitive type; +// generated codecs convert named service aliases to and from native values. // // To define HTTP headers, Headers must appear in an Service HTTP expression // to define request headers common to all the service methods. Headers may @@ -69,6 +73,11 @@ func Headers(args any) { if eval.Execute(fn, attr) { e.Headers = expr.NewMappedAttributeExpr(attr) } + case *expr.GRPCErrorExpr: + attr := &expr.AttributeExpr{} + if eval.Execute(fn, attr) { + e.Response.Headers = expr.NewMappedAttributeExpr(attr) + } default: h := headers(eval.Current()) if h == nil { diff --git a/dsl/http.go b/dsl/http.go index 40fd5c90f1..66cfe0445b 100644 --- a/dsl/http.go +++ b/dsl/http.go @@ -154,7 +154,13 @@ func HTTP(fns ...func()) { } switch actual := eval.Current().(type) { case *expr.APIExpr: - eval.Execute(fn, expr.Root) + previous := actual.HTTP.DSLFunc + actual.HTTP.DSLFunc = func() { + if previous != nil { + previous() + } + eval.Execute(fn, expr.Root) + } case *expr.ServiceExpr: res := expr.Root.API.HTTP.ServiceFor(actual, expr.Root.API.HTTP) res.DSLFunc = fn @@ -369,11 +375,13 @@ func route(method, path string) *expr.RouteExpr { // Header must appear in the API HTTP or JSONRPC expression (to define request // headers common to all the API endpoints), a service HTTP or JSONRPC // expression (to define request headers common to all the service endpoints) a -// specific method HTTP or JSONRPC expression (to define request headers) or a -// Response expression (to define the response headers). Header may also appear +// specific method HTTP or JSONRPC expression (to define request headers) or an +// HTTP Response expression (to define response headers). Header may also appear // in a method GRPC expression (to define headers sent in message metadata), or // in a Response expression (to define headers sent in result metadata). Finally -// Header may also appear in a Headers expression. +// Header may also appear in a Headers expression. A JSONRPC Response cannot map +// a result or error field to an HTTP header because one HTTP response may carry +// several JSON-RPC messages. // // Header accepts the same arguments as the Attribute function. The header name // may define a mapping between the attribute name and the HTTP header name when @@ -415,8 +423,10 @@ func Header(name string, args ...any) { // Cookie must appear in the API HTTP or JSONRPC expression (to define request // cookies common to all the API endpoints), a service HTTP or JSONRPC // expression (to define request cookies common to all the service endpoints) a -// specific method HTTP or JSONRPC expression (to define request cookies) or a -// Response expression (to define the response cookies). +// specific method HTTP or JSONRPC expression (to define request cookies) or an +// HTTP Response expression (to define response cookies). A JSONRPC Response +// cannot map a result or error field to an HTTP cookie because one HTTP +// response may carry several JSON-RPC messages. // // Cookie accepts the same arguments as the Attribute function. The cookie name // may define a mapping between the attribute name and the cookie name. The @@ -487,8 +497,7 @@ func Cookie(name string, args ...any) { // }) // }) func CookieMaxAge(n int) { - _, ok := eval.Current().(*expr.HTTPResponseExpr) - if !ok { + if httpResponse(eval.Current()) == nil { eval.IncompatibleDSL() return } @@ -515,8 +524,7 @@ func CookieMaxAge(n int) { // }) // }) func CookieDomain(d string) { - _, ok := eval.Current().(*expr.HTTPResponseExpr) - if !ok { + if httpResponse(eval.Current()) == nil { eval.IncompatibleDSL() return } @@ -543,8 +551,7 @@ func CookieDomain(d string) { // }) // }) func CookiePath(p string) { - _, ok := eval.Current().(*expr.HTTPResponseExpr) - if !ok { + if httpResponse(eval.Current()) == nil { eval.IncompatibleDSL() return } @@ -570,8 +577,7 @@ func CookiePath(p string) { // }) // }) func CookieSecure() { - _, ok := eval.Current().(*expr.HTTPResponseExpr) - if !ok { + if httpResponse(eval.Current()) == nil { eval.IncompatibleDSL() return } @@ -597,8 +603,7 @@ func CookieSecure() { // }) // }) func CookieHTTPOnly() { - _, ok := eval.Current().(*expr.HTTPResponseExpr) - if !ok { + if httpResponse(eval.Current()) == nil { eval.IncompatibleDSL() return } @@ -625,8 +630,7 @@ func CookieHTTPOnly() { // }) // }) func CookieSameSite(s expr.CookieSameSiteValue) { - _, ok := eval.Current().(*expr.HTTPResponseExpr) - if !ok { + if httpResponse(eval.Current()) == nil { eval.IncompatibleDSL() return } @@ -783,6 +787,8 @@ func MapParams(args ...any) { // MIME multipart encoding as defined in RFC 2046. // // MultipartRequest must appear in a HTTP endpoint expression. +// At least one payload value must remain in the request body after path, +// query, header, and cookie mappings are applied. // // goa generates a custom encoder that writes the payload for requests made to // HTTP endpoints that use MultipartRequest. The generated encoder accept a @@ -790,11 +796,11 @@ func MapParams(args ...any) { // multipart content. The user provided function accepts a multipart writer // and a reference to the payload and is responsible for encoding the payload. // goa also generates a custom decoder that reads back the multipart content -// into the payload struct. The generated decoder also accepts a user provided -// function that takes a multipart reader and a reference to the payload struct -// as parameter. The user provided decoder is responsible for decoding the -// multipart content into the payload. The example command generates a default -// implementation for the user decoder and encoder. +// into the generated HTTP request body. The generated decoder accepts a user +// provided function that takes a multipart reader and a reference to that body +// as parameters. Goa validates the decoded body before it builds the service +// payload. The example command generates a default implementation for the user +// decoder and encoder. func MultipartRequest() { e, ok := eval.Current().(*expr.HTTPEndpointExpr) if !ok { @@ -932,7 +938,9 @@ func Body(args ...any) { } kind = "Request" case *expr.HTTPErrorExpr: - ref = e.AttributeExpr + if e.ErrorExpr != nil { + ref = e.AttributeExpr + } setter = func(att *expr.AttributeExpr) { if e.Response == nil { e.Response = &expr.HTTPResponseExpr{} @@ -979,7 +987,7 @@ func Body(args ...any) { eval.ReportError("%s type does not have an attribute named %#v", kind, a) return } - attr = expr.DupAtt(attr) + attr = expr.DupAttForDSL(attr) attr.AddMeta("origin:attribute", a) if rt, ok := attr.Type.(*expr.ResultTypeExpr); ok && expr.IsArray(rt.Type) { // If the attribute type is a result type collection add the type to the @@ -1092,8 +1100,8 @@ func CanonicalMethod(name string) { // }) // }) func Tag(name, value string) { - res, ok := eval.Current().(*expr.HTTPResponseExpr) - if !ok { + res := httpResponse(eval.Current()) + if res == nil { eval.IncompatibleDSL() return } @@ -1118,6 +1126,8 @@ func ContentType(typ string) { actual.ContentType = typ // deprecated case *expr.HTTPResponseExpr: actual.ContentType = typ + case *expr.HTTPErrorExpr: + actual.Response.ContentType = typ default: eval.IncompatibleDSL() } @@ -1147,6 +1157,11 @@ func headers(exp eval.Expression) *expr.MappedAttributeExpr { e.Headers = expr.NewEmptyMappedAttributeExpr() } return e.Headers + case *expr.HTTPErrorExpr: + if e.Response.Headers == nil { + e.Response.Headers = expr.NewEmptyMappedAttributeExpr() + } + return e.Response.Headers case *expr.MappedAttributeExpr: return e default: @@ -1178,6 +1193,11 @@ func cookies(exp eval.Expression) *expr.MappedAttributeExpr { e.Cookies = expr.NewEmptyMappedAttributeExpr() } return e.Cookies + case *expr.HTTPErrorExpr: + if e.Response.Cookies == nil { + e.Response.Cookies = expr.NewEmptyMappedAttributeExpr() + } + return e.Response.Cookies case *expr.MappedAttributeExpr: return e default: @@ -1215,6 +1235,20 @@ func params(exp eval.Expression) *expr.MappedAttributeExpr { // cookieAttribute initialize the current attribute metadata with the details of // a HTTP cookie attribute for use by the HTTP code generator. func cookieAttribute(name, value string) { - c := eval.Current().(*expr.HTTPResponseExpr).Cookies + c := httpResponse(eval.Current()).Cookies c.AddMeta("cookie:"+name, value) } + +// httpResponse returns the response changed by HTTP response DSL. Error +// callbacks describe their nested response while success callbacks use the +// response directly. +func httpResponse(current eval.Expression) *expr.HTTPResponseExpr { + switch actual := current.(type) { + case *expr.HTTPResponseExpr: + return actual + case *expr.HTTPErrorExpr: + return actual.Response + default: + return nil + } +} diff --git a/dsl/jsonrpc.go b/dsl/jsonrpc.go index c6d7240cb1..bac92a4f53 100644 --- a/dsl/jsonrpc.go +++ b/dsl/jsonrpc.go @@ -27,31 +27,34 @@ const ( // JSONRPC configures a service to use JSON-RPC 2.0 transport. // The generated code handles JSON-RPC protocol details: request parsing, method dispatch, // response formatting, and batch processing. All service JSON-RPC methods share -// a single HTTP endpoint and must use the same transport (HTTP, WebSocket or SSE). +// a single HTTP POST endpoint. Methods may stream results over Server-Sent Events. // // JSONRPC can be used at three levels: // -// - At the API level: JSONRPC maps service errors to standard JSON-RPC error -// codes. +// - At the API level: JSONRPC defines default error code mappings that +// services and methods may use. // - At the service level: JSONRPC sets the HTTP endpoint path for all // JSON-RPC methods in the service and defines common errors and their // error code mappings. -// - At the method level: JSONRPC configures how the request and response "id" -// fields are mapped to payload/result attributes and allows you to define -// method-specific error code mappings. Methods without Result() are automatically -// treated as notifications (no response expected). +// - At the method level: JSONRPC configures an optional request ID payload +// field, whether generated clients send a notification, and method-specific +// error code mappings. // // Request Handling: // // The generated code decodes the JSON-RPC "params" field into the method -// payload and the "id" field to the payload attribute specified by the ID -// function. +// payload. An object, map, array, or union keeps its JSON shape. A primitive, +// named primitive, byte slice, or Any value is carried as one positional value, +// for example ["hello"]. The "id" field is decoded separately into the direct +// payload field specified by ID. // -// Non-Streaming: +// Ordinary Requests and Notifications: // -// For non-streaming methods, if the result's ID attribute is not -// already set, the generated code automatically copies the request ID from the -// payload to the result's ID attribute. +// An ordinary request always includes an ID. If the payload does not define an +// ID field, the generated client creates one. Notification explicitly marks a +// method as one-way: the generated client omits the ID and does not decode a +// JSON-RPC response. A method with no result remains an ordinary request unless +// it calls Notification. // // Non-Streaming Batch Requests: // @@ -61,32 +64,20 @@ const ( // notifications), and marshal the responses into a single array of JSON-RPC // response objects in the HTTP response body. // -// WebSocket: -// -// For WebSocket transport, methods that use StreamingPayload() and/or StreamingResult() -// enable bidirectional streaming: each payload or result element is sent as a separate, -// complete JSON-RPC message over the WebSocket connection. When using WebSockets, all -// methods must use StreamingPayload() for their payload (if any) and StreamingResult() -// for their result (if any), because a single WebSocket connection is shared by all -// methods of a service and client. Non-streaming methods are not supported over WebSockets. -// -// WebSocket methods can have three patterns: -// - StreamingPayload() only: Client-to-server notifications (no response) -// - StreamingResult() only: Server-to-client notifications (no request ID, sent without client request) -// - Both StreamingPayload() and StreamingResult(): Bidirectional request/response streaming -// -// Server-side notifications (methods with StreamingResult() but no StreamingPayload()) are -// sent from the server to the client without an associated request ID, as they are not -// responses to client requests but rather server-initiated messages. -// // Server-Sent Events: // -// For Server-Sent Events (SSE), enable SSE by calling the ServerSentEvents() function -// within the JSONRPC expression. In this mode, each element of the result is sent as a -// separate JSON-RPC response within its own SSE event. The SSE id field is mapped to -// the result's ID attribute. Because all methods for a given service and client -// share the same HTTP endpoint, every method must use both StreamingResult() and -// ServerSentEvents() to ensure correct streaming behavior. +// A JSON-RPC method may stream results by defining StreamingResult() and calling +// ServerSentEvents() in its method-level JSONRPC expression. The client sends one +// JSON-RPC request. Each streamed value is sent as a complete JSON-RPC message in +// a separate SSE event. SSEEventData selects the value used for notification +// params. SSEEventID, SSEEventType, and SSEEventRetry map result fields to the +// corresponding outer SSE lines; Goa omits each line when it has no mapping. +// SSERequestID maps a payload field to the Last-Event-ID request header. A +// viewed streaming result cannot select one field with SSEEventData because the +// selected value would omit the view name needed by the client. JSON-RPC does +// not support StreamingPayload(), bidirectional streaming, or one method that +// defines both Result() and StreamingResult(). Use separate methods when +// clients need both a stream and a final resource. // // Using JSON-RPC with Other Transports: // @@ -94,15 +85,6 @@ const ( // For example, a method can have both standard HTTP or gRPC endpoints in addition // to a JSON-RPC endpoint. // -// Important WebSocket Limitation: -// -// A service cannot mix JSON-RPC WebSocket endpoints with pure HTTP WebSocket endpoints. -// This is because JSON-RPC WebSocket uses a single underlying WebSocket connection -// for all methods in the service, with method dispatch happening at the protocol level -// through JSON-RPC message routing. In contrast, pure HTTP WebSocket creates individual -// connections per streaming endpoint. These two approaches are fundamentally incompatible -// and cannot coexist in the same service. -// // Error Codes: // // Use the predefined constants for standard JSON-RPC errors: @@ -112,7 +94,7 @@ const ( // - RPCInvalidParams (-32602): Invalid method parameters // - RPCInternalError (-32603): Internal JSON-RPC error (default for unmapped errors) // -// Example - Complete service with request/notification handling and streaming: +// Example - Service with request and notification handling: // // Service("calc", func() { // Error("timeout", ErrTimeout, "Request timed out") // Define an error that all service methods can return @@ -123,6 +105,16 @@ const ( // }) // }) // +// Method("record", func() { +// Payload(func() { +// Attribute("message", String) +// Required("message") +// }) +// JSONRPC(func() { +// Notification() +// }) +// }) +// // Method("divide", func() { // Payload(func() { // ID("req_id") // Map request ID to payload field @@ -131,7 +123,6 @@ const ( // Required("dividend", "divisor") // }) // Result(func() { -// ID("req_id") // Map request ID to result field // Attribute("result", Float64) // }) // Error("div_zero", ErrorResult, "Division by zero") // Define method-specific error @@ -144,44 +135,6 @@ const ( // }) // }) // -// Example - WebSocket streaming service: -// -// Service("chat", func() { -// JSONRPC(func() { -// GET("/ws") // Use GET for WebSocket endpoint -// }) -// Method("send", func() { -// StreamingPayload(func() { -// Attribute("message", String, "Message to send") -// }) -// JSONRPC(func() { -// // Client-to-server notification (no response) -// }) -// }) -// Method("notify", func() { -// StreamingResult(func() { -// Attribute("event", String, "Server notification") -// Attribute("data", Any, "Notification data") -// }) -// JSONRPC(func() { -// // Server-to-client notification (no request ID, server-initiated) -// }) -// }) -// Method("echo", func() { -// StreamingPayload(func() { -// ID("req_id", String, "Request ID") -// Attribute("message", String, "Message to echo") -// }) -// StreamingResult(func() { -// ID("req_id", String, "Request ID") -// Attribute("echo", String, "Echoed message") -// }) -// JSONRPC(func() { -// // Bidirectional request/response streaming -// }) -// }) -// }) -// // Example - SSE streaming service: // // Service("updater", func() { @@ -194,13 +147,13 @@ const ( // Attribute("last_event_id", String, "ID of last event received by client") // }) // StreamingResult(func() { -// ID("id", String, "JSON-RPC request ID") +// Attribute("event_id", String, "Event ID") // Attribute("data", Data, "Event data") // }) // JSONRPC(func() { -// ServerSentEvents(func() { // Use SSE instead of WebSocket +// ServerSentEvents(func() { // Stream results as server-sent events // SSERequestID("last_event_id") // Map SSE Last-Event-ID header to payload "last_event_id" attribute -// SSEEventID("id") // Use "id" result attribute as SSE event ID +// SSEEventID("event_id") // Use "event_id" as the SSE event ID // }) // }) // }) @@ -208,7 +161,13 @@ const ( func JSONRPC(dsl func()) { switch actual := eval.Current().(type) { case *expr.APIExpr: - eval.Execute(dsl, actual.JSONRPC) + previous := actual.JSONRPC.DSLFunc + actual.JSONRPC.DSLFunc = func() { + if previous != nil { + previous() + } + dsl() + } case *expr.ServiceExpr: svc := expr.Root.API.JSONRPC.ServiceFor(actual, &expr.Root.API.JSONRPC.HTTPExpr) svc.DSLFunc = dsl @@ -240,16 +199,19 @@ func JSONRPC(dsl func()) { } } -// ID defines the payload or result attribute which is used as the JSON-RPC -// request ID. It must be of type String. It is an error to omit ID on a -// JSON-RPC endpoint payload or result unless the method is a notification (see -// Notification). +// ID defines the payload attribute used as the JSON-RPC request ID. It must be +// a string. When an ordinary request has no ID attribute, the generated client +// creates an ID. An optional field with no default is a pointer: nil makes the +// client create an ID, while a non-nil value, including an empty string, is sent +// exactly. A field with a default is a value, and an absent incoming ID uses +// that default. A required field with no default rejects an absent or null ID. +// Notifications cannot define an ID attribute. // -// Note: For non-streaming methods, the generated code will automatically copy -// the request ID from the payload to the result's ID attribute, unless the -// result's ID attribute is already set. +// The transport copies the exact request ID into every JSON-RPC response. A +// service result cannot define or replace it. // -// ID must appear in a Payload or Result expression. +// ID must appear as a direct field in a Payload expression. A payload may +// define at most one ID field. // // ID accepts the same arguments as the Attribute DSL function. // @@ -261,16 +223,36 @@ func JSONRPC(dsl func()) { // Attribute("expression", String, "Mathematical expression") // Required("request_id", "expression") // }) -// Result(func() { -// ID("request_id", String, "Unique request identifier") -// Attribute("result", Float64) -// Required("request_id", "result") -// }) -// JSONRPC(func() { -// POST("/") -// }) +// Result(Float64) +// JSONRPC(func() {}) // }) func ID(name string, args ...any) { args = useDSL(args, func() { Meta("jsonrpc:id", "") }) Attribute(name, args...) } + +// Notification makes generated clients call the JSON-RPC method without a +// request ID or a JSON-RPC response. The method may have a payload but cannot +// define a result, an ID field, or a stream. +// +// Notification must appear in a method-level JSONRPC expression. +// +// Example: +// +// Method("record", func() { +// Payload(func() { +// Attribute("message", String) +// Required("message") +// }) +// JSONRPC(func() { +// Notification() +// }) +// }) +func Notification() { + endpoint, ok := eval.Current().(*expr.HTTPEndpointExpr) + if !ok || !endpoint.IsJSONRPC() { + eval.IncompatibleDSL() + return + } + endpoint.JSONRPCNotification = true +} diff --git a/dsl/meta.go b/dsl/meta.go index 750f1b56ad..c20adae516 100644 --- a/dsl/meta.go +++ b/dsl/meta.go @@ -157,10 +157,13 @@ const DefaultProtoc = expr.DefaultProtoc // that command. The given command will have additional arguments appended and // is expected to behave similar to protoc. // -// Can be used to specify custom options or alternate implementations. The -// default command can be specified using DefaultProtoc. +// Goa always uses protoc-gen-go v1.36.12 and protoc-gen-go-grpc v1.6.2. It +// finds and checks both programs before code generation starts, then passes +// their absolute paths to the chosen compiler. A protoc:cmd value may add +// other compiler options, but it cannot use --plugin to replace either of +// these programs. The default compiler can be specified using DefaultProtoc. // -// // Use Go run to run a drop-in replacement for protoc. +// // Use Go run to run another compiler that accepts protoc arguments. // var _ = API("myapi", func() { // Meta("protoc:cmd", "go", "run", "github.com/duckbrain/goprotoc") // }) diff --git a/dsl/payload.go b/dsl/payload.go index 2e96fd8a37..8fc5abc3f4 100644 --- a/dsl/payload.go +++ b/dsl/payload.go @@ -88,9 +88,9 @@ func Payload(val any, args ...any) { // // The arguments to a StreamingPayload DSL is same as the Payload DSL. // -// StreamingPayload requires a transport that supports client-to-server streaming -// such as gRPC or WebSockets. When using HTTP or JSON-RPC transports, methods -// with StreamingPayload must use WebSockets (via GET endpoints). +// StreamingPayload requires a transport that supports client-to-server +// streaming. gRPC supports it directly. Ordinary HTTP methods use a WebSocket +// through a GET endpoint. JSON-RPC methods do not support StreamingPayload. // For gRPC methods that define both Payload and StreamingPayload, the ordinary // method payload is sent once as the initial typed stream frame and the // StreamingPayload values are sent as subsequent stream item frames. @@ -175,7 +175,7 @@ func methodDSL(m *expr.MethodExpr, suffix string, p any, args ...any) *expr.Attr // Do not duplicate type if it is not customized return &expr.AttributeExpr{Type: actual} } - dupped := expr.Dup(actual) + dupped := expr.DupForDSL(actual) att = &expr.AttributeExpr{Type: dupped} if f, ok := args[len(args)-1].(func()); ok { numreqs := 0 diff --git a/dsl/randomizer_test.go b/dsl/randomizer_test.go new file mode 100644 index 0000000000..e425183d88 --- /dev/null +++ b/dsl/randomizer_test.go @@ -0,0 +1,37 @@ +// This file verifies that the API DSL stores immutable example factory +// configuration instead of a mutable random stream. +package dsl + +import ( + "testing" + + "github.com/stretchr/testify/require" + + "goa.design/goa/v3/eval" + "goa.design/goa/v3/expr" +) + +func TestRandomizerStoresFactory(t *testing.T) { + factory := expr.NewDeterministicRandomizerFactory() + api := expr.NewAPIExpr("test", func() {}) + eval.Context = &eval.DSLContext{} + + eval.Execute(func() { + Randomizer(factory) + }, api) + + require.Empty(t, eval.Context.Errors) + require.Equal(t, factory, api.RandomizerFactory) +} + +func TestRandomizerRejectsNilFactory(t *testing.T) { + api := expr.NewAPIExpr("test", func() {}) + eval.Context = &eval.DSLContext{} + + eval.Execute(func() { + Randomizer(nil) + }, api) + + require.Len(t, eval.Context.Errors, 1) + require.Contains(t, eval.Context.Errors[0].Error(), "non-nil randomizer factory") +} diff --git a/dsl/response.go b/dsl/response.go index 24ccdd9c62..0e8563faab 100644 --- a/dsl/response.go +++ b/dsl/response.go @@ -35,9 +35,11 @@ import ( // JSON-RPC response structure. The JSON-RPC protocol defines a fixed envelope // with "id", "result" and "error" fields. // -// - Success responses: The entire method result is used as the "result" field -// - Error responses: Map errors to JSON-RPC error codes using Response -// - The response "id" automatically matches the request "id" if present +// - Success responses: The entire method result is used as the "result" field +// - Error responses: Map errors to JSON-RPC error codes using Response +// - The response "id" automatically matches the request "id" if present +// - Header and Cookie cannot appear in a JSON-RPC Response because a batch or +// stream may carry several messages in one HTTP response // // The valid invocations for successful response are thus: // @@ -178,12 +180,14 @@ func Response(val any, args ...any) { return } code, fn := parseResponseArgs(val, args...) - if code == 0 { + _, codeSet := val.(int) + if !codeSet { code = expr.StatusOK } resp := &expr.HTTPResponseExpr{ - StatusCode: code, - Parent: t, + StatusCode: code, + StatusCodeSet: codeSet, + Parent: t, } if fn != nil { eval.Execute(fn, resp) @@ -220,8 +224,14 @@ func Code(code int) { switch t := eval.Current().(type) { case *expr.HTTPResponseExpr: t.StatusCode = code + t.StatusCodeSet = true + case *expr.HTTPErrorExpr: + t.Response.StatusCode = code + t.Response.StatusCodeSet = true case *expr.GRPCResponseExpr: t.StatusCode = code + case *expr.GRPCErrorExpr: + t.Response.StatusCode = code default: eval.IncompatibleDSL() } @@ -247,13 +257,15 @@ func grpcError(n string, p eval.Expression, args ...any) *expr.GRPCErrorExpr { StatusCode: code, Parent: p, } - if fn != nil { - eval.Execute(fn, resp) + mapping := &expr.GRPCErrorExpr{ + ErrorExpr: errorForResponse(n, p), + Name: n, + Response: resp, } - return &expr.GRPCErrorExpr{ - Name: n, - Response: resp, + if fn != nil { + eval.Execute(fn, mapping) } + return mapping } func parseResponseArgs(val any, args ...any) (code int, fn func()) { @@ -298,18 +310,47 @@ func httpOrJSONRPCError(n string, p eval.Expression, args ...any) *expr.HTTPErro val = args[0] args = args[1:] code, fn = parseResponseArgs(val, args...) - if code == 0 { + _, codeSet := val.(int) + if !codeSet { code = expr.StatusBadRequest } resp := &expr.HTTPResponseExpr{ - StatusCode: code, - Parent: p, + StatusCode: code, + StatusCodeSet: codeSet, + Parent: p, + } + mapping := &expr.HTTPErrorExpr{ + ErrorExpr: errorForResponse(n, p), + Name: n, + Response: resp, + } + if !codeSet && mapping.IsJSONRPC() { + resp.StatusCode = expr.RPCInternalError } if fn != nil { - eval.Execute(fn, resp) + eval.Execute(fn, mapping) } - return &expr.HTTPErrorExpr{ - Name: n, - Response: resp, + return mapping +} + +// errorForResponse returns the declared error available where a transport +// response is defined. The response callback uses this error immediately, so +// inherited API and service mappings must resolve it before endpoint creation. +func errorForResponse(name string, parent eval.Expression) *expr.ErrorExpr { + switch actual := parent.(type) { + case *expr.HTTPEndpointExpr: + return actual.MethodExpr.Error(name) + case *expr.GRPCEndpointExpr: + return actual.MethodExpr.Error(name) + case *expr.HTTPServiceExpr: + return actual.Error(name) + case *expr.GRPCServiceExpr: + return actual.Error(name) + case *expr.RootExpr: + return actual.Error(name) + case *expr.HTTPExpr, *expr.JSONRPCExpr, *expr.GRPCExpr: + return expr.Root.Error(name) + default: + return nil } } diff --git a/dsl/result_type.go b/dsl/result_type.go index bb82848587..a796df713f 100644 --- a/dsl/result_type.go +++ b/dsl/result_type.go @@ -529,7 +529,7 @@ func buildView(name string, mt *expr.ResultTypeExpr, at *expr.AttributeExpr) (*e n := nat.Name cat := nat.Attribute if existing := mt.Find(n); existing != nil { - dup := expr.DupAtt(existing) + dup := expr.DupAttForDSL(existing) if v, ok := cat.Meta.Last(expr.ViewMetaKey); ok { dup.AddMeta("view", v) } diff --git a/dsl/security.go b/dsl/security.go index e8a938e79c..0c9942410a 100644 --- a/dsl/security.go +++ b/dsl/security.go @@ -401,6 +401,7 @@ func NoSecurity() { // // The generated code produced by goa uses the value of the corresponding // payload field to compute the basic authentication Authorization header value. +// The attribute must use String or a named String type and cannot have a default. // // Username must appear in Payload or Type. // @@ -438,6 +439,7 @@ func UsernameField(tag any, name string, args ...any) { // // The generated code produced by goa uses the value of the corresponding // payload field to compute the basic authentication Authorization header value. +// The attribute must use String or a named String type and cannot have a default. // // Password must appear in Payload or Type. // @@ -476,6 +478,7 @@ func PasswordField(tag any, name string, args ...any) { // // The generated code produced by goa uses the value of the corresponding // payload field to set the API key value. +// The attribute must use String or a named String type and cannot have a default. // // APIKey must appear in Payload or Type. // @@ -527,6 +530,7 @@ func APIKeyField(tag any, scheme, name string, args ...any) { // // The generated code produced by goa uses the value of the corresponding // payload field to initialize the Authorization header. +// The attribute must use String or a named String type and cannot have a default. // // AccessToken must appear in Payload or Type. // @@ -565,6 +569,7 @@ func AccessTokenField(tag any, name string, args ...any) { // // The generated code produced by goa uses the value of the corresponding // payload field to initialize the Authorization header. +// The attribute must use String or a named String type and cannot have a default. // // Example: // @@ -604,6 +609,7 @@ func BearerTokenField(tag any, name string, args ...any) { // // The generated code produced by goa uses the value of the corresponding // payload field to initialize the Authorization header. +// The attribute must use String or a named String type and cannot have a default. // // Example: // diff --git a/expr/api.go b/expr/api.go index 98afdcc4ed..b67dcd1149 100644 --- a/expr/api.go +++ b/expr/api.go @@ -1,3 +1,5 @@ +// This file defines the evaluated API expression and the immutable example +// randomizer configuration shared by independent code generation runs. package expr import ( @@ -47,8 +49,9 @@ type ( // JSONRPC contains the JSON-RPC specific API level expressions. JSONRPC *JSONRPCExpr - // random generator used to build examples for the API types. - ExampleGenerator *ExampleGenerator + // RandomizerFactory is the immutable configuration used to create a + // fresh example value stream for each code generation run. + RandomizerFactory RandomizerFactory } // ContactExpr contains the API contact information. @@ -116,12 +119,12 @@ type ( // NewAPIExpr initializes an API expression. func NewAPIExpr(name string, dsl func()) *APIExpr { return &APIExpr{ - Name: name, - HTTP: new(HTTPExpr), - GRPC: new(GRPCExpr), - JSONRPC: new(JSONRPCExpr), - DSLFunc: dsl, - ExampleGenerator: NewRandom(name), + Name: name, + HTTP: new(HTTPExpr), + GRPC: new(GRPCExpr), + JSONRPC: new(JSONRPCExpr), + DSLFunc: dsl, + RandomizerFactory: NewFakerRandomizerFactory(name), } } diff --git a/expr/attached_service.go b/expr/attached_service.go new file mode 100644 index 0000000000..d0a16390c9 --- /dev/null +++ b/expr/attached_service.go @@ -0,0 +1,239 @@ +// This file checks and finishes services that generators add after the design +// DSL has run. +package expr + +import ( + "fmt" + "slices" + + "goa.design/goa/v3/eval" +) + +// EvaluateAttachedServices prepares, checks, and finishes services that a +// generator added to r. The services and types must already belong to r. No +// service is finished unless every added expression is valid. +func (r *RootExpr) EvaluateAttachedServices(services []*ServiceExpr, types ...UserType) error { + sets, err := r.attachedServiceExpressions(services, types) + if err != nil { + return err + } + prepareExpressions(sets) + if err := validateExpressions(r, sets); err != nil { + return err + } + finalizeExpressions(sets) + return nil +} + +// attachedServiceExpressions verifies that each service and type belongs to r +// and that every endpoint points to a method on its service. It returns the +// expressions in the order that Prepare, Validate, and Finalize must run. +func (r *RootExpr) attachedServiceExpressions( + services []*ServiceExpr, + types []UserType, +) ([]eval.ExpressionSet, error) { + selected := make(map[*ServiceExpr]struct{}, len(services)) + methods := make(eval.ExpressionSet, 0) + for _, service := range services { + if !slices.Contains(r.Services, service) { + return nil, fmt.Errorf("service %q is not part of this design", service.Name) + } + if r.Service(service.Name) != service { + return nil, fmt.Errorf("service name %q is already used in this design", service.Name) + } + if _, ok := selected[service]; ok { + return nil, fmt.Errorf("service %q was provided more than once", service.Name) + } + selected[service] = struct{}{} + for _, method := range service.Methods { + if method.Service != service { + return nil, fmt.Errorf("method %q belongs to a different service", method.Name) + } + methods = append(methods, method) + } + } + + typeExpressions := make(eval.ExpressionSet, len(types)) + for index, userType := range types { + if !slices.Contains(r.Types, userType) { + return nil, fmt.Errorf("type %q is not part of this design", userType.Name()) + } + typeExpressions[index] = userType.Attribute() + } + + var ( + httpServices, httpEndpoints, httpFileServers eval.ExpressionSet + jsonrpcServices, jsonrpcEndpoints, jsonrpcFileServers eval.ExpressionSet + grpcServices, grpcEndpoints eval.ExpressionSet + err error + ) + if r.API.HTTP != nil { + httpServices, httpEndpoints, httpFileServers, err = collectHTTPExpressions( + r.API.HTTP.Services, + r.API.HTTP, + selected, + ) + if err != nil { + return nil, err + } + } + if r.API.JSONRPC != nil { + jsonrpcServices, jsonrpcEndpoints, jsonrpcFileServers, err = collectHTTPExpressions( + r.API.JSONRPC.Services, + &r.API.JSONRPC.HTTPExpr, + selected, + ) + if err != nil { + return nil, err + } + } + if r.API.GRPC != nil { + grpcServices, grpcEndpoints, err = collectGRPCExpressions(r.API.GRPC.Services, selected) + if err != nil { + return nil, err + } + } + + for service := range selected { + service.design = r + } + return []eval.ExpressionSet{ + typeExpressions, + eval.ToExpressionSet(services), + methods, + httpServices, + httpEndpoints, + httpFileServers, + jsonrpcServices, + jsonrpcEndpoints, + jsonrpcFileServers, + grpcServices, + grpcEndpoints, + }, nil +} + +// collectHTTPExpressions returns the selected HTTP services, endpoints, and +// file servers. It rejects a child that points to another service. +func collectHTTPExpressions( + transports []*HTTPServiceExpr, + httpRoot *HTTPExpr, + selected map[*ServiceExpr]struct{}, +) (eval.ExpressionSet, eval.ExpressionSet, eval.ExpressionSet, error) { + services := make(eval.ExpressionSet, 0) + endpoints := make(eval.ExpressionSet, 0) + fileServers := make(eval.ExpressionSet, 0) + for _, transport := range transports { + if _, ok := selected[transport.ServiceExpr]; !ok { + continue + } + if transport.Root != httpRoot { + return nil, nil, nil, fmt.Errorf("HTTP service %q uses a different design", transport.Name()) + } + services = append(services, transport) + for _, endpoint := range transport.HTTPEndpoints { + if endpoint.Service != transport { + return nil, nil, nil, fmt.Errorf( + "HTTP endpoint %q belongs to a different HTTP service", + endpoint.Name(), + ) + } + if !slices.Contains(transport.ServiceExpr.Methods, endpoint.MethodExpr) { + return nil, nil, nil, fmt.Errorf( + "HTTP endpoint %q uses a method outside service %q", + endpoint.Name(), + transport.Name(), + ) + } + endpoints = append(endpoints, endpoint) + } + for _, fileServer := range transport.FileServers { + if fileServer.Service != transport { + return nil, nil, nil, fmt.Errorf( + "HTTP file server %q belongs to a different HTTP service", + fileServer.FilePath, + ) + } + fileServers = append(fileServers, fileServer) + } + } + return services, endpoints, fileServers, nil +} + +// collectGRPCExpressions returns the selected gRPC services and endpoints and +// rejects any endpoint that points outside its service. +func collectGRPCExpressions( + transports []*GRPCServiceExpr, + selected map[*ServiceExpr]struct{}, +) (eval.ExpressionSet, eval.ExpressionSet, error) { + services := make(eval.ExpressionSet, 0) + endpoints := make(eval.ExpressionSet, 0) + for _, transport := range transports { + if _, ok := selected[transport.ServiceExpr]; !ok { + continue + } + services = append(services, transport) + for _, endpoint := range transport.GRPCEndpoints { + if endpoint.Service != transport { + return nil, nil, fmt.Errorf( + "gRPC endpoint %q belongs to a different gRPC service", + endpoint.Name(), + ) + } + if !slices.Contains(transport.ServiceExpr.Methods, endpoint.MethodExpr) { + return nil, nil, fmt.Errorf( + "gRPC endpoint %q uses a method outside service %q", + endpoint.Name(), + transport.Name(), + ) + } + endpoints = append(endpoints, endpoint) + } + } + return services, endpoints, nil +} + +// prepareExpressions calls Prepare on each added expression in Goa's required +// order. +func prepareExpressions(sets []eval.ExpressionSet) { + for _, set := range sets { + for _, expression := range set { + if preparer, ok := expression.(eval.Preparer); ok { + preparer.Prepare() + } + } + } +} + +// validateExpressions checks the complete design and each added expression. It +// returns all errors together. +func validateExpressions(root *RootExpr, sets []eval.ExpressionSet) error { + errors := new(eval.ValidationErrors) + if err := root.Validate(); err != nil { + errors.AddError(root, err) + } + for _, set := range sets { + for _, expression := range set { + if validator, ok := expression.(eval.Validator); ok { + if err := validator.Validate(); err != nil { + errors.AddError(expression, err) + } + } + } + } + if len(errors.Errors) > 0 { + return errors + } + return nil +} + +// finalizeExpressions calls Finalize on each added expression after every +// check succeeds. +func finalizeExpressions(sets []eval.ExpressionSet) { + for _, set := range sets { + for _, expression := range set { + if finalizer, ok := expression.(eval.Finalizer); ok { + finalizer.Finalize() + } + } + } +} diff --git a/expr/attached_service_test.go b/expr/attached_service_test.go new file mode 100644 index 0000000000..cb3987c539 --- /dev/null +++ b/expr/attached_service_test.go @@ -0,0 +1,283 @@ +// This file checks services that generators add after the design DSL has +// finished running. +package expr + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestEvaluateAttachedServicesUsesOwningRoot(t *testing.T) { + originalRoot := Root + t.Cleanup(func() { + Root = originalRoot + }) + owner := newRootExprForTest("owner_header") + other := newRootExprForTest("other_header") + Root = other + + service, _, endpoint := attachTestService(owner, "generated") + require.NoError(t, owner.EvaluateAttachedServices([]*ServiceExpr{service})) + + headers := AsObject(endpoint.Headers.Type) + require.NotNil(t, headers.Attribute("owner_header")) + require.Nil(t, headers.Attribute("other_header")) +} + +func TestEvaluateAttachedServicesDoesNotReadPackageRoot(t *testing.T) { + originalRoot := Root + t.Cleanup(func() { + Root = originalRoot + }) + owner := newRootExprForTest("owner_header") + service, _, endpoint := attachTestService(owner, "generated") + Root = nil + + require.NoError(t, owner.EvaluateAttachedServices([]*ServiceExpr{service})) + require.NotNil(t, AsObject(endpoint.Headers.Type).Attribute("owner_header")) +} + +func TestEvaluateAttachedJSONRPCErrorDoesNotReadPackageRoot(t *testing.T) { + originalRoot := Root + t.Cleanup(func() { + Root = originalRoot + }) + owner := &RootExpr{API: NewAPIExpr("test", func() {})} + service := attachTestJSONRPCService(owner, "generated", "/rpc") + serviceError := &ErrorExpr{ + AttributeExpr: &AttributeExpr{Type: ErrorResult}, + Name: "failed", + } + service.Errors = append(service.Errors, serviceError) + transport := owner.API.JSONRPC.Service(service.Name) + transport.HTTPErrors = append(transport.HTTPErrors, &HTTPErrorExpr{ + Name: "failed", + Response: &HTTPResponseExpr{ + StatusCode: RPCInternalError, + Parent: transport, + }, + }) + Root = nil + + require.NoError(t, owner.EvaluateAttachedServices([]*ServiceExpr{service})) +} + +func TestEvaluateAttachedGRPCServiceUsesOwningRootForAPIErrors(t *testing.T) { + originalRoot := Root + t.Cleanup(func() { + Root = originalRoot + }) + owner := newRootExprForTest("owner_header") + service := attachTestGRPCServiceWithAPIError(owner, "generated") + Root = nil + + require.NoError(t, owner.EvaluateAttachedServices([]*ServiceExpr{service})) +} + +func TestEvaluateAttachedGRPCServiceIgnoresPackageRootAPIErrors(t *testing.T) { + originalRoot := Root + t.Cleanup(func() { + Root = originalRoot + }) + owner := newRootExprForTest("owner_header") + service := attachTestGRPCServiceWithAPIError(owner, "generated") + other := newRootExprForTest("other_header") + other.Errors = append(other.Errors, &ErrorExpr{ + AttributeExpr: &AttributeExpr{Type: String}, + Name: "failed", + }) + Root = other + + require.NoError(t, owner.EvaluateAttachedServices([]*ServiceExpr{service})) +} + +func TestEvaluateAttachedServiceFinishesFileServerWithOwningRoot(t *testing.T) { + originalRoot := Root + t.Cleanup(func() { + Root = originalRoot + }) + owner := newRootExprForTest("owner_header") + owner.API.HTTP.Path = "/owner" + service, transport, _ := attachTestService(owner, "generated") + transport.Paths = []string{"/generated"} + fileServer := &HTTPFileServerExpr{ + Service: transport, + FilePath: "./public", + RequestPaths: []string{"/assets/{*path}"}, + } + transport.FileServers = append(transport.FileServers, fileServer) + Root = nil + + require.NoError(t, owner.EvaluateAttachedServices([]*ServiceExpr{service})) + require.Equal(t, []string{"/owner/generated/assets/{*path}"}, fileServer.RequestPaths) +} + +func TestEvaluateAttachedServiceIgnoresPackageRootForFileServer(t *testing.T) { + originalRoot := Root + t.Cleanup(func() { + Root = originalRoot + }) + owner := newRootExprForTest("owner_header") + owner.API.HTTP.Path = "/owner" + service, transport, _ := attachTestService(owner, "generated") + transport.Paths = []string{"/generated"} + fileServer := &HTTPFileServerExpr{ + Service: transport, + FilePath: "./public", + RequestPaths: []string{"/assets/{*path}"}, + } + transport.FileServers = append(transport.FileServers, fileServer) + other := newRootExprForTest("other_header") + other.API.HTTP.Path = "/other" + Root = other + + require.NoError(t, owner.EvaluateAttachedServices([]*ServiceExpr{service})) + require.Equal(t, []string{"/owner/generated/assets/{*path}"}, fileServer.RequestPaths) +} + +func TestEvaluateAttachedServicesChecksAllBeforeFinishing(t *testing.T) { + root := newRootExprForTest("owner_header") + first, firstTransport, _ := attachTestService(root, "first") + second, _, _ := attachTestService(root, "second") + second.Methods[0].Payload.Validation = &ValidationExpr{Required: []string{"missing"}} + + err := root.EvaluateAttachedServices([]*ServiceExpr{first, second}) + + require.Error(t, err) + require.Empty(t, firstTransport.Paths) +} + +func TestEvaluateAttachedServicesRejectsMixedHTTPRouteCollision(t *testing.T) { + root := newRootExprForTest("owner_header") + _, _, httpEndpoint := attachTestService(root, "ordinary") + httpEndpoint.Routes[0].Path = "/tasks/{taskID}" + jsonrpcService := attachTestJSONRPCService(root, "generated", "/tasks/{id}") + + err := root.EvaluateAttachedServices([]*ServiceExpr{jsonrpcService}) + + require.ErrorContains(t, err, "ordinary HTTP route POST \"/tasks/{taskID}\"") + require.ErrorContains(t, err, "JSON-RPC route POST \"/tasks/{id}\"") +} + +func TestEvaluateAttachedServicesRunsDifferentRootsTogether(t *testing.T) { + originalRoot := Root + t.Cleanup(func() { + Root = originalRoot + }) + firstRoot := newRootExprForTest("first_header") + firstService, _, firstEndpoint := attachTestService(firstRoot, "first") + secondRoot := newRootExprForTest("second_header") + secondService, _, secondEndpoint := attachTestService(secondRoot, "second") + Root = nil + + errors := make(chan error, 2) + go func() { + errors <- firstRoot.EvaluateAttachedServices([]*ServiceExpr{firstService}) + }() + go func() { + errors <- secondRoot.EvaluateAttachedServices([]*ServiceExpr{secondService}) + }() + require.NoError(t, <-errors) + require.NoError(t, <-errors) + + require.NotNil(t, AsObject(firstEndpoint.Headers.Type).Attribute("first_header")) + require.Nil(t, AsObject(firstEndpoint.Headers.Type).Attribute("second_header")) + require.NotNil(t, AsObject(secondEndpoint.Headers.Type).Attribute("second_header")) + require.Nil(t, AsObject(secondEndpoint.Headers.Type).Attribute("first_header")) +} + +// newRootExprForTest returns a design with one API header. +func newRootExprForTest(header string) *RootExpr { + api := NewAPIExpr("test", func() {}) + obj := &Object{} + obj.Set(header, &AttributeExpr{Type: String}) + api.HTTP.Headers = NewMappedAttributeExpr(&AttributeExpr{Type: obj}) + return &RootExpr{API: api} +} + +// attachTestService adds one HTTP service whose payload contains the API +// header used by the test. +func attachTestService(root *RootExpr, name string) (*ServiceExpr, *HTTPServiceExpr, *HTTPEndpointExpr) { + payload := &Object{} + for _, header := range *AsObject(root.API.HTTP.Headers.Type) { + payload.Set(header.Name, header.Attribute) + } + service := &ServiceExpr{Name: name} + method := &MethodExpr{ + Name: "run", + Payload: &AttributeExpr{Type: payload}, + Result: &AttributeExpr{Type: Empty}, + Service: service, + Stream: NoStreamKind, + } + service.Methods = []*MethodExpr{method} + root.Services = append(root.Services, service) + transport := root.API.HTTP.ServiceFor(service, root.API.HTTP) + endpoint := transport.EndpointFor(method) + endpoint.Routes = []*RouteExpr{{ + Method: "POST", + Path: "/run", + Endpoint: endpoint, + }} + return service, transport, endpoint +} + +// attachTestJSONRPCService adds one JSON-RPC method at routePath. +func attachTestJSONRPCService(root *RootExpr, name, routePath string) *ServiceExpr { + service := &ServiceExpr{Name: name} + method := &MethodExpr{ + Name: "run", + Payload: &AttributeExpr{Type: Empty}, + Result: &AttributeExpr{Type: Empty}, + Service: service, + Stream: NoStreamKind, + } + service.Methods = []*MethodExpr{method} + root.Services = append(root.Services, service) + transport := root.API.JSONRPC.ServiceFor(service, &root.API.JSONRPC.HTTPExpr) + endpoint := transport.EndpointFor(method) + endpoint.Routes = []*RouteExpr{{ + Method: "POST", + Path: routePath, + Endpoint: endpoint, + }} + return service +} + +// attachTestGRPCServiceWithAPIError adds one gRPC method that uses an error +// response defined for the complete API. +func attachTestGRPCServiceWithAPIError(root *RootExpr, name string) *ServiceExpr { + apiError := &ErrorExpr{ + AttributeExpr: &AttributeExpr{Type: ErrorResult}, + Name: "failed", + } + root.Errors = append(root.Errors, apiError) + response := &GRPCResponseExpr{ + StatusCode: 13, + Parent: root.API.GRPC, + } + response.Prepare() + root.API.GRPC.Errors = append(root.API.GRPC.Errors, &GRPCErrorExpr{ + Name: "failed", + Response: response, + }) + + service := &ServiceExpr{Name: name} + method := &MethodExpr{ + Name: "run", + Payload: &AttributeExpr{Type: Empty}, + Result: &AttributeExpr{Type: Empty}, + Errors: []*ErrorExpr{{ + AttributeExpr: &AttributeExpr{Type: ErrorResult}, + Name: "failed", + }}, + Service: service, + Stream: NoStreamKind, + } + service.Methods = []*MethodExpr{method} + root.Services = append(root.Services, service) + transport := root.API.GRPC.ServiceFor(service) + transport.EndpointFor(method.Name, method) + return service +} diff --git a/expr/attribute.go b/expr/attribute.go index 3a67e9b9c1..b80ec53af4 100644 --- a/expr/attribute.go +++ b/expr/attribute.go @@ -1,3 +1,6 @@ +// This file defines Goa attributes and their validation rules. It also lets +// transport and generated-type copies point back to the exact attribute +// written in the evaluated design. package expr import ( @@ -32,9 +35,12 @@ type ( DefaultValue any // UserExample set in DSL or computed in Finalize UserExamples []*ExampleExpr - // finalized is true if the attribute has been finalized - only - // applies if attribute type is an object + // finalized reports whether bases and references have already been + // applied to this attribute. finalized bool + // authored points to the first attribute copied from the evaluated + // design. It is nil while this value is that original attribute. + authored *AttributeExpr } // ExampleExpr represents an example. @@ -109,6 +115,15 @@ type ( CookieSameSiteValue string ) +// AuthoredAttribute returns the first attribute from which a was copied. It +// returns a when a was written directly in the evaluated design. +func (a *AttributeExpr) AuthoredAttribute() *AttributeExpr { + if a.authored != nil { + return a.authored + } + return a +} + const ( // FormatDate describes RFC3339 date values. FormatDate ValidationFormat = "date" @@ -160,9 +175,6 @@ const ( CookieSameSiteDefault CookieSameSiteValue = "default" ) -// validated keeps track of validated attributes to handle cyclical definitions. -var validated = make(map[*AttributeExpr]bool) - // TaggedAttribute returns the name of the child attribute of a with the given // tag if a is an object. func TaggedAttribute(a *AttributeExpr, tag string) string { @@ -237,10 +249,21 @@ func (a *AttributeExpr) EvalName() string { // 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] { + return a.validate(ctx, parent, make(map[*AttributeExpr]struct{}), true) +} + +// validate checks attributes reached from a. The map stops a type that refers +// to itself from being checked forever. +func (a *AttributeExpr) validate( + ctx string, + parent eval.Expression, + visited map[*AttributeExpr]struct{}, + validateDefaults bool, +) *eval.ValidationErrors { + if _, ok := visited[a]; ok { return nil } - validated[a] = true + visited[a] = struct{}{} verr := new(eval.ValidationErrors) if a.Type == nil { verr.Add(parent, "attribute type is nil") @@ -249,11 +272,17 @@ func (a *AttributeExpr) Validate(ctx string, parent eval.Expression) *eval.Valid if ctx != "" { ctx += " - " } - verr.Merge(a.validateEnumDefault(ctx, parent)) + if validateDefaults { + verr.Merge(a.validateDefaultValue(ctx, parent)) + } if v := a.Validation; v != nil { verr.Merge(v.Validate(ctx, parent)) } verr.Merge(a.validateExamples(ctx, parent)) + childDefaults := validateDefaults + if _, named := a.Type.(UserType); named { + childDefaults = false + } if o := AsObject(a.Type); o != nil { for _, n := range a.AllRequired() { if a.Find(n) == nil { @@ -269,14 +298,17 @@ func (a *AttributeExpr) Validate(ctx string, parent eval.Expression) *eval.Valid for _, nat := range *o { verr.Merge(a.validatePkgPath(pkgPath, nat.Attribute.Type)) ctx = fmt.Sprintf("field %s", nat.Name) - verr.Merge(nat.Attribute.Validate(ctx, parent)) + verr.Merge(nat.Attribute.validate(ctx, parent, visited, childDefaults)) } } else if ar := AsArray(a.Type); ar != nil { elemType := ar.ElemType - verr.Merge(elemType.Validate(ctx, a)) + verr.Merge(elemType.validate(ctx, a, visited, childDefaults)) + } else if mapped := AsMap(a.Type); mapped != nil { + verr.Merge(mapped.KeyType.validate(ctx, a, visited, childDefaults)) + verr.Merge(mapped.ElemType.validate(ctx, a, visited, childDefaults)) } else if u := AsUnion(a.Type); u != nil { for _, ut := range u.Values { - verr.Merge(ut.Attribute.Validate(ctx, parent)) + verr.Merge(ut.Attribute.validate(ctx, parent, visited, childDefaults)) } } @@ -743,32 +775,6 @@ func (a *AttributeExpr) debug(prefix string, seen map[*AttributeExpr]int, indent } } -// validateEnumDefault makes sure that the attribute default value is one of the -// enum values. -func (a *AttributeExpr) validateEnumDefault(ctx string, parent eval.Expression) *eval.ValidationErrors { - //TODO: We only do the default value and enum check just for primitive types. - if _, ok := a.Type.(Primitive); !ok { - return nil - } - verr := new(eval.ValidationErrors) - if a.DefaultValue != nil && a.Validation != nil && a.Validation.Values != nil { - var found bool - if slices.Contains(a.Validation.Values, a.DefaultValue) { - found = true - } - if !found { - verr.Add( - parent, - "%sdefault value %#v is not one of the accepted values: %#v", - ctx, - a.DefaultValue, - a.Validation.Values, - ) - } - } - return verr -} - // validateExamples makes sure that the attribute example values are compatible // with the attribute type. func (a *AttributeExpr) validateExamples(ctx string, parent eval.Expression) *eval.ValidationErrors { diff --git a/expr/attribute_graph_copier.go b/expr/attribute_graph_copier.go new file mode 100644 index 0000000000..c65c592ebe --- /dev/null +++ b/expr/attribute_graph_copier.go @@ -0,0 +1,335 @@ +// This file copies complete attribute graphs for generators that must keep a +// private expression snapshot and still resolve every copied node back to the +// exact input node used during planning. +package expr + +import ( + "fmt" + "reflect" + "slices" +) + +type ( + // AttributeGraphCopier copies one connected attribute graph without merging + // distinct user types or breaking recursive links. Copies retain the authored + // declaration and finalized state of every attribute. Reuse one copier for + // every root that must share copied nodes. + AttributeGraphCopier struct { + attributes map[*AttributeExpr]*AttributeExpr + originals map[*AttributeExpr]*AttributeExpr + types map[DataType]DataType + } + + // attributeValueReference identifies mutable data on the active copy path. + // Slice length and capacity distinguish separate views of one array. + attributeValueReference struct { + typeOf reflect.Type + pointer uintptr + length int + capacity int + } +) + +// NewAttributeGraphCopier creates a copier for one expression graph. Call Copy +// for each root that belongs to that graph, then use Original when a generator +// must resolve a copied node through declarations planned from the input graph. +func NewAttributeGraphCopier() *AttributeGraphCopier { + return &AttributeGraphCopier{ + attributes: make(map[*AttributeExpr]*AttributeExpr), + originals: make(map[*AttributeExpr]*AttributeExpr), + types: make(map[DataType]DataType), + } +} + +// Copy returns a deep copy of attribute. Shared and recursive nodes remain +// shared in the result. Calling Copy with a result made by this copier returns +// that result unchanged. +func (c *AttributeGraphCopier) Copy(attribute *AttributeExpr) *AttributeExpr { + if attribute == nil { + return nil + } + if _, copied := c.originals[attribute]; copied { + return attribute + } + if copied, ok := c.attributes[attribute]; ok { + return copied + } + copied := &AttributeExpr{ + Description: attribute.Description, + DSLFunc: attribute.DSLFunc, + finalized: attribute.finalized, + authored: attribute.AuthoredAttribute(), + } + c.attributes[attribute] = copied + c.originals[copied] = attribute + copied.Type = c.dataType(attribute.Type) + copied.Bases = c.dataTypes(attribute.Bases) + copied.References = c.dataTypes(attribute.References) + if attribute.Docs != nil { + docs := *attribute.Docs + copied.Docs = &docs + } + if attribute.Validation != nil { + copied.Validation = copyAttributeValidation(attribute.Validation) + } + copied.Meta = copyAttributeMeta(attribute.Meta) + copied.DefaultValue = copyAttributeValue(attribute.DefaultValue) + if len(attribute.UserExamples) > 0 { + copied.UserExamples = make([]*ExampleExpr, len(attribute.UserExamples)) + for index, example := range attribute.UserExamples { + if example == nil { + continue + } + copiedExample := *example + copiedExample.Value = copyAttributeValue(example.Value) + copied.UserExamples[index] = &copiedExample + } + } + return copied +} + +// Original returns the input node copied to create attribute. It returns +// attribute unchanged when this copier did not create it. +func (c *AttributeGraphCopier) Original(attribute *AttributeExpr) *AttributeExpr { + if original := c.originals[attribute]; original != nil { + return original + } + return attribute +} + +// dataTypes copies a list through the same graph so repeated and recursive +// declarations remain shared. +func (c *AttributeGraphCopier) dataTypes(dataTypes []DataType) []DataType { + if dataTypes == nil { + return nil + } + copied := make([]DataType, len(dataTypes)) + for index, dataType := range dataTypes { + copied[index] = c.dataType(dataType) + } + return copied +} + +// dataType installs each new type before copying its children so a recursive +// child points back to the same copied type. +func (c *AttributeGraphCopier) dataType(dataType DataType) DataType { + if dataType == nil || dataType == Empty { + return dataType + } + if copied, ok := c.types[dataType]; ok { + return copied + } + switch actual := dataType.(type) { + case Primitive: + return actual + case *Array: + copied := &Array{NonNullableElems: actual.NonNullableElems} + c.types[dataType] = copied + copied.ElemType = c.Copy(actual.ElemType) + return copied + case *Object: + copied := &Object{} + c.types[dataType] = copied + for _, named := range *actual { + copied.Set(named.Name, c.Copy(named.Attribute)) + } + return copied + case *Map: + copied := &Map{} + c.types[dataType] = copied + copied.KeyType = c.Copy(actual.KeyType) + copied.ElemType = c.Copy(actual.ElemType) + return copied + case *Union: + copied := &Union{ + TypeName: actual.TypeName, + TypeKey: actual.TypeKey, + ValueKey: actual.ValueKey, + Values: make([]*NamedAttributeExpr, len(actual.Values)), + } + c.types[dataType] = copied + for index, named := range actual.Values { + copied.Values[index] = &NamedAttributeExpr{ + Name: named.Name, + Attribute: c.Copy(named.Attribute), + } + } + return copied + case *ResultTypeExpr: + copied := actual.Dup(nil).(*ResultTypeExpr) + copied.ContentType = actual.ContentType + copied.Views = make([]*ViewExpr, len(actual.Views)) + c.types[dataType] = copied + copied.SetAttribute(c.Copy(actual.Attribute())) + for index, view := range actual.Views { + copied.Views[index] = &ViewExpr{ + AttributeExpr: c.Copy(view.AttributeExpr), + Name: view.Name, + Parent: copied, + } + } + return copied + case UserType: + copied := actual.Dup(nil) + c.types[dataType] = copied + copied.SetAttribute(c.Copy(actual.Attribute())) + return copied + default: + panic(fmt.Sprintf("cannot copy attribute type %T", dataType)) // bug + } +} + +// copyAttributeValidation copies every slice, pointer, and value held by one +// validation so changing the copy cannot change the input graph. +func copyAttributeValidation(validation *ValidationExpr) *ValidationExpr { + copied := validation.Dup() + copied.Values = copyAttributeValue(validation.Values).([]any) + copied.ExclusiveMinimum = dupFloat(validation.ExclusiveMinimum) + copied.Minimum = dupFloat(validation.Minimum) + copied.Maximum = dupFloat(validation.Maximum) + copied.ExclusiveMaximum = dupFloat(validation.ExclusiveMaximum) + copied.MinLength = dupInt(validation.MinLength) + copied.MaxLength = dupInt(validation.MaxLength) + return copied +} + +// copyAttributeMeta copies both the metadata map and each value slice. +func copyAttributeMeta(meta MetaExpr) MetaExpr { + if meta == nil { + return nil + } + copied := meta.Dup() + for name, values := range copied { + copied[name] = slices.Clone(values) + } + return copied +} + +// copyAttributeValue copies values used by defaults, validations, and examples +// without changing their concrete Go type. +func copyAttributeValue(value any) any { + if value == nil { + return nil + } + return copyAttributeReflectValue( + reflect.ValueOf(value), + make(map[attributeValueReference]struct{}), + ).Interface() +} + +// copyAttributeReflectValue copies every mutable field reachable from value. +// Unsupported mutable values are rejected instead of being shared. +func copyAttributeReflectValue(value reflect.Value, active map[attributeValueReference]struct{}) reflect.Value { + switch value.Kind() { + case reflect.Interface: + if value.IsNil() { + return reflect.Zero(value.Type()) + } + copied := reflect.New(value.Type()).Elem() + copied.Set(copyAttributeReflectValue(value.Elem(), active)) + return copied + case reflect.Pointer: + if value.IsNil() { + return reflect.Zero(value.Type()) + } + reference := enterAttributeValue(value, active) + defer delete(active, reference) + copied := reflect.New(value.Type().Elem()) + copied.Elem().Set(copyAttributeReflectValue(value.Elem(), active)) + return copied + case reflect.Slice: + if value.IsNil() { + return reflect.Zero(value.Type()) + } + reference := enterAttributeValue(value, active) + defer delete(active, reference) + copied := reflect.MakeSlice(value.Type(), value.Len(), value.Len()) + for index := range value.Len() { + copied.Index(index).Set(copyAttributeReflectValue(value.Index(index), active)) + } + return copied + case reflect.Map: + if value.IsNil() { + return reflect.Zero(value.Type()) + } + reference := enterAttributeValue(value, active) + defer delete(active, reference) + copied := reflect.MakeMapWithSize(value.Type(), value.Len()) + iterator := value.MapRange() + for iterator.Next() { + copied.SetMapIndex( + copyAttributeReflectValue(iterator.Key(), active), + copyAttributeReflectValue(iterator.Value(), active), + ) + } + return copied + case reflect.Array: + copied := reflect.New(value.Type()).Elem() + for index := range value.Len() { + copied.Index(index).Set(copyAttributeReflectValue(value.Index(index), active)) + } + return copied + case reflect.Struct: + copied := reflect.New(value.Type()).Elem() + copied.Set(value) + for index := range value.NumField() { + field := value.Type().Field(index) + if !field.IsExported() { + if attributeTypeContainsReference(field.Type) { + panic(fmt.Sprintf( + "cannot copy attribute value of type %s: unexported field %s contains mutable data", + value.Type(), + field.Name, + )) + } + continue + } + copied.Field(index).Set(copyAttributeReflectValue(value.Field(index), active)) + } + return copied + case reflect.Bool, reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, + reflect.Int64, reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, + reflect.Uint64, reflect.Uintptr, reflect.Float32, reflect.Float64, + reflect.Complex64, reflect.Complex128, reflect.String: + return value + default: + panic(fmt.Sprintf("cannot copy attribute value of type %s", value.Type())) + } +} + +// enterAttributeValue rejects a value that points back to one already being +// copied. Repeated values outside the active path are copied separately. +func enterAttributeValue(value reflect.Value, active map[attributeValueReference]struct{}) attributeValueReference { + reference := attributeValueReference{ + typeOf: value.Type(), + pointer: value.Pointer(), + } + if value.Kind() == reflect.Slice { + reference.length = value.Len() + reference.capacity = value.Cap() + } + if _, exists := active[reference]; exists { + panic(fmt.Sprintf("cannot copy cyclic attribute value of type %s", value.Type())) + } + active[reference] = struct{}{} + return reference +} + +// attributeTypeContainsReference reports whether a private field could share +// mutable state with the input value. +func attributeTypeContainsReference(valueType reflect.Type) bool { + switch valueType.Kind() { + case reflect.Slice, reflect.Map, reflect.Pointer, reflect.Interface, + reflect.Func, reflect.Chan, reflect.UnsafePointer: + return true + case reflect.Array: + return attributeTypeContainsReference(valueType.Elem()) + case reflect.Struct: + for index := range valueType.NumField() { + if attributeTypeContainsReference(valueType.Field(index).Type) { + return true + } + } + } + return false +} diff --git a/expr/attribute_graph_copier_test.go b/expr/attribute_graph_copier_test.go new file mode 100644 index 0000000000..fd0a2e4739 --- /dev/null +++ b/expr/attribute_graph_copier_test.go @@ -0,0 +1,228 @@ +// This file verifies that private expression snapshots remain exact copies of +// the authored graph while callers may safely mutate either graph afterward. +package expr + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +type ( + attributeGraphPublicValue struct { + Values []string + } + + attributeGraphPrivateValue struct { + values []string + } +) + +func TestAttributeGraphCopierPreservesCycles(t *testing.T) { + node := &UserTypeExpr{TypeName: "Node", UID: "node"} + nodeObject := &Object{} + node.SetAttribute(&AttributeExpr{Type: nodeObject}) + nodeObject.Set("next", &AttributeExpr{Type: node}) + original := &AttributeExpr{Type: node} + + copied := NewAttributeGraphCopier().Copy(original) + copiedNode := copied.Type.(UserType) + copiedNext := AsObject(copiedNode).Attribute("next").Type.(UserType) + + require.NotSame(t, node, copiedNode) + require.Same(t, copiedNode, copiedNext) +} + +func TestAttributeGraphCopierKeepsDistinctCopiesOfOneUserType(t *testing.T) { + authored := &UserTypeExpr{ + AttributeExpr: &AttributeExpr{Type: String}, + TypeName: "Value", + UID: "value", + } + first := authored.Dup(&AttributeExpr{Type: String}) + second := authored.Dup(&AttributeExpr{Type: Int}) + original := &AttributeExpr{Type: &Object{ + {Name: "first", Attribute: &AttributeExpr{Type: first}}, + {Name: "second", Attribute: &AttributeExpr{Type: second}}, + }} + + copied := NewAttributeGraphCopier().Copy(original) + copiedObject := copied.Type.(*Object) + copiedFirst := copiedObject.Attribute("first").Type.(UserType) + copiedSecond := copiedObject.Attribute("second").Type.(UserType) + + require.NotSame(t, copiedFirst, copiedSecond) + require.Same(t, authored, copiedFirst.Origin()) + require.Same(t, authored, copiedSecond.Origin()) + require.Equal(t, String, copiedFirst.Attribute().Type) + require.Equal(t, Int, copiedSecond.Attribute().Type) +} + +func TestAttributeGraphCopierDetachesResultTypeViews(t *testing.T) { + viewAttribute := &AttributeExpr{ + Type: &Object{{ + Name: "name", + Attribute: &AttributeExpr{ + Type: String, + Description: "input field", + }, + }}, + Description: "input view", + Meta: MetaExpr{"marker": {"input"}}, + } + result := NewResultTypeExpr("Item", "application/vnd.item", nil) + result.ContentType = "application/json" + result.Views = []*ViewExpr{{ + AttributeExpr: viewAttribute, + Name: "default", + Parent: result, + }} + input := &AttributeExpr{Type: result} + + working := NewAttributeGraphCopier().Copy(input).Type.(*ResultTypeExpr) + baseline := NewAttributeGraphCopier().Copy(&AttributeExpr{Type: working}).Type.(*ResultTypeExpr) + workingView := working.Views[0] + baselineView := baseline.Views[0] + + require.Equal(t, "application/json", working.ContentType) + require.Equal(t, "application/json", baseline.ContentType) + require.NotSame(t, result, working) + require.NotSame(t, result.Views[0], workingView) + require.NotSame(t, result.Views[0].AttributeExpr, workingView.AttributeExpr) + require.NotSame(t, workingView, baselineView) + require.NotSame(t, workingView.AttributeExpr, baselineView.AttributeExpr) + require.Same(t, working, workingView.Parent) + require.Same(t, baseline, baselineView.Parent) + + working.ContentType = "text/plain" + workingView.Name = "working" + workingView.Description = "working view" + workingView.Meta["marker"][0] = "working" + AsObject(workingView.Type).Attribute("name").Description = "working field" + + require.Equal(t, "application/json", result.ContentType) + require.Equal(t, "default", result.Views[0].Name) + require.Equal(t, "input view", result.Views[0].Description) + require.Equal(t, "input", result.Views[0].Meta["marker"][0]) + require.Equal(t, "input field", AsObject(result.Views[0].Type).Attribute("name").Description) + require.Equal(t, "application/json", baseline.ContentType) + require.Equal(t, "default", baselineView.Name) + require.Equal(t, "input view", baselineView.Description) + require.Equal(t, "input", baselineView.Meta["marker"][0]) + require.Equal(t, "input field", AsObject(baselineView.Type).Attribute("name").Description) +} + +func TestAttributeGraphCopierMapsCopiesToOriginals(t *testing.T) { + originalChild := &AttributeExpr{Type: String} + original := &AttributeExpr{ + Type: &Object{{Name: "child", Attribute: originalChild}}, + finalized: true, + } + copier := NewAttributeGraphCopier() + + copied := copier.Copy(original) + copiedChild := copied.Type.(*Object).Attribute("child") + + require.NotSame(t, original, copied) + require.Same(t, copied, copier.Copy(copied)) + require.Same(t, original, copier.Original(copied)) + require.Same(t, originalChild, copier.Original(copiedChild)) + require.Same(t, original, copied.AuthoredAttribute()) + require.Same(t, originalChild, copiedChild.AuthoredAttribute()) + require.True(t, copied.finalized) +} + +func TestAttributeGraphCopierDetachesMutableValues(t *testing.T) { + minimum := 1.0 + maximumLength := 3 + original := &AttributeExpr{ + Type: String, + Docs: &DocsExpr{Description: "original"}, + Validation: &ValidationExpr{ + Values: []any{map[string]any{"items": []string{"first"}}}, + Minimum: &minimum, + MaxLength: &maximumLength, + Required: []string{"value"}, + }, + Meta: MetaExpr{"struct:tag": {"one", "two"}}, + DefaultValue: map[string]any{"items": []string{"first"}}, + UserExamples: []*ExampleExpr{{Value: map[string]any{"items": []string{"first"}}}}, + } + + copied := NewAttributeGraphCopier().Copy(original) + copied.Docs.Description = "copied" + copied.Validation.Values[0].(map[string]any)["items"].([]string)[0] = "copied" + *copied.Validation.Minimum = 2 + *copied.Validation.MaxLength = 4 + copied.Validation.Required[0] = "copied" + copied.Meta["struct:tag"][0] = "copied" + copied.DefaultValue.(map[string]any)["items"].([]string)[0] = "copied" + copied.UserExamples[0].Value.(map[string]any)["items"].([]string)[0] = "copied" + + require.Equal(t, "original", original.Docs.Description) + require.Equal(t, "first", original.Validation.Values[0].(map[string]any)["items"].([]string)[0]) + require.Equal(t, 1.0, *original.Validation.Minimum) + require.Equal(t, 3, *original.Validation.MaxLength) + require.Equal(t, "value", original.Validation.Required[0]) + require.Equal(t, "one", original.Meta["struct:tag"][0]) + require.Equal(t, "first", original.DefaultValue.(map[string]any)["items"].([]string)[0]) + require.Equal(t, "first", original.UserExamples[0].Value.(map[string]any)["items"].([]string)[0]) +} + +func TestAttributeGraphCopierCopiesTypedValues(t *testing.T) { + values := []string{"original"} + object := attributeGraphPublicValue{Values: []string{"original"}} + original := &AttributeExpr{Type: String, DefaultValue: [2]any{&values, object}} + + copied := NewAttributeGraphCopier().Copy(original) + values[0] = "changed" + object.Values[0] = "changed" + copiedValues := copied.DefaultValue.([2]any) + + require.Equal(t, "original", (*copiedValues[0].(*[]string))[0]) + require.Equal(t, "original", copiedValues[1].(attributeGraphPublicValue).Values[0]) +} + +func TestAttributeGraphCopierRejectsUnsupportedMutableValues(t *testing.T) { + tests := []struct { + name string + value any + message string + }{ + { + name: "channel", + value: make(chan int), + message: "cannot copy attribute value of type chan int", + }, + { + name: "private mutable field", + value: attributeGraphPrivateValue{values: []string{"value"}}, + message: "cannot copy attribute value of type expr.attributeGraphPrivateValue: unexported field values contains mutable data", + }, + { + name: "function", + value: func() {}, + message: "cannot copy attribute value of type func()", + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + require.PanicsWithValue(t, test.message, func() { + NewAttributeGraphCopier().Copy(&AttributeExpr{Type: String, DefaultValue: test.value}) + }) + }) + } +} + +func TestAttributeGraphCopierRejectsCyclicMutableValue(t *testing.T) { + value := make(map[string]any) + value["self"] = value + + require.PanicsWithValue( + t, + "cannot copy cyclic attribute value of type map[string]interface {}", + func() { + NewAttributeGraphCopier().Copy(&AttributeExpr{Type: String, DefaultValue: value}) + }, + ) +} diff --git a/expr/attribute_test.go b/expr/attribute_test.go index b8451687de..320998cb76 100644 --- a/expr/attribute_test.go +++ b/expr/attribute_test.go @@ -1129,3 +1129,79 @@ func TestAttributeExprValidationValidate(t *testing.T) { } } } + +func TestAttributeExprValidateChecksEachCall(t *testing.T) { + parent := &UserTypeExpr{ + AttributeExpr: &AttributeExpr{Type: String}, + TypeName: "Parent", + } + attribute := &AttributeExpr{ + Type: &Object{}, + Validation: &ValidationExpr{Required: []string{"missing"}}, + } + + first := attribute.Validate("payload", parent) + second := attribute.Validate("payload", parent) + + if first == nil { + t.Error("first check returned no errors, expected 1") + } else if len(first.Errors) != 1 { + t.Errorf("first check returned %d errors, expected 1", len(first.Errors)) + } + if second == nil { + t.Error("second check returned no errors, expected 1") + } else if len(second.Errors) != 1 { + t.Errorf("second check returned %d errors, expected 1", len(second.Errors)) + } +} + +func TestAttributeExprValidateChecksSharedTypeOncePerCall(t *testing.T) { + minLength, maxLength := 2, 1 + shared := &UserTypeExpr{ + AttributeExpr: &AttributeExpr{Type: &Object{ + &NamedAttributeExpr{ + Name: "value", + Attribute: &AttributeExpr{ + Type: String, + Validation: &ValidationExpr{ + MinLength: &minLength, + MaxLength: &maxLength, + }, + }, + }, + }}, + TypeName: "Shared", + } + attribute := &AttributeExpr{Type: &Object{ + &NamedAttributeExpr{ + Name: "first", + Attribute: &AttributeExpr{Type: shared}, + }, + &NamedAttributeExpr{ + Name: "second", + Attribute: &AttributeExpr{Type: shared}, + }, + }} + parent := &UserTypeExpr{ + AttributeExpr: &AttributeExpr{Type: String}, + TypeName: "Parent", + } + + checks := []*eval.ValidationErrors{ + attribute.Validate("payload", parent), + attribute.Validate("payload", parent), + } + for i, result := range checks { + if result == nil { + t.Errorf("check %d returned no errors, expected 1", i+1) + continue + } + if len(result.Errors) != 1 { + t.Errorf("check %d returned %d errors, expected 1", i+1, len(result.Errors)) + continue + } + if got, want := result.Errors[0].Error(), "field value - min length is greater than max length"; got != want { + t.Errorf("check %d returned %q, expected %q", i+1, got, want) + } + } +} diff --git a/expr/default_value.go b/expr/default_value.go new file mode 100644 index 0000000000..f6ed8ed79a --- /dev/null +++ b/expr/default_value.go @@ -0,0 +1,617 @@ +// This file validates values written with the Default DSL before code +// generation starts. It walks the design type and applies the same validation +// rules that generated boundary validators apply to request values. +package expr + +import ( + "fmt" + "math" + "reflect" + "sort" + "unicode/utf8" + + "goa.design/goa/v3/eval" + "goa.design/goa/v3/internal/codegenname" + goa "goa.design/goa/v3/pkg" +) + +type ( + // defaultValueValidator records errors against the expression that owns the + // authored default. + defaultValueValidator struct { + parent eval.Expression + errors *eval.ValidationErrors + } + + // defaultMapEntry keeps map validation deterministic even though Go map + // iteration order is not stable. + defaultMapEntry struct { + key reflect.Value + value reflect.Value + label string + } +) + +// validateDefaultValue verifies the complete value and every supplied value +// below it. Defaults are compile-time design data, so generators may assume +// this method has already proved their type and validation rules. +func (a *AttributeExpr) validateDefaultValue(ctx string, parent eval.Expression) *eval.ValidationErrors { + if a.DefaultValue == nil { + return nil + } + validator := &defaultValueValidator{ + parent: parent, + errors: new(eval.ValidationErrors), + } + validator.validate(a, reflect.ValueOf(a.DefaultValue), ctx+"default value") + return validator.errors +} + +// validateDefaultValues finds every authored default below an attribute. The +// visited set stops recursive user types while still validating each declared +// attribute once. +func (a *AttributeExpr) validateDefaultValues(ctx string, parent eval.Expression) *eval.ValidationErrors { + return a.validateDefaultValuesRecursive(ctx, parent, make(map[*AttributeExpr]struct{})) +} + +// validateDefaultValuesRecursive validates one attribute and descends through +// each kind that can contain another attribute. +func (a *AttributeExpr) validateDefaultValuesRecursive(ctx string, parent eval.Expression, visited map[*AttributeExpr]struct{}) *eval.ValidationErrors { + if _, ok := visited[a]; ok { + return nil + } + visited[a] = struct{}{} + errors := new(eval.ValidationErrors) + errors.Merge(a.validateDefaultValue(ctx+" - ", parent)) + if _, named := a.Type.(UserType); named { + return errors + } + switch { + case IsObject(a.Type): + for _, field := range *AsObject(a.Type) { + errors.Merge(field.Attribute.validateDefaultValuesRecursive( + fmt.Sprintf("%s field %q", ctx, field.Name), parent, visited, + )) + } + case IsArray(a.Type): + errors.Merge(AsArray(a.Type).ElemType.validateDefaultValuesRecursive(ctx+" element", parent, visited)) + case IsMap(a.Type): + mapped := AsMap(a.Type) + errors.Merge(mapped.KeyType.validateDefaultValuesRecursive(ctx+" map key", parent, visited)) + errors.Merge(mapped.ElemType.validateDefaultValuesRecursive(ctx+" map value", parent, visited)) + case IsUnion(a.Type): + for _, branch := range AsUnion(a.Type).Values { + errors.Merge(branch.Attribute.validateDefaultValuesRecursive( + fmt.Sprintf("%s OneOf branch %q", ctx, branch.Name), parent, visited, + )) + } + } + return errors +} + +// validate checks one value and then visits the values selected by its design +// type. A type error stops only that branch because its validations cannot be +// applied safely. +func (v *defaultValueValidator) validate(attribute *AttributeExpr, value reflect.Value, path string) { + if defaultPrimitive(attribute.Type) == Any { + v.validateAny(value, path) + return + } + value, ok := concreteDefaultValue(value) + if !ok { + v.add("%s must not be nil", path) + return + } + switch { + case IsPrimitive(attribute.Type): + if !attribute.Type.IsCompatible(value.Interface()) { + converted, ok := customPrimitiveDefaultValue(attribute, value) + if !ok { + v.addTypeError(path, value, attribute.Type) + return + } + value = converted + } + primitive := defaultPrimitive(attribute.Type) + if !defaultPrimitiveValueFits(primitive, value) { + v.add("%s value %v is outside the range of %s", path, value.Interface(), primitive.Name()) + return + } + case IsArray(attribute.Type): + if value.Kind() != reflect.Array && value.Kind() != reflect.Slice { + v.addTypeError(path, value, attribute.Type) + return + } + case IsMap(attribute.Type), IsUnion(attribute.Type): + if value.Kind() != reflect.Map { + v.addTypeError(path, value, attribute.Type) + return + } + case IsObject(attribute.Type): + if value.Kind() != reflect.Map && value.Kind() != reflect.Struct { + v.addTypeError(path, value, attribute.Type) + return + } + default: + v.add("%s has unsupported design type %s", path, attribute.Type.Name()) + return + } + + v.validateRules(attribute, value, path) + switch { + case IsArray(attribute.Type): + v.validateArray(attribute, value, path) + case IsMap(attribute.Type): + v.validateMap(attribute, value, path) + case IsObject(attribute.Type): + v.validateObject(attribute, value, path) + case IsUnion(attribute.Type): + v.validateUnion(attribute, value, path) + } +} + +// validateAny accepts the JSON-shaped values that the Go value renderer can +// specialize into source. Nil is valid inside an Any collection because the +// generated service field itself has type any. +func (v *defaultValueValidator) validateAny(value reflect.Value, path string) { + if !value.IsValid() { + return + } + for value.Kind() == reflect.Interface || value.Kind() == reflect.Pointer { + if value.IsNil() { + return + } + value = value.Elem() + } + switch value.Kind() { + case reflect.Bool, + reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64, + reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64, + reflect.String: + return + case reflect.Float32, reflect.Float64: + if math.IsInf(value.Float(), 0) || math.IsNaN(value.Float()) { + v.add("%s number must be finite", path) + } + case reflect.Array, reflect.Slice: + for index := range value.Len() { + v.validateAny(value.Index(index), fmt.Sprintf("%s element %d", path, index)) + } + case reflect.Map: + for _, entry := range sortedDefaultMap(value) { + key, ok := concreteDefaultValue(entry.key) + if !ok || key.Kind() != reflect.String { + v.add("%s object key must be a string", path) + continue + } + v.validateAny(entry.value, fmt.Sprintf("%s field %q", path, key.String())) + } + default: + v.add("%s has unsupported Go type %s", path, value.Type()) + } +} + +// validateRules applies the validations inherited through primitive aliases +// as well as rules written directly on the attribute. +func (v *defaultValueValidator) validateRules(attribute *AttributeExpr, value reflect.Value, path string) { + rules := EffectiveValidation(attribute) + if rules == nil { + return + } + if len(rules.Values) > 0 { + valid := false + for _, allowed := range rules.Values { + if defaultEnumValueEqual(attribute.Type, value, allowed) { + valid = true + break + } + } + if !valid { + v.add("%s must be one of %#v", path, rules.Values) + } + } + if value.Kind() == reflect.String { + if rules.Format != "" { + if err := goa.ValidateFormat(path, value.String(), goa.Format(rules.Format)); err != nil { + v.add("%s does not match format %q", path, rules.Format) + } + } + if rules.Pattern != "" { + if err := goa.ValidatePattern(path, value.String(), rules.Pattern); err != nil { + v.add("%s does not match pattern %q", path, rules.Pattern) + } + } + } + if number, ok := defaultNumber(value); ok { + if rules.ExclusiveMinimum != nil && number <= *rules.ExclusiveMinimum { + v.add("%s must be greater than %v", path, *rules.ExclusiveMinimum) + } + if rules.Minimum != nil && number < *rules.Minimum { + v.add("%s must be at least %v", path, *rules.Minimum) + } + if rules.ExclusiveMaximum != nil && number >= *rules.ExclusiveMaximum { + v.add("%s must be less than %v", path, *rules.ExclusiveMaximum) + } + if rules.Maximum != nil && number > *rules.Maximum { + v.add("%s must be at most %v", path, *rules.Maximum) + } + } + if length, ok := defaultLength(value); ok { + if rules.MinLength != nil && length < *rules.MinLength { + v.add("%s length must be at least %d", path, *rules.MinLength) + } + if rules.MaxLength != nil && length > *rules.MaxLength { + v.add("%s length must be at most %d", path, *rules.MaxLength) + } + } +} + +// defaultEnumValueEqual compares numeric enum values after converting both to +// the primitive selected by the design. Generated Go comparisons apply this +// same conversion to untyped enum constants. +func defaultEnumValueEqual(dataType DataType, value reflect.Value, allowed any) bool { + primitive := defaultPrimitive(dataType) + if primitive < Int || primitive > Float64 { + return reflect.DeepEqual(value.Interface(), allowed) + } + allowedValue, ok := concreteDefaultValue(reflect.ValueOf(allowed)) + if !ok || !primitive.IsCompatible(allowedValue.Interface()) { + return false + } + target := defaultPrimitiveReflectType(primitive) + if !value.Type().ConvertibleTo(target) || !allowedValue.Type().ConvertibleTo(target) { + return false + } + return reflect.DeepEqual(value.Convert(target).Interface(), allowedValue.Convert(target).Interface()) +} + +// validateArray applies the element contract to every authored element. +func (v *defaultValueValidator) validateArray(attribute *AttributeExpr, value reflect.Value, path string) { + element := AsArray(attribute.Type).ElemType + for index := range value.Len() { + v.validate(element, value.Index(index), fmt.Sprintf("%s element %d", path, index)) + } +} + +// validateMap applies both key and value contracts to every authored entry. +func (v *defaultValueValidator) validateMap(attribute *AttributeExpr, value reflect.Value, path string) { + mapped := AsMap(attribute.Type) + for _, entry := range sortedDefaultMap(value) { + v.validate(mapped.KeyType, entry.key, path+" map key") + v.validate(mapped.ElemType, entry.value, fmt.Sprintf("%s map value for %s", path, entry.label)) + } +} + +// validateObject checks required fields, rejects unknown fields, and applies +// each supplied field's own contract. +func (v *defaultValueValidator) validateObject(attribute *AttributeExpr, value reflect.Value, path string) { + object := AsObject(attribute.Type) + fields := make(map[string]*AttributeExpr, len(*object)) + for _, field := range *object { + fields[field.Name] = field.Attribute + } + values, invalidKeys := defaultObjectFields(value, object) + for _, keyType := range invalidKeys { + v.add("%s object key has type %s, expected string", path, keyType) + } + for _, required := range attribute.AllRequired() { + if _, ok := values[required]; !ok { + v.add("%s is missing required field %q", path, required) + } + } + for _, name := range sortedDefaultNames(values) { + field := fields[name] + if field == nil { + v.add("%s contains unknown field %q", path, name) + continue + } + v.validate(field, values[name], fmt.Sprintf("%s field %q", path, name)) + } +} + +// defaultObjectFields returns values using their design names. Struct fields +// use the same Go names as generated object literals. +func defaultObjectFields(value reflect.Value, object *Object) (map[string]reflect.Value, []string) { + if value.Kind() == reflect.Map { + return defaultStringMap(value) + } + values := make(map[string]reflect.Value, len(*object)) + for _, field := range *object { + name := codegenname.AttributeName(field.Name, field.Attribute.Meta["struct:field:name"]) + fieldValue := value.FieldByName(codegenname.Goify(name, true)) + if fieldValue.IsValid() { + values[field.Name] = fieldValue + } + } + return values, nil +} + +// validateUnion requires the canonical tagged envelope and validates only the +// branch named by its discriminator. +func (v *defaultValueValidator) validateUnion(attribute *AttributeExpr, value reflect.Value, path string) { + union := AsUnion(attribute.Type) + values, invalidKeys := defaultStringMap(value) + if len(invalidKeys) > 0 { + for _, keyType := range invalidKeys { + v.add("%s OneOf key has type %s, expected string", path, keyType) + } + return + } + tagValue, hasTag := values[union.GetTypeKey()] + branchValue, hasValue := values[union.GetValueKey()] + if !hasTag { + v.add("%s is missing OneOf discriminator %q", path, union.GetTypeKey()) + return + } + tagValue, ok := concreteDefaultValue(tagValue) + if !ok || tagValue.Kind() != reflect.String { + v.add("%s OneOf discriminator %q must be a string", path, union.GetTypeKey()) + return + } + tag := tagValue.String() + var branch *AttributeExpr + for _, candidate := range union.Values { + if candidate.Name == tag { + branch = candidate.Attribute + break + } + } + if branch == nil { + v.add("%s selects unknown OneOf branch %q", path, tag) + return + } + if !hasValue { + v.add("%s is missing the value for OneOf branch %q", path, tag) + return + } + if len(values) != 2 { + v.add("%s OneOf value must contain only %q and %q", path, union.GetTypeKey(), union.GetValueKey()) + } + v.validate(branch, branchValue, fmt.Sprintf("%s OneOf branch %q", path, tag)) +} + +// add records one design error with the expression that owns the default. +func (v *defaultValueValidator) add(format string, args ...any) { + v.errors.Add(v.parent, format, args...) +} + +// addTypeError reports the concrete authored Go type and expected Goa type. +func (v *defaultValueValidator) addTypeError(path string, value reflect.Value, dataType DataType) { + v.add("%s has type %s, expected %s", path, value.Type(), dataType.Name()) +} + +// concreteDefaultValue removes interfaces and pointers while preserving the +// concrete value used for type and validation checks. +func concreteDefaultValue(value reflect.Value) (reflect.Value, bool) { + if !value.IsValid() { + return reflect.Value{}, false + } + for value.Kind() == reflect.Interface || value.Kind() == reflect.Pointer { + if value.IsNil() { + return reflect.Value{}, false + } + value = value.Elem() + } + return value, true +} + +// defaultPrimitive returns the primitive below a chain of named types. +func defaultPrimitive(dataType DataType) Primitive { + for { + if primitive, ok := dataType.(Primitive); ok { + return primitive + } + userType, ok := dataType.(UserType) + if !ok { + return 0 + } + dataType = userType.Attribute().Type + } +} + +// customPrimitiveDefaultValue converts a value already declared with the exact +// custom Go type into the primitive value used by design validations. The +// metadata contains the package path and type name, so this check never needs +// to load or inspect a Go package. +func customPrimitiveDefaultValue(attribute *AttributeExpr, value reflect.Value) (reflect.Value, bool) { + metadata := attribute.Meta["struct:field:type"] + if len(metadata) < 2 || value.Type().PkgPath() != metadata[1] { + return reflect.Value{}, false + } + name := metadata[0] + for index := len(name) - 1; index >= 0; index-- { + if name[index] == '.' { + name = name[index+1:] + break + } + } + if value.Type().Name() != name { + return reflect.Value{}, false + } + target := defaultPrimitiveReflectType(defaultPrimitive(attribute.Type)) + if target == nil || !value.Type().ConvertibleTo(target) { + return reflect.Value{}, false + } + return value.Convert(target), true +} + +// defaultPrimitiveReflectType returns the native Go type used to evaluate a +// primitive's design validations. +func defaultPrimitiveReflectType(primitive Primitive) reflect.Type { + switch primitive { + case Boolean: + return reflect.TypeOf(false) + case Int: + return reflect.TypeOf(int(0)) + case Int32: + return reflect.TypeOf(int32(0)) + case Int64: + return reflect.TypeOf(int64(0)) + case UInt: + return reflect.TypeOf(uint(0)) + case UInt32: + return reflect.TypeOf(uint32(0)) + case UInt64: + return reflect.TypeOf(uint64(0)) + case Float32: + return reflect.TypeOf(float32(0)) + case Float64: + return reflect.TypeOf(float64(0)) + case String: + return reflect.TypeOf("") + case Bytes: + return reflect.TypeOf([]byte(nil)) + default: + return nil + } +} + +// defaultPrimitiveValueFits reports whether a compatible numeric value can be +// represented by the generated primitive type without overflow. +func defaultPrimitiveValueFits(primitive Primitive, value reflect.Value) bool { + switch primitive { + case Int: + return defaultSignedValueFits(value, 0) + case Int32: + return defaultSignedValueFits(value, 32) + case Int64: + return defaultSignedValueFits(value, 64) + case UInt: + return defaultUnsignedValueFits(value, 0) + case UInt32: + return defaultUnsignedValueFits(value, 32) + case UInt64: + return defaultUnsignedValueFits(value, 64) + case Float32: + number, ok := defaultNumber(value) + return ok && !math.IsInf(number, 0) && !math.IsNaN(number) && math.Abs(number) <= math.MaxFloat32 + case Float64: + number, ok := defaultNumber(value) + return ok && !math.IsInf(number, 0) && !math.IsNaN(number) + default: + return true + } +} + +// defaultSignedValueFits checks signed bounds. A zero bit count means the +// generated Go int width on the current generation platform. +func defaultSignedValueFits(value reflect.Value, bits int) bool { + if bits == 0 { + bits = reflect.TypeOf(int(0)).Bits() + } + var maximum uint64 + if bits == 64 { + maximum = math.MaxInt64 + } else { + maximum = uint64(1)<<(bits-1) - 1 + } + switch value.Kind() { + case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: + number := value.Int() + minimum := -int64(maximum) - 1 + return number >= minimum && (number < 0 || uint64(number) <= maximum) + case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64: + return value.Uint() <= maximum + default: + return false + } +} + +// defaultUnsignedValueFits checks unsigned bounds. A zero bit count means the +// generated Go uint width on the current generation platform. +func defaultUnsignedValueFits(value reflect.Value, bits int) bool { + if bits == 0 { + bits = reflect.TypeOf(uint(0)).Bits() + } + maximum := uint64(math.MaxUint64) + if bits < 64 { + maximum = uint64(1)<= 0 && uint64(value.Int()) <= maximum + case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64: + return value.Uint() <= maximum + default: + return false + } +} + +// defaultNumber converts a numeric design value to the representation used by +// numeric validation expressions. +func defaultNumber(value reflect.Value) (float64, bool) { + switch value.Kind() { + case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: + return float64(value.Int()), true + case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64: + return float64(value.Uint()), true + case reflect.Float32, reflect.Float64: + return value.Float(), true + default: + return 0, false + } +} + +// defaultLength returns the length measured by generated validators. String +// lengths count Unicode characters; collections and bytes count elements. +func defaultLength(value reflect.Value) (int, bool) { + switch value.Kind() { + case reflect.String: + return utf8.RuneCountInString(value.String()), true + case reflect.Array, reflect.Slice, reflect.Map: + return value.Len(), true + default: + return 0, false + } +} + +// defaultStringMap returns an object's or OneOf's authored fields. Callers +// already proved that value is a map. +func defaultStringMap(value reflect.Value) (map[string]reflect.Value, []string) { + values := make(map[string]reflect.Value, value.Len()) + var invalid []string + iterator := value.MapRange() + for iterator.Next() { + key, ok := concreteDefaultValue(iterator.Key()) + if !ok || key.Kind() != reflect.String { + keyType := "nil" + if key.IsValid() { + keyType = key.Type().String() + } + invalid = append(invalid, keyType) + continue + } + values[key.String()] = iterator.Value() + } + sort.Strings(invalid) + return values, invalid +} + +// sortedDefaultMap returns map entries ordered by their printed key so error +// order does not change between evaluations. +func sortedDefaultMap(value reflect.Value) []defaultMapEntry { + entries := make([]defaultMapEntry, 0, value.Len()) + iterator := value.MapRange() + for iterator.Next() { + key := iterator.Key() + entries = append(entries, defaultMapEntry{ + key: key, + value: iterator.Value(), + label: fmt.Sprintf("%#v", key.Interface()), + }) + } + sort.Slice(entries, func(i, j int) bool { + return entries[i].label < entries[j].label + }) + return entries +} + +// sortedDefaultNames returns object field names in stable order. +func sortedDefaultNames(values map[string]reflect.Value) []string { + names := make([]string, 0, len(values)) + for name := range values { + names = append(names, name) + } + sort.Strings(names) + return names +} diff --git a/expr/default_value_validation_test.go b/expr/default_value_validation_test.go new file mode 100644 index 0000000000..129aaa5140 --- /dev/null +++ b/expr/default_value_validation_test.go @@ -0,0 +1,456 @@ +// This file verifies that authored defaults satisfy the complete design +// contract before generators receive them. +package expr_test + +import ( + "encoding/json" + "math" + "strings" + "testing" + + "github.com/stretchr/testify/require" + + . "goa.design/goa/v3/dsl" + "goa.design/goa/v3/expr" +) + +type ( + structDefault struct { + Profile structDefaultProfile + } + + structDefaultProfile struct { + Name string + Limit int + CustomProfileID string + } + + invalidStructDefault struct { + Profile invalidStructDefaultProfile + } + + invalidStructDefaultProfile struct { + Name string + Limit int + Count string + } +) + +func TestValidDefaultValues(t *testing.T) { + for _, test := range []struct { + name string + dsl func() + }{ + { + name: "nested object array map and union", + dsl: func() { + Type("Defaults", func() { + Attribute("profile", func() { + Attribute("name", String, func() { + Pattern("^[a-z]+$") + }) + Attribute("scores", ArrayOf(Int, func() { + Minimum(1) + })) + Attribute("limits", MapOf(String, Int, func() { + Key(func() { + MinLength(2) + }) + Elem(func() { + Maximum(10) + }) + })) + OneOf("state", func() { + Attribute("active", func() { + Attribute("label", String, func() { + MinLength(2) + }) + Required("label") + }) + Attribute("inactive", Empty) + }) + Required("name", "scores", "limits", "state") + }) + Default(map[string]any{ + "profile": map[string]any{ + "name": "valid", + "scores": []int{1, 2}, + "limits": map[string]int{"ok": 10}, + "state": map[string]any{ + "type": "inactive", + "value": map[string]any{}, + }, + }, + }) + Required("profile") + }) + }, + }, + { + name: "nested Go struct", + dsl: func() { + Type("Defaults", func() { + Attribute("profile", func() { + Attribute("name", String, func() { + Pattern("^[a-z]+$") + }) + Attribute("limit", Int, func() { + Minimum(1) + }) + Attribute("profile_id", String, func() { + Meta("struct:field:name", "custom_profile_id") + }) + Required("name", "limit", "profile_id") + }) + Default(structDefault{ + Profile: structDefaultProfile{ + Name: "valid", + Limit: 2, + CustomProfileID: "profile-1", + }, + }) + Required("profile") + }) + }, + }, + { + name: "native constant for custom primitive field", + dsl: func() { + Type("ErrorHandling", Int64, func() { + Meta("struct:field:type", "flag.ErrorHandling", "flag") + Default(1) + }) + }, + }, + { + name: "numeric enum uses primitive values", + dsl: func() { + Type("Score", Float32, func() { + Enum(1.2, 5, 10, 100.8) + Default(5.0) + }) + }, + }, + { + name: "typed custom primitive value", + dsl: func() { + Type("RawMessage", String, func() { + Meta("struct:field:type", "json.RawMessage", "encoding/json", "json") + Default(json.RawMessage("true")) + }) + }, + }, + { + name: "canonical Any value", + dsl: func() { + Type("Metadata", Any, func() { + Default(map[string]any{ + "enabled": true, + "count": 2, + "ratio": 1.5, + "name": "valid", + "items": []any{"first", nil}, + }) + }) + }, + }, + } { + t.Run(test.name, func(t *testing.T) { + expr.RunDSL(t, test.dsl) + }) + } +} + +func TestInvalidNestedStructDefault(t *testing.T) { + err := expr.RunInvalidDSL(t, func() { + Type("Defaults", func() { + Attribute("profile", func() { + Attribute("name", String, func() { + Pattern("^[a-z]+$") + }) + Attribute("limit", Int, func() { + Minimum(1) + }) + Attribute("count", Int) + Required("name", "limit", "count") + }) + Default(invalidStructDefault{ + Profile: invalidStructDefaultProfile{ + Name: "INVALID", + Limit: 0, + Count: "many", + }, + }) + Required("profile") + }) + }) + require.ErrorContains(t, err, `default value field "profile" field "name" does not match pattern`) + require.ErrorContains(t, err, `default value field "profile" field "limit" must be at least 1`) + require.ErrorContains(t, err, `default value field "profile" field "count" has type string, expected int`) +} + +func TestDeclaredDefaultReportsOnceWhenTypeIsReferenced(t *testing.T) { + err := expr.RunInvalidDSL(t, func() { + defaults := Type("Defaults", func() { + Attribute("name", String, func() { + Pattern("^valid$") + Default("invalid") + }) + }) + Service("Service", func() { + Method("First", func() { + Payload(defaults) + }) + Method("Second", func() { + Result(defaults) + }) + }) + }) + require.Equal(t, 1, strings.Count(err.Error(), "default value does not match pattern")) +} + +func TestErrorDefaultReportsOnceWhenInherited(t *testing.T) { + err := expr.RunInvalidDSL(t, func() { + Service("Service", func() { + Error("bad_request", String, func() { + Pattern("^valid$") + Default("invalid") + }) + Method("Method", func() { + Error("bad_request") + }) + }) + }) + require.Equal(t, 1, strings.Count(err.Error(), "default value does not match pattern")) +} + +func TestInvalidDefaultValues(t *testing.T) { + for _, test := range []struct { + name string + dsl func() + want string + }{ + { + name: "missing required nested field", + dsl: func() { + Type("Defaults", func() { + Attribute("profile", func() { + Attribute("name", String) + Required("name") + }) + Default(map[string]any{"profile": map[string]any{}}) + }) + }, + want: `default value field "profile" is missing required field "name"`, + }, + { + name: "nested pattern", + dsl: func() { + Type("Defaults", func() { + Attribute("profile", func() { + Attribute("name", String, func() { + Pattern("^[a-z]+$") + }) + }) + Default(map[string]any{"profile": map[string]any{"name": "INVALID"}}) + }) + }, + want: `default value field "profile" field "name" does not match pattern`, + }, + { + name: "nested enum", + dsl: func() { + Type("Defaults", func() { + Attribute("profile", func() { + Attribute("mode", String, func() { + Enum("safe", "fast") + }) + }) + Default(map[string]any{"profile": map[string]any{"mode": "unknown"}}) + }) + }, + want: `default value field "profile" field "mode" must be one of`, + }, + { + name: "nested format", + dsl: func() { + Type("Defaults", func() { + Attribute("profile", func() { + Attribute("email", String, func() { + Format(FormatEmail) + }) + }) + Default(map[string]any{"profile": map[string]any{"email": "not-an-email"}}) + }) + }, + want: `default value field "profile" field "email" does not match format "email"`, + }, + { + name: "wrong nested type", + dsl: func() { + Type("Defaults", func() { + Attribute("profile", func() { + Attribute("count", Int) + }) + Default(map[string]any{"profile": map[string]any{"count": "many"}}) + }) + }, + want: `default value field "profile" field "count" has type string, expected int`, + }, + { + name: "negative unsigned value", + dsl: func() { + Type("Defaults", UInt, func() { + Default(-1) + }) + }, + want: "default value value -1 is outside the range of uint", + }, + { + name: "integer overflow", + dsl: func() { + Type("Defaults", Int32, func() { + Default(int(math.MaxInt32) + 1) + }) + }, + want: "default value value 2147483648 is outside the range of int32", + }, + { + name: "object key type", + dsl: func() { + Type("Defaults", func() { + Attribute("count", Int) + Default(map[int]int{1: 2}) + }) + }, + want: "default value object key has type int, expected string", + }, + { + name: "array element validation", + dsl: func() { + Type("Defaults", ArrayOf(String, func() { + MinLength(3) + }), func() { + Default([]string{"ok"}) + }) + }, + want: "default value element 0 length must be at least 3", + }, + { + name: "array length validation", + dsl: func() { + Type("Defaults", ArrayOf(String), func() { + MaxLength(1) + Default([]string{"first", "second"}) + }) + }, + want: "default value length must be at most 1", + }, + { + name: "map key validation", + dsl: func() { + Type("Defaults", MapOf(String, Int, func() { + Key(func() { + Pattern("^[a-z]+$") + }) + }), func() { + Default(map[string]int{"INVALID": 1}) + }) + }, + want: "default value map key does not match pattern", + }, + { + name: "map element validation", + dsl: func() { + Type("Defaults", MapOf(String, Int, func() { + Elem(func() { + Minimum(1) + }) + }), func() { + Default(map[string]int{"valid": 0}) + }) + }, + want: `default value map value for "valid" must be at least 1`, + }, + { + name: "unknown union branch", + dsl: func() { + Type("Defaults", func() { + OneOf("state", func() { + Attribute("active", String) + Attribute("inactive", Empty) + Default(map[string]any{"type": "missing", "value": "value"}) + }) + }) + }, + want: `default value selects unknown OneOf branch "missing"`, + }, + { + name: "wrong union branch value", + dsl: func() { + Type("Defaults", func() { + OneOf("state", func() { + Attribute("active", Int) + Attribute("inactive", Empty) + Default(map[string]any{"type": "active", "value": "wrong"}) + }) + }) + }, + want: `default value OneOf branch "active" has type string, expected stateActive`, + }, + { + name: "Any map key", + dsl: func() { + Type("Defaults", Any, func() { + Default(map[int]string{1: "invalid"}) + }) + }, + want: "default value object key must be a string", + }, + { + name: "Any non-finite number", + dsl: func() { + Type("Defaults", Any, func() { + Default(math.Inf(1)) + }) + }, + want: "default value number must be finite", + }, + { + name: "Any unsupported value", + dsl: func() { + Type("Defaults", Any, func() { + Default(struct{ Name string }{Name: "invalid"}) + }) + }, + want: "default value has unsupported Go type", + }, + { + name: "untyped nil", + dsl: func() { + Type("Defaults", String, func() { + Default(nil) + }) + }, + want: "default value must not be nil", + }, + { + name: "service error default", + dsl: func() { + Service("Service", func() { + Error("bad_request", String, func() { + Pattern("^valid$") + Default("invalid") + }) + Method("Method", func() {}) + }) + }, + want: `error "bad_request" - default value does not match pattern`, + }, + } { + t.Run(test.name, func(t *testing.T) { + err := expr.RunInvalidDSL(t, test.dsl) + require.ErrorContains(t, err, test.want) + }) + } +} diff --git a/expr/dup.go b/expr/dup.go index b658286526..ac361470d8 100644 --- a/expr/dup.go +++ b/expr/dup.go @@ -1,3 +1,5 @@ +// This file copies design types while preserving which original declaration +// each copied type came from. package expr import ( @@ -9,32 +11,65 @@ func Dup(d DataType) DataType { return newDupper().DupType(d) } -// DupAtt creates a copy of the given attribute. +// DupForDSL creates a copy of the given data type and registers copied result +// types so Goa evaluates their DSL. +func DupForDSL(dataType DataType) DataType { + dupper := newDSLDupper() + result := dupper.DupType(dataType) + dupper.registerResultTypes() + return result +} + +// DupAtt creates a copy of the given attribute without changing the design. func DupAtt(att *AttributeExpr) *AttributeExpr { - dupper := newDupper() - duppedBases := make([]DataType, len(att.Bases)) - for i, b := range att.Bases { - duppedBases[i] = dupper.DupType(b) - } - res := dupper.DupAttribute(att) - res.Bases = duppedBases - return res + return newDupper().DupAtt(att) +} + +// DupAttForDSL creates a copy of the given attribute and registers copied +// result types so Goa evaluates their DSL. +func DupAttForDSL(att *AttributeExpr) *AttributeExpr { + dupper := newDSLDupper() + result := dupper.DupAtt(att) + dupper.registerResultTypes() + return result } // dupper implements recursive and cycle safe copy of data types. type dupper struct { - uts map[string]UserType - ats map[*AttributeExpr]struct{} + uts map[UserType]UserType + ats map[*AttributeExpr]struct{} + registerTypes bool + resultTypeCopies []*ResultTypeExpr } -// newDupper returns a new initialized dupper. +// newDupper returns a copier that does not change the evaluated design. func newDupper() *dupper { return &dupper{ - uts: make(map[string]UserType), + uts: make(map[UserType]UserType), ats: make(map[*AttributeExpr]struct{}), } } +// newDSLDupper returns a copier that records generated result types whose DSL +// must run before evaluation is complete. +func newDSLDupper() *dupper { + dupper := newDupper() + dupper.registerTypes = true + return dupper +} + +// DupAtt creates a copy of att and its base attributes with one shared type +// map so repeated and recursive types remain shared in the copy. +func (d *dupper) DupAtt(att *AttributeExpr) *AttributeExpr { + duppedBases := make([]DataType, len(att.Bases)) + for i, b := range att.Bases { + duppedBases[i] = d.DupType(b) + } + res := d.DupAttribute(att) + res.Bases = duppedBases + return res +} + // DupAttribute creates a copy of the given attribute. func (d *dupper) DupAttribute(att *AttributeExpr) *AttributeExpr { if _, ok := d.ats[att]; ok { @@ -59,6 +94,7 @@ func (d *dupper) DupAttribute(att *AttributeExpr) *AttributeExpr { DSLFunc: att.DSLFunc, UserExamples: att.UserExamples, finalized: att.finalized, + authored: att.AuthoredAttribute(), } d.ats[&dup] = struct{}{} return &dup @@ -101,20 +137,20 @@ func (d *dupper) DupType(t DataType) DataType { } return &dp case UserType: - if u, ok := d.uts[actual.ID()]; ok { + origin := actual.Origin() + if u, ok := d.uts[origin]; ok { return u } dp := actual.Dup(nil) - d.uts[actual.ID()] = dp + d.uts[origin] = dp dupAtt := d.DupAttribute(actual.Attribute()) dp.SetAttribute(dupAtt) - // Make sure that if we are dupping a generated type we also put - // the dup in the generated type list so that it gets properly - // eval'd. - if rt, ok := dp.(*ResultTypeExpr); ok { + // DSL copies must be evaluated because their DSL may define views + // used by the attribute that contains the copy. + if rt, ok := dp.(*ResultTypeExpr); d.registerTypes && ok { if GeneratedResultType(rt.Identifier) != nil { - GeneratedResultTypes.Append(rt) + d.resultTypeCopies = append(d.resultTypeCopies, rt) } } @@ -122,3 +158,11 @@ func (d *dupper) DupType(t DataType) DataType { } panic("unknown type " + fmt.Sprintf("%T", t)) } + +// registerResultTypes adds every generated result type found in a DSL copy +// after the complete graph has been copied. +func (d *dupper) registerResultTypes() { + for _, resultType := range d.resultTypeCopies { + GeneratedResultTypes.Append(resultType) + } +} diff --git a/expr/dup_test.go b/expr/dup_test.go index f1295a20c8..752b3adbeb 100644 --- a/expr/dup_test.go +++ b/expr/dup_test.go @@ -21,3 +21,92 @@ func TestDupPreservesNonNullableArrayElements(t *testing.T) { assert.NotSame(t, original.ElemType, duplicate.ElemType) assert.True(t, duplicate.NonNullableElems) } + +// TestDupGeneratedResultTypes verifies that ordinary copies do not change the +// evaluated design and DSL copies register the result types whose DSL must run. +func TestDupGeneratedResultTypes(t *testing.T) { + ResetDSL(t) + + resultType := NewResultTypeExpr("Item", "application/vnd.item", func() {}) + GeneratedResultTypes.Append(resultType) + attribute := &AttributeExpr{Type: &Array{ElemType: &AttributeExpr{Type: resultType}}} + + duplicate := DupAtt(attribute) + require.Len(t, *GeneratedResultTypes, 1) + require.NotSame(t, resultType, duplicate.Type.(*Array).ElemType.Type) + + dslDuplicate := DupAttForDSL(attribute) + require.Len(t, *GeneratedResultTypes, 2) + require.Same(t, dslDuplicate.Type.(*Array).ElemType.Type, (*GeneratedResultTypes)[1]) +} + +func TestIsErrorResultRecognizesCopies(t *testing.T) { + duplicate := DupAtt(&AttributeExpr{Type: ErrorResult}).Type + + require.True(t, IsErrorResult(ErrorResult)) + require.True(t, IsErrorResult(duplicate)) + require.False(t, IsErrorResult(&UserTypeExpr{ + AttributeExpr: &AttributeExpr{Type: String}, + TypeName: "error", + })) + require.False(t, IsErrorResult(String)) +} + +func TestDupKeepsRootTypeAndSameNameUnionAliasDistinct(t *testing.T) { + rootType := &UserTypeExpr{ + AttributeExpr: &AttributeExpr{Type: String}, + TypeName: "ValueBool", + } + unionAlias := &UserTypeExpr{ + AttributeExpr: &AttributeExpr{Type: Boolean}, + TypeName: "ValueBool", + } + attribute := &AttributeExpr{Type: &Object{ + {Name: "root", Attribute: &AttributeExpr{Type: rootType}}, + {Name: "choice", Attribute: &AttributeExpr{Type: &Union{ + TypeName: "Value", + Values: []*NamedAttributeExpr{{ + Name: "bool", + Attribute: &AttributeExpr{Type: unionAlias}, + }}, + }}}, + }} + + duplicate := DupAtt(attribute) + object := duplicate.Type.(*Object) + rootCopy := object.Attribute("root").Type.(UserType) + union := object.Attribute("choice").Type.(*Union) + aliasCopy := union.Values[0].Attribute.Type.(UserType) + require.NotSame(t, rootCopy, aliasCopy) + require.Same(t, rootType, rootCopy.Origin()) + require.Same(t, unionAlias, aliasCopy.Origin()) + require.Equal(t, String, rootCopy.Attribute().Type) + require.Equal(t, Boolean, aliasCopy.Attribute().Type) +} + +// TestDupAttributeKeepsAuthoredAttribute verifies that every transport copy +// can find the exact attribute written in the design, including after more +// than one copy. +func TestDupAttributeKeepsAuthoredAttribute(t *testing.T) { + originalChild := &AttributeExpr{Type: String} + original := &AttributeExpr{Type: &Object{ + {Name: "child", Attribute: originalChild}, + }} + + first := DupAtt(original) + second := DupAtt(first) + + require.Same(t, original, first.AuthoredAttribute()) + require.Same(t, original, second.AuthoredAttribute()) + require.Same(t, originalChild, first.Type.(*Object).Attribute("child").AuthoredAttribute()) + require.Same(t, originalChild, second.Type.(*Object).Attribute("child").AuthoredAttribute()) +} + +func TestDupSchemeKeepsAuthoredScheme(t *testing.T) { + authored := &SchemeExpr{SchemeName: "key"} + first := DupScheme(authored) + second := DupScheme(first) + + require.Same(t, authored, first.AuthoredScheme()) + require.Same(t, authored, second.AuthoredScheme()) +} diff --git a/expr/error_contract.go b/expr/error_contract.go new file mode 100644 index 0000000000..ee6218c66a --- /dev/null +++ b/expr/error_contract.go @@ -0,0 +1,423 @@ +// This file builds the error definition that a method inherits from its +// service or API. HTTP and gRPC settings may change how the error is sent, but +// they do not change the service error value. +package expr + +import ( + "reflect" + "slices" +) + +type ( + // attributePair identifies two nodes already compared while traversing + // recursive error types. + attributePair struct { + first *AttributeExpr + second *AttributeExpr + } + + // effectiveErrorCopier copies an inherited error without changing the + // evaluated design. The maps reconnect recursive types to their copies. + effectiveErrorCopier struct { + attributes map[*AttributeExpr]*AttributeExpr + userTypes map[UserType]UserType + } +) + +// equivalentErrorAttributes reports whether two error attributes generate the +// same service value contract. Descriptions and examples are documentation; +// types, validations, defaults, and metadata affect generated code or runtime +// behavior and must match. +func equivalentErrorAttributes(first, second *AttributeExpr) bool { + if first == second { + return true + } + if first == nil || second == nil { + return false + } + first = effectiveErrorAttribute(first) + second = effectiveErrorAttribute(second) + return equivalentErrorAttributeNodes(first, second, make(map[attributePair]struct{})) +} + +// differingErrorQualifierSettings lists error settings that would change the +// generated service error returned to callers. +func differingErrorQualifierSettings(first, second *AttributeExpr) []string { + first = effectiveErrorAttribute(first) + second = effectiveErrorAttribute(second) + qualifiers := []struct { + name string + key string + }{ + {name: "temporary", key: "goa:error:temporary"}, + {name: "timeout", key: "goa:error:timeout"}, + {name: "fault", key: "goa:error:fault"}, + } + var different []string + for _, qualifier := range qualifiers { + _, firstSet := first.Meta[qualifier.key] + _, secondSet := second.Meta[qualifier.key] + if firstSet != secondSet { + different = append(different, qualifier.name) + } + } + return different +} + +// effectiveErrorAttribute returns a detached copy with References and Bases +// applied by AttributeExpr.Finalize. Validation can therefore compare the +// value contracts code generation will see without mutating evaluated design. +func effectiveErrorAttribute(source *AttributeExpr) *AttributeExpr { + copier := &effectiveErrorCopier{ + attributes: make(map[*AttributeExpr]*AttributeExpr), + userTypes: make(map[UserType]UserType), + } + result := copier.attribute(source) + result.Finalize() + return result +} + +// attribute copies one attribute shell before following its type and +// inheritance edges so self-recursive graphs terminate on the copied shell. +func (c *effectiveErrorCopier) attribute(source *AttributeExpr) *AttributeExpr { + if source == nil { + return nil + } + if copied, ok := c.attributes[source]; ok { + return copied + } + copied := &AttributeExpr{ + Description: source.Description, + DefaultValue: cloneErrorContractValue(source.DefaultValue), + DSLFunc: source.DSLFunc, + } + c.attributes[source] = copied + if source.Docs != nil { + docs := *source.Docs + copied.Docs = &docs + } + if source.Validation != nil { + copied.Validation = cloneErrorValidation(source.Validation) + } + if source.Meta != nil { + copied.Meta = source.Meta.Dup() + } + if len(source.UserExamples) > 0 { + copied.UserExamples = make([]*ExampleExpr, len(source.UserExamples)) + for index, example := range source.UserExamples { + copy := *example + copy.Value = cloneErrorContractValue(example.Value) + copied.UserExamples[index] = © + } + } + copied.Type = c.dataType(source.Type) + copied.Bases = c.dataTypes(source.Bases) + copied.References = c.dataTypes(source.References) + return copied +} + +// cloneErrorValidation detaches slices and scalar pointers that +// ValidationExpr.Dup deliberately shares with its source. +func cloneErrorValidation(source *ValidationExpr) *ValidationExpr { + copied := source.Dup() + copied.Values = make([]any, len(source.Values)) + for index, value := range source.Values { + copied.Values[index] = cloneErrorContractValue(value) + } + copied.ExclusiveMinimum = dupFloat(source.ExclusiveMinimum) + copied.Minimum = dupFloat(source.Minimum) + copied.Maximum = dupFloat(source.Maximum) + copied.ExclusiveMaximum = dupFloat(source.ExclusiveMaximum) + copied.MinLength = dupInt(source.MinLength) + copied.MaxLength = dupInt(source.MaxLength) + return copied +} + +// cloneErrorContractValue copies the collection values accepted by defaults, +// enum validations, and examples. Primitive values are immutable and can be +// shared safely. +func cloneErrorContractValue(source any) any { + switch actual := source.(type) { + case Val: + copied := make(Val, len(actual)) + for name, value := range actual { + copied[name] = cloneErrorContractValue(value) + } + return copied + case ArrayVal: + copied := make(ArrayVal, len(actual)) + for index, value := range actual { + copied[index] = cloneErrorContractValue(value) + } + return copied + case MapVal: + copied := make(MapVal, len(actual)) + for key, value := range actual { + copied[cloneErrorContractValue(key)] = cloneErrorContractValue(value) + } + return copied + case []any: + copied := make([]any, len(actual)) + for index, value := range actual { + copied[index] = cloneErrorContractValue(value) + } + return copied + case []byte: + return append([]byte(nil), actual...) + case map[string]any: + copied := make(map[string]any, len(actual)) + for name, value := range actual { + copied[name] = cloneErrorContractValue(value) + } + return copied + case map[any]any: + copied := make(map[any]any, len(actual)) + for key, value := range actual { + copied[cloneErrorContractValue(key)] = cloneErrorContractValue(value) + } + return copied + default: + return actual + } +} + +// dataTypes reconnects inheritance declarations to the same copied graph used +// by attribute types. +func (c *effectiveErrorCopier) dataTypes(source []DataType) []DataType { + if len(source) == 0 { + return nil + } + copied := make([]DataType, len(source)) + for index, dataType := range source { + copied[index] = c.dataType(dataType) + } + return copied +} + +// dataType copies each concrete type without registering generated result +// types. User-type shells are installed before their attributes are followed. +func (c *effectiveErrorCopier) dataType(source DataType) DataType { + switch actual := source.(type) { + case nil: + return nil + case Primitive: + return actual + case *Object: + copied := make(Object, 0, len(*actual)) + for _, field := range *actual { + copied = append(copied, &NamedAttributeExpr{ + Name: field.Name, + Attribute: c.attribute(field.Attribute), + }) + } + return &copied + case *Array: + return &Array{ + ElemType: c.attribute(actual.ElemType), + NonNullableElems: actual.NonNullableElems, + } + case *Map: + return &Map{ + KeyType: c.attribute(actual.KeyType), + ElemType: c.attribute(actual.ElemType), + } + case *Union: + copied := &Union{ + TypeName: actual.TypeName, + TypeKey: actual.TypeKey, + ValueKey: actual.ValueKey, + Values: make([]*NamedAttributeExpr, len(actual.Values)), + } + for index, branch := range actual.Values { + copied.Values[index] = &NamedAttributeExpr{ + Name: branch.Name, + Attribute: c.attribute(branch.Attribute), + } + } + return copied + case *ResultTypeExpr: + origin := actual.Origin() + if copied, ok := c.userTypes[origin]; ok { + return copied + } + copied := &ResultTypeExpr{ + UserTypeExpr: &UserTypeExpr{ + TypeName: actual.TypeName, + UID: actual.UID, + }, + Identifier: actual.Identifier, + ContentType: actual.ContentType, + } + c.userTypes[origin] = copied + copied.AttributeExpr = c.attribute(actual.AttributeExpr) + copied.Views = make([]*ViewExpr, len(actual.Views)) + for index, view := range actual.Views { + copied.Views[index] = &ViewExpr{ + AttributeExpr: c.attribute(view.AttributeExpr), + Name: view.Name, + Parent: copied, + } + } + return copied + case *UserTypeExpr: + origin := actual.Origin() + if copied, ok := c.userTypes[origin]; ok { + return copied + } + copied := &UserTypeExpr{ + TypeName: actual.TypeName, + UID: actual.UID, + } + c.userTypes[origin] = copied + copied.AttributeExpr = c.attribute(actual.AttributeExpr) + return copied + case UserType: + origin := actual.Origin() + if copied, ok := c.userTypes[origin]; ok { + return copied + } + copied := actual.Dup(nil) + c.userTypes[origin] = copied + copied.SetAttribute(c.attribute(actual.Attribute())) + return copied + default: + panic("unknown error attribute type") + } +} + +// equivalentErrorAttributeNodes compares every contract-bearing node while +// stopping when recursive user types revisit the same declaration pair. +func equivalentErrorAttributeNodes(first, second *AttributeExpr, seen map[attributePair]struct{}) bool { + if first == second { + return true + } + pair := attributePair{first: first, second: second} + if _, ok := seen[pair]; ok { + return true + } + seen[pair] = struct{}{} + if !equivalentErrorValidation(first.Validation, second.Validation) || + !reflect.DeepEqual(first.DefaultValue, second.DefaultValue) || + !equivalentErrorMetadata(first.Meta, second.Meta) { + return false + } + + switch firstType := first.Type.(type) { + case Primitive: + secondType, ok := second.Type.(Primitive) + return ok && firstType == secondType + case UserType: + secondType, ok := second.Type.(UserType) + return ok && + firstType.Name() == secondType.Name() && + equivalentErrorAttributeNodes(firstType.Attribute(), secondType.Attribute(), seen) + case *Object: + secondType, ok := second.Type.(*Object) + if !ok || len(*firstType) != len(*secondType) { + return false + } + for _, field := range *firstType { + other := secondType.Attribute(field.Name) + if other == nil || !equivalentErrorAttributeNodes(field.Attribute, other, seen) { + return false + } + } + case *Array: + secondType, ok := second.Type.(*Array) + return ok && + firstType.NonNullableElems == secondType.NonNullableElems && + equivalentErrorAttributeNodes(firstType.ElemType, secondType.ElemType, seen) + case *Map: + secondType, ok := second.Type.(*Map) + return ok && + equivalentErrorAttributeNodes(firstType.KeyType, secondType.KeyType, seen) && + equivalentErrorAttributeNodes(firstType.ElemType, secondType.ElemType, seen) + case *Union: + secondType, ok := second.Type.(*Union) + if !ok || + firstType.TypeName != secondType.TypeName || + firstType.GetTypeKey() != secondType.GetTypeKey() || + firstType.GetValueKey() != secondType.GetValueKey() || + len(firstType.Values) != len(secondType.Values) { + return false + } + for index, branch := range firstType.Values { + other := secondType.Values[index] + if branch.Name != other.Name || !equivalentErrorAttributeNodes(branch.Attribute, other.Attribute, seen) { + return false + } + } + default: + return false + } + return true +} + +// equivalentErrorValidation compares validation behavior independently of the +// authored order of required fields and enum values. +func equivalentErrorValidation(first, second *ValidationExpr) bool { + if first == nil { + first = new(ValidationExpr) + } + if second == nil { + second = new(ValidationExpr) + } + firstScalars, secondScalars := *first, *second + firstScalars.Required, secondScalars.Required = nil, nil + firstScalars.Values, secondScalars.Values = nil, nil + return reflect.DeepEqual(firstScalars, secondScalars) && + equivalentStringSet(first.Required, second.Required) && + equivalentValueSet(first.Values, second.Values) +} + +// equivalentStringSet reports whether both slices contain the same distinct +// strings; validation order does not affect runtime behavior. +func equivalentStringSet(first, second []string) bool { + if len(first) != len(second) { + return false + } + for _, value := range first { + if !slices.Contains(second, value) { + return false + } + } + return true +} + +// equivalentValueSet reports whether both enum lists contain the same values +// regardless of declaration order. +func equivalentValueSet(first, second []any) bool { + if len(first) != len(second) { + return false + } + matched := make([]bool, len(second)) + for _, value := range first { + found := false + for index, candidate := range second { + if !matched[index] && reflect.DeepEqual(value, candidate) { + matched[index] = true + found = true + break + } + } + if !found { + return false + } + } + return true +} + +// equivalentErrorMetadata compares metadata keys and ordered values while +// treating nil and empty maps or value slices as the same absent content. +func equivalentErrorMetadata(first, second MetaExpr) bool { + if len(first) != len(second) { + return false + } + for key, values := range first { + other, ok := second[key] + if !ok || !slices.Equal(values, other) { + return false + } + } + return true +} diff --git a/expr/error_contract_test.go b/expr/error_contract_test.go new file mode 100644 index 0000000000..f34a98081b --- /dev/null +++ b/expr/error_contract_test.go @@ -0,0 +1,268 @@ +// This file verifies canonical comparison of reusable transport error value +// contracts independently of authored ordering and explicit default storage. +package expr + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestEquivalentErrorAttributesUseEffectiveUnionKeys(t *testing.T) { + branches := []*NamedAttributeExpr{ + {Name: "text", Attribute: &AttributeExpr{Type: String}}, + } + implicit := &AttributeExpr{Type: &Union{TypeName: "Value", Values: branches}} + explicit := &AttributeExpr{Type: &Union{ + TypeName: "Value", + TypeKey: "type", + ValueKey: "value", + Values: branches, + }} + + require.True(t, equivalentErrorAttributes(implicit, explicit)) +} + +func TestEquivalentErrorAttributesIgnoreRequiredOrder(t *testing.T) { + first := requiredObject("first", "second") + second := requiredObject("second", "first") + + require.True(t, equivalentErrorAttributes(first, second)) +} + +func TestEquivalentErrorAttributesMaterializeBases(t *testing.T) { + base := &UserTypeExpr{ + TypeName: "BaseError", + AttributeExpr: &AttributeExpr{ + Type: &Object{ + {Name: "message", Attribute: &AttributeExpr{ + Type: String, + DefaultValue: "invalid", + Meta: MetaExpr{"struct:field:name": {"Message"}}, + }}, + }, + Validation: &ValidationExpr{Required: []string{"message"}}, + }, + } + composed := &AttributeExpr{Type: &Object{}, Bases: []DataType{base}} + explicit := &AttributeExpr{ + Type: &Object{ + {Name: "message", Attribute: &AttributeExpr{ + Type: String, + DefaultValue: "invalid", + Meta: MetaExpr{"struct:field:name": {"Message"}}, + }}, + }, + Validation: &ValidationExpr{Required: []string{"message"}}, + } + + require.True(t, equivalentErrorAttributes(composed, explicit)) + require.Len(t, composed.Bases, 1) + require.False(t, composed.finalized) + require.Empty(t, *AsObject(composed.Type)) + require.False(t, base.AttributeExpr.finalized) +} + +func TestEquivalentErrorAttributesRejectDifferentEffectiveBases(t *testing.T) { + stringBase := errorBase("Base", String) + integerBase := errorBase("Base", Int) + first := &AttributeExpr{Type: &Object{}, Bases: []DataType{stringBase}} + second := &AttributeExpr{Type: &Object{}, Bases: []DataType{integerBase}} + + require.False(t, equivalentErrorAttributes(first, second)) +} + +func TestEquivalentErrorAttributesMaterializeReferences(t *testing.T) { + reference := &UserTypeExpr{ + TypeName: "ReferenceError", + AttributeExpr: &AttributeExpr{ + Type: &Object{ + {Name: "message", Attribute: &AttributeExpr{ + Type: String, + DefaultValue: "invalid", + }}, + }, + Validation: &ValidationExpr{Required: []string{"message"}}, + }, + } + referenced := &AttributeExpr{ + Type: &Object{ + {Name: "message", Attribute: &AttributeExpr{Type: String}}, + }, + References: []DataType{reference}, + } + explicit := &AttributeExpr{ + Type: &Object{ + {Name: "message", Attribute: &AttributeExpr{ + Type: String, + DefaultValue: "invalid", + }}, + }, + Validation: &ValidationExpr{Required: []string{"message"}}, + } + + require.True(t, equivalentErrorAttributes(referenced, explicit)) + require.Len(t, referenced.References, 1) + require.Nil(t, referenced.Find("message").Validation) + require.Nil(t, referenced.Find("message").DefaultValue) +} + +func TestEquivalentErrorAttributesRejectReferenceContractDrift(t *testing.T) { + for _, test := range []struct { + name string + mutate func(*AttributeExpr) + }{ + {"validation", func(attribute *AttributeExpr) { + minimum := 3 + attribute.Validation.MinLength = &minimum + }}, + {"default", func(attribute *AttributeExpr) { attribute.DefaultValue = "different" }}, + {"metadata", func(attribute *AttributeExpr) { + attribute.Meta["struct:field:name"] = []string{"Different"} + }}, + } { + t.Run(test.name, func(t *testing.T) { + referenced, explicit := referencedErrorContracts() + test.mutate(explicit.Find("message")) + + require.False(t, equivalentErrorAttributes(referenced, explicit)) + }) + } +} + +func TestEquivalentErrorAttributesCompareUnionBranchesPositionally(t *testing.T) { + first := &AttributeExpr{Type: &Union{TypeName: "Value", Values: []*NamedAttributeExpr{ + {Name: "text", Attribute: &AttributeExpr{Type: String}}, + {Name: "count", Attribute: &AttributeExpr{Type: Int}}, + }}} + second := &AttributeExpr{Type: &Union{TypeName: "Value", Values: []*NamedAttributeExpr{ + {Name: "count", Attribute: &AttributeExpr{Type: Int}}, + {Name: "text", Attribute: &AttributeExpr{Type: String}}, + }}} + + require.False(t, equivalentErrorAttributes(first, second)) +} + +func TestEquivalentErrorAttributesCopiesRecursiveDeclarations(t *testing.T) { + first := recursiveErrorType("RecursiveError") + second := recursiveErrorType("RecursiveError") + + require.True(t, equivalentErrorAttributes( + &AttributeExpr{Type: first}, + &AttributeExpr{Type: second}, + )) + require.False(t, first.AttributeExpr.finalized) + require.Same(t, first, first.Find("next").Type) +} + +func TestEffectiveErrorAttributeSharesNoMutableContractValues(t *testing.T) { + minimum := 2 + source := &AttributeExpr{ + Type: String, + Docs: &DocsExpr{Description: "source"}, + Validation: &ValidationExpr{ + MinLength: &minimum, + Values: []any{"first", "second"}, + }, + DefaultValue: []any{map[string]any{"message": "invalid"}}, + UserExamples: []*ExampleExpr{{Value: map[string]any{"message": "invalid"}}}, + } + + effective := effectiveErrorAttribute(source) + *effective.Validation.MinLength = 5 + effective.Validation.Values[0] = "changed" + effective.DefaultValue.([]any)[0].(map[string]any)["message"] = "changed" + effective.Docs.Description = "changed" + effective.UserExamples[0].Value.(map[string]any)["message"] = "changed" + + require.Equal(t, 2, *source.Validation.MinLength) + require.Equal(t, "first", source.Validation.Values[0]) + require.Equal(t, "invalid", source.DefaultValue.([]any)[0].(map[string]any)["message"]) + require.Equal(t, "source", source.Docs.Description) + require.Equal(t, "invalid", source.UserExamples[0].Value.(map[string]any)["message"]) +} + +func TestEffectiveErrorAttributeReconnectsCopiesByOrigin(t *testing.T) { + source := recursiveErrorType("RecursiveError") + first := Dup(source).(UserType) + second := Dup(source).(UserType) + root := &AttributeExpr{Type: &Object{ + {Name: "first", Attribute: &AttributeExpr{Type: first}}, + {Name: "second", Attribute: &AttributeExpr{Type: second}}, + }} + + effective := effectiveErrorAttribute(root) + firstCopy := effective.Find("first").Type.(UserType) + secondCopy := effective.Find("second").Type.(UserType) + + require.Same(t, firstCopy, secondCopy) + require.Same(t, firstCopy, firstCopy.Origin()) + require.NotSame(t, source, firstCopy.Origin()) +} + +// errorBase returns an object declaration whose only field has the given +// primitive type. The shared type name proves comparison uses effective shape. +func errorBase(name string, fieldType DataType) *UserTypeExpr { + return &UserTypeExpr{ + TypeName: name, + AttributeExpr: &AttributeExpr{Type: &Object{ + {Name: "value", Attribute: &AttributeExpr{Type: fieldType}}, + }}, + } +} + +// recursiveErrorType returns an unfinalized self-referential declaration. +func recursiveErrorType(name string) *UserTypeExpr { + result := &UserTypeExpr{TypeName: name} + result.AttributeExpr = &AttributeExpr{Type: &Object{ + {Name: "message", Attribute: &AttributeExpr{Type: String}}, + {Name: "next", Attribute: &AttributeExpr{Type: result}}, + }} + return result +} + +// referencedErrorContracts returns one inherited and one explicit error with +// the same field validation, default, and generated Go name. +func referencedErrorContracts() (*AttributeExpr, *AttributeExpr) { + referenceMinimum := 2 + field := &AttributeExpr{ + Type: String, + Validation: &ValidationExpr{MinLength: &referenceMinimum}, + DefaultValue: "invalid", + Meta: MetaExpr{"struct:field:name": {"Message"}}, + } + reference := &UserTypeExpr{ + TypeName: "ReferenceError", + AttributeExpr: &AttributeExpr{Type: &Object{ + {Name: "message", Attribute: field}, + }}, + } + referenced := &AttributeExpr{ + Type: &Object{ + {Name: "message", Attribute: &AttributeExpr{Type: String}}, + }, + References: []DataType{reference}, + } + explicitMinimum := 2 + explicit := &AttributeExpr{Type: &Object{ + {Name: "message", Attribute: &AttributeExpr{ + Type: String, + Validation: &ValidationExpr{MinLength: &explicitMinimum}, + DefaultValue: "invalid", + Meta: MetaExpr{"struct:field:name": {"Message"}}, + }}, + }} + return referenced, explicit +} + +// requiredObject returns the same two-field error object with the requested +// validation order so the test distinguishes authored order from semantics. +func requiredObject(required ...string) *AttributeExpr { + return &AttributeExpr{ + Type: &Object{ + {Name: "first", Attribute: &AttributeExpr{Type: String}}, + {Name: "second", Attribute: &AttributeExpr{Type: String}}, + }, + Validation: &ValidationExpr{Required: required}, + } +} diff --git a/expr/example.go b/expr/example.go index a2f1e32cc9..e10675ba5d 100644 --- a/expr/example.go +++ b/expr/example.go @@ -1,3 +1,6 @@ +// This file generates JSON-compatible examples from evaluated attributes. Each +// service, method, type, and field uses its own repeatable sequence so changes +// elsewhere do not change its example values. package expr import ( @@ -18,6 +21,13 @@ const ( // isn't such a value then Example computes a random value for the attribute // using the given random value producer. func (a *AttributeExpr) Example(r *ExampleGenerator) any { + if r.factory == nil { + return nil + } + if r.exampleRandomizer == nil { + panic("example generator must be anchored before drawing a value") + } + if ex := a.ExtractUserExamples(); len(ex) > 0 { // Return the last item in the slice so that examples can be overridden // in the DSL. Overridden examples are always appended to the UserExamples @@ -25,10 +35,6 @@ func (a *AttributeExpr) Example(r *ExampleGenerator) any { return ex[len(ex)-1].Value } - if r.Randomizer == nil { - return nil - } - value, ok := a.Meta.Last("openapi:example") if !ok { value, ok = a.Meta.Last("swagger:example") @@ -157,15 +163,15 @@ func byLength(a *AttributeExpr, r *ExampleGenerator) any { case MapKind: raw := make(map[any]any) m := dt.(*Map) - for range count { - raw[m.KeyType.Example(r)] = m.ElemType.Example(r) + for i := range count { + raw[m.KeyType.Example(r.MapKey(i))] = m.ElemType.Example(r.MapValue(i)) } return m.MakeMap(raw) case ArrayKind: raw := make([]any, count) ar := dt.(*Array) for i := range count { - raw[i] = ar.ElemType.Example(r) + raw[i] = ar.ElemType.Example(r.ArrayElement(i)) } return ar.MakeSlice(raw) default: diff --git a/expr/example_identity.go b/expr/example_identity.go new file mode 100644 index 0000000000..82ca6eb27d --- /dev/null +++ b/expr/example_identity.go @@ -0,0 +1,268 @@ +// This file builds repeatable keys for example values from evaluated service, +// method, type, field, and transport names. +package expr + +import ( + "encoding/base64" + "encoding/binary" +) + +type ( + // ExampleIdentity selects a repeatable sequence of generated example values. + // Equal values select the same sequence. Its fields are private so callers + // must use the constructors below. + ExampleIdentity struct { + seed string + } + + exampleIdentityKind byte +) + +const ( + userTypeExampleKind exampleIdentityKind = iota + 1 + methodPayloadExampleKind + methodResultExampleKind + methodStreamingPayloadExampleKind + methodStreamingResultExampleKind + methodErrorExampleKind + httpRequestBodyExampleKind + httpResponseBodyExampleKind + httpErrorResponseBodyExampleKind + jsonRPCRequestBodyExampleKind + jsonRPCResponseBodyExampleKind + jsonRPCErrorResponseBodyExampleKind + grpcRequestMessageExampleKind + grpcResponseMessageExampleKind + grpcStreamingRequestMessageExampleKind + grpcStreamingResponseMessageExampleKind + grpcErrorMessageExampleKind + grpcArrayWrapperExampleKind + grpcMapWrapperExampleKind + memberExampleKind + arrayElementExampleKind + mapKeyExampleKind + mapValueExampleKind + unionMemberExampleKind +) + +// UserTypeExampleIdentity returns the example key for typ. +func UserTypeExampleIdentity(typ UserType) ExampleIdentity { + if identity, ok := GeneratedUserTypeExampleIdentity(typ); ok { + return identity + } + return newExampleIdentity(userTypeExampleKind, []byte(typ.ID())) +} + +// GeneratedUserTypeExampleIdentity returns the example key stored on a user +// type created by Goa. The second result is false for types written in the +// design. +func GeneratedUserTypeExampleIdentity(typ UserType) (ExampleIdentity, bool) { + var identity ExampleIdentity + switch generated := typ.(type) { + case *UserTypeExpr: + identity = generated.exampleIdentity + case *ResultTypeExpr: + identity = generated.UserTypeExpr.exampleIdentity + } + return identity, identity.seed != "" +} + +// MethodPayloadExampleIdentity returns the example key for method's payload. +func MethodPayloadExampleIdentity(method *MethodExpr) ExampleIdentity { + return methodExampleIdentity(methodPayloadExampleKind, method) +} + +// MethodResultExampleIdentity returns the example key for method's result. +func MethodResultExampleIdentity(method *MethodExpr) ExampleIdentity { + return methodExampleIdentity(methodResultExampleKind, method) +} + +// MethodStreamingPayloadExampleIdentity returns the example key for method's +// streaming payload. +func MethodStreamingPayloadExampleIdentity(method *MethodExpr) ExampleIdentity { + return methodExampleIdentity(methodStreamingPayloadExampleKind, method) +} + +// MethodStreamingResultExampleIdentity returns the example key for method's +// streaming result. +func MethodStreamingResultExampleIdentity(method *MethodExpr) ExampleIdentity { + return methodExampleIdentity(methodStreamingResultExampleKind, method) +} + +// MethodErrorExampleIdentity returns the example key for err in method. +func MethodErrorExampleIdentity(method *MethodExpr, err *ErrorExpr) ExampleIdentity { + return newExampleIdentity( + methodErrorExampleKind, + []byte(method.Service.Name), + []byte(method.Name), + []byte(err.Name), + ) +} + +// RequestBodyExampleIdentity returns the example key for endpoint's request +// body. HTTP and JSON-RPC endpoints receive different keys. +func RequestBodyExampleIdentity(endpoint *HTTPEndpointExpr) ExampleIdentity { + kind := httpRequestBodyExampleKind + if endpoint.IsJSONRPC() { + kind = jsonRPCRequestBodyExampleKind + } + return newExampleIdentity( + kind, + []byte(endpoint.MethodExpr.Service.Name), + []byte(endpoint.MethodExpr.Name), + ) +} + +// ResponseBodyExampleIdentity returns the example key for a successful response +// body. HTTP and JSON-RPC endpoints receive different keys, and each successful +// status code receives its own key. +func ResponseBodyExampleIdentity(endpoint *HTTPEndpointExpr, response *HTTPResponseExpr) ExampleIdentity { + kind := httpResponseBodyExampleKind + if endpoint.IsJSONRPC() { + kind = jsonRPCResponseBodyExampleKind + } + return newExampleIdentity( + kind, + []byte(endpoint.MethodExpr.Service.Name), + []byte(endpoint.MethodExpr.Name), + exampleIdentityInt(response.StatusCode), + ) +} + +// ErrorResponseBodyExampleIdentity returns the example key for an error response +// body. HTTP and JSON-RPC endpoints receive different keys. The error name keeps +// two errors with the same HTTP status separate. +func ErrorResponseBodyExampleIdentity(endpoint *HTTPEndpointExpr, response *HTTPErrorExpr) ExampleIdentity { + kind := httpErrorResponseBodyExampleKind + if endpoint.IsJSONRPC() { + kind = jsonRPCErrorResponseBodyExampleKind + } + return newExampleIdentity( + kind, + []byte(endpoint.MethodExpr.Service.Name), + []byte(endpoint.MethodExpr.Name), + []byte(response.Name), + exampleIdentityInt(response.Response.StatusCode), + ) +} + +// GRPCRequestMessageExampleIdentity returns the example key for method's gRPC +// request message. +func GRPCRequestMessageExampleIdentity(method *MethodExpr) ExampleIdentity { + return methodExampleIdentity(grpcRequestMessageExampleKind, method) +} + +// GRPCResponseMessageExampleIdentity returns the example key for method's gRPC +// response message. +func GRPCResponseMessageExampleIdentity(method *MethodExpr) ExampleIdentity { + return methodExampleIdentity(grpcResponseMessageExampleKind, method) +} + +// GRPCStreamingRequestMessageExampleIdentity returns the example key for +// method's streaming gRPC request message. +func GRPCStreamingRequestMessageExampleIdentity(method *MethodExpr) ExampleIdentity { + return methodExampleIdentity(grpcStreamingRequestMessageExampleKind, method) +} + +// GRPCStreamingResponseMessageExampleIdentity returns the example key for +// method's streaming gRPC response message. +func GRPCStreamingResponseMessageExampleIdentity(method *MethodExpr) ExampleIdentity { + return methodExampleIdentity(grpcStreamingResponseMessageExampleKind, method) +} + +// GRPCErrorMessageExampleIdentity returns the example key for err's gRPC +// message in method. +func GRPCErrorMessageExampleIdentity(method *MethodExpr, err *ErrorExpr) ExampleIdentity { + return newExampleIdentity( + grpcErrorMessageExampleKind, + []byte(method.Service.Name), + []byte(method.Name), + []byte(err.Name), + ) +} + +// GRPCArrayWrapperExampleIdentity returns the example key for the gRPC message +// that wraps an array type written in the design. +func GRPCArrayWrapperExampleIdentity(typ UserType) ExampleIdentity { + if !IsArray(typ) { + panic("gRPC array wrapper identity requires an array user type") + } + return newExampleIdentity(grpcArrayWrapperExampleKind, []byte(typ.Origin().ID())) +} + +// GRPCMapWrapperExampleIdentity returns the example key for the gRPC message +// that wraps a map type written in the design. +func GRPCMapWrapperExampleIdentity(typ UserType) ExampleIdentity { + if !IsMap(typ) { + panic("gRPC map wrapper identity requires a map user type") + } + return newExampleIdentity(grpcMapWrapperExampleKind, []byte(typ.Origin().ID())) +} + +// Seed returns the complete encoded key passed to custom randomizer factories. +func (i ExampleIdentity) Seed() string { + return base64.RawURLEncoding.EncodeToString([]byte(i.seed)) +} + +// Member returns the example key for name within i. +func (i ExampleIdentity) Member(name string) ExampleIdentity { + return i.append(memberExampleKind, []byte(name)) +} + +// ArrayElement returns the example key for index within the array at i. +func (i ExampleIdentity) ArrayElement(index int) ExampleIdentity { + return i.append(arrayElementExampleKind, exampleIdentityInt(index)) +} + +// MapKey returns the example key for key index within the map at i. +func (i ExampleIdentity) MapKey(index int) ExampleIdentity { + return i.append(mapKeyExampleKind, exampleIdentityInt(index)) +} + +// MapValue returns the example key for value index within the map at i. +func (i ExampleIdentity) MapValue(index int) ExampleIdentity { + return i.append(mapValueExampleKind, exampleIdentityInt(index)) +} + +// UnionMember returns the example key for name within the union at i. +func (i ExampleIdentity) UnionMember(name string) ExampleIdentity { + return i.append(unionMemberExampleKind, []byte(name)) +} + +// A new example key encodes a value kind and each component's byte length so +// different component lists cannot produce the same key. +func newExampleIdentity(kind exampleIdentityKind, components ...[]byte) ExampleIdentity { + return ExampleIdentity{seed: string(appendExampleIdentitySegment(nil, kind, components...))} +} + +// Method example keys are built from the evaluated service and method names. +func methodExampleIdentity(kind exampleIdentityKind, method *MethodExpr) ExampleIdentity { + return newExampleIdentity(kind, []byte(method.Service.Name), []byte(method.Name)) +} + +// Integers in example keys are written as eight bytes in big-endian order. +func exampleIdentityInt(value int) []byte { + return binary.BigEndian.AppendUint64(nil, uint64(value)) +} + +// Each added part writes its kind, number of components, and every component's +// byte length before the component bytes. +func appendExampleIdentitySegment(seed []byte, kind exampleIdentityKind, components ...[]byte) []byte { + seed = append(seed, byte(kind)) + seed = binary.BigEndian.AppendUint64(seed, uint64(len(components))) + for _, component := range components { + seed = binary.BigEndian.AppendUint64(seed, uint64(len(component))) + seed = append(seed, component...) + } + return seed +} + +// append adds one member, array, map, or union part to the current key. +func (i ExampleIdentity) append(kind exampleIdentityKind, components ...[]byte) ExampleIdentity { + if i.seed == "" { + panic("example identity must have a semantic owner before structural descent") + } + seed := append([]byte(nil), i.seed...) + seed = appendExampleIdentitySegment(seed, kind, components...) + return ExampleIdentity{seed: string(seed)} +} diff --git a/expr/example_stability_test.go b/expr/example_stability_test.go index 95e4d17016..0391e5f98e 100644 --- a/expr/example_stability_test.go +++ b/expr/example_stability_test.go @@ -1,3 +1,5 @@ +// This file verifies that typed example owners make values independent of +// unrelated draws while preserving member-local values in composite examples. package expr_test import ( @@ -27,11 +29,16 @@ func TestExampleOrderIndependence(t *testing.T) { } // Reference value computed on a fresh generator. - ref := newUT("Stable").Example(expr.NewRandom("test")) + stable := newUT("Stable") + ref := stable.Example(expr.NewExampleGenerator(expr.NewFakerRandomizerFactory("test")).At( + expr.UserTypeExampleIdentity(stable), + )) require.NotNil(t, ref) // Same value after unrelated draws were consumed from the generator. - r := expr.NewRandom("test") + r := expr.NewExampleGenerator(expr.NewFakerRandomizerFactory("test")).At( + expr.MethodPayloadExampleIdentity(exampleMethod("noise", "draw")), + ) noise := make([]any, 0, 14) for range 7 { noise = append(noise, r.Int(), r.String()) @@ -40,8 +47,11 @@ func TestExampleOrderIndependence(t *testing.T) { require.Equal(t, ref, newUT("Stable").Example(r)) // Same value after another type's example was computed first. - r = expr.NewRandom("test") - newUT("Other").Example(r) + r = expr.NewExampleGenerator(expr.NewFakerRandomizerFactory("test")).At( + expr.MethodPayloadExampleIdentity(exampleMethod("noise", "other")), + ) + other := newUT("Other") + other.Example(r.At(expr.UserTypeExampleIdentity(other))) require.Equal(t, ref, newUT("Stable").Example(r)) } @@ -57,8 +67,9 @@ func TestExampleFieldLocality(t *testing.T) { {Name: "b", Attribute: &expr.AttributeExpr{Type: expr.Int}}, {Name: "c", Attribute: &expr.AttributeExpr{Type: expr.Boolean}}, } - exSmall := small.Example(expr.NewRandom("test")).(map[string]any) - exLarge := large.Example(expr.NewRandom("test")).(map[string]any) + owner := expr.MethodPayloadExampleIdentity(exampleMethod("locality", "object")) + exSmall := small.Example(expr.NewExampleGenerator(expr.NewFakerRandomizerFactory("test")).At(owner)).(map[string]any) + exLarge := large.Example(expr.NewExampleGenerator(expr.NewFakerRandomizerFactory("test")).At(owner)).(map[string]any) require.Equal(t, exSmall["a"], exLarge["a"]) require.Equal(t, exSmall["b"], exLarge["b"]) } @@ -77,9 +88,8 @@ func TestExampleFieldAnchor(t *testing.T) { }, }, } - parent := &expr.AttributeExpr{Type: ut} - - composite := ut.Example(expr.NewRandom("test")).(map[string]any) - standalone := field.Example(expr.NewRandom("test").Field(parent, "id")) + identity := expr.UserTypeExampleIdentity(ut) + composite := ut.Example(expr.NewExampleGenerator(expr.NewFakerRandomizerFactory("test")).At(identity)).(map[string]any) + standalone := field.Example(expr.NewExampleGenerator(expr.NewFakerRandomizerFactory("test")).At(identity).Member("id")) require.Equal(t, composite["id"], standalone) } diff --git a/expr/example_test.go b/expr/example_test.go index 9efbeb9e06..57541d486b 100644 --- a/expr/example_test.go +++ b/expr/example_test.go @@ -1,3 +1,5 @@ +// This file exercises attribute example generation across validation rules and +// confirms every configured generator is anchored to its owning expression. package expr_test import ( @@ -21,11 +23,13 @@ func TestByPattern(t *testing.T) { {"max-len", "foo[a-z]+", 9}, {"max-len-2", "^/api/example/[0-9]+$", 19}, } - r := expr.NewRandom("test") for _, k := range cases { t.Run(k.Name, func(t *testing.T) { val := &expr.ValidationExpr{Pattern: k.Pattern} att := expr.AttributeExpr{Validation: val} + r := expr.NewExampleGenerator(expr.NewFakerRandomizerFactory("test")).At( + expr.MethodPayloadExampleIdentity(exampleMethod("pattern", k.Name)), + ) example := att.Example(r).(string) @@ -42,7 +46,9 @@ func TestByPattern(t *testing.T) { func TestByFormatUUID(t *testing.T) { val := &expr.ValidationExpr{Format: expr.FormatUUID} att := expr.AttributeExpr{Validation: val} - r := expr.NewRandom("test") + r := expr.NewExampleGenerator(expr.NewFakerRandomizerFactory("test")).At( + expr.MethodPayloadExampleIdentity(exampleMethod("format", "uuid")), + ) example := att.Example(r).(string) if !regexp.MustCompile(`[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}`).MatchString(example) { t.Errorf("got %s, expected a match with `[0-9A-F]{8}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{12}`", example) @@ -68,12 +74,15 @@ func TestExample(t *testing.T) { {"openapi-generate-false-array-example", testdata.OpenAPIGenerateFalseArrayExampleDSL, map[string]any{"items": []map[string]any{{"name": "example"}}}, ""}, {"overriding-hidden-examples", testdata.OverridingHiddenExamplesDSL, "example", ""}, } - r := expr.NewRandom("test") for _, k := range cases { t.Run(k.Name, func(t *testing.T) { if k.Error == "" { expr.RunDSL(t, k.DSL) - example := expr.Root.Services[0].Methods[0].Payload.Example(r) + method := expr.Root.Services[0].Methods[0] + r := expr.NewExampleGenerator(expr.NewFakerRandomizerFactory("test")).At( + expr.MethodPayloadExampleIdentity(method), + ) + example := method.Payload.Example(r) if !reflect.DeepEqual(example, k.Expected) { t.Errorf("invalid example: got %v, expected %v", example, k.Expected) } @@ -92,14 +101,15 @@ func TestExample(t *testing.T) { // can generate examples correctly. Previously, this would panic because the // code checked a.Type.Kind() instead of the underlying type's kind. func TestByLengthWithAliasType(t *testing.T) { - r := expr.NewRandom("test") - // Create an alias type based on String with length validation // We need to use the DSL package properly root := expr.RunDSL(t, testdata.AliasLengthValidationDSL) aliasType := root.UserType("ValidatedString") att := aliasType.Attribute() + r := expr.NewExampleGenerator(expr.NewFakerRandomizerFactory("test")).At( + expr.UserTypeExampleIdentity(aliasType), + ) // This should not panic and should generate a string example // The key test is that byLength handles alias types correctly by unaliasing @@ -119,12 +129,13 @@ func TestByLengthWithAliasType(t *testing.T) { // TestByLengthWithAliasArray tests that alias array types with length // validations generate examples correctly. func TestByLengthWithAliasArray(t *testing.T) { - r := expr.NewRandom("test") - root := expr.RunDSL(t, testdata.AliasArrayLengthValidationDSL) aliasType := root.UserType("StringArray") att := aliasType.Attribute() + r := expr.NewExampleGenerator(expr.NewFakerRandomizerFactory("test")).At( + expr.UserTypeExampleIdentity(aliasType), + ) // This should not panic and should generate an array example example := att.Example(r) diff --git a/expr/grpc.go b/expr/grpc.go index f85b437b4e..dd15a3ff06 100644 --- a/expr/grpc.go +++ b/expr/grpc.go @@ -1,8 +1,11 @@ package expr +import "goa.design/goa/v3/eval" + type ( // GRPCExpr contains the API level gRPC specific expressions. GRPCExpr struct { + eval.DSLFunc // Services contains the gRPC services created by the DSL. Services []*GRPCServiceExpr // Errors lists the error gRPC error responses defined globally. diff --git a/expr/grpc_endpoint.go b/expr/grpc_endpoint.go index 11a0f76d45..2e621361b4 100644 --- a/expr/grpc_endpoint.go +++ b/expr/grpc_endpoint.go @@ -1,3 +1,5 @@ +// This file prepares, validates, and finalizes the gRPC transport contract for +// one service method. package expr import ( @@ -118,7 +120,7 @@ func (e *GRPCEndpointExpr) Prepare() { continue } // Lookup undefined GRPC errors in API. - for _, v := range Root.API.GRPC.Errors { + for _, v := range e.MethodExpr.Service.design.API.GRPC.Errors { if me.Name == v.Name { e.GRPCErrors = append(e.GRPCErrors, v.Dup()) } @@ -138,7 +140,7 @@ func (e *GRPCEndpointExpr) Prepare() { } } if !found { - for _, ae := range Root.API.GRPC.Errors { + for _, ae := range e.MethodExpr.Service.design.API.GRPC.Errors { if se.Name == ae.Name { e.GRPCErrors = append(e.GRPCErrors, ae.Dup()) break @@ -171,6 +173,9 @@ func (e *GRPCEndpointExpr) Validate() error { if e.Name() == "" { verr.Add(e, "Endpoint name cannot be empty") } + if e.MethodExpr.HasMixedResults() { + verr.Add(e, "gRPC method %q cannot define both Result and StreamingResult because one gRPC call cannot return a separate result after its response stream", e.MethodExpr.Name) + } verr.Merge(e.validateStreamCompat()) seenUnions := make(map[*Union]struct{}) @@ -247,10 +252,36 @@ func (e *GRPCEndpointExpr) Validate() error { // their fields must define field numbers, mirroring the payload and // result checks above. Default ErrorResult errors travel in the gRPC // status and need no tags. - if ee := e.MethodExpr.Error(er.Name); ee != nil && ee.Type != ErrorResult && IsObject(ee.Type) { + if ee := e.MethodExpr.Error(er.Name); ee != nil && !IsErrorResult(ee.Type) && IsObject(ee.Type) { verr.Merge(validateRPCTags(AsObject(ee.Type), e)) } } + verr.Merge(e.validateErrorMappings()) + return verr +} + +// validateErrorMappings ensures inherited gRPC response policy describes the +// same concrete error value returned by the endpoint method. +func (e *GRPCEndpointExpr) validateErrorMappings() *eval.ValidationErrors { + verr := new(eval.ValidationErrors) + for _, mapping := range e.GRPCErrors { + mapped, owner := mapping.mappedError(e.MethodExpr.Service.design) + method := e.MethodExpr.Error(mapping.Name) + if mapped == nil || method == nil || equivalentErrorAttributes(mapped.AttributeExpr, method.AttributeExpr) { + continue + } + verr.Add( + mapping.Response, + `gRPC error mapping %q inherited from the %s uses error type %q, but method %q of service %q uses %q; both definitions must define the same error attribute; %s`, + mapping.Name, + owner, + mapped.Type.Name(), + e.MethodExpr.Name, + e.MethodExpr.Service.Name, + method.Type.Name(), + errorAttributeDifference(mapped.AttributeExpr, method.AttributeExpr), + ) + } return verr } @@ -545,6 +576,8 @@ func validateMetadata(metAtt *MappedAttributeExpr, serviceAtt *AttributeExpr, e for _, nat := range *AsObject(metAtt.Type) { if a := serviceAtt.Find(nat.Name); a == nil { verr.Add(e, "%s metadata attribute %q is not found in %s", metKind, nat.Name, serviceKind) + } else if !isMetadataEncodable(a.Type) { + verr.Add(e, "%s metadata attribute %q must be a primitive or an array of primitives, got %s", metKind, nat.Name, a.Type.Name()) } } } else { @@ -602,7 +635,7 @@ func (e *GRPCEndpointExpr) streamCompatValue() (string, bool) { if v, ok := e.Service.ServiceExpr.Meta.Last(streamCompatMetaKey); ok { return v, true } - if v, ok := Root.API.Meta.Last(streamCompatMetaKey); ok { + if v, ok := e.MethodExpr.Service.design.API.Meta.Last(streamCompatMetaKey); ok { return v, true } return "", false diff --git a/expr/grpc_endpoint_test.go b/expr/grpc_endpoint_test.go index 953e1430d0..cee9bfd6d3 100644 --- a/expr/grpc_endpoint_test.go +++ b/expr/grpc_endpoint_test.go @@ -1,3 +1,5 @@ +// This file verifies gRPC endpoint preparation and validation, including the +// native primitive contract required by request and response metadata. package expr_test import ( @@ -20,6 +22,22 @@ func TestGRPCEndpointValidation(t *testing.T) { DSL: testdata.GRPCEndpointWithAnyType, Errors: []string{}, // Any type is now supported in gRPC }, + "endpoint-with-mixed-results": { + DSL: testdata.GRPCEndpointWithMixedResults, + Errors: []string{ + `service "Service" gRPC endpoint "Method": gRPC method "Method" cannot define both Result and StreamingResult because one gRPC call cannot return a separate result after its response stream`, + }, + }, + "endpoint-with-matching-mixed-results": { + DSL: testdata.GRPCEndpointWithMatchingMixedResults, + Errors: []string{ + `service "Service" gRPC endpoint "Method": gRPC method "Method" cannot define both Result and StreamingResult because one gRPC call cannot return a separate result after its response stream`, + }, + }, + "endpoint-with-streaming-result": { + DSL: testdata.GRPCEndpointWithStreamingResult, + Errors: []string{}, + }, "endpoint-with-untagged-fields": { DSL: testdata.GRPCEndpointWithUntaggedFields, Errors: []string{`service "Service" gRPC endpoint "Method": attribute "req_not_field" does not have "rpc:tag" defined in the meta, use "Field" to define the attribute of a type used in a gRPC method @@ -40,6 +58,15 @@ service "Service" gRPC endpoint "Method": field number 2 in attribute "key_dup_i DSL: testdata.GRPCEndpointWithExtendedTypes, Errors: []string{}, }, + "endpoint-with-composite-metadata": { + DSL: testdata.GRPCEndpointWithCompositeMetadata, + Errors: []string{`service "Service" gRPC endpoint "Method": Request metadata attribute "object" must be a primitive or an array of primitives, got MetadataObject +service "Service" gRPC endpoint "Method": Request metadata attribute "mapping" must be a primitive or an array of primitives, got map +service "Service" gRPC endpoint "Method": Request metadata attribute "choice" must be a primitive or an array of primitives, got choice +service "Service" gRPC endpoint "Method": Response metadata attribute "object" must be a primitive or an array of primitives, got MetadataObject +service "Service" gRPC endpoint "Method": Response metadata attribute "mapping" must be a primitive or an array of primitives, got map +service "Service" gRPC endpoint "Method": Response metadata attribute "choice" must be a primitive or an array of primitives, got choice`}, + }, "endpoint-with-inherit-error": { DSL: testdata.GRPCEndpointWithInheritErrorDSL, Errors: []string{}, diff --git a/expr/grpc_error.go b/expr/grpc_error.go index b387254e91..af4fc7a75e 100644 --- a/expr/grpc_error.go +++ b/expr/grpc_error.go @@ -1,3 +1,5 @@ +// This file binds reusable gRPC error-response policy to the concrete error +// returned by each endpoint method. package expr import ( @@ -29,15 +31,15 @@ func (e *GRPCErrorExpr) Validate() *eval.ValidationErrors { switch p := e.Response.Parent.(type) { case *GRPCEndpointExpr: if p.MethodExpr.Error(e.Name) == nil { - verr.Add(e, "Error %#v does not match an error defined in the method", e.Name) + verr.Add(e.Response, "Error %#v does not match an error defined in the method", e.Name) } case *GRPCServiceExpr: if p.Error(e.Name) == nil { - verr.Add(e, "Error %#v does not match an error defined in the service", e.Name) + verr.Add(e.Response, "Error %#v does not match an error defined in the service", e.Name) } case *RootExpr: - if Root.Error(e.Name) == nil { - verr.Add(e, "Error %#v does not match an error defined in the API", e.Name) + if p.Error(e.Name) == nil { + verr.Add(e.Response, "Error %#v does not match an error defined in the API", e.Name) } } return verr @@ -45,17 +47,24 @@ func (e *GRPCErrorExpr) Validate() *eval.ValidationErrors { // Finalize looks up the corresponding method error expression. func (e *GRPCErrorExpr) Finalize(a *GRPCEndpointExpr) { - var ee *ErrorExpr - switch p := e.Response.Parent.(type) { + e.ErrorExpr = a.MethodExpr.Error(e.Name) + e.Response.Finalize(a, e.AttributeExpr) +} + +// mappedError returns the error declaration described by this reusable gRPC +// response before it is copied to an endpoint. +func (e *GRPCErrorExpr) mappedError(root *RootExpr) (*ErrorExpr, string) { + switch parent := e.Response.Parent.(type) { case *GRPCEndpointExpr: - ee = p.MethodExpr.Error(e.Name) + return parent.MethodExpr.Error(e.Name), "method" case *GRPCServiceExpr: - ee = p.Error(e.Name) + return parent.Error(e.Name), "service" case *GRPCExpr: - ee = Root.Error(e.Name) + return root.Error(e.Name), "API" + case *RootExpr: + return parent.Error(e.Name), "API" } - e.ErrorExpr = ee - e.Response.Finalize(a, e.AttributeExpr) + return nil, "" } // Dup creates a copy of the error expression. diff --git a/expr/grpc_service.go b/expr/grpc_service.go index 2fffe92eba..f3db5662ec 100644 --- a/expr/grpc_service.go +++ b/expr/grpc_service.go @@ -60,14 +60,13 @@ func (svc *GRPCServiceExpr) EndpointFor(name string, m *MethodExpr) *GRPCEndpoin return a } -// Error returns the error with the given name. +// Error returns the error contract available to service-level gRPC mappings. +// Service declarations take precedence over reusable API declarations. func (svc *GRPCServiceExpr) Error(name string) *ErrorExpr { - for _, erro := range svc.ServiceExpr.Errors { - if erro.Name == name { - return erro - } + if serviceError := svc.ServiceExpr.Error(name); serviceError != nil { + return serviceError } - return Root.Error(name) + return svc.ServiceExpr.design.Error(name) } // GRPCError returns the service gRPC error with given name if any. @@ -102,7 +101,7 @@ func (svc *GRPCServiceExpr) Validate() error { for _, er := range svc.GRPCErrors { verr.Merge(er.Validate()) } - for _, er := range Root.API.GRPC.Errors { + for _, er := range svc.ServiceExpr.design.API.GRPC.Errors { // This may result in the same error being validated multiple // times however service is the top level expression being // walked and errors cannot be walked until all expressions have diff --git a/expr/http.go b/expr/http.go index 9e77784744..52090bac36 100644 --- a/expr/http.go +++ b/expr/http.go @@ -2,11 +2,14 @@ package expr import ( "regexp" + + "goa.design/goa/v3/eval" ) type ( // HTTPExpr contains the API level HTTP specific expressions. HTTPExpr struct { + eval.DSLFunc // Path is the common request path prefix to all the service // HTTP endpoints. Path string diff --git a/expr/http_authored_attribute_test.go b/expr/http_authored_attribute_test.go new file mode 100644 index 0000000000..7782b4a5f4 --- /dev/null +++ b/expr/http_authored_attribute_test.go @@ -0,0 +1,71 @@ +// This file verifies that copied HTTP request and response fields still point +// to the service fields that supplied their descriptions and examples. +package expr + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestHTTPBodyAttributesKeepAuthoredAttribute(t *testing.T) { + payloadChild := &AttributeExpr{Type: String} + payload := &AttributeExpr{Type: &Object{{Name: "message", Attribute: payloadChild}}} + resultChild := &AttributeExpr{Type: String} + result := &AttributeExpr{Type: &Object{{Name: "message", Attribute: resultChild}}} + serviceMethod := &ServiceExpr{Name: "messages"} + serviceMethod.design = &RootExpr{API: NewAPIExpr("messages", nil)} + method := &MethodExpr{ + Name: "show", + Service: serviceMethod, + Payload: payload, + Result: result, + } + service := &HTTPServiceExpr{ServiceExpr: serviceMethod} + endpoint := &HTTPEndpointExpr{ + MethodExpr: method, + Service: service, + Params: NewEmptyMappedAttributeExpr(), + Headers: NewEmptyMappedAttributeExpr(), + Cookies: NewEmptyMappedAttributeExpr(), + } + response := &HTTPResponseExpr{ + Headers: NewEmptyMappedAttributeExpr(), + Cookies: NewEmptyMappedAttributeExpr(), + } + + requestBody := httpRequestBody(endpoint) + responseBody := buildHTTPResponseBody("show", result, response, MethodResultExampleIdentity(method)) + + require.Same(t, payload, requestBody.AuthoredAttribute()) + require.Same(t, result, responseBody.AuthoredAttribute()) + require.Same(t, payloadChild, AsObject(requestBody.Type).Attribute("message").AuthoredAttribute()) + require.Same(t, resultChild, AsObject(responseBody.Type).Attribute("message").AuthoredAttribute()) +} + +func TestHTTPStreamingBodyKeepsAuthoredAttribute(t *testing.T) { + streaming := &AttributeExpr{Type: &Object{{Name: "message", Attribute: &AttributeExpr{Type: String}}}} + method := &MethodExpr{ + Name: "watch", + Service: &ServiceExpr{Name: "events"}, + Payload: &AttributeExpr{Type: Empty}, + Result: &AttributeExpr{Type: Empty}, + StreamingPayload: streaming, + Stream: ClientStreamKind, + } + endpoint := &HTTPEndpointExpr{MethodExpr: method} + + body := httpStreamingBody(endpoint) + + require.Same(t, streaming, body.AuthoredAttribute()) +} + +func TestHTTPPlaceholderKeepsAuthoredAttribute(t *testing.T) { + authored := &AttributeExpr{Type: String, Description: "description"} + placeholder := &AttributeExpr{Type: String} + + initAttrFromDesign(placeholder, authored) + + require.Same(t, authored, placeholder.AuthoredAttribute()) + require.Equal(t, "description", placeholder.Description) +} diff --git a/expr/http_body_types.go b/expr/http_body_types.go index d681e90c0c..2d4bd25d19 100644 --- a/expr/http_body_types.go +++ b/expr/http_body_types.go @@ -1,67 +1,13 @@ +// HTTP body type helpers derive request and response shapes from service types +// without changing the original design declarations. package expr import ( - "encoding/json" - "fmt" "net/http" "strings" "unicode" ) -// UnionToObject returns an object adequate to serialize the given union type in -// HTTP requests and responses. The object has two fields for the discriminator -// and value, with names determined by the union's Meta tags (defaulting to -// "Type" and "Value"). The discriminator field indicates the name of the union -// type, and the value field contains the JSON encoded union value. -func UnionToObject(att *AttributeExpr) *AttributeExpr { - example := att.Example(Root.API.ExampleGenerator) - js, err := json.Marshal(example) - if err != nil { - js = []byte("null") - } - union := AsUnion(att.Type) - values := union.Values - typeKey := union.GetTypeKey() - valueKey := union.GetValueKey() - - names := make([]any, len(values)) - vals := make([]string, len(values)) - bases := make([]DataType, len(values)) - for i, nat := range values { - names[i] = nat.Name - vals[i] = fmt.Sprintf("- %q", nat.Name) - bases[i] = nat.Attribute.Type - } - obj := Object([]*NamedAttributeExpr{ - {Name: typeKey, Attribute: &AttributeExpr{ - Type: String, - Description: "Union type name, one of:\n" + strings.Join(vals, "\n"), - Validation: &ValidationExpr{Values: names}, - Meta: MetaExpr{ - "struct:tag:form": {typeKey}, - "struct:tag:json": {typeKey}, - "struct:tag:xml": {typeKey}, - }, - }}, - {Name: valueKey, Attribute: &AttributeExpr{ - Type: String, - Description: "JSON encoded union value", - UserExamples: []*ExampleExpr{{Value: string(js)}}, - Bases: bases, // For OpenAPI generation - Meta: MetaExpr{ - "struct:tag:form": {valueKey}, - "struct:tag:json": {valueKey}, - "struct:tag:xml": {valueKey}, - }, - }}, - }) - return &AttributeExpr{ - Type: &obj, - Description: att.Description, - Validation: &ValidationExpr{Required: []string{typeKey, valueKey}}, - } -} - // defaultRequestHeaderAttributes returns a map keyed by the names of the // payload attributes that should come from the request HTTP headers by default. // This includes mapping done for certain authorization schemes (basic auth, @@ -80,8 +26,8 @@ func defaultRequestHeaderAttributes(e *HTTPEndpointExpr) map[string]bool { requirements = e.MethodExpr.Requirements case len(e.Service.ServiceExpr.Requirements) > 0: requirements = e.Service.ServiceExpr.Requirements - case len(Root.API.Requirements) > 0: - requirements = Root.API.Requirements + case len(e.MethodExpr.Service.design.API.Requirements) > 0: + requirements = e.MethodExpr.Service.design.API.Requirements } if len(requirements) == 0 { return nil @@ -129,15 +75,17 @@ func defaultRequestHeaderAttributes(e *HTTPEndpointExpr) map[string]bool { // by removing the attributes of the method payload used to define headers and // parameters. func httpRequestBody(a *HTTPEndpointExpr) *AttributeExpr { - const suffix = "RequestBody" var ( name = concat(a.Name(), "Request", "Body") ) if a.Body != nil { + if a.Body.Type == Empty { + return a.Body + } a.Body = DupAtt(a.Body) - renameType(a.Body, name, suffix) - if ut, ok := a.Body.Type.(*UserTypeExpr); ok { - ut.UID = a.Service.Name() + "#" + name + renameType(a.Body, name) + if ut, ok := a.Body.Type.(UserType); ok { + a.Body.Type = generatedUserType(ut, RequestBodyExampleIdentity(a)) } return a.Body } @@ -150,7 +98,6 @@ func httpRequestBody(a *HTTPEndpointExpr) *AttributeExpr { bodyOnly = headers.IsEmpty() && params.IsEmpty() && cookies.IsEmpty() && a.MapQueryParams == nil ) - // 1. If Payload is not an object then check whether there are // 2. If Payload is not an object then check whether there are // params, cookies or headers defined and if so return empty type // (payload encoded in request params or headers) otherwise return @@ -159,7 +106,7 @@ func httpRequestBody(a *HTTPEndpointExpr) *AttributeExpr { if bodyOnly { payload = DupAtt(payload) RemovePkgPath(payload) - renameType(payload, name, suffix) + renameType(payload, name) return payload } return &AttributeExpr{Type: Empty} @@ -178,21 +125,22 @@ func httpRequestBody(a *HTTPEndpointExpr) *AttributeExpr { for att := range defaultRequestHeaderAttributes(a) { removeAttribute(body, att) } + if a.IsJSONRPC() { + for _, id := range jsonRPCIDFields(payload) { + if id.depth == 1 { + removeAttribute(body, id.name) + } + } + } - // 4. Return empty type if no attribute left + // 5. Return empty type if no attribute left if len(*AsObject(body.Type)) == 0 { return &AttributeExpr{Type: Empty} } - // 5. Build computed user type + // 6. Build computed user type att := body.Attribute() - ut := &UserTypeExpr{ - AttributeExpr: att, - TypeName: name, - UID: a.Service.Name() + "#" + a.Name(), - } - appendSuffix(ut.Attribute().Type, suffix) - + ut := NewGeneratedUserType(name, att, RequestBodyExampleIdentity(a)) if t, ok := payload.Type.(UserType); ok { copyOpenAPITypeMeta(t, ut) } @@ -201,6 +149,7 @@ func httpRequestBody(a *HTTPEndpointExpr) *AttributeExpr { Type: ut, Validation: att.Validation, UserExamples: att.UserExamples, + authored: payload.AuthoredAttribute(), } } @@ -214,7 +163,6 @@ func httpStreamingBody(e *HTTPEndpointExpr) *AttributeExpr { if !IsObject(att.Type) { return DupAtt(att) } - const suffix = "StreamingBody" dupped := DupAtt(att) // Method attributes that reference user types keep validation on the // referenced type. Promote it to the computed body so HTTP type generation @@ -225,17 +173,17 @@ func httpStreamingBody(e *HTTPEndpointExpr) *AttributeExpr { } } RemovePkgPath(dupped) - appendSuffix(dupped.Type, suffix) - ut := &UserTypeExpr{ - AttributeExpr: dupped, - TypeName: concat(e.Name(), "Streaming", "Body"), - UID: e.Service.Name() + "#" + e.Name() + "StreamingBody", - } + ut := NewGeneratedUserType( + concat(e.Name(), "Streaming", "Body"), + dupped, + MethodStreamingPayloadExampleIdentity(e.MethodExpr), + ) return &AttributeExpr{ Type: ut, Validation: dupped.Validation, UserExamples: att.UserExamples, + authored: att.AuthoredAttribute(), } } @@ -250,7 +198,8 @@ func httpResponseBody(a *HTTPEndpointExpr, resp *HTTPResponseExpr) *AttributeExp suffix = http.StatusText(resp.StatusCode) } name = a.Name() + suffix - return buildHTTPResponseBody(name, a.MethodExpr.Result, resp, a.Service) + identity := ResponseBodyExampleIdentity(a, resp) + return buildHTTPResponseBody(name, a.MethodExpr.Result, resp, identity) } // httpErrorResponseBody returns an attribute describing the response body of a @@ -260,11 +209,11 @@ func httpResponseBody(a *HTTPEndpointExpr, resp *HTTPResponseExpr) *AttributeExp // parameters. func httpErrorResponseBody(e *HTTPEndpointExpr, v *HTTPErrorExpr) *AttributeExpr { name := e.Name() + "_" + v.ErrorExpr.Name - return buildHTTPResponseBody(name, v.AttributeExpr, v.Response, e.Service) + identity := ErrorResponseBodyExampleIdentity(e, v) + return buildHTTPResponseBody(name, v.AttributeExpr, v.Response, identity) } -func buildHTTPResponseBody(name string, attr *AttributeExpr, resp *HTTPResponseExpr, svc *HTTPServiceExpr) *AttributeExpr { - const suffix = "ResponseBody" +func buildHTTPResponseBody(name string, attr *AttributeExpr, resp *HTTPResponseExpr, identity ExampleIdentity) *AttributeExpr { name = concat(name, "Response", "Body") if attr == nil || attr.Type == Empty { return &AttributeExpr{Type: Empty} @@ -282,12 +231,9 @@ func buildHTTPResponseBody(name string, attr *AttributeExpr, resp *HTTPResponseE return &AttributeExpr{Type: Empty} } att := DupAtt(resp.Body) - renameType(att, name, suffix) - if ut, ok := att.Type.(*UserTypeExpr); ok { - ut.UID = svc.Name() + "#" + name - } - if rt, ok := att.Type.(*ResultTypeExpr); ok { - rt.UID = svc.Name() + "#" + name + renameType(att, name) + if ut, ok := att.Type.(UserType); ok { + att.Type = generatedUserType(ut, identity) } return att } @@ -300,7 +246,7 @@ func buildHTTPResponseBody(name string, attr *AttributeExpr, resp *HTTPResponseE if resp.Headers.IsEmpty() && resp.Cookies.IsEmpty() { attr = DupAtt(attr) RemovePkgPath(attr) - renameType(attr, name, "Response") // Do not use ResponseBody as it could clash with name of element + renameType(attr, name) return attr } return &AttributeExpr{Type: Empty} @@ -309,9 +255,12 @@ func buildHTTPResponseBody(name string, attr *AttributeExpr, resp *HTTPResponseE RemovePkgPath(body.AttributeExpr) extendBodyAttribute(body) - // 4. Remove header and cookie attributes + // 4. Remove values supplied outside the response body. removeAttributes(body, resp.Headers) removeAttributes(body, resp.Cookies) + if resp.Tag[0] != "" { + removeAttribute(body, resp.Tag[0]) + } // 4. Return empty type if no attribute left if len(*AsObject(body.Type)) == 0 { @@ -320,28 +269,24 @@ func buildHTTPResponseBody(name string, attr *AttributeExpr, resp *HTTPResponseE // 5. Build computed user type bodyAtt := body.Attribute() - if bodyAtt.Description == "" { - if t, ok := attr.Type.(UserType); ok { - bodyAtt.Description = t.Attribute().Description - } - } - if bodyAtt.Description == "" { + if t, ok := attr.Type.(UserType); ok { + // The generated body type describes the named Goa type after fields used + // by headers and cookies have been removed. Keep the type description + // separate from text that explains one method response. + typeAtt := t.Attribute() + bodyAtt.Description = typeAtt.Description + bodyAtt.authored = typeAtt.AuthoredAttribute() + } else if bodyAtt.Description == "" { bodyAtt.Description = attr.Description } - userType := &UserTypeExpr{ - AttributeExpr: bodyAtt, - TypeName: name, - UID: concat(svc.Name(), "#", name), - } + userType := NewGeneratedUserType(name, bodyAtt, identity) if t, ok := attr.Type.(UserType); ok { // Remember original type name for example to generate friendly - // OpenAPI specs. userType.AddMeta("name:original", t.Name()) copyOpenAPITypeMeta(t, userType) } - appendSuffix(userType.Attribute().Type, suffix) rt, isrt := attr.Type.(*ResultTypeExpr) if !isrt { return &AttributeExpr{ @@ -349,6 +294,7 @@ func buildHTTPResponseBody(name string, attr *AttributeExpr, resp *HTTPResponseE Description: userType.Description, Validation: userType.Validation, Meta: attr.Meta, + authored: attr.AuthoredAttribute(), } } views := make([]*ViewExpr, len(rt.Views)) @@ -356,6 +302,9 @@ func buildHTTPResponseBody(name string, attr *AttributeExpr, resp *HTTPResponseE mv := NewMappedAttributeExpr(v.AttributeExpr) removeAttributes(mv, resp.Headers) removeAttributes(mv, resp.Cookies) + if resp.Tag[0] != "" { + removeAttribute(mv, resp.Tag[0]) + } nv := &ViewExpr{ AttributeExpr: mv.Attribute(), Name: v.Name, @@ -376,7 +325,23 @@ func buildHTTPResponseBody(name string, attr *AttributeExpr, resp *HTTPResponseE Description: userType.Description, Validation: userType.Validation, Meta: attr.Meta, + authored: attr.AuthoredAttribute(), + } +} + +// generatedUserType preserves result-type behavior while giving a computed +// transport type its own original declaration and repeatable example sequence. +func generatedUserType(typ UserType, identity ExampleIdentity) UserType { + generated := NewGeneratedUserType(typ.Name(), typ.Attribute(), identity) + if result, ok := typ.(*ResultTypeExpr); ok { + result.UserTypeExpr = generated + result.origin = nil + for _, view := range result.Views { + view.Parent = result + } + return result } + return generated } // concat concatenates the given strings with "smart(?) casing". @@ -449,19 +414,10 @@ func concat(strs ...string) string { return name } -func renameType(att *AttributeExpr, name, suffix string) { +func renameType(att *AttributeExpr, name string) { RemovePkgPath(att) - rt := att.Type - switch rtt := rt.(type) { - case UserType: - rtt.Rename(name) - appendSuffix(rtt.Attribute().Type, suffix) - case *Object: - appendSuffix(rt, suffix) - case *Array: - appendSuffix(rt, suffix) - case *Map: - appendSuffix(rt, suffix) + if userType, ok := att.Type.(UserType); ok { + userType.Rename(name) } } @@ -478,14 +434,6 @@ func RemovePkgPath(attr *AttributeExpr) { } } -// appendSuffix recursively traverses the given data type and appends the given -// suffix to all the user type names. -func appendSuffix(dt DataType, suffix string) { - walk(dt, func(ut UserType) { - ut.Rename(ut.Name() + suffix) - }) -} - func removeAttributes(attr, sub *MappedAttributeExpr) { o := AsObject(sub.Type) for _, nat := range *o { @@ -539,17 +487,18 @@ func extendBodyAttribute(body *MappedAttributeExpr) { // walk traverses the given data type and invokes the given function for each // user type it finds including dt itself. func walk(dt DataType, do func(UserType)) { - walkrec(dt, do, make(map[string]struct{})) + walkrec(dt, do, make(map[UserType]struct{})) } -func walkrec(dt DataType, do func(UserType), seen map[string]struct{}) { +func walkrec(dt DataType, do func(UserType), seen map[UserType]struct{}) { switch dt := dt.(type) { case UserType: - if _, ok := seen[dt.ID()]; ok { + origin := dt.Origin() + if _, ok := seen[origin]; ok { return } + seen[origin] = struct{}{} do(dt) - seen[dt.ID()] = struct{}{} walkrec(dt.Attribute().Type, do, seen) case *Object: for _, nat := range *dt { diff --git a/expr/http_body_types_test.go b/expr/http_body_types_test.go index 7eeb9641a1..8c4d5426bf 100644 --- a/expr/http_body_types_test.go +++ b/expr/http_body_types_test.go @@ -1,9 +1,12 @@ +// This file verifies HTTP body graph rewrites visit independent declarations +// and terminate when recursive copies return to their authored origin. package expr import ( "testing" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) func TestHTTPStreamingBodyValidation(t *testing.T) { @@ -69,3 +72,102 @@ func TestHTTPStreamingBodyValidation(t *testing.T) { }) } } + +func TestComputedBodyExamplesDistinguishHTTPAndJSONRPC(t *testing.T) { + service := &ServiceExpr{Name: "Service"} + method := &MethodExpr{Name: "Method", Service: service} + httpEndpoint := &HTTPEndpointExpr{ + MethodExpr: method, + Service: &HTTPServiceExpr{ServiceExpr: service}, + Body: &AttributeExpr{Type: &UserTypeExpr{ + TypeName: "HTTPBody", + AttributeExpr: &AttributeExpr{Type: &Object{ + {Name: "http", Attribute: &AttributeExpr{Type: String}}, + }}, + }}, + } + jsonRPCEndpoint := &HTTPEndpointExpr{ + MethodExpr: method, + Service: &HTTPServiceExpr{ServiceExpr: service}, + Meta: MetaExpr{"jsonrpc": {}}, + Body: &AttributeExpr{Type: &UserTypeExpr{ + TypeName: "JSONRPCBody", + AttributeExpr: &AttributeExpr{Type: &Object{ + {Name: "jsonrpc", Attribute: &AttributeExpr{Type: String}}, + }}, + }}, + } + httpBody := httpRequestBody(httpEndpoint) + jsonRPCBody := httpRequestBody(jsonRPCEndpoint) + require.NotEqual(t, httpBody.Type.(UserType).ID(), jsonRPCBody.Type.(UserType).ID()) + + cases := []struct { + Name string + First *HTTPEndpointExpr + FirstBody *AttributeExpr + Second *HTTPEndpointExpr + SecondBody *AttributeExpr + }{ + { + Name: "HTTP then JSON-RPC", + First: httpEndpoint, + FirstBody: httpBody, + Second: jsonRPCEndpoint, + SecondBody: jsonRPCBody, + }, + { + Name: "JSON-RPC then HTTP", + First: jsonRPCEndpoint, + FirstBody: jsonRPCBody, + Second: httpEndpoint, + SecondBody: httpBody, + }, + } + for _, c := range cases { + t.Run(c.Name, func(t *testing.T) { + generator := NewExampleGenerator(NewFakerRandomizerFactory("test")) + first := c.FirstBody.Example(generator.At(RequestBodyExampleIdentity(c.First))).(map[string]any) + second := c.SecondBody.Example(generator.At(RequestBodyExampleIdentity(c.Second))).(map[string]any) + + require.Contains(t, first, transportBodyField(c.First)) + require.NotContains(t, first, transportBodyField(c.Second)) + require.Contains(t, second, transportBodyField(c.Second)) + require.NotContains(t, second, transportBodyField(c.First)) + }) + } +} + +// transportBodyField returns the field unique to the endpoint's body mapping. +func transportBodyField(endpoint *HTTPEndpointExpr) string { + if endpoint.IsJSONRPC() { + return "jsonrpc" + } + return "http" +} + +func TestRemovePkgPathDistinguishesEqualUIDOrigins(t *testing.T) { + first := &UserTypeExpr{ + AttributeExpr: &AttributeExpr{ + Type: &Object{}, + Meta: MetaExpr{"struct:pkg:path": {"first/types"}}, + }, + TypeName: "First", + UID: "shared", + } + second := &UserTypeExpr{ + AttributeExpr: &AttributeExpr{ + Type: &Object{}, + Meta: MetaExpr{"struct:pkg:path": {"second/types"}}, + }, + TypeName: "Second", + UID: "shared", + } + root := &AttributeExpr{Type: &Object{ + {Name: "first", Attribute: &AttributeExpr{Type: first}}, + {Name: "second", Attribute: &AttributeExpr{Type: second}}, + }} + + RemovePkgPath(root) + require.NotContains(t, first.Attribute().Meta, "struct:pkg:path") + require.NotContains(t, second.Attribute().Meta, "struct:pkg:path") +} diff --git a/expr/http_endpoint.go b/expr/http_endpoint.go index 565d63bb8c..8db203222a 100644 --- a/expr/http_endpoint.go +++ b/expr/http_endpoint.go @@ -1,3 +1,5 @@ +// This file prepares, validates, and finalizes the HTTP transport contract for +// one service method. package expr import ( @@ -47,12 +49,17 @@ type ( // StreamingBody describes the body transferred through the websocket // stream. StreamingBody *AttributeExpr - // PayloadIDAttribute is the name of the JSON-RPC request ID - // payload attribute. + // PayloadIDAttribute is retained for generator compatibility. + // + // Deprecated: inspect the payload field marked with "jsonrpc:id". PayloadIDAttribute string - // ResultIDAttribute is the name of the JSON-RPC result ID - // result attribute. + // ResultIDAttribute is retained for generator compatibility. + // + // Deprecated: JSON-RPC results cannot define an ID field. ResultIDAttribute string + // JSONRPCNotification is true when generated clients send this method + // without an ID and do not wait for a JSON-RPC response. + JSONRPCNotification bool // SkipRequestBodyEncodeDecode indicates that the service method accepts // a reader and that the client provides a reader to stream the request // body. @@ -98,8 +105,19 @@ type ( // dsl.Meta Meta MetaExpr } + + // jsonRPCIDField records where one ID declaration appears so validation can + // reject declarations that code generation cannot represent. + jsonRPCIDField struct { + name string + attribute *AttributeExpr + depth int + } ) +// A generated JSON-RPC request ID uses the standard 36-character UUID form. +const generatedJSONRPCRequestIDLength = 36 + // Name of HTTP endpoint func (e *HTTPEndpointExpr) Name() string { return e.MethodExpr.Name @@ -130,16 +148,22 @@ func (e *HTTPEndpointExpr) IsJSONRPC() bool { return ok } +// IsJSONRPCNotification reports whether generated clients call the JSON-RPC +// method without an ID and do not wait for a JSON-RPC response. +func (e *HTTPEndpointExpr) IsJSONRPCNotification() bool { + return e.IsJSONRPC() && e.JSONRPCNotification +} + // UsesSSE returns true if the endpoint streams result events over Server-Sent // Events. func (e *HTTPEndpointExpr) UsesSSE() bool { return e.SSE != nil && (e.MethodExpr.IsResultStreaming() || e.MethodExpr.HasMixedResults()) } -// UsesWebSocket returns true if the endpoint streams payloads or results over a -// WebSocket connection. +// UsesWebSocket returns true if an ordinary HTTP endpoint streams payloads or +// results over a WebSocket connection. func (e *HTTPEndpointExpr) UsesWebSocket() bool { - return e.MethodExpr.IsStreaming() && e.SSE == nil + return !e.IsJSONRPC() && e.MethodExpr.IsStreaming() && e.SSE == nil } // HasAbsoluteRoutes returns true if all the endpoint routes are absolute. @@ -228,15 +252,15 @@ func (e *HTTPEndpointExpr) Prepare() { // Inherit headers, cookies and params from parent service and API headers := NewEmptyMappedAttributeExpr() - headers.Merge(Root.API.HTTP.Headers) + headers.Merge(e.MethodExpr.Service.design.API.HTTP.Headers) headers.Merge(e.Service.Headers) cookies := NewEmptyMappedAttributeExpr() - cookies.Merge(Root.API.HTTP.Cookies) + cookies.Merge(e.MethodExpr.Service.design.API.HTTP.Cookies) cookies.Merge(e.Service.Cookies) params := NewEmptyMappedAttributeExpr() - params.Merge(Root.API.HTTP.Params) + params.Merge(e.MethodExpr.Service.design.API.HTTP.Params) params.Merge(e.Service.Params) if p := e.Service.Parent(); p != nil { @@ -308,8 +332,8 @@ func (e *HTTPEndpointExpr) Prepare() { if e.MethodExpr.Stream == ServerStreamKind && e.SSE == nil { if e.Service.SSE != nil { e.SSE = e.Service.SSE - } else if Root.API.HTTP.SSE != nil { - e.SSE = Root.API.HTTP.SSE + } else if e.MethodExpr.Service.design.API.HTTP.SSE != nil { + e.SSE = e.MethodExpr.Service.design.API.HTTP.SSE } } @@ -355,7 +379,7 @@ func (e *HTTPEndpointExpr) Prepare() { } } if !found { - for _, ae := range Root.API.HTTP.Errors { + for _, ae := range e.MethodExpr.Service.design.API.HTTP.Errors { if se.Name == ae.Name { e.HTTPErrors = append(e.HTTPErrors, ae.Dup()) break @@ -364,8 +388,7 @@ func (e *HTTPEndpointExpr) Prepare() { } } - // Make sure JSON-RPC HTTP verb is set to GET if the endpoint is a - // WebSocket endpoint + // WebSocket endpoints use GET for the HTTP upgrade request. if e.UsesWebSocket() && len(e.Routes) > 0 { e.Routes[0].Method = "GET" } @@ -389,7 +412,7 @@ func (e *HTTPEndpointExpr) Validate() error { // SkipRequestBodyEncodeDecode is not compatible with gRPC or WebSocket if e.SkipRequestBodyEncodeDecode { - if s := Root.API.GRPC.Service(e.Service.Name()); s != nil { + if s := e.MethodExpr.Service.design.API.GRPC.Service(e.Service.Name()); s != nil { if s.Endpoint(e.Name()) != nil { verr.Add(e, "Endpoint cannot use SkipRequestBodyEncodeDecode and define a gRPC transport.") } @@ -404,7 +427,7 @@ func (e *HTTPEndpointExpr) Validate() error { // SkipResponseBodyEncodeDecode is not compatible with gRPC or WebSocket. if e.SkipResponseBodyEncodeDecode { - if s := Root.API.GRPC.Service(e.Service.Name()); s != nil { + if s := e.MethodExpr.Service.design.API.GRPC.Service(e.Service.Name()); s != nil { if s.Endpoint(e.Name()) != nil { verr.Add(e, "Endpoint response cannot use SkipResponseBodyEncodeDecode and define a gRPC transport.") } @@ -422,6 +445,23 @@ func (e *HTTPEndpointExpr) Validate() error { } } + // A WebSocket client learns the result view from the connection handshake. + // Receiving a streamed payload starts that connection before the service can + // choose a result view, so the design must select the view in advance. + if e.UsesWebSocket() && e.MethodExpr.IsPayloadStreaming() && !e.MethodExpr.HasMixedResults() { + if result, ok := e.MethodExpr.Result.Type.(*ResultTypeExpr); ok { + viewCount := len(result.Views) + if result.View(DefaultView) == nil { + viewCount++ + } + _, selectedByMethod := e.MethodExpr.Result.Meta.Last(ViewMetaKey) + _, selectedByType := result.Meta.Last(ViewMetaKey) + if viewCount > 1 && !selectedByMethod && !selectedByType { + verr.Add(e, "Endpoint cannot choose a result view at runtime when the method defines StreamingPayload because the WebSocket connection starts before the result view is known. Select a view in Result or StreamingResult.") + } + } + } + // Validate streaming endpoints for SSE compatibility if e.MethodExpr.Stream == ServerStreamKind { if e.SSE != nil { @@ -431,14 +471,35 @@ func (e *HTTPEndpointExpr) Validate() error { verr.Merge(valErr) } } + if e.IsJSONRPC() && e.SSE.DataField != "" { + result := e.MethodExpr.StreamingResult + if result == nil { + result = e.MethodExpr.Result + } + if _, viewed := result.Type.(*ResultTypeExpr); viewed { + verr.Add(e, "SSE event data cannot select one field from a viewed streaming result because the selected data would omit the result view needed to decode the stream") + } + } + if e.SSE.RequestIDField != "" { + for _, field := range *AsObject(e.Headers.Type) { + header := e.Headers.ElemName(field.Name) + if field.Name == e.SSE.RequestIDField { + verr.Add(e, "SSE request ID field %q cannot also be mapped with Header because ServerSentEvents maps it to Last-Event-ID", field.Name) + continue + } + if strings.EqualFold(header, "Last-Event-ID") { + verr.Add(e, "HTTP header %q is reserved for SSE request ID field %q", header, e.SSE.RequestIDField) + } + } + } } } // Validate mixed results configuration if e.MethodExpr.HasMixedResults() { - // Mixed results (different Result and StreamingResult types) requires SSE + // A separate streaming result requires SSE. if e.SSE == nil { - verr.Add(e, "Methods with both Result and StreamingResult defined with different types must use ServerSentEvents()") + verr.Add(e, "Methods with both Result and StreamingResult must use ServerSentEvents()") } // Cannot have bidirectional streaming with mixed results if e.MethodExpr.IsPayloadStreaming() { @@ -462,26 +523,82 @@ func (e *HTTPEndpointExpr) Validate() error { // JSON-RPC validation if e.IsJSONRPC() { - // JSON-RPC WebSocket endpoints with server streaming cannot have both Payload and StreamingPayload - if e.UsesWebSocket() && e.MethodExpr.Stream == ServerStreamKind { - if e.MethodExpr.Payload.Type != Empty && e.MethodExpr.StreamingPayload.Type != Empty { - verr.Add(e, "JSON-RPC WebSocket server streaming method %q cannot define both Payload and StreamingPayload. Use Payload for the request data", e.MethodExpr.Name) + if strings.HasPrefix(e.MethodExpr.Name, "rpc.") { + verr.Add(e, "JSON-RPC method %q cannot begin with %q because JSON-RPC reserves that namespace", e.MethodExpr.Name, "rpc.") + } + requestIDs := jsonRPCIDFields(e.MethodExpr.Payload) + if len(requestIDs) > 1 { + verr.Add(e, "JSON-RPC method %q cannot define more than one request ID field", e.MethodExpr.Name) + } + for _, id := range requestIDs { + if id.depth != 1 { + verr.Add(e, "JSON-RPC request ID field %q must be a direct payload field", id.name) + } + if !isJSONRPCIDString(id.attribute.Type) { + verr.Add(e, "JSON-RPC request ID field %q must be a string", id.name) } } - - // JSON-RPC ID field validation: - // Result may only define an ID field if the corresponding request type (Payload or StreamingPayload) also defines one - if e.MethodExpr.Result != nil && e.MethodExpr.Result.Type != Empty && hasJSONRPCIDField(e.MethodExpr.Result) { - // Check if request has ID field - requestHasID := false - if e.MethodExpr.IsPayloadStreaming() { - requestHasID = hasJSONRPCIDField(e.MethodExpr.StreamingPayload) - } else { - requestHasID = hasJSONRPCIDField(e.MethodExpr.Payload) + if len(requestIDs) == 1 { + id := requestIDs[0] + name := id.name + if id.depth == 1 && isJSONRPCIDString(id.attribute.Type) && + !e.MethodExpr.Payload.IsRequired(name) && e.MethodExpr.Payload.GetDefault(name) == nil { + if incompatible := incompatibleGeneratedJSONRPCRequestIDValidations(id.attribute); len(incompatible) > 0 { + verr.Add(e, "JSON-RPC request ID field %q is optional and has no default, so generated clients create a UUID when it is absent; the following validation rules may reject that UUID: %s; make the field required or give it a default", name, strings.Join(incompatible, ", ")) + } } - - if !requestHasID { - verr.Add(e, "JSON-RPC method %q result defines an ID field but the request (payload) does not. Result may only have ID field if request does", e.MethodExpr.Name) + if e.Body != nil && jsonRPCBodyContainsID(e.Body, name) { + verr.Add(e, "JSON-RPC request ID field %q cannot also appear in params", name) + } + if _, ok := e.Params.FindKey(name); ok { + verr.Add(e, "JSON-RPC request ID field %q cannot also be mapped as an HTTP parameter", name) + } + if _, ok := e.Headers.FindKey(name); ok { + verr.Add(e, "JSON-RPC request ID field %q cannot also be mapped as an HTTP header", name) + } + if _, ok := e.Cookies.FindKey(name); ok { + verr.Add(e, "JSON-RPC request ID field %q cannot also be mapped as an HTTP cookie", name) + } + if e.SSE != nil && e.SSE.RequestIDField == name { + verr.Add(e, "JSON-RPC request ID field %q cannot also be mapped to the Last-Event-ID header", name) + } + } + if len(jsonRPCIDFields(e.MethodExpr.Result)) > 0 || len(jsonRPCIDFields(e.MethodExpr.StreamingResult)) > 0 { + verr.Add(e, "JSON-RPC method %q cannot define an ID field in its result because the transport copies the request ID", e.MethodExpr.Name) + } + seenErrors := make(map[string]struct{}) + for _, mapped := range e.HTTPErrors { + if _, ok := seenErrors[mapped.Name]; ok { + continue + } + seenErrors[mapped.Name] = struct{}{} + designError := e.MethodExpr.Error(mapped.Name) + if designError != nil && len(jsonRPCIDFields(designError.AttributeExpr)) > 0 { + verr.Add(e, "JSON-RPC error %q cannot define an ID field because the transport copies the request ID", mapped.Name) + } + } + if e.IsJSONRPCNotification() { + if e.MethodExpr.Result.Type != Empty { + verr.Add(e, "JSON-RPC notification %q cannot define a result because notifications receive no response", e.MethodExpr.Name) + } + if e.MethodExpr.IsStreaming() { + verr.Add(e, "JSON-RPC notification %q cannot stream because notifications send one message and receive no response", e.MethodExpr.Name) + } + if hasJSONRPCIDField(e.MethodExpr.Payload) || hasJSONRPCIDField(e.MethodExpr.StreamingPayload) { + verr.Add(e, "JSON-RPC notification %q cannot define an ID field because notifications omit the request ID", e.MethodExpr.Name) + } + } + if e.MethodExpr.HasMixedResults() { + verr.Add(e, "JSON-RPC method %q cannot define both Result and StreamingResult because its client stream cannot return a separate final result", e.MethodExpr.Name) + } + switch e.MethodExpr.Stream { + case ClientStreamKind: + verr.Add(e, "JSON-RPC method %q cannot use client streaming because one JSON-RPC request contains one params value", e.MethodExpr.Name) + case BidirectionalStreamKind: + verr.Add(e, "JSON-RPC method %q cannot use bidirectional streaming because one JSON-RPC request contains one params value", e.MethodExpr.Name) + case ServerStreamKind: + if e.SSE == nil { + verr.Add(e, "JSON-RPC method %q with a streaming result must use ServerSentEvents()", e.MethodExpr.Name) } } } @@ -529,9 +646,11 @@ func (e *HTTPEndpointExpr) Validate() error { // Validate responses - // All responses but one must have tags for the same status code + // Exactly one response must omit a tag so the generated server has a + // response to use when no tagged response matches the result. hasTags := false allTagged := true + untaggedResponse := false successResp := false for i, r := range e.Responses { verr.Merge(r.Validate(e)) @@ -542,10 +661,22 @@ func (e *HTTPEndpointExpr) Validate() error { } if r.Tag[0] == "" { allTagged = false + if untaggedResponse { + verr.Add(r, "Multiple response definitions without a Tag") + } + untaggedResponse = true } else { hasTags = true } if r.StatusCode < 400 { + if e.MethodExpr.IsStreaming() { + if !r.Headers.IsEmpty() { + verr.Add(r, "streaming success response cannot map result attributes to HTTP headers") + } + if !r.Cookies.IsEmpty() { + verr.Add(r, "streaming success response cannot map result attributes to HTTP cookies") + } + } if successResp && e.MethodExpr.Stream == ServerStreamKind { verr.Add(r, "At most one success response can be defined for a streaming endpoint.") if r.Body != nil && r.Body.Type == Empty { @@ -604,6 +735,7 @@ func (e *HTTPEndpointExpr) Validate() error { for _, er := range e.HTTPErrors { verr.Merge(er.Validate()) } + verr.Merge(e.validateErrorMappings()) // Validate definitions of params, headers and bodies against definition of payload var ( @@ -731,18 +863,16 @@ func (e *HTTPEndpointExpr) Validate() error { } body := httpRequestBody(e) + if e.MultipartRequest && body.Type == Empty { + verr.Add(e, "MultipartRequest requires a request body.") + } if e.SkipRequestBodyEncodeDecode && body.Type != Empty { verr.Add(e, "HTTP endpoint request body must be empty when using SkipRequestBodyEncodeDecode but not all method payload attributes are mapped to headers and params. Make sure to define Headers and Params as needed.") } - // For streaming endpoints, check if request body is allowed + // WebSocket upgrade requests cannot carry a request body. if e.MethodExpr.IsStreaming() && body.Type != Empty { - // SSE endpoints can have request bodies, but WebSocket endpoints cannot - // Refer WebSocket protocol - https://tools.ietf.org/html/rfc6455 - // Exception: JSON-RPC WebSocket endpoints can have payloads as they are sent - // as JSON-RPC messages after the WebSocket connection is established - _, isJSONRPC := e.MethodExpr.Meta["jsonrpc"] - if e.UsesWebSocket() && !isJSONRPC { + if e.UsesWebSocket() { verr.Add(e, "HTTP endpoint request body must be empty when the endpoint uses streaming. Payload attributes must be mapped to headers and/or params.") } } @@ -750,26 +880,46 @@ func (e *HTTPEndpointExpr) Validate() error { return verr } +// validateErrorMappings ensures inherited HTTP response policy describes the +// same concrete error value returned by the endpoint method. +func (e *HTTPEndpointExpr) validateErrorMappings() *eval.ValidationErrors { + verr := new(eval.ValidationErrors) + for _, mapping := range e.HTTPErrors { + mapped, owner := mapping.mappedError() + method := e.MethodExpr.Error(mapping.Name) + if mapped == nil || method == nil || equivalentErrorAttributes(mapped.AttributeExpr, method.AttributeExpr) { + continue + } + verr.Add( + mapping.Response, + `HTTP error mapping %q inherited from the %s uses error type %q, but method %q of service %q uses %q; both definitions must define the same error attribute; %s`, + mapping.Name, + owner, + mapped.Type.Name(), + e.MethodExpr.Name, + e.MethodExpr.Service.Name, + method.Type.Name(), + errorAttributeDifference(mapped.AttributeExpr, method.AttributeExpr), + ) + } + return verr +} + +// errorAttributeDifference names qualifier settings when they are the reason +// two reusable error definitions disagree. +func errorAttributeDifference(first, second *AttributeExpr) string { + if settings := differingErrorQualifierSettings(first, second); len(settings) > 0 { + return "the " + strings.Join(settings, ", ") + " setting differs" + } + return "their type, validations, defaults, or metadata differ" +} + // Finalize is run post DSL execution. It merges response definitions, creates // implicit endpoint parameters and initializes querystring parameters. It also // flattens the error responses and makes sure the error types are all user // types so that the response encoding code can properly use the type to infer // the response that it needs to build. func (e *HTTPEndpointExpr) Finalize() { - // For JSON-RPC WebSocket endpoints with server streaming and non-streaming payload, - // move the payload to streaming payload. This is because the payload is sent as - // JSON-RPC messages after the WebSocket connection is established, making it - // effectively a streaming payload from the transport perspective. - if _, isJSONRPC := e.MethodExpr.Meta["jsonrpc"]; isJSONRPC && e.UsesWebSocket() && e.MethodExpr.Stream == ServerStreamKind { - if e.MethodExpr.Payload.Type != Empty && e.MethodExpr.StreamingPayload.Type == Empty { - // Move payload to streaming payload - e.MethodExpr.StreamingPayload = e.MethodExpr.Payload - e.MethodExpr.Payload = &AttributeExpr{Type: Empty} - // Change stream kind to bidirectional since we now have both streaming payload and result - e.MethodExpr.Stream = BidirectionalStreamKind - } - } - // Compute security scheme attribute name and corresponding HTTP location requirements := EffectiveSecurityRequirements(e.MethodExpr.Requirements) if reqLen := len(requirements); reqLen > 0 { @@ -819,6 +969,22 @@ func (e *HTTPEndpointExpr) Finalize() { initAttr(e.Params, e.MethodExpr.Payload) initAttr(e.Headers, e.MethodExpr.Payload) initAttr(e.Cookies, e.MethodExpr.Payload) + if e.SSE != nil && e.SSE.RequestIDField != "" { + name := e.SSE.RequestIDField + attribute := DupAtt(e.MethodExpr.Payload.Find(name)) + if attribute.Meta == nil { + attribute.Meta = make(MetaExpr) + } + attribute.Meta["sse:last-event-id"] = []string{"true"} + AsObject(e.Headers.Type).Set(name, attribute) + e.Headers.Map("Last-Event-ID", name) + if e.MethodExpr.Payload.IsRequired(name) { + if e.Headers.Validation == nil { + e.Headers.Validation = &ValidationExpr{} + } + e.Headers.Validation.AddRequired(name) + } + } e.Body = httpRequestBody(e) e.Body.Finalize() @@ -828,17 +994,6 @@ func (e *HTTPEndpointExpr) Finalize() { e.StreamingBody.Finalize() } - // For JSON-RPC, WebSocket handling is managed at the server level. - // Each endpoint is treated as a standard HTTP endpoint; the server is responsible - // for upgrading the connection, decoding incoming JSON-RPC requests, and dispatching - // them to the appropriate endpoint handlers. - if e.IsJSONRPC() { - if e.MethodExpr.IsPayloadStreaming() { - e.MethodExpr.Payload = e.MethodExpr.StreamingPayload - e.Body = e.StreamingBody - } - } - // Initialize responses parent, headers and body for _, r := range e.Responses { r.Finalize(e, e.MethodExpr.Result) @@ -1150,6 +1305,7 @@ func initAttrFromDesign(att, patt *AttributeExpr) { if patt == nil || patt.Type == Empty { return } + att.authored = patt.AuthoredAttribute() att.Type = patt.Type if att.Description == "" { att.Description = patt.Description @@ -1195,47 +1351,112 @@ func isEmpty(a *AttributeExpr) bool { return true } -// hasJSONRPCIDField returns true if an attribute or any of its nested attributes -// has the "jsonrpc:id" meta tag, indicating it's designated as the JSON-RPC ID field. +// hasJSONRPCIDField reports whether an attribute graph contains an ID +// declaration. func hasJSONRPCIDField(attr *AttributeExpr) bool { - return hasJSONRPCIDFieldRec(attr, make(map[*AttributeExpr]struct{}), make(map[string]struct{})) + return len(jsonRPCIDFields(attr)) > 0 } -// hasJSONRPCIDFieldRec walks the attribute graph looking for the jsonrpc:id meta -// while guarding against cycles that may occur with recursive user types. -func hasJSONRPCIDFieldRec(attr *AttributeExpr, seen map[*AttributeExpr]struct{}, seenUT map[string]struct{}) bool { +// jsonRPCIDFields lists every ID declaration and its distance from the root +// attribute. A direct payload field has depth one. +func jsonRPCIDFields(attr *AttributeExpr) []jsonRPCIDField { + var fields []jsonRPCIDField + collectJSONRPCIDFields(attr, "", 0, make(map[*AttributeExpr]struct{}), &fields) + return fields +} + +// collectJSONRPCIDFields walks one attribute path at a time so a shared type +// can be checked at each place it is used while recursive types still stop. +func collectJSONRPCIDFields(attr *AttributeExpr, name string, depth int, active map[*AttributeExpr]struct{}, fields *[]jsonRPCIDField) { if attr == nil || attr.Type == Empty { - return false + return } - if _, ok := seen[attr]; ok { - return false + if _, ok := active[attr]; ok { + return } - seen[attr] = struct{}{} + active[attr] = struct{}{} + defer delete(active, attr) - // Check if this attribute itself has the jsonrpc:id meta tag - if attr.Meta != nil { - if _, hasID := attr.Meta["jsonrpc:id"]; hasID { - return true + if _, ok := attr.Meta["jsonrpc:id"]; ok { + *fields = append(*fields, jsonRPCIDField{name: name, attribute: attr, depth: depth}) + } + if userType, ok := attr.Type.(UserType); ok { + collectJSONRPCIDFields(userType.Attribute(), name, depth, active, fields) + return + } + if object := AsObject(attr.Type); object != nil { + for _, field := range *object { + collectJSONRPCIDFields(field.Attribute, field.Name, depth+1, active, fields) } + return } - - // For object types, check all nested attributes - if obj := AsObject(attr.Type); obj != nil { - for _, nat := range *obj { - if hasJSONRPCIDFieldRec(nat.Attribute, seen, seenUT) { - return true - } + if array := AsArray(attr.Type); array != nil { + collectJSONRPCIDFields(array.ElemType, name, depth+1, active, fields) + return + } + if mapped := AsMap(attr.Type); mapped != nil { + collectJSONRPCIDFields(mapped.KeyType, name, depth+1, active, fields) + collectJSONRPCIDFields(mapped.ElemType, name, depth+1, active, fields) + return + } + if union := AsUnion(attr.Type); union != nil { + for _, field := range union.Values { + collectJSONRPCIDFields(field.Attribute, field.Name, depth+1, active, fields) } } +} - // For user types, check the underlying attribute (guarding for recursion) - if ut, ok := attr.Type.(UserType); ok { - if ut != nil { - if _, ok := seenUT[ut.ID()]; ok { - return false - } - seenUT[ut.ID()] = struct{}{} - return hasJSONRPCIDFieldRec(ut.Attribute(), seen, seenUT) +// isJSONRPCIDString reports whether an ID field is a string or a named string. +func isJSONRPCIDString(dataType DataType) bool { + switch actual := dataType.(type) { + case Primitive: + return actual == String + case UserType: + return isJSONRPCIDString(actual.Attribute().Type) + default: + return false + } +} + +// incompatibleGeneratedJSONRPCRequestIDValidations lists the rules that are +// not guaranteed by the UUID generated when an optional request ID is absent. +func incompatibleGeneratedJSONRPCRequestIDValidations(attribute *AttributeExpr) []string { + validation := EffectiveValidation(attribute) + if validation == nil { + return nil + } + var incompatible []string + if len(validation.Values) > 0 { + incompatible = append(incompatible, "enum") + } + if validation.Format != "" && validation.Format != FormatUUID { + incompatible = append(incompatible, fmt.Sprintf("format %q", validation.Format)) + } + if validation.Pattern != "" { + incompatible = append(incompatible, "pattern") + } + if validation.MinLength != nil && *validation.MinLength > generatedJSONRPCRequestIDLength { + incompatible = append(incompatible, fmt.Sprintf("minimum length %d", *validation.MinLength)) + } + if validation.MaxLength != nil && *validation.MaxLength < generatedJSONRPCRequestIDLength { + incompatible = append(incompatible, fmt.Sprintf("maximum length %d", *validation.MaxLength)) + } + return incompatible +} + +// jsonRPCBodyContainsID reports whether an explicitly designed params body +// includes the payload field carried by the JSON-RPC envelope. +func jsonRPCBodyContainsID(body *AttributeExpr, name string) bool { + if origin, ok := body.Meta["origin:attribute"]; ok { + return len(origin) > 0 && origin[0] == name + } + object := AsObject(body.Type) + if object == nil { + return false + } + for _, field := range *object { + if strings.Split(field.Name, ":")[0] == name { + return true } } return false diff --git a/expr/http_endpoint_internal_test.go b/expr/http_endpoint_internal_test.go new file mode 100644 index 0000000000..140b4e9458 --- /dev/null +++ b/expr/http_endpoint_internal_test.go @@ -0,0 +1,31 @@ +// This file verifies recursive HTTP and JSON-RPC expression inspection across +// unrelated declarations that share a semantic identifier. +package expr + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestHasJSONRPCIDFieldDistinguishesEqualUIDOrigins(t *testing.T) { + first := &UserTypeExpr{ + AttributeExpr: &AttributeExpr{Type: String}, + TypeName: "First", + UID: "shared", + } + second := &UserTypeExpr{ + AttributeExpr: &AttributeExpr{ + Type: String, + Meta: MetaExpr{"jsonrpc:id": {}}, + }, + TypeName: "Second", + UID: "shared", + } + root := &AttributeExpr{Type: &Object{ + {Name: "first", Attribute: &AttributeExpr{Type: first}}, + {Name: "second", Attribute: &AttributeExpr{Type: second}}, + }} + + require.True(t, hasJSONRPCIDField(root)) +} diff --git a/expr/http_endpoint_test.go b/expr/http_endpoint_test.go index b3931f8aa7..6b4f4f4af8 100644 --- a/expr/http_endpoint_test.go +++ b/expr/http_endpoint_test.go @@ -5,6 +5,7 @@ import ( "strings" "testing" + "goa.design/goa/v3/dsl" "goa.design/goa/v3/eval" "goa.design/goa/v3/expr" "goa.design/goa/v3/expr/testdata" @@ -176,6 +177,43 @@ service "Service" HTTP endpoint "Method": HTTP endpoint response body must be em DSL: testdata.EndpointPayloadMissingRequired, Error: `service "Service" HTTP endpoint "Method": The following HTTP request body attribute is required but the corresponding method payload attribute is not: nonreq. Use 'Required' to make the attribute required in the method payload as well.`, }, + "endpoint-multipart-without-body": { + DSL: testdata.EndpointMultipartWithoutBody, + Error: `service "Service" HTTP endpoint "Method": MultipartRequest requires a request body.`, + }, + "two untagged success responses": { + DSL: func() { + dsl.Service("Service", func() { + dsl.Method("Method", func() { + dsl.Result(dsl.String) + dsl.HTTP(func() { + dsl.GET("/") + dsl.Response(dsl.StatusOK) + dsl.Response(dsl.StatusAccepted) + }) + }) + }) + }, + Error: `HTTP response of service "Service" HTTP endpoint "Method": Multiple response definitions without a Tag`, + }, + "tagged response and default response": { + DSL: func() { + dsl.Service("Service", func() { + dsl.Method("Method", func() { + dsl.Result(func() { + dsl.Attribute("outcome", dsl.String) + }) + dsl.HTTP(func() { + dsl.GET("/") + dsl.Response(dsl.StatusAccepted, func() { + dsl.Tag("outcome", "accepted") + }) + dsl.Response(dsl.StatusOK) + }) + }) + }) + }, + }, "streaming-endpoint-has-request-body": { DSL: testdata.StreamingEndpointRequestBody, Error: `service "Service" HTTP endpoint "MethodA": HTTP endpoint request body must be empty when the endpoint uses streaming. Payload attributes must be mapped to headers and/or params. @@ -210,6 +248,143 @@ service "Service" HTTP endpoint "MethodC": HTTP endpoint request body must be em } } +func TestHTTPWebSocketViewedResultValidation(t *testing.T) { + tests := []struct { + name string + collection bool + collectionView string + method func(*expr.ResultTypeExpr) + err string + }{ + { + name: "client stream with caller-selected view", + method: func(result *expr.ResultTypeExpr) { + dsl.StreamingPayload(dsl.String) + dsl.Result(result) + }, + err: `service "Service" HTTP endpoint "Method": Endpoint cannot choose a result view at runtime when the method defines StreamingPayload because the WebSocket connection starts before the result view is known. Select a view in Result or StreamingResult.`, + }, + { + name: "bidirectional stream with caller-selected view", + method: func(result *expr.ResultTypeExpr) { + dsl.StreamingPayload(dsl.String) + dsl.StreamingResult(result) + }, + err: `service "Service" HTTP endpoint "Method": Endpoint cannot choose a result view at runtime when the method defines StreamingPayload because the WebSocket connection starts before the result view is known. Select a view in Result or StreamingResult.`, + }, + { + name: "client stream with caller-selected collection view", + collection: true, + method: func(result *expr.ResultTypeExpr) { + dsl.StreamingPayload(dsl.String) + dsl.Result(result) + }, + err: `service "Service" HTTP endpoint "Method": Endpoint cannot choose a result view at runtime when the method defines StreamingPayload because the WebSocket connection starts before the result view is known. Select a view in Result or StreamingResult.`, + }, + { + name: "client stream with fixed view", + method: func(result *expr.ResultTypeExpr) { + dsl.StreamingPayload(dsl.String) + dsl.Result(result, func() { + dsl.View("tiny") + }) + }, + }, + { + name: "bidirectional stream with fixed view", + method: func(result *expr.ResultTypeExpr) { + dsl.StreamingPayload(dsl.String) + dsl.StreamingResult(result, func() { + dsl.View("tiny") + }) + }, + }, + { + name: "client stream with fixed collection view", + collection: true, + method: func(result *expr.ResultTypeExpr) { + dsl.StreamingPayload(dsl.String) + dsl.Result(result, func() { + dsl.View("tiny") + }) + }, + }, + { + name: "client stream with view fixed by collection type", + collection: true, + collectionView: "tiny", + method: func(result *expr.ResultTypeExpr) { + dsl.StreamingPayload(dsl.String) + dsl.Result(result) + }, + }, + { + name: "server stream with caller-selected view", + method: func(result *expr.ResultTypeExpr) { + dsl.StreamingResult(result) + }, + }, + { + name: "client stream without views", + method: func(*expr.ResultTypeExpr) { + dsl.StreamingPayload(dsl.String) + dsl.Result(dsl.String) + }, + }, + { + name: "bidirectional stream without views", + method: func(*expr.ResultTypeExpr) { + dsl.StreamingPayload(dsl.String) + dsl.StreamingResult(dsl.String) + }, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + design := httpWebSocketViewedResultDSL(test.collection, test.collectionView, test.method) + if test.err == "" { + expr.RunDSL(t, design) + return + } + err := expr.RunInvalidDSL(t, design) + if got := stripValidationLocations(err.Error()); got != test.err { + t.Errorf("got %q, expected %q", got, test.err) + } + }) + } +} + +// httpWebSocketViewedResultDSL defines a result with two response shapes and +// lets each test choose how the method streams it. +func httpWebSocketViewedResultDSL(collection bool, collectionView string, method func(*expr.ResultTypeExpr)) func() { + return func() { + result := dsl.ResultType("application/vnd.websocket-view", func() { + dsl.Attribute("name", dsl.String) + dsl.View("tiny", func() { + dsl.Attribute("name") + }) + }) + if collection { + if collectionView == "" { + result = dsl.CollectionOf(result) + } else { + result = dsl.CollectionOf(result, func() { + dsl.View(collectionView) + }) + } + } + dsl.Service("Service", func() { + dsl.Method("Method", func() { + method(result) + dsl.HTTP(func() { + dsl.GET("/") + }) + }) + }) + } +} + func TestHTTPEndpointParentRequired(t *testing.T) { root := expr.RunDSL(t, testdata.EndpointHasParent) svc := root.Service("Child") diff --git a/expr/http_error.go b/expr/http_error.go index 9f2ef4d1be..62ad6801c5 100644 --- a/expr/http_error.go +++ b/expr/http_error.go @@ -1,3 +1,5 @@ +// This file binds reusable HTTP error-response policy to the concrete error +// returned by each endpoint method. package expr import ( @@ -23,22 +25,58 @@ func (e *HTTPErrorExpr) EvalName() string { return "HTTP error " + e.Name } +// IsJSONRPC reports whether the response maps an error for a JSON-RPC API, +// service, or method. +func (e *HTTPErrorExpr) IsJSONRPC() bool { + switch parent := e.Response.Parent.(type) { + case *JSONRPCExpr: + return true + case *HTTPServiceExpr: + return parent.IsJSONRPC() + case *HTTPEndpointExpr: + return parent.IsJSONRPC() + default: + return false + } +} + // Validate makes sure there is a error expression that matches the HTTP error // expression. func (e *HTTPErrorExpr) Validate() *eval.ValidationErrors { verr := new(eval.ValidationErrors) + if e.IsJSONRPC() { + if jsonRPCErrorCodeReserved(e.Response.StatusCode) { + verr.Add( + e.Response, + "JSON-RPC error code %d is reserved; use a standard protocol code, a code from -32099 through -32000, or an application code outside -32768 through -32000", + e.Response.StatusCode, + ) + } + // One JSON-RPC error message cannot add HTTP headers or cookies to the + // shared response used for a batch or server-sent-event connection. + if e.Response.Headers != nil && !e.Response.Headers.IsEmpty() { + verr.Add(e.Response, "JSON-RPC error response cannot map error attributes to HTTP headers") + } + if e.Response.Cookies != nil && !e.Response.Cookies.IsEmpty() { + verr.Add(e.Response, "JSON-RPC error response cannot map error attributes to HTTP cookies") + } + } switch p := e.Response.Parent.(type) { case *HTTPEndpointExpr: if p.MethodExpr.Error(e.Name) == nil { - verr.Add(e, "Error %#v does not match an error defined in the method", e.Name) + verr.Add(e.Response, "Error %#v does not match an error defined in the method", e.Name) } case *HTTPServiceExpr: if p.Error(e.Name) == nil { - verr.Add(e, "Error %#v does not match an error defined in the service", e.Name) + verr.Add(e.Response, "Error %#v does not match an error defined in the service", e.Name) } case *RootExpr: + if p.Error(e.Name) == nil { + verr.Add(e.Response, "Error %#v does not match an error defined in the API", e.Name) + } + case *JSONRPCExpr: if Root.Error(e.Name) == nil { - verr.Add(e, "Error %#v does not match an error defined in the API", e.Name) + verr.Add(e.Response, "Error %#v does not match an error defined in the API", e.Name) } } @@ -49,8 +87,13 @@ func (e *HTTPErrorExpr) Validate() *eval.ValidationErrors { case *HTTPServiceExpr: ee = p.Error(e.Name) case *RootExpr: + ee = p.Error(e.Name) + case *JSONRPCExpr: ee = Root.Error(e.Name) } + if ee == nil { + return verr + } // validate headers if e.Response.Headers != nil && !e.Response.Headers.IsEmpty() { @@ -85,18 +128,26 @@ func (e *HTTPErrorExpr) Validate() *eval.ValidationErrors { return verr } +// jsonRPCErrorCodeReserved reports whether code belongs to the part of the +// JSON-RPC reserved range that an application cannot use. +func jsonRPCErrorCodeReserved(code int) bool { + if code < -32768 || code > -32000 { + return false + } + if code >= -32099 { + return false + } + switch code { + case RPCParseError, RPCInvalidRequest, RPCMethodNotFound, RPCInvalidParams, RPCInternalError: + return false + default: + return true + } +} + // Finalize looks up the corresponding method error expression. func (e *HTTPErrorExpr) Finalize(a *HTTPEndpointExpr) { - var ee *ErrorExpr - switch p := e.Response.Parent.(type) { - case *HTTPEndpointExpr: - ee = p.MethodExpr.Error(e.Name) - case *HTTPServiceExpr: - ee = p.Error(e.Name) - case *RootExpr: - ee = Root.Error(e.Name) - } - e.ErrorExpr = ee + e.ErrorExpr = a.MethodExpr.Error(e.Name) e.Response.Finalize(a, e.AttributeExpr) if e.Response.Body == nil { e.Response.Body = httpErrorResponseBody(a, e) @@ -119,6 +170,22 @@ func (e *HTTPErrorExpr) Finalize(a *HTTPEndpointExpr) { e.Response.ContentType = mt.Identifier } +// mappedError returns the error declaration that owns this reusable HTTP +// response policy before the policy is applied to an endpoint method. +func (e *HTTPErrorExpr) mappedError() (*ErrorExpr, string) { + switch parent := e.Response.Parent.(type) { + case *HTTPEndpointExpr: + return parent.MethodExpr.Error(e.Name), "method" + case *HTTPServiceExpr: + return parent.Error(e.Name), "service" + case *RootExpr: + return parent.Error(e.Name), "API" + case *JSONRPCExpr: + return Root.Error(e.Name), "API" + } + return nil, "" +} + // Dup creates a copy of the error expression. func (e *HTTPErrorExpr) Dup() *HTTPErrorExpr { return &HTTPErrorExpr{ diff --git a/expr/http_file_server.go b/expr/http_file_server.go index 9ed969a98c..2d6d0fb3b1 100644 --- a/expr/http_file_server.go +++ b/expr/http_file_server.go @@ -57,7 +57,7 @@ func (f *HTTPFileServerExpr) Finalize() { if isAbs { p = current } else { - p = path.Join(Root.API.HTTP.Path, sp, current) + p = path.Join(f.Service.Root.Path, sp, current) } // Make sure request path starts with a "/" so codegen can rely on it. if !strings.HasPrefix(p, "/") { diff --git a/expr/http_response.go b/expr/http_response.go index a6bb093beb..f7cc01e8d6 100644 --- a/expr/http_response.go +++ b/expr/http_response.go @@ -88,6 +88,9 @@ type ( HTTPResponseExpr struct { // HTTP status StatusCode int + // StatusCodeSet is true when the design explicitly chose StatusCode. + // It distinguishes code zero from an omitted code. + StatusCodeSet bool // Response description Description string // Headers describe the HTTP response headers. @@ -134,6 +137,18 @@ func (r *HTTPResponseExpr) Prepare() { func (r *HTTPResponseExpr) Validate(e *HTTPEndpointExpr) *eval.ValidationErrors { verr := new(eval.ValidationErrors) + // A JSON-RPC message cannot carry HTTP headers or cookies that belong only + // to that message. Several messages may share one batch response or + // server-sent-event connection. + if e.IsJSONRPC() { + if r.Headers != nil && !r.Headers.IsEmpty() { + verr.Add(r, "JSON-RPC success response cannot map result attributes to HTTP headers") + } + if r.Cookies != nil && !r.Cookies.IsEmpty() { + verr.Add(r, "JSON-RPC success response cannot map result attributes to HTTP cookies") + } + } + if r.StatusCode == 0 { verr.Add(r, "HTTP response status not defined") } else if !bodyAllowedForStatus(r.StatusCode) && !e.MethodExpr.IsStreaming() { @@ -324,11 +339,12 @@ func (r *HTTPResponseExpr) Finalize(a *HTTPEndpointExpr, svcAtt *AttributeExpr) // Dup creates a copy of the response expression. func (r *HTTPResponseExpr) Dup() *HTTPResponseExpr { res := HTTPResponseExpr{ - StatusCode: r.StatusCode, - Description: r.Description, - ContentType: r.ContentType, - Parent: r.Parent, - Meta: r.Meta, + StatusCode: r.StatusCode, + StatusCodeSet: r.StatusCodeSet, + Description: r.Description, + ContentType: r.ContentType, + Parent: r.Parent, + Meta: r.Meta, } if r.Body != nil { res.Body = DupAtt(r.Body) @@ -348,7 +364,7 @@ func (r *HTTPResponseExpr) Dup() *HTTPResponseExpr { // attributes are mapped to special goa headers in the form of // "Goa-Attribute(-)". func (r *HTTPResponseExpr) mapUnmappedAttrs(svcAtt *AttributeExpr) { - if svcAtt.Type != ErrorResult { + if !IsErrorResult(svcAtt.Type) { return } diff --git a/expr/http_service.go b/expr/http_service.go index 1bd9ba63c7..6f93483004 100644 --- a/expr/http_service.go +++ b/expr/http_service.go @@ -64,14 +64,13 @@ func (svc *HTTPServiceExpr) Description() string { return svc.ServiceExpr.Description } -// Error returns the error with the given name. +// Error returns the error contract available to service-level HTTP mappings. +// Service declarations take precedence over reusable API declarations. func (svc *HTTPServiceExpr) Error(name string) *ErrorExpr { - for _, erro := range svc.ServiceExpr.Errors { - if erro.Name == name { - return erro - } + if serviceError := svc.ServiceExpr.Error(name); serviceError != nil { + return serviceError } - return Root.Error(name) + return svc.ServiceExpr.design.Error(name) } // Endpoint returns the service endpoint with the given name or nil if there @@ -174,10 +173,15 @@ func (svc *HTTPServiceExpr) EvalName() string { return fmt.Sprintf("service %#v", svc.Name()) } +// IsJSONRPC reports whether svc describes the JSON-RPC transport for its service. +func (svc *HTTPServiceExpr) IsJSONRPC() bool { + return svc.ServiceExpr.Meta != nil && svc.ServiceExpr.Meta["jsonrpc:service"] != nil +} + // Prepare initializes the error responses. func (svc *HTTPServiceExpr) Prepare() { // Create routes for JSON-RPC endpoints if needed - if svc.ServiceExpr.Meta != nil && svc.ServiceExpr.Meta["jsonrpc:service"] != nil { + if svc.IsJSONRPC() { svc.prepareJSONRPCRoutes() } @@ -280,57 +284,13 @@ func (svc *HTTPServiceExpr) validateErrors(verr *eval.ValidationErrors) { } } -// validateTransports validates transport compatibility and JSON-RPC constraints +// validateTransports validates JSON-RPC route constraints. func (svc *HTTPServiceExpr) validateTransports(verr *eval.ValidationErrors) { - var ( - hasPureHTTPWebSocket bool - hasJSONRPCWebSocket bool - ) - - // Analyze endpoints - for _, e := range svc.HTTPEndpoints { - if e.IsJSONRPC() { - if e.UsesWebSocket() { - hasJSONRPCWebSocket = true - } - } else if e.UsesWebSocket() { - hasPureHTTPWebSocket = true - } - } - - // Validate JSON-RPC and pure HTTP WebSocket mixing - if hasJSONRPCWebSocket && hasPureHTTPWebSocket { - verr.Add(svc, "Service cannot mix JSON-RPC WebSocket endpoints with pure HTTP WebSocket endpoints. JSON-RPC uses a single WebSocket connection for all methods, while pure HTTP WebSocket creates individual connections per endpoint.") - } - - // Validate JSON-RPC WebSocket constraints - if hasJSONRPCWebSocket { - svc.validateJSONRPCWebSocketConstraints(verr) - } - - // Validate JSON-RPC transport consistency - if svc.ServiceExpr.Meta != nil && svc.ServiceExpr.Meta["jsonrpc:service"] != nil { - svc.validateJSONRPCTransportConsistency(verr) + if svc.IsJSONRPC() { svc.validateJSONRPCRoutes(verr) } } -// validateJSONRPCWebSocketConstraints validates constraints for JSON-RPC WebSocket endpoints -func (svc *HTTPServiceExpr) validateJSONRPCWebSocketConstraints(verr *eval.ValidationErrors) { - for _, e := range svc.HTTPEndpoints { - name := e.MethodExpr.Name - if !e.Headers.IsEmpty() { - verr.Add(e, "JSON-RPC endpoint %q using WebSocket cannot have header mappings", name) - } - if !e.Cookies.IsEmpty() { - verr.Add(e, "JSON-RPC endpoint %q using WebSocket cannot have cookie mappings", name) - } - if !e.Params.IsEmpty() { - verr.Add(e, "JSON-RPC endpoint %q using WebSocket cannot have parameter mappings", name) - } - } -} - // Finalize initializes the path if no path is set in design. func (svc *HTTPServiceExpr) Finalize() { if len(svc.Paths) == 0 { @@ -367,18 +327,8 @@ func (svc *HTTPServiceExpr) prepareJSONRPCRoutes() { path = svc.Paths[0] } - method := "POST" // default - - // If using WebSocket, force GET - for _, e := range svc.HTTPEndpoints { - if e.IsJSONRPC() && e.UsesWebSocket() { - method = "GET" // WebSocket requires GET - break - } - } - route = &RouteExpr{ - Method: method, + Method: "POST", Path: path, } } @@ -395,46 +345,13 @@ func (svc *HTTPServiceExpr) prepareJSONRPCRoutes() { } } -// validateJSONRPCTransportConsistency validates JSON-RPC transport combinations. -// WebSocket cannot be mixed with other transports, but HTTP and SSE can coexist. -func (svc *HTTPServiceExpr) validateJSONRPCTransportConsistency(verr *eval.ValidationErrors) { - var hasWebSocket, hasSSE, hasRegular bool - - for _, e := range svc.HTTPEndpoints { - if e.IsJSONRPC() { - switch { - case e.UsesWebSocket(): - hasWebSocket = true - case e.UsesSSE(): - hasSSE = true - default: - hasRegular = true - } - } - } - - // WebSocket cannot be mixed with any other transport - if hasWebSocket && (hasSSE || hasRegular) { - verr.Add(svc, "JSON-RPC service %q cannot mix WebSocket with other transports (SSE or regular HTTP). WebSocket requires a single persistent connection for all methods.", svc.Name()) - } - // HTTP and SSE can be mixed - they both use POST requests and can share the same endpoint -} - -// validateJSONRPCRoutes validates that JSON-RPC routes use the correct HTTP method. +// validateJSONRPCRoutes checks that every JSON-RPC route uses POST. func (svc *HTTPServiceExpr) validateJSONRPCRoutes(verr *eval.ValidationErrors) { for _, e := range svc.HTTPEndpoints { if e.IsJSONRPC() { for _, r := range e.Routes { - // WebSocket requires GET - if e.UsesWebSocket() { - if r.Method != "GET" { - verr.Add(r, "JSON-RPC WebSocket endpoint must use GET method, got %q", r.Method) - } - } else { - // Regular JSON-RPC and SSE require POST - if r.Method != "POST" { - verr.Add(r, "JSON-RPC endpoint must use POST method, got %q", r.Method) - } + if r.Method != "POST" { + verr.Add(r, "JSON-RPC endpoint must use POST method, got %q", r.Method) } } } diff --git a/expr/http_service_test.go b/expr/http_service_test.go deleted file mode 100644 index 1fa5729c2d..0000000000 --- a/expr/http_service_test.go +++ /dev/null @@ -1,168 +0,0 @@ -package expr_test - -import ( - "strings" - "testing" - - . "goa.design/goa/v3/dsl" - "goa.design/goa/v3/expr" -) - -func TestHTTPServiceValidate(t *testing.T) { - cases := []struct { - Name string - DSL func() - Error string - ContainsError string - }{ - {"valid jsonrpc websocket", validJSONRPCWebSocketDSL, "", ""}, - {"jsonrpc websocket with headers", jsonrpcWebSocketWithHeadersDSL, "", `JSON-RPC endpoint "method" using WebSocket cannot have header mappings`}, - {"jsonrpc websocket with cookies", jsonrpcWebSocketWithCookiesDSL, "", `JSON-RPC endpoint "method" using WebSocket cannot have cookie mappings`}, - {"jsonrpc websocket with params", jsonrpcWebSocketWithParamsDSL, "", `JSON-RPC endpoint "method" using WebSocket cannot have parameter mappings`}, - {"jsonrpc websocket with all mappings", jsonrpcWebSocketWithAllMappingsDSL, "", `JSON-RPC endpoint "method" using WebSocket cannot have header mappings`}, - } - - for _, tc := range cases { - t.Run(tc.Name, func(t *testing.T) { - if tc.Error == "" && tc.ContainsError == "" { - expr.RunDSL(t, tc.DSL) - } else { - err := expr.RunInvalidDSL(t, tc.DSL) - if tc.Error != "" { - if err.Error() != tc.Error { - t.Errorf("got error %q, expected %q", err.Error(), tc.Error) - } - } else if tc.ContainsError != "" { - if !strings.Contains(err.Error(), tc.ContainsError) { - t.Errorf("error %q does not contain expected substring %q", err.Error(), tc.ContainsError) - } - } - } - }) - } -} - -// Test DSL functions - -var validJSONRPCWebSocketDSL = func() { - Service("calc", func() { - JSONRPC(func() { - GET("/ws") - }) - Method("method", func() { - StreamingPayload(func() { - ID("request_id", String) - Attribute("data", String) - Required("request_id") - }) - StreamingResult(func() { - ID("response_id", String) - Attribute("value", String) - Required("response_id") - }) - JSONRPC(func() {}) - }) - }) -} - -var jsonrpcWebSocketWithHeadersDSL = func() { - Service("calc", func() { - JSONRPC(func() { - GET("/ws") - }) - Method("method", func() { - StreamingPayload(func() { - ID("request_id", String) - Attribute("data", String) - Required("request_id") - }) - StreamingResult(func() { - ID("response_id", String) - Attribute("value", String) - Required("response_id") - }) - JSONRPC(func() { - Headers(func() { - Header("X-API-Version", String) - }) - }) - }) - }) -} - -var jsonrpcWebSocketWithCookiesDSL = func() { - Service("calc", func() { - JSONRPC(func() { - GET("/ws") - }) - Method("method", func() { - StreamingPayload(func() { - ID("request_id", String) - Attribute("data", String) - Required("request_id") - }) - StreamingResult(func() { - ID("response_id", String) - Attribute("value", String) - Required("response_id") - }) - JSONRPC(func() { - Cookie("session", String) - }) - }) - }) -} - -var jsonrpcWebSocketWithParamsDSL = func() { - Service("calc", func() { - JSONRPC(func() { - GET("/ws") - }) - Method("method", func() { - StreamingPayload(func() { - ID("request_id", String) - Attribute("data", String) - Required("request_id") - }) - StreamingResult(func() { - ID("response_id", String) - Attribute("value", String) - Required("response_id") - }) - JSONRPC(func() { - Params(func() { - Param("id", String) - }) - }) - }) - }) -} - -var jsonrpcWebSocketWithAllMappingsDSL = func() { - Service("calc", func() { - JSONRPC(func() { - GET("/ws") - }) - Method("method", func() { - StreamingPayload(func() { - ID("request_id", String) - Attribute("data", String) - Required("request_id") - }) - StreamingResult(func() { - ID("response_id", String) - Attribute("value", String) - Required("response_id") - }) - JSONRPC(func() { - Headers(func() { - Header("X-API-Version", String) - }) - Cookie("session", String) - Params(func() { - Param("id", String) - }) - }) - }) - }) -} diff --git a/expr/http_sse.go b/expr/http_sse.go index 7aeef807c3..395c986f1a 100644 --- a/expr/http_sse.go +++ b/expr/http_sse.go @@ -44,24 +44,31 @@ func (e *HTTPSSEExpr) EvalName() string { // Validate validates the Server-Sent Events expression against a specific result type. func (e *HTTPSSEExpr) Validate(method *MethodExpr) error { - if method == nil || method.Result == nil { + if method == nil { + return nil + } + result := method.StreamingResult + if result == nil { + result = method.Result + } + if result == nil { return nil } verr := new(eval.ValidationErrors) - if err := validateSSEField(method.Payload, e.RequestIDField, "request ID", []DataType{String}); err != nil { + if err := validateSSEField(method.Payload, e.RequestIDField, "request ID", "payload", []DataType{String}); err != nil { verr.Add(method, "%s", err.Error()) } - if err := validateSSEField(method.Result, e.DataField, "event data", nil); err != nil { + if err := validateSSEField(result, e.DataField, "event data", "result type", nil); err != nil { verr.Add(method, "%s", err.Error()) } - if err := validateSSEField(method.Result, e.IDField, "event id", []DataType{String}); err != nil { + if err := validateSSEField(result, e.IDField, "event id", "result type", []DataType{String}); err != nil { verr.Add(method, "%s", err.Error()) } - if err := validateSSEField(method.Result, e.EventField, "event type", []DataType{String}); err != nil { + if err := validateSSEField(result, e.EventField, "event type", "result type", []DataType{String}); err != nil { verr.Add(method, "%s", err.Error()) } - if err := validateSSEField(method.Result, e.RetryField, "event retry", []DataType{Int, Int32, Int64, UInt, UInt32, UInt64}); err != nil { + if err := validateSSEField(result, e.RetryField, "event retry", "result type", []DataType{Int, Int32, Int64, UInt, UInt32, UInt64}); err != nil { verr.Add(method, "%s", err.Error()) } @@ -72,26 +79,28 @@ func (e *HTTPSSEExpr) Validate(method *MethodExpr) error { } // validateSSEField validates that the given field exists in the result type and has the expected type. -func validateSSEField(rt *AttributeExpr, field, desc string, expectedTypes []DataType) error { +func validateSSEField(rt *AttributeExpr, field, desc, source string, expectedTypes []DataType) error { if field == "" { return nil } if rt == nil { - return fmt.Errorf("cannot use %q for SSE %s field: result type is nil", field, desc) + return fmt.Errorf("cannot use %q for SSE %s field: %s is nil", field, desc, source) } obj := AsObject(rt.Type) if obj == nil { - return fmt.Errorf("cannot use %q for SSE %s field: result type is not an object", field, desc) + return fmt.Errorf("cannot use %q for SSE %s field: %s is not an object", field, desc, source) } att := obj.Attribute(field) if att == nil { - return fmt.Errorf("cannot use %q for SSE %s field: attribute not found in result type", field, desc) + return fmt.Errorf("cannot use %q for SSE %s field: attribute not found in %s", field, desc, source) } - if len(expectedTypes) > 0 && !slices.Contains(expectedTypes, att.Type) { + if len(expectedTypes) > 0 && !slices.ContainsFunc(expectedTypes, func(expected DataType) bool { + return expected.Kind() == sseFieldKind(att.Type) + }) { typeNames := make([]string, len(expectedTypes)) for i, t := range expectedTypes { typeNames[i] = t.Name() @@ -101,3 +110,11 @@ func validateSSEField(rt *AttributeExpr, field, desc string, expectedTypes []Dat return nil } + +// sseFieldKind returns the primitive kind beneath any named aliases. +func sseFieldKind(dataType DataType) Kind { + for IsAlias(dataType) { + dataType = dataType.(UserType).Attribute().Type + } + return dataType.Kind() +} diff --git a/expr/http_sse_test.go b/expr/http_sse_test.go index 0601bc4184..584fb49760 100644 --- a/expr/http_sse_test.go +++ b/expr/http_sse_test.go @@ -14,6 +14,7 @@ func TestHTTPSSEExprValidation(t *testing.T) { SSE *expr.HTTPSSEExpr Payload *expr.AttributeExpr Result *expr.AttributeExpr + StreamResult *expr.AttributeExpr ExpectedErrs []string }{ "valid-empty": { @@ -43,6 +44,22 @@ func TestHTTPSSEExprValidation(t *testing.T) { }, }, }, + "valid-named-fields": { + SSE: &expr.HTTPSSEExpr{ + RequestIDField: "request_id", + IDField: "id", + EventField: "event", + RetryField: "retry", + }, + Payload: &expr.AttributeExpr{Type: &expr.Object{ + &expr.NamedAttributeExpr{Name: "request_id", Attribute: &expr.AttributeExpr{Type: namedPrimitive("RequestID", expr.String)}}, + }}, + Result: &expr.AttributeExpr{Type: &expr.Object{ + &expr.NamedAttributeExpr{Name: "id", Attribute: &expr.AttributeExpr{Type: namedPrimitive("EventID", expr.String)}}, + &expr.NamedAttributeExpr{Name: "event", Attribute: &expr.AttributeExpr{Type: namedPrimitive("EventType", expr.String)}}, + &expr.NamedAttributeExpr{Name: "retry", Attribute: &expr.AttributeExpr{Type: namedPrimitive("Retry", expr.UInt32)}}, + }}, + }, "invalid-id-field-type": { SSE: &expr.HTTPSSEExpr{ IDField: "id", @@ -103,6 +120,27 @@ func TestHTTPSSEExprValidation(t *testing.T) { }, ExpectedErrs: []string{"cannot use \"missing\" for SSE event data field: attribute not found in result type"}, }, + "valid-streaming-result-field": { + SSE: &expr.HTTPSSEExpr{DataField: "event_data"}, + Payload: &expr.AttributeExpr{Type: &expr.Object{}}, + Result: &expr.AttributeExpr{Type: &expr.Object{ + &expr.NamedAttributeExpr{Name: "final_data", Attribute: &expr.AttributeExpr{Type: expr.String}}, + }}, + StreamResult: &expr.AttributeExpr{Type: &expr.Object{ + &expr.NamedAttributeExpr{Name: "event_data", Attribute: &expr.AttributeExpr{Type: expr.String}}, + }}, + }, + "invalid-ordinary-result-field": { + SSE: &expr.HTTPSSEExpr{DataField: "final_data"}, + Payload: &expr.AttributeExpr{Type: &expr.Object{}}, + Result: &expr.AttributeExpr{Type: &expr.Object{ + &expr.NamedAttributeExpr{Name: "final_data", Attribute: &expr.AttributeExpr{Type: expr.String}}, + }}, + StreamResult: &expr.AttributeExpr{Type: &expr.Object{ + &expr.NamedAttributeExpr{Name: "event_data", Attribute: &expr.AttributeExpr{Type: expr.String}}, + }}, + ExpectedErrs: []string{"cannot use \"final_data\" for SSE event data field: attribute not found in result type"}, + }, "missing-request-id-field": { SSE: &expr.HTTPSSEExpr{ RequestIDField: "missing", @@ -113,7 +151,7 @@ func TestHTTPSSEExprValidation(t *testing.T) { }, }, Result: &expr.AttributeExpr{Type: &expr.Object{}}, - ExpectedErrs: []string{"cannot use \"missing\" for SSE request ID field: attribute not found in result type"}, + ExpectedErrs: []string{"cannot use \"missing\" for SSE request ID field: attribute not found in payload"}, }, "nil-result-type": { SSE: &expr.HTTPSSEExpr{ @@ -148,10 +186,11 @@ func TestHTTPSSEExprValidation(t *testing.T) { t.Run(name, func(t *testing.T) { // Create a mock HTTP endpoint expression for validation methodExpr := &expr.MethodExpr{ - Name: "TestMethod", - Payload: tc.Payload, - Result: tc.Result, - Stream: expr.ServerStreamKind, // Must be a streaming method for SSE + Name: "TestMethod", + Payload: tc.Payload, + Result: tc.Result, + StreamingResult: tc.StreamResult, + Stream: expr.ServerStreamKind, // Must be a streaming method for SSE } // Run validation @@ -169,3 +208,11 @@ func TestHTTPSSEExprValidation(t *testing.T) { }) } } + +func namedPrimitive(name string, primitive expr.Primitive) *expr.UserTypeExpr { + return &expr.UserTypeExpr{ + AttributeExpr: &expr.AttributeExpr{Type: primitive}, + TypeName: name, + UID: name, + } +} diff --git a/expr/interceptor.go b/expr/interceptor.go index 1451c967b9..5a0f483b94 100644 --- a/expr/interceptor.go +++ b/expr/interceptor.go @@ -119,12 +119,12 @@ func (i *InterceptorExpr) validate(m *MethodExpr) *eval.ValidationErrors { if !m.IsResultStreaming() { verr.Add(m, "interceptor %q cannot be applied because the method result is not streaming", i.Name) } else { - if !IsObject(m.Result.Type) { + if !IsObject(m.StreamingResult.Type) { verr.Add(m, "interceptor %q cannot be applied because the method result is not an object", i.Name) } else { - result := DupAtt(m.Result) - if m.Result.Bases != nil { - for _, base := range m.Result.Bases { + result := DupAtt(m.StreamingResult) + if m.StreamingResult.Bases != nil { + for _, base := range m.StreamingResult.Bases { if ut, ok := base.(UserType); ok { result.Merge(ut.Attribute()) } diff --git a/expr/interceptor_test.go b/expr/interceptor_test.go index 08e7b2f6cc..37ea6a83d3 100644 --- a/expr/interceptor_test.go +++ b/expr/interceptor_test.go @@ -70,6 +70,14 @@ func TestInterceptorExpr_Validate(t *testing.T) { m.StreamingPayload = &AttributeExpr{Type: ut} }), }, + "streaming-result-distinct-from-result": { + intercept: makeInterceptor(t, withReadStreamingResult(t, namedAttr(t, "event"))), + method: makeMethod(t, func(m *MethodExpr) { + m.Stream = ServerStreamKind + m.Result = &AttributeExpr{Type: &Object{namedAttr(t, "summary")}} + m.StreamingResult = &AttributeExpr{Type: &Object{namedAttr(t, "event")}} + }), + }, "invalid-payload-not-object": { intercept: makeInterceptor(t, withReadPayload(t, namedAttr(t, "foo"))), method: makeMethod(t, func(m *MethodExpr) { diff --git a/expr/jsonrpc.go b/expr/jsonrpc.go index fcd05f86b1..c92658fbb8 100644 --- a/expr/jsonrpc.go +++ b/expr/jsonrpc.go @@ -12,12 +12,13 @@ func (*JSONRPCExpr) EvalName() string { return "API JSON-RPC" } -// Prepare copies the HTTP API constructs over to the JSON-RPC API. +// Prepare copies shared HTTP request settings to the JSON-RPC API. JSON-RPC +// error mappings stay separate because their codes belong to JSON-RPC, not +// HTTP. func (j *JSONRPCExpr) Prepare() { j.Path = Root.API.HTTP.Path j.Params = Root.API.HTTP.Params j.Headers = Root.API.HTTP.Headers j.Cookies = Root.API.HTTP.Cookies - j.Errors = Root.API.HTTP.Errors j.SSE = Root.API.HTTP.SSE } diff --git a/expr/jsonrpc_error_contract_test.go b/expr/jsonrpc_error_contract_test.go new file mode 100644 index 0000000000..a39a756cdd --- /dev/null +++ b/expr/jsonrpc_error_contract_test.go @@ -0,0 +1,232 @@ +// This file verifies that JSON-RPC error mappings remain separate from HTTP +// mappings and use only error codes allowed by JSON-RPC 2.0. +package expr_test + +import ( + "fmt" + "testing" + + "github.com/stretchr/testify/require" + + . "goa.design/goa/v3/dsl" + "goa.design/goa/v3/expr" +) + +func TestJSONRPCAPIErrorMappingOverridesHTTPDefault(t *testing.T) { + root := expr.RunDSL(t, func() { + API("jobs", func() { + Error("busy", String) + HTTP(func() { + Path("/v1") + Response("busy", StatusServiceUnavailable) + }) + JSONRPC(func() { + Response("busy", 7001) + }) + }) + Service("jobs", func() { + Method("run", func() { + Error("busy") + HTTP(func() { + POST("/run") + }) + JSONRPC(func() {}) + }) + Method("inspect", func() { + JSONRPC(func() {}) + }) + }) + }) + + httpEndpoint := root.API.HTTP.Service("jobs").Endpoint("run") + jsonrpcService := root.API.JSONRPC.Service("jobs") + jsonrpcEndpoint := jsonrpcService.Endpoint("run") + + require.Equal(t, StatusServiceUnavailable, httpEndpoint.HTTPErrors[0].Response.StatusCode) + require.Equal(t, 7001, jsonrpcEndpoint.HTTPErrors[0].Response.StatusCode) + require.Empty(t, jsonrpcService.Endpoint("inspect").HTTPErrors) + require.Equal(t, "/v1", root.API.JSONRPC.Path) +} + +func TestJSONRPCPreparePreservesJSONRPCErrorDefaults(t *testing.T) { + previousRoot := expr.Root + t.Cleanup(func() { + expr.Root = previousRoot + }) + httpMapping := &expr.HTTPErrorExpr{ + Name: "busy", + Response: &expr.HTTPResponseExpr{ + StatusCode: StatusServiceUnavailable, + }, + } + jsonrpcMapping := &expr.HTTPErrorExpr{ + Name: "busy", + Response: &expr.HTTPResponseExpr{ + StatusCode: 7001, + }, + } + expr.Root = &expr.RootExpr{ + API: &expr.APIExpr{ + HTTP: &expr.HTTPExpr{ + Errors: []*expr.HTTPErrorExpr{httpMapping}, + }, + JSONRPC: &expr.JSONRPCExpr{ + HTTPExpr: expr.HTTPExpr{ + Errors: []*expr.HTTPErrorExpr{jsonrpcMapping}, + }, + }, + }, + } + + expr.Root.API.JSONRPC.Prepare() + + require.Equal(t, []*expr.HTTPErrorExpr{jsonrpcMapping}, expr.Root.API.JSONRPC.Errors) +} + +func TestJSONRPCAPIErrorMappingRequiresReusableError(t *testing.T) { + err := expr.RunInvalidDSL(t, func() { + API("jobs", func() { + JSONRPC(func() { + Response("busy", 7001) + }) + }) + Service("jobs", func() { + Method("inspect", func() { + JSONRPC(func() {}) + }) + }) + }) + + require.ErrorContains(t, err, `Error "busy" does not match an error defined in the API`) +} + +func TestJSONRPCErrorCodeAcceptsAllowedValues(t *testing.T) { + for _, test := range []struct { + name string + code int + useCodeDSL bool + }{ + {name: fmt.Sprint(RPCParseError), code: RPCParseError}, + {name: fmt.Sprint(RPCInvalidRequest), code: RPCInvalidRequest}, + {name: fmt.Sprint(RPCMethodNotFound), code: RPCMethodNotFound}, + {name: fmt.Sprint(RPCInvalidParams), code: RPCInvalidParams}, + {name: fmt.Sprint(RPCInternalError), code: RPCInternalError}, + {name: "server range lower bound", code: -32099}, + {name: "server range upper bound", code: -32000}, + {name: "below reserved range", code: -32769}, + {name: "zero argument", code: 0}, + {name: "Code zero", code: 0, useCodeDSL: true}, + {name: "positive application code", code: 7001}, + } { + t.Run(test.name, func(t *testing.T) { + root := expr.RunDSL(t, jsonRPCErrorCodeDSL(test.code, test.useCodeDSL)) + response := root.API.JSONRPC.Service("jobs").Endpoint("run").HTTPErrors[0].Response + + require.Equal(t, test.code, response.StatusCode) + require.True(t, response.StatusCodeSet) + }) + } +} + +func TestJSONRPCErrorCodeDefaultsToInternalError(t *testing.T) { + root := expr.RunDSL(t, func() { + Service("jobs", func() { + Method("run", func() { + Error("failed", String) + JSONRPC(func() { + Response("failed", func() {}) + }) + }) + }) + }) + response := root.API.JSONRPC.Service("jobs").Endpoint("run").HTTPErrors[0].Response + + require.Equal(t, RPCInternalError, response.StatusCode) + require.False(t, response.StatusCodeSet) +} + +func TestJSONRPCErrorCodeRejectsReservedValues(t *testing.T) { + for _, code := range []int{-32768, -32701, -32699, -32604, -32100} { + t.Run(fmt.Sprint(code), func(t *testing.T) { + err := expr.RunInvalidDSL(t, jsonRPCErrorCodeDSL(code, false)) + + require.ErrorContains(t, err, fmt.Sprintf("JSON-RPC error code %d is reserved", code)) + }) + } +} + +func TestJSONRPCErrorCodeMayBeReusedByDifferentMethods(t *testing.T) { + root := expr.RunDSL(t, func() { + API("jobs", func() { + Error("busy", String) + Error("failed", String) + JSONRPC(func() { + Response("busy", 7001) + Response("failed", 7001) + }) + }) + Service("jobs", func() { + Method("run", func() { + Error("busy") + JSONRPC(func() {}) + }) + Method("inspect", func() { + Error("failed") + JSONRPC(func() {}) + }) + }) + }) + + service := root.API.JSONRPC.Service("jobs") + require.Equal(t, 7001, service.Endpoint("run").HTTPErrors[0].Response.StatusCode) + require.Equal(t, "busy", service.Endpoint("run").HTTPErrors[0].Name) + require.Equal(t, 7001, service.Endpoint("inspect").HTTPErrors[0].Response.StatusCode) + require.Equal(t, "failed", service.Endpoint("inspect").HTTPErrors[0].Name) +} + +func TestJSONRPCMethodErrorMappingReplacesAPIDefault(t *testing.T) { + root := expr.RunDSL(t, func() { + API("jobs", func() { + Error("busy", String) + JSONRPC(func() { + Response("busy", 7001) + }) + }) + Service("jobs", func() { + Method("run", func() { + Error("busy") + Error("failed", String) + JSONRPC(func() { + Response("busy", 7002) + Response("failed", 7001) + }) + }) + }) + }) + + errors := root.API.JSONRPC.Service("jobs").Endpoint("run").HTTPErrors + require.Len(t, errors, 2) + require.Equal(t, "busy", errors[0].Name) + require.Equal(t, 7002, errors[0].Response.StatusCode) + require.Equal(t, "failed", errors[1].Name) + require.Equal(t, 7001, errors[1].Response.StatusCode) +} + +func jsonRPCErrorCodeDSL(code int, useCodeDSL bool) func() { + return func() { + Service("jobs", func() { + Method("run", func() { + Error("failed", String) + JSONRPC(func() { + if useCodeDSL { + Response("failed", func() { + Code(code) + }) + return + } + Response("failed", code) + }) + }) + }) + } +} diff --git a/expr/jsonrpc_notification_contract_test.go b/expr/jsonrpc_notification_contract_test.go new file mode 100644 index 0000000000..46becc18b3 --- /dev/null +++ b/expr/jsonrpc_notification_contract_test.go @@ -0,0 +1,329 @@ +// This file checks the design rules for one-way JSON-RPC calls. +package expr_test + +import ( + "testing" + + "github.com/stretchr/testify/require" + + "goa.design/goa/v3/dsl" + "goa.design/goa/v3/expr" +) + +const jsonRPCInternalError = -32603 + +// TestJSONRPCNotificationContract checks that notifications are explicit and +// cannot declare response or request-ID behavior. +func TestJSONRPCNotificationContract(t *testing.T) { + t.Run("notification with payload", func(t *testing.T) { + root := expr.RunDSL(t, jsonRPCNotificationDSL(func() { + dsl.Payload(func() { + dsl.Attribute("message", dsl.String) + dsl.Required("message") + }) + dsl.JSONRPC(func() { + dsl.Notification() + }) + })) + endpoint := root.API.JSONRPC.Services[0].HTTPEndpoints[0] + require.True(t, endpoint.IsJSONRPCNotification()) + }) + + t.Run("ordinary method without result", func(t *testing.T) { + root := expr.RunDSL(t, jsonRPCNotificationDSL(func() { + dsl.JSONRPC(func() {}) + })) + endpoint := root.API.JSONRPC.Services[0].HTTPEndpoints[0] + require.False(t, endpoint.IsJSONRPCNotification()) + }) + + t.Run("named string request ID", func(t *testing.T) { + expr.RunDSL(t, func() { + requestID := dsl.Type("RequestID", dsl.String) + dsl.Service("notifications", func() { + dsl.JSONRPC(func() { + dsl.POST("/rpc") + }) + dsl.Method("notify", func() { + dsl.Payload(func() { + dsl.ID("request_id", requestID) + }) + dsl.JSONRPC(func() {}) + }) + }) + }) + }) + + t.Run("notification outside JSON-RPC", func(t *testing.T) { + err := expr.RunInvalidDSL(t, func() { + dsl.Service("records", func() { + dsl.Method("record", func() { + dsl.HTTP(func() { + dsl.POST("/records") + dsl.Notification() + }) + }) + }) + }) + require.Contains(t, err.Error(), `invalid use of Notification in service "records" HTTP endpoint "record"`) + }) + + invalid := []struct { + name string + method func() + wantErr string + }{ + { + name: "result", + method: func() { + dsl.Result(dsl.String) + dsl.JSONRPC(func() { + dsl.Notification() + }) + }, + wantErr: `JSON-RPC notification "notify" cannot define a result because notifications receive no response`, + }, + { + name: "request ID", + method: func() { + dsl.Payload(func() { + dsl.ID("request_id") + }) + dsl.JSONRPC(func() { + dsl.Notification() + }) + }, + wantErr: `JSON-RPC notification "notify" cannot define an ID field because notifications omit the request ID`, + }, + { + name: "result ID", + method: func() { + dsl.Result(func() { + dsl.ID("request_id") + }) + dsl.JSONRPC(func() {}) + }, + wantErr: `JSON-RPC method "notify" cannot define an ID field in its result because the transport copies the request ID`, + }, + { + name: "error ID", + method: func() { + dsl.Error("failed", func() { + dsl.ID("request_id") + }) + dsl.JSONRPC(func() { + dsl.Response("failed", jsonRPCInternalError) + }) + }, + wantErr: `JSON-RPC error "failed" cannot define an ID field because the transport copies the request ID`, + }, + { + name: "non-string request ID", + method: func() { + dsl.Payload(func() { + dsl.ID("request_id", dsl.Int) + }) + dsl.JSONRPC(func() {}) + }, + wantErr: `JSON-RPC request ID field "request_id" must be a string`, + }, + { + name: "nested request ID", + method: func() { + dsl.Payload(func() { + dsl.Attribute("request", func() { + dsl.ID("id") + }) + }) + dsl.JSONRPC(func() {}) + }, + wantErr: `JSON-RPC request ID field "id" must be a direct payload field`, + }, + { + name: "multiple request IDs", + method: func() { + dsl.Payload(func() { + dsl.ID("first") + dsl.ID("second") + }) + dsl.JSONRPC(func() {}) + }, + wantErr: `JSON-RPC method "notify" cannot define more than one request ID field`, + }, + { + name: "stream", + method: func() { + dsl.StreamingResult(dsl.String) + dsl.JSONRPC(func() { + dsl.Notification() + dsl.ServerSentEvents(func() {}) + }) + }, + wantErr: `JSON-RPC notification "notify" cannot stream because notifications send one message and receive no response`, + }, + } + for _, test := range invalid { + t.Run(test.name, func(t *testing.T) { + err := expr.RunInvalidDSL(t, jsonRPCNotificationDSL(test.method)) + require.Contains(t, err.Error(), test.wantErr) + }) + } +} + +// TestJSONRPCRequestIDBodyContract checks that the request ID exists only in +// the JSON-RPC envelope and cannot also be mapped into params. +func TestJSONRPCRequestIDBodyContract(t *testing.T) { + t.Run("computed params omit ID", func(t *testing.T) { + root := expr.RunDSL(t, jsonRPCNotificationDSL(func() { + dsl.Payload(func() { + dsl.ID("request_id") + dsl.Attribute("message", dsl.String) + dsl.Required("request_id", "message") + }) + dsl.JSONRPC(func() {}) + })) + body := root.API.JSONRPC.Services[0].HTTPEndpoints[0].Body + require.Nil(t, body.Find("request_id")) + require.NotNil(t, body.Find("message")) + require.False(t, body.IsRequired("request_id")) + require.True(t, body.IsRequired("message")) + }) + + t.Run("ID-only payload has no params body", func(t *testing.T) { + root := expr.RunDSL(t, jsonRPCNotificationDSL(func() { + dsl.Payload(func() { + dsl.ID("request_id") + dsl.Required("request_id") + }) + dsl.JSONRPC(func() {}) + })) + body := root.API.JSONRPC.Services[0].HTTPEndpoints[0].Body + require.Equal(t, expr.Empty, body.Type) + }) + + t.Run("explicit params reject ID", func(t *testing.T) { + err := expr.RunInvalidDSL(t, jsonRPCNotificationDSL(func() { + dsl.Payload(func() { + dsl.ID("request_id") + dsl.Attribute("message", dsl.String) + }) + dsl.JSONRPC(func() { + dsl.Body(func() { + dsl.Attribute("request_id") + dsl.Attribute("message") + }) + }) + })) + require.Contains(t, err.Error(), `JSON-RPC request ID field "request_id" cannot also appear in params`) + }) +} + +// TestJSONRPCRequestIDTransportContract checks that the envelope ID is not +// also read from another part of the HTTP request. +func TestJSONRPCRequestIDTransportContract(t *testing.T) { + tests := []struct { + name string + mapping func() + wantErr string + }{ + {name: "query parameter", mapping: func() { dsl.Param("request_id") }, wantErr: `cannot also be mapped as an HTTP parameter`}, + {name: "header", mapping: func() { dsl.Header("request_id") }, wantErr: `cannot also be mapped as an HTTP header`}, + {name: "cookie", mapping: func() { dsl.Cookie("request_id") }, wantErr: `cannot also be mapped as an HTTP cookie`}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + err := expr.RunInvalidDSL(t, jsonRPCNotificationDSL(func() { + dsl.Payload(func() { + dsl.ID("request_id") + }) + dsl.JSONRPC(func() { + test.mapping() + }) + })) + require.Contains(t, err.Error(), test.wantErr) + }) + } + + t.Run("path parameter", func(t *testing.T) { + err := expr.RunInvalidDSL(t, func() { + dsl.Service("records", func() { + dsl.JSONRPC(func() { + dsl.POST("/rpc/{request_id}") + }) + dsl.Method("lookup", func() { + dsl.Payload(func() { + dsl.ID("request_id") + }) + dsl.JSONRPC(func() { + dsl.Param("request_id") + }) + }) + }) + }) + require.Contains(t, err.Error(), `cannot also be mapped as an HTTP parameter`) + }) + + t.Run("SSE reconnect header", func(t *testing.T) { + err := expr.RunInvalidDSL(t, jsonRPCNotificationDSL(func() { + dsl.Payload(func() { + dsl.ID("request_id") + }) + dsl.StreamingResult(dsl.String) + dsl.JSONRPC(func() { + dsl.ServerSentEvents(func() { + dsl.SSERequestID("request_id") + }) + }) + })) + require.Contains(t, err.Error(), `cannot also be mapped to the Last-Event-ID header`) + }) + + t.Run("ordinary HTTP mapping remains valid", func(t *testing.T) { + expr.RunDSL(t, func() { + dsl.Service("records", func() { + dsl.JSONRPC(func() { + dsl.POST("/rpc") + }) + dsl.Method("lookup", func() { + dsl.Payload(func() { + dsl.ID("request_id") + dsl.Required("request_id") + }) + dsl.HTTP(func() { + dsl.GET("/records/{request_id}") + dsl.Param("request_id") + }) + dsl.JSONRPC(func() {}) + }) + }) + }) + }) +} + +// TestJSONRPCMethodNameContract checks that application methods do not use the +// namespace reserved by JSON-RPC itself. +func TestJSONRPCMethodNameContract(t *testing.T) { + err := expr.RunInvalidDSL(t, func() { + dsl.Service("records", func() { + dsl.JSONRPC(func() { + dsl.POST("/rpc") + }) + dsl.Method("rpc.lookup", func() { + dsl.JSONRPC(func() {}) + }) + }) + }) + require.Contains(t, err.Error(), `JSON-RPC method "rpc.lookup" cannot begin with "rpc."`) +} + +// jsonRPCNotificationDSL exposes one method through the shared JSON-RPC route. +func jsonRPCNotificationDSL(method func()) func() { + return func() { + dsl.Service("notifications", func() { + dsl.JSONRPC(func() { + dsl.POST("/rpc") + }) + dsl.Method("notify", method) + }) + } +} diff --git a/expr/jsonrpc_request_id_validation_test.go b/expr/jsonrpc_request_id_validation_test.go new file mode 100644 index 0000000000..5bc3dc2eb8 --- /dev/null +++ b/expr/jsonrpc_request_id_validation_test.go @@ -0,0 +1,171 @@ +// This file checks that a JSON-RPC request ID can accept values created by +// generated clients when callers leave an optional ID unset. +package expr_test + +import ( + "testing" + + "github.com/stretchr/testify/require" + + "goa.design/goa/v3/dsl" + "goa.design/goa/v3/expr" +) + +// TestJSONRPCGeneratedRequestIDValidation checks that an optional request ID +// accepts every UUID that a generated client may create for it. +func TestJSONRPCGeneratedRequestIDValidation(t *testing.T) { + tests := []struct { + name string + validation func() + want string + }{ + { + name: "enum", + validation: func() { + dsl.Enum("known-id") + }, + want: `enum`, + }, + { + name: "non-UUID format", + validation: func() { + dsl.Format(dsl.FormatEmail) + }, + want: `format "email"`, + }, + { + name: "pattern", + validation: func() { + dsl.Pattern(`^req-`) + }, + want: `pattern`, + }, + { + name: "minimum length", + validation: func() { + dsl.MinLength(37) + }, + want: `minimum length 37`, + }, + { + name: "maximum length", + validation: func() { + dsl.MaxLength(35) + }, + want: `maximum length 35`, + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + err := expr.RunInvalidDSL(t, jsonRPCRequestIDValidationDSL(dsl.String, test.validation, false, nil)) + require.Contains(t, err.Error(), `JSON-RPC request ID field "request_id" is optional and has no default, so generated clients create a UUID when it is absent`) + require.Contains(t, err.Error(), `the following validation rules may reject that UUID: `+test.want) + require.Contains(t, err.Error(), `make the field required or give it a default`) + }) + } +} + +// TestJSONRPCGeneratedRequestIDInheritedValidation checks that named string +// types cannot hide a rule that rejects a client-created UUID. +func TestJSONRPCGeneratedRequestIDInheritedValidation(t *testing.T) { + err := expr.RunInvalidDSL(t, func() { + requestID := dsl.Type("RequestID", dsl.String, func() { + dsl.Pattern(`^req-`) + }) + jsonRPCRequestIDValidationService(requestID, nil, false, nil) + }) + require.Contains(t, err.Error(), `the following validation rules may reject that UUID: pattern`) +} + +// TestJSONRPCGeneratedRequestIDCompatibleValidation checks the rules that every +// generated UUID satisfies and the cases where Goa never creates the ID. +func TestJSONRPCGeneratedRequestIDCompatibleValidation(t *testing.T) { + tests := []struct { + name string + typ func() expr.DataType + validation func() + required bool + defaultVal any + }{ + { + name: "optional UUID", + typ: func() expr.DataType { + return dsl.String + }, + validation: func() { + dsl.Format(dsl.FormatUUID) + dsl.MinLength(36) + dsl.MaxLength(36) + }, + }, + { + name: "inherited UUID", + typ: func() expr.DataType { + return dsl.Type("RequestID", dsl.String, func() { + dsl.Format(dsl.FormatUUID) + dsl.MinLength(1) + dsl.MaxLength(64) + }) + }, + }, + { + name: "required pattern", + typ: func() expr.DataType { + return dsl.String + }, + validation: func() { + dsl.Pattern(`^req-`) + }, + required: true, + }, + { + name: "defaulted enum", + typ: func() expr.DataType { + return dsl.String + }, + validation: func() { + dsl.Enum("known-id") + }, + defaultVal: "known-id", + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + expr.RunDSL(t, jsonRPCRequestIDValidationDSL(test.typ(), test.validation, test.required, test.defaultVal)) + }) + } +} + +// jsonRPCRequestIDValidationDSL defines one request ID with the given field +// contract. +func jsonRPCRequestIDValidationDSL(typ expr.DataType, validation func(), required bool, defaultVal any) func() { + return func() { + jsonRPCRequestIDValidationService(typ, validation, required, defaultVal) + } +} + +// jsonRPCRequestIDValidationService exposes one method through JSON-RPC so its +// request ID rules are evaluated. +func jsonRPCRequestIDValidationService(typ expr.DataType, validation func(), required bool, defaultVal any) { + dsl.Service("records", func() { + dsl.JSONRPC(func() { + dsl.POST("/rpc") + }) + dsl.Method("lookup", func() { + dsl.Payload(func() { + dsl.ID("request_id", typ, func() { + if validation != nil { + validation() + } + if defaultVal != nil { + dsl.Default(defaultVal) + } + }) + if required { + dsl.Required("request_id") + } + }) + dsl.JSONRPC(func() {}) + }) + }) +} diff --git a/expr/jsonrpc_response_metadata_test.go b/expr/jsonrpc_response_metadata_test.go new file mode 100644 index 0000000000..0b0b99cf5c --- /dev/null +++ b/expr/jsonrpc_response_metadata_test.go @@ -0,0 +1,183 @@ +// This file verifies that JSON-RPC responses contain only JSON-RPC message +// data. HTTP response headers and cookies cannot belong to one message in a +// batch or server-sent-event stream. +package expr_test + +import ( + "testing" + + "github.com/stretchr/testify/require" + + "goa.design/goa/v3/dsl" + "goa.design/goa/v3/expr" +) + +// TestJSONRPCSuccessResponseRejectsHTTPMetadata checks the method result +// mapping, which is the only level where a success response can be declared. +func TestJSONRPCSuccessResponseRejectsHTTPMetadata(t *testing.T) { + for _, test := range []struct { + name string + mapping func() + wantErr string + }{ + { + name: "header", + mapping: func() { dsl.Header("value:X-Value") }, + wantErr: "JSON-RPC success response cannot map result attributes to HTTP headers", + }, + { + name: "cookie", + mapping: func() { dsl.Cookie("value:result") }, + wantErr: "JSON-RPC success response cannot map result attributes to HTTP cookies", + }, + } { + t.Run(test.name, func(t *testing.T) { + err := expr.RunInvalidDSL(t, jsonRPCSuccessResponseMetadataDSL(test.mapping)) + require.ErrorContains(t, err, test.wantErr) + }) + } +} + +// TestJSONRPCErrorResponseRejectsHTTPMetadata checks mappings declared at each +// level where an error response can be shared or overridden. +func TestJSONRPCErrorResponseRejectsHTTPMetadata(t *testing.T) { + for _, scope := range []string{"API", "service", "method"} { + for _, test := range []struct { + name string + mapping func() + wantErr string + }{ + { + name: "header", + mapping: func() { dsl.Header("detail:X-Detail") }, + wantErr: "JSON-RPC error response cannot map error attributes to HTTP headers", + }, + { + name: "cookie", + mapping: func() { dsl.Cookie("detail:error") }, + wantErr: "JSON-RPC error response cannot map error attributes to HTTP cookies", + }, + } { + t.Run(scope+" "+test.name, func(t *testing.T) { + err := expr.RunInvalidDSL(t, jsonRPCErrorResponseMetadataDSL(scope, test.mapping)) + require.ErrorContains(t, err, test.wantErr) + }) + } + } +} + +// TestResponseMetadataPreservesSupportedMappings checks that the rejection is +// limited to JSON-RPC response headers and cookies. Ordinary HTTP responses, +// JSON-RPC request headers and cookies, and server-sent-event fields remain +// valid. +func TestResponseMetadataPreservesSupportedMappings(t *testing.T) { + expr.RunDSL(t, func() { + dsl.Service("records", func() { + dsl.Method("fetch", func() { + dsl.Result(func() { + dsl.Attribute("value", dsl.String) + dsl.Attribute("etag", dsl.String) + dsl.Attribute("session", dsl.String) + }) + dsl.Error("failed", func() { + dsl.Attribute("detail", dsl.String) + }) + dsl.HTTP(func() { + dsl.GET("/records") + dsl.Response(func() { + dsl.Header("etag:X-ETag") + dsl.Cookie("session:SID") + }) + dsl.Response("failed", dsl.StatusBadRequest, func() { + dsl.Header("detail:X-Detail") + dsl.Cookie("detail:error") + }) + }) + }) + }) + dsl.Service("events", func() { + dsl.JSONRPC(func() { + dsl.POST("/rpc") + }) + dsl.Method("watch", func() { + dsl.Payload(func() { + dsl.Attribute("token", dsl.String) + dsl.Attribute("session", dsl.String) + dsl.Attribute("resume", dsl.String) + }) + dsl.StreamingResult(func() { + dsl.Attribute("value", dsl.String) + dsl.Attribute("event_id", dsl.String) + dsl.Attribute("event_type", dsl.String) + dsl.Attribute("retry", dsl.Int) + }) + dsl.JSONRPC(func() { + dsl.Header("token:X-Token") + dsl.Cookie("session:SID") + dsl.ServerSentEvents(func() { + dsl.SSERequestID("resume") + dsl.SSEEventData("value") + dsl.SSEEventID("event_id") + dsl.SSEEventType("event_type") + dsl.SSEEventRetry("retry") + }) + }) + }) + }) + }) +} + +// jsonRPCSuccessResponseMetadataDSL defines one result response with the given +// transport mapping. +func jsonRPCSuccessResponseMetadataDSL(mapping func()) func() { + return func() { + dsl.Service("records", func() { + dsl.Method("fetch", func() { + dsl.Result(func() { + dsl.Attribute("value", dsl.String) + }) + dsl.JSONRPC(func() { + dsl.Response(mapping) + }) + }) + }) + } +} + +// jsonRPCErrorResponseMetadataDSL defines one error mapping at the requested +// inheritance level and selects that error from one JSON-RPC method. +func jsonRPCErrorResponseMetadataDSL(scope string, mapping func()) func() { + return func() { + errorType := func() { + dsl.Attribute("detail", dsl.String) + } + dsl.API("records", func() { + if scope == "API" { + dsl.Error("failed", errorType) + dsl.JSONRPC(func() { + dsl.Response("failed", 7001, mapping) + }) + } + }) + dsl.Service("records", func() { + if scope == "service" { + dsl.Error("failed", errorType) + dsl.JSONRPC(func() { + dsl.Response("failed", 7001, mapping) + }) + } + dsl.Method("fetch", func() { + if scope == "API" || scope == "service" { + dsl.Error("failed") + } else { + dsl.Error("failed", errorType) + } + dsl.JSONRPC(func() { + if scope == "method" { + dsl.Response("failed", 7001, mapping) + } + }) + }) + }) + } +} diff --git a/expr/jsonrpc_stream_contract_test.go b/expr/jsonrpc_stream_contract_test.go new file mode 100644 index 0000000000..5aba347102 --- /dev/null +++ b/expr/jsonrpc_stream_contract_test.go @@ -0,0 +1,187 @@ +// This file checks which Goa stream shapes JSON-RPC can represent. +package expr_test + +import ( + "testing" + + "github.com/stretchr/testify/require" + + "goa.design/goa/v3/dsl" + "goa.design/goa/v3/expr" +) + +// TestJSONRPCStreamContract checks the request and response stream shapes that +// JSON-RPC can carry over HTTP and server-sent events. +func TestJSONRPCStreamContract(t *testing.T) { + valid := []struct { + name string + method func() + }{ + { + name: "one request and one response", + method: func() { + dsl.Payload(dsl.String) + dsl.Result(dsl.String) + dsl.JSONRPC(func() {}) + }, + }, + { + name: "server stream over server sent events", + method: func() { + dsl.Payload(dsl.String) + dsl.StreamingResult(dsl.String) + dsl.JSONRPC(func() { + dsl.ServerSentEvents(func() {}) + }) + }, + }, + } + for _, test := range valid { + t.Run(test.name, func(t *testing.T) { + expr.RunDSL(t, jsonRPCStreamDSL(test.method)) + }) + } + + invalid := []struct { + name string + method func() + wantErr string + }{ + { + name: "client stream", + method: func() { + dsl.StreamingPayload(dsl.String) + dsl.Result(dsl.String) + dsl.JSONRPC(func() {}) + }, + wantErr: `JSON-RPC method "stream" cannot use client streaming because one JSON-RPC request contains one params value`, + }, + { + name: "bidirectional stream", + method: func() { + dsl.StreamingPayload(dsl.String) + dsl.StreamingResult(dsl.String) + dsl.JSONRPC(func() {}) + }, + wantErr: `JSON-RPC method "stream" cannot use bidirectional streaming because one JSON-RPC request contains one params value`, + }, + { + name: "server stream without server sent events", + method: func() { + dsl.Payload(dsl.String) + dsl.StreamingResult(dsl.String) + dsl.JSONRPC(func() {}) + }, + wantErr: `JSON-RPC method "stream" with a streaming result must use ServerSentEvents()`, + }, + { + name: "synchronous and streaming results", + method: func() { + dsl.Payload(dsl.String) + dsl.Result(dsl.Int) + dsl.StreamingResult(dsl.String) + dsl.JSONRPC(func() { + dsl.ServerSentEvents(func() {}) + }) + }, + wantErr: `JSON-RPC method "stream" cannot define both Result and StreamingResult because its client stream cannot return a separate final result`, + }, + { + name: "matching synchronous and streaming results", + method: func() { + dsl.Payload(dsl.String) + dsl.Result(dsl.String) + dsl.StreamingResult(dsl.String) + dsl.JSONRPC(func() { + dsl.ServerSentEvents(func() {}) + }) + }, + wantErr: `JSON-RPC method "stream" cannot define both Result and StreamingResult because its client stream cannot return a separate final result`, + }, + } + for _, test := range invalid { + t.Run(test.name, func(t *testing.T) { + err := expr.RunInvalidDSL(t, jsonRPCStreamDSL(test.method)) + require.Contains(t, err.Error(), test.wantErr) + }) + } +} + +// TestJSONRPCSSEViewedDataContract rejects a wire shape that cannot carry the +// result view the client needs to rebuild the streamed result. +func TestJSONRPCSSEViewedDataContract(t *testing.T) { + err := expr.RunInvalidDSL(t, func() { + viewed := dsl.ResultType("ViewedEvent", func() { + dsl.Attribute("message", dsl.String) + dsl.View("default", func() { + dsl.Attribute("message") + }) + }) + dsl.Service("streamer", func() { + dsl.JSONRPC(func() { + dsl.POST("/rpc") + }) + dsl.Method("stream", func() { + dsl.StreamingResult(viewed) + dsl.JSONRPC(func() { + dsl.ServerSentEvents(func() { + dsl.SSEEventData("message") + }) + }) + }) + }) + }) + require.Contains(t, err.Error(), "the selected data would omit the result view needed to decode the stream") +} + +// TestJSONRPCSSERequestIDOwnsLastEventID rejects another mapping that would +// make two fields compete for the same request header. +func TestJSONRPCSSERequestIDOwnsLastEventID(t *testing.T) { + tests := []struct { + name string + header string + want string + }{ + {name: "same field", header: "resume:X-Resume", want: "cannot also be mapped with Header"}, + {name: "another field", header: "other:last-event-id", want: "is reserved for SSE request ID field"}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + err := expr.RunInvalidDSL(t, func() { + dsl.Service("streamer", func() { + dsl.JSONRPC(func() { + dsl.POST("/rpc") + }) + dsl.Method("stream", func() { + dsl.Payload(func() { + dsl.Attribute("resume", dsl.String) + dsl.Attribute("other", dsl.String) + }) + dsl.StreamingResult(dsl.String) + dsl.JSONRPC(func() { + dsl.Header(test.header) + dsl.ServerSentEvents(func() { + dsl.SSERequestID("resume") + }) + }) + }) + }) + }) + require.Contains(t, err.Error(), test.want) + }) + } +} + +// jsonRPCStreamDSL exposes one method through the shared JSON-RPC HTTP route. +func jsonRPCStreamDSL(method func()) func() { + return func() { + dsl.Service("streamer", func() { + dsl.JSONRPC(func() { + dsl.POST("/rpc") + }) + dsl.Method("stream", func() { + method() + }) + }) + } +} diff --git a/expr/jsonrpc_validation_test.go b/expr/jsonrpc_validation_test.go deleted file mode 100644 index 77201bf1eb..0000000000 --- a/expr/jsonrpc_validation_test.go +++ /dev/null @@ -1,215 +0,0 @@ -package expr_test - -import ( - "errors" - "testing" - - "goa.design/goa/v3/eval" - "goa.design/goa/v3/expr" -) - -func TestJSONRPCTransportConsistency(t *testing.T) { - cases := []struct { - Name string - Setup func() *expr.HTTPServiceExpr - WantErr bool - ErrorMsg string - }{ - { - Name: "valid HTTP and SSE mix", - Setup: func() *expr.HTTPServiceExpr { - service := &expr.ServiceExpr{ - Name: "TestService", - Meta: expr.MetaExpr{"jsonrpc:service": []string{}}, - } - - httpService := &expr.HTTPServiceExpr{ - ServiceExpr: service, - Root: &expr.HTTPExpr{}, - } - - // Regular HTTP method - m1 := &expr.MethodExpr{ - Name: "GetUser", - Service: service, - Payload: &expr.AttributeExpr{Type: expr.String}, - Result: &expr.AttributeExpr{Type: expr.String}, - } - e1 := &expr.HTTPEndpointExpr{ - MethodExpr: m1, - Service: httpService, - Meta: expr.MetaExpr{"jsonrpc": []string{}}, - Headers: &expr.MappedAttributeExpr{AttributeExpr: &expr.AttributeExpr{Type: &expr.Object{}}}, - Cookies: &expr.MappedAttributeExpr{AttributeExpr: &expr.AttributeExpr{Type: &expr.Object{}}}, - Params: &expr.MappedAttributeExpr{AttributeExpr: &expr.AttributeExpr{Type: &expr.Object{}}}, - } - - // SSE streaming method - m2 := &expr.MethodExpr{ - Name: "WatchUsers", - Service: service, - Payload: &expr.AttributeExpr{Type: expr.String}, - Result: &expr.AttributeExpr{Type: expr.String}, - Stream: expr.ServerStreamKind, - } - e2 := &expr.HTTPEndpointExpr{ - MethodExpr: m2, - Service: httpService, - Meta: expr.MetaExpr{"jsonrpc": []string{}}, - Headers: &expr.MappedAttributeExpr{AttributeExpr: &expr.AttributeExpr{Type: &expr.Object{}}}, - Cookies: &expr.MappedAttributeExpr{AttributeExpr: &expr.AttributeExpr{Type: &expr.Object{}}}, - Params: &expr.MappedAttributeExpr{AttributeExpr: &expr.AttributeExpr{Type: &expr.Object{}}}, - SSE: &expr.HTTPSSEExpr{}, - } - - httpService.HTTPEndpoints = []*expr.HTTPEndpointExpr{e1, e2} - return httpService - }, - WantErr: false, - }, - { - Name: "invalid WebSocket and HTTP mix", - Setup: func() *expr.HTTPServiceExpr { - service := &expr.ServiceExpr{ - Name: "TestService", - Meta: expr.MetaExpr{"jsonrpc:service": []string{}}, - } - - httpService := &expr.HTTPServiceExpr{ - ServiceExpr: service, - Root: &expr.HTTPExpr{}, - } - - // WebSocket streaming method - m1 := &expr.MethodExpr{ - Name: "Stream", - Service: service, - StreamingPayload: &expr.AttributeExpr{Type: expr.String}, - Result: &expr.AttributeExpr{Type: expr.String}, - Stream: expr.BidirectionalStreamKind, - } - e1 := &expr.HTTPEndpointExpr{ - MethodExpr: m1, - Service: httpService, - Meta: expr.MetaExpr{"jsonrpc": []string{}}, - Headers: &expr.MappedAttributeExpr{AttributeExpr: &expr.AttributeExpr{Type: &expr.Object{}}}, - Cookies: &expr.MappedAttributeExpr{AttributeExpr: &expr.AttributeExpr{Type: &expr.Object{}}}, - Params: &expr.MappedAttributeExpr{AttributeExpr: &expr.AttributeExpr{Type: &expr.Object{}}}, - } - - // Regular HTTP method - m2 := &expr.MethodExpr{ - Name: "Get", - Service: service, - Payload: &expr.AttributeExpr{Type: expr.String}, - Result: &expr.AttributeExpr{Type: expr.String}, - } - e2 := &expr.HTTPEndpointExpr{ - MethodExpr: m2, - Service: httpService, - Meta: expr.MetaExpr{"jsonrpc": []string{}}, - Headers: &expr.MappedAttributeExpr{AttributeExpr: &expr.AttributeExpr{Type: &expr.Object{}}}, - Cookies: &expr.MappedAttributeExpr{AttributeExpr: &expr.AttributeExpr{Type: &expr.Object{}}}, - Params: &expr.MappedAttributeExpr{AttributeExpr: &expr.AttributeExpr{Type: &expr.Object{}}}, - } - - httpService.HTTPEndpoints = []*expr.HTTPEndpointExpr{e1, e2} - return httpService - }, - WantErr: true, - ErrorMsg: "cannot mix WebSocket with other transports", - }, - { - Name: "invalid WebSocket and SSE mix", - Setup: func() *expr.HTTPServiceExpr { - service := &expr.ServiceExpr{ - Name: "TestService", - Meta: expr.MetaExpr{"jsonrpc:service": []string{}}, - } - - httpService := &expr.HTTPServiceExpr{ - ServiceExpr: service, - Root: &expr.HTTPExpr{}, - } - - // WebSocket streaming method - m1 := &expr.MethodExpr{ - Name: "Stream", - Service: service, - StreamingPayload: &expr.AttributeExpr{Type: expr.String}, - Result: &expr.AttributeExpr{Type: expr.String}, - Stream: expr.BidirectionalStreamKind, - } - e1 := &expr.HTTPEndpointExpr{ - MethodExpr: m1, - Service: httpService, - Meta: expr.MetaExpr{"jsonrpc": []string{}}, - Headers: &expr.MappedAttributeExpr{AttributeExpr: &expr.AttributeExpr{Type: &expr.Object{}}}, - Cookies: &expr.MappedAttributeExpr{AttributeExpr: &expr.AttributeExpr{Type: &expr.Object{}}}, - Params: &expr.MappedAttributeExpr{AttributeExpr: &expr.AttributeExpr{Type: &expr.Object{}}}, - } - - // SSE streaming method - m2 := &expr.MethodExpr{ - Name: "Watch", - Service: service, - Payload: &expr.AttributeExpr{Type: expr.String}, - Result: &expr.AttributeExpr{Type: expr.String}, - Stream: expr.ServerStreamKind, - } - e2 := &expr.HTTPEndpointExpr{ - MethodExpr: m2, - Service: httpService, - Meta: expr.MetaExpr{"jsonrpc": []string{}}, - Headers: &expr.MappedAttributeExpr{AttributeExpr: &expr.AttributeExpr{Type: &expr.Object{}}}, - Cookies: &expr.MappedAttributeExpr{AttributeExpr: &expr.AttributeExpr{Type: &expr.Object{}}}, - Params: &expr.MappedAttributeExpr{AttributeExpr: &expr.AttributeExpr{Type: &expr.Object{}}}, - SSE: &expr.HTTPSSEExpr{}, - } - - httpService.HTTPEndpoints = []*expr.HTTPEndpointExpr{e1, e2} - return httpService - }, - WantErr: true, - ErrorMsg: "cannot mix WebSocket with other transports", - }, - } - - for _, c := range cases { - t.Run(c.Name, func(t *testing.T) { - svc := c.Setup() - err := svc.Validate() - - if c.WantErr { - if err == nil { - t.Errorf("expected error containing %q but got none", c.ErrorMsg) - } else if !containsStr(err.Error(), c.ErrorMsg) { - t.Errorf("expected error containing %q but got %q", c.ErrorMsg, err.Error()) - } - } else { - if err != nil { - // Check if it's a ValidationErrors with no actual errors - var verr *eval.ValidationErrors - if errors.As(err, &verr) && len(verr.Errors) == 0 { - // Empty validation errors, ignore - } else { - t.Logf("Error type: %T", err) - t.Errorf("unexpected error: %v", err) - } - } - } - }) - } -} - -func containsStr(s, substr string) bool { - if len(s) < len(substr) { - return false - } - for i := 0; i <= len(s)-len(substr); i++ { - if s[i:i+len(substr)] == substr { - return true - } - } - return false -} diff --git a/expr/method.go b/expr/method.go index 8b00d53ff5..11d49e70e8 100644 --- a/expr/method.go +++ b/expr/method.go @@ -1,8 +1,11 @@ +// This file defines service methods and finalizes their payload, result, +// streaming, error, security, and interceptor contracts. package expr import ( "errors" "fmt" + "strings" "goa.design/goa/v3/eval" ) @@ -49,9 +52,8 @@ type ( // StreamingPayload is the payload sent across the stream. StreamingPayload *AttributeExpr // StreamingResult is the result sent across the stream when using SSE. - // When both Result and StreamingResult are defined with different types, - // the method supports content negotiation between standard HTTP responses - // (using Result) and SSE streams (using StreamingResult). + // When Result and StreamingResult are both defined, the method supports + // normal HTTP responses using Result and SSE streams using StreamingResult. StreamingResult *AttributeExpr } ) @@ -70,8 +72,8 @@ const ( BidirectionalStreamKind ) -// Error returns the error with the given name. It looks up recursively in the -// endpoint then the service and finally the root expression. +// Error returns the error with the given name declared by the method or its +// service, if any. func (m *MethodExpr) Error(name string) *ErrorExpr { for _, err := range m.Errors { if err.Name == name { @@ -143,14 +145,30 @@ func (m *MethodExpr) Validate() error { // validateRequirements validates the security requirements. func (m *MethodExpr) validateRequirements() *eval.ValidationErrors { verr := new(eval.ValidationErrors) + if payload := AsObject(m.Payload.Type); payload != nil { + for _, field := range *payload { + if !isSecurityAttribute(field.Attribute.Meta) { + continue + } + if !isStringType(field.Attribute.Type) { + verr.Add(m, "security attribute %q must use String or a named String type", field.Name) + } + if _, custom := field.Attribute.Meta["struct:field:type"]; custom { + verr.Add(m, "security attribute %q cannot use struct:field:type because authorization functions receive strings", field.Name) + } + if m.Payload.HasDefaultValue(field.Name) { + verr.Add(m, "security attribute %q cannot have a default value because missing credentials must remain missing", field.Name) + } + } + } var requirements []*SecurityExpr switch { case len(m.Requirements) > 0: requirements = m.Requirements case len(m.Service.Requirements) > 0: requirements = m.Service.Requirements - case len(Root.API.Requirements) > 0: - requirements = Root.API.Requirements + case len(m.Service.design.API.Requirements) > 0: + requirements = m.Service.design.API.Requirements } var ( hasBasicAuth bool @@ -241,34 +259,45 @@ func (m *MethodExpr) validateRequirements() *eval.ValidationErrors { return verr } +// isSecurityAttribute reports whether metadata marks a method payload field as +// a username, password, token, or API key used by a security scheme. +func isSecurityAttribute(meta MetaExpr) bool { + for name := range meta { + switch name { + case "security:username", "security:password", "security:bearer", "security:token", "security:accesstoken": + return true + } + if strings.HasPrefix(name, "security:apikey:") { + return true + } + } + return false +} + +// isStringType reports whether a design type is String or a named String type. +func isStringType(dt DataType) bool { + for { + switch actual := dt.(type) { + case Primitive: + return actual == String + case UserType: + dt = actual.Attribute().Type + default: + return false + } + } +} + // validateErrors validates the method errors. func (m *MethodExpr) validateErrors() *eval.ValidationErrors { verr := new(eval.ValidationErrors) - for i, e := range m.Errors { + for _, e := range m.Errors { if err := e.Validate(); err != nil { var verrs *eval.ValidationErrors if errors.As(err, &verrs) { verr.Merge(verrs) } } - for j, e2 := range m.Errors { - // If an object type is used to define more than one errors validate the - // presence of struct:error:name meta in the object type. - if i != j && e.Type == e2.Type && IsObject(e.Type) { - var found bool - walkAttribute(e.AttributeExpr, func(_ string, att *AttributeExpr) error { // nolint: errcheck - if _, ok := att.Meta["struct:error:name"]; ok { - found = true - return fmt.Errorf("struct:error:name found: stop iteration") - } - return nil - }) - if !found { - verr.Add(e, "type %q is used to define multiple errors and must identify the attribute containing the error name with ErrorName", e.Type.Name()) - break - } - } - } } return verr } @@ -276,11 +305,19 @@ func (m *MethodExpr) validateErrors() *eval.ValidationErrors { // validateInterceptors validates the method interceptors. func (m *MethodExpr) validateInterceptors() *eval.ValidationErrors { verr := new(eval.ValidationErrors) - m.ClientInterceptors = mergeInterceptors(m.ClientInterceptors, m.Service.ClientInterceptors, Root.API.ClientInterceptors) + m.ClientInterceptors = mergeInterceptors( + m.ClientInterceptors, + m.Service.ClientInterceptors, + m.Service.design.API.ClientInterceptors, + ) for _, i := range m.ClientInterceptors { verr.Merge(i.validate(m)) } - m.ServerInterceptors = mergeInterceptors(m.ServerInterceptors, m.Service.ServerInterceptors, Root.API.ServerInterceptors) + m.ServerInterceptors = mergeInterceptors( + m.ServerInterceptors, + m.Service.ServerInterceptors, + m.Service.design.API.ServerInterceptors, + ) for _, i := range m.ServerInterceptors { verr.Merge(i.validate(m)) } @@ -400,6 +437,10 @@ func (m *MethodExpr) Finalize() { } } for _, e := range m.Errors { + if _, authored := e.Type.(UserType); !authored { + e.finalizeMethodType(m) + continue + } e.Finalize() } @@ -411,8 +452,8 @@ func (m *MethodExpr) Finalize() { if len(m.Requirements) == 0 { if len(m.Service.Requirements) > 0 { m.Requirements = copyReqs(m.Service.Requirements) - } else if len(Root.API.Requirements) > 0 { - m.Requirements = copyReqs(Root.API.Requirements) + } else if len(m.Service.design.API.Requirements) > 0 { + m.Requirements = copyReqs(m.Service.design.API.Requirements) } } } @@ -432,8 +473,8 @@ func (m *MethodExpr) IsResultStreaming() bool { return m.Stream == ServerStreamKind || m.Stream == BidirectionalStreamKind } -// HasMixedResults returns true if the method has both Result and StreamingResult -// defined with different types, indicating support for content negotiation. +// HasMixedResults returns true if the method defines Result and StreamingResult +// separately so HTTP clients can choose a normal response or an SSE stream. func (m *MethodExpr) HasMixedResults() bool { return m.Result != nil && m.StreamingResult != nil && m.Result != m.StreamingResult } diff --git a/expr/method_test.go b/expr/method_test.go index 213e574ea0..f5f24c7fa3 100644 --- a/expr/method_test.go +++ b/expr/method_test.go @@ -7,6 +7,7 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "goa.design/goa/v3/eval" "goa.design/goa/v3/expr" "goa.design/goa/v3/expr/testdata" ) @@ -18,6 +19,18 @@ func TestMethodExprValidate(t *testing.T) { Error string }{ {"valid-security-schemes-extend", testdata.ValidSecuritySchemesExtendDSL, ""}, + {"valid-named-security-field-types", testdata.ValidNamedSecurityFieldTypesDSL, ""}, + {"invalid-security-field-type", testdata.InvalidSecurityFieldTypeDSL, + `service "InvalidSecurityFieldType" method "Authenticate": security attribute "username" cannot use struct:field:type because authorization functions receive strings`}, + {"invalid-security-field-data-types", testdata.InvalidSecurityFieldDataTypesDSL, + `service "InvalidSecurityFieldDataTypes" method "Authenticate": security attribute "username" must use String or a named String type +service "InvalidSecurityFieldDataTypes" method "Authenticate": security attribute "password" must use String or a named String type +service "InvalidSecurityFieldDataTypes" method "Authenticate": security attribute "api_key" must use String or a named String type +service "InvalidSecurityFieldDataTypes" method "Authenticate": security attribute "bearer" must use String or a named String type +service "InvalidSecurityFieldDataTypes" method "Authenticate": security attribute "jwt" must use String or a named String type +service "InvalidSecurityFieldDataTypes" method "Authenticate": security attribute "oauth" must use String or a named String type`}, + {"invalid-security-field-default", testdata.InvalidSecurityFieldDefaultDSL, + `service "InvalidSecurityFieldDefault" method "Authenticate": security attribute "token" cannot have a default value because missing credentials must remain missing`}, {"invalid-security-schemes", testdata.InvalidSecuritySchemesDSL, `service "InvalidSecuritySchemesService" method "SecureMethod": payload of method "SecureMethod" of service "InvalidSecuritySchemesService" does not define a username attribute, use Username to define one service "InvalidSecuritySchemesService" method "SecureMethod": payload of method "SecureMethod" of service "InvalidSecuritySchemesService" does not define a password attribute, use Password to define one @@ -80,11 +93,6 @@ func TestMethodExprFinalizeInheritsBearerFormat(t *testing.T) { }, } - root := expr.Root - t.Cleanup(func() { - expr.Root = root - }) - for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { scheme := &expr.SchemeExpr{ @@ -96,7 +104,10 @@ func TestMethodExprFinalizeInheritsBearerFormat(t *testing.T) { } req := &expr.SecurityExpr{Schemes: []*expr.SchemeExpr{scheme}} api, service := tc.setup(req) - expr.Root = &expr.RootExpr{API: api} + designAPI := expr.NewAPIExpr("test", func() {}) + designAPI.Requirements = api.Requirements + design := &expr.RootExpr{API: designAPI, Services: []*expr.ServiceExpr{service}} + design.WalkSets(func(eval.ExpressionSet) {}) method := &expr.MethodExpr{ Name: "Method", @@ -114,27 +125,22 @@ func TestMethodExprFinalizeInheritsBearerFormat(t *testing.T) { } func TestMethodExprFinalizePreservesNoSecurityMarker(t *testing.T) { - root := expr.Root - t.Cleanup(func() { - expr.Root = root - }) - - expr.Root = &expr.RootExpr{ - API: &expr.APIExpr{ - Requirements: []*expr.SecurityExpr{{ - Schemes: []*expr.SchemeExpr{{ - Kind: expr.JWTKind, - SchemeName: "jwt", - }}, - }}, - }, - } + service := &expr.ServiceExpr{Name: "Service"} + api := expr.NewAPIExpr("test", func() {}) + api.Requirements = []*expr.SecurityExpr{{ + Schemes: []*expr.SchemeExpr{{ + Kind: expr.JWTKind, + SchemeName: "jwt", + }}, + }} + design := &expr.RootExpr{API: api, Services: []*expr.ServiceExpr{service}} + design.WalkSets(func(eval.ExpressionSet) {}) method := &expr.MethodExpr{ Name: "Health", Requirements: []*expr.SecurityExpr{{ Schemes: []*expr.SchemeExpr{{Kind: expr.NoKind}}, }}, - Service: &expr.ServiceExpr{Name: "Service"}, + Service: service, } method.Finalize() @@ -154,9 +160,6 @@ func TestMethodExprError(t *testing.T) { errorBar = &expr.ErrorExpr{ Name: "bar", } - errorBaz = &expr.ErrorExpr{ - Name: "baz", - } ) cases := map[string]struct { name string @@ -170,19 +173,12 @@ func TestMethodExprError(t *testing.T) { name: "bar", expected: errorBar, }, - "exist in root": { - name: "baz", - expected: errorBaz, - }, "not exist": { name: "qux", expected: nil, }, } - expr.Root.Errors = []*expr.ErrorExpr{ - errorBaz, - } s := expr.ServiceExpr{ Errors: []*expr.ErrorExpr{ errorBar, diff --git a/expr/project_test.go b/expr/project_test.go index dd663a88e1..0aac79f6a9 100644 --- a/expr/project_test.go +++ b/expr/project_test.go @@ -1,3 +1,5 @@ +// This file verifies result-type projections preserve view shape, field +// metadata, recursion, and synthesized example ownership. package expr import ( @@ -10,7 +12,12 @@ import ( ) var ( - testrand = NewRandom("test") + testrand = NewExampleGenerator(NewFakerRandomizerFactory("test")).At( + MethodPayloadExampleIdentity(&MethodExpr{ + Name: "project", + Service: &ServiceExpr{Name: "test"}, + }), + ) simpleResult = resultType("a", String, "b", Int, view("default", "a", String, "b", Int), view("link", "a", String)) simpleResultDefault = resultType("a", String, "b", Int) @@ -82,6 +89,21 @@ func TestProject(t *testing.T) { } } +func TestProjectPreservesGeneratedExampleIdentity(t *testing.T) { + source := resultType("value", String, view("default", "value", String)) + owner := MethodResultExampleIdentity(&MethodExpr{ + Name: "read", + Service: &ServiceExpr{Name: "values"}, + }) + source.UserTypeExpr = NewGeneratedUserType(source.TypeName, source.AttributeExpr, owner) + + projected, err := Project(source, DefaultView) + require.NoError(t, err) + projectedOwner, ok := GeneratedUserTypeExampleIdentity(projected) + require.True(t, ok) + require.Equal(t, owner, projectedOwner) +} + // TestProjectDoesNotAliasFieldAttributes verifies that fields sharing a type // share the projected type but never the AttributeExpr wrapping it, so that // per-field metadata such as descriptions does not leak across fields. diff --git a/expr/random.go b/expr/random.go index 5555883cd8..b25809540b 100644 --- a/expr/random.go +++ b/expr/random.go @@ -1,3 +1,6 @@ +// This file creates the repeatable example values used by one code generation +// run. Each ExampleGenerator has its own sequence of values and shares only the +// map used while building recursive types. package expr import ( @@ -7,172 +10,126 @@ import ( "math/rand" "net" "strings" - "sync" "github.com/manveru/faker" ) -// Randomizer generates consistent random values of different types given a seed. -// -// The random values should be consistent in that given the same seed the same -// random values get generated. -// -// Setting the randomizer to nil disables example generation. -type Randomizer interface { - // ArrayLength decides how long an example array will be - ArrayLength() int - // Int generates an integer example - Int() int - // Int32 generates an int32 example - Int32() int32 - // Int64 generates an int64 example - Int64() int64 - // String generates a string example - String() string - // Bool generates a bool example - Bool() bool - // Float32 generates a float32 example - Float32() float32 - // Float64 generates a float64 example - Float64() float64 - // UInt generates a uint example - UInt() uint - // UInt32 generates a uint example - UInt32() uint32 - // UInt64 generates a uint example - UInt64() uint64 - // Name generates a human name example - Name() string - // Email generates an example email address - Email() string - // Hostname generates an example hostname - Hostname() string - // IPv4Address generates an example IPv4 address - IPv4Address() net.IP - // IPv6Address generates an example IPv6 address - IPv6Address() net.IP - // URL generates an example URL - URL() string - // Characters generates a n-character string example - Characters(n int) string - // UUID generates a random v4 UUID - UUID() string -} - -// NewRandom returns a random value generator seeded from the given string -// value, using the faker library to generate random but realistic values. -func NewRandom(seed string) *ExampleGenerator { - return &ExampleGenerator{ - Randomizer: NewFakerRandomizer(seed), - seed: seed, +type ( + // Randomizer produces the primitive values used in generated examples. Two + // Randomizer values created with the same settings and equal ExampleIdentity + // keys must produce the same sequence. + Randomizer interface { + // ArrayLength decides how long an example array will be. + ArrayLength() int + // Int generates an integer example. + Int() int + // Int32 generates an int32 example. + Int32() int32 + // Int64 generates an int64 example. + Int64() int64 + // String generates a string example. + String() string + // Bool generates a bool example. + Bool() bool + // Float32 generates a float32 example. + Float32() float32 + // Float64 generates a float64 example. + Float64() float64 + // UInt generates a uint example. + UInt() uint + // UInt32 generates a uint32 example. + UInt32() uint32 + // UInt64 generates a uint64 example. + UInt64() uint64 + // Name generates a human name example. + Name() string + // Email generates an email address example. + Email() string + // Hostname generates a hostname example. + Hostname() string + // IPv4Address generates an IPv4 address example. + IPv4Address() net.IP + // IPv6Address generates an IPv6 address example. + IPv6Address() net.IP + // URL generates a URL example. + URL() string + // Characters generates a string containing n characters. + Characters(n int) string + // UUID generates a random version 4 UUID. + UUID() string } -} -// ExampleGenerator generates examples from a value stream seeded by a design -// identity. Example computations derive child generators at stable design -// boundaries (user type IDs, object field names, array indices) via Derived -// so that an example is a pure function of the design: it does not change -// when unrelated parts of the design change or when code generators evaluate -// attributes in a different order. -type ExampleGenerator struct { - Randomizer - // seed identifies the design element this generator draws values for; - // generators derived from it extend the seed via Derived. It is empty - // for generators built around a caller-supplied Randomizer, which - // cannot re-seed and therefore never derive. - seed string - // root points to the generator this one was derived from so that all - // derived generators share the root's seen cache. It is nil on roots. - root *ExampleGenerator - seen map[string]*any - mu sync.RWMutex -} - -// Derived returns a generator whose value stream is seeded from this -// generator's seed extended with the given identity, independent of how many -// values were drawn so far. Derived generators share the root generator's -// seen values so a user type keeps a single example wherever it appears. -// Generators that cannot re-seed (disabled example generation or a -// caller-supplied Randomizer) return themselves. -func (r *ExampleGenerator) Derived(id string) *ExampleGenerator { - return r.reseeded(r.seed + "/" + id) -} - -// Rebased returns a generator whose value stream is seeded from the root -// design seed and the given absolute identity, discarding the current -// derivation path. It anchors examples of design elements that own a global -// identity — user type IDs in particular — so the computed value is the same -// no matter where in the design the element is reached from. Generators that -// cannot re-seed (disabled example generation or a caller-supplied -// Randomizer) return themselves. -func (r *ExampleGenerator) Rebased(id string) *ExampleGenerator { - return r.reseeded(r.store().seed + ":" + id) -} - -// PreviouslySeen returns the previously seen value for a given ID -func (r *ExampleGenerator) PreviouslySeen(typeID string) (*any, bool) { - s := r.store() - s.mu.RLock() - defer s.mu.RUnlock() - if s.seen == nil { - return nil, false + // RandomizerFactory stores settings used to create Randomizer values. + // NewRandomizer must return a new Randomizer on every call. Its + // ExampleIdentity argument selects a repeatable sequence without sharing + // values already consumed by another call. + RandomizerFactory interface { + // NewRandomizer creates an independent value sequence for the supplied + // ExampleIdentity. + NewRandomizer(identity ExampleIdentity) Randomizer } - val, haveSeen := s.seen[typeID] - return val, haveSeen -} -// HaveSeen stores the seen value in the randomizer, for reuse later -func (r *ExampleGenerator) HaveSeen(typeID string, val *any) { - s := r.store() - s.mu.Lock() - defer s.mu.Unlock() - if s.seen == nil { - s.seen = make(map[string]*any) + // exampleRandomizer lets ExampleGenerator expose Randomizer methods without + // exposing its stored Randomizer field. + exampleRandomizer interface { + Randomizer } - s.seen[typeID] = val -} + // ExampleGenerator builds examples from one value sequence. Child generators + // use separate repeatable keys for fields and collection entries, and share + // the map of values currently being built so recursive types can stop. One + // planning thread uses each generator; concurrent runs use separate values. + ExampleGenerator struct { + exampleRandomizer + factory RandomizerFactory + identity ExampleIdentity + // root points to the first generator so child generators share its map of + // values currently being built. It is nil on the first generator. + root *ExampleGenerator + seen map[UserType]*any + } -// Field returns a generator anchored to the identity of the named field of -// the given parent attribute: the parent type identity extended with the -// field name when the parent is a user type, the field name alone otherwise. -// Code generators use it when they compute the example of one element -// extracted from a payload or result (transport params, headers, cookies, -// metadata) so the standalone example matches the corresponding field value -// in the parent type's composite example and stays stable across generator -// changes. -func (r *ExampleGenerator) Field(parent *AttributeExpr, name string) *ExampleGenerator { - if ut, ok := parent.Type.(UserType); ok { - return r.Rebased(ut.ID()).Derived(name) + // FakerRandomizer produces repeatable example values with the faker library. + FakerRandomizer struct { + // Seed is the input used to create this value sequence. + Seed string + faker *faker.Faker + rand *rand.Rand } - return r.Rebased(name) -} -// store returns the generator owning the seen cache and the root design -// seed: the generator this one was derived from, or the generator itself -// when it is a root. -func (r *ExampleGenerator) store() *ExampleGenerator { - if r.root != nil { - return r.root + // DeterministicRandomizer returns the same fixed value from every method. + DeterministicRandomizer struct{} + + // fakerRandomizerFactory stores the seed configured by the API DSL. + fakerRandomizerFactory struct { + seed string } - return r + + // deterministicRandomizerFactory needs no settings. + deterministicRandomizerFactory struct{} +) + +// NewExampleGenerator returns a generator with no selected example sequence and +// no values currently being built. Call At with an ExampleIdentity before +// requesting a value. +func NewExampleGenerator(factory RandomizerFactory) *ExampleGenerator { + return &ExampleGenerator{factory: factory} } -// reseeded returns a generator drawing from a fresh value stream seeded with -// the given seed and sharing this generator's root state. -func (r *ExampleGenerator) reseeded(seed string) *ExampleGenerator { - if r.Randomizer == nil || r.store().seed == "" { - return r - } - return &ExampleGenerator{ - Randomizer: NewFakerRandomizer(seed), - seed: seed, - root: r.store(), - } +// NewFakerRandomizerFactory returns settings that create independent faker +// value sequences from seed. +func NewFakerRandomizerFactory(seed string) RandomizerFactory { + return fakerRandomizerFactory{seed: seed} } -// NewFakerRandomizer creates a randomizer that uses the faker library to -// generate fake but reasonable values. +// NewDeterministicRandomizerFactory returns settings that create independent +// Randomizer values whose methods return fixed values. +func NewDeterministicRandomizerFactory() RandomizerFactory { + return deterministicRandomizerFactory{} +} + +// NewFakerRandomizer returns a repeatable faker value sequence created from +// seed. func NewFakerRandomizer(seed string) Randomizer { hasher := md5.New() hasher.Write([]byte(seed)) @@ -192,64 +149,162 @@ func NewFakerRandomizer(seed string) Randomizer { } } -// FakerRandomizer implements the Random interface, using the Faker library. -type FakerRandomizer struct { - Seed string - faker *faker.Faker - rand *rand.Rand +// NewDeterministicRandomizer returns a value sequence whose methods return +// fixed values. +func NewDeterministicRandomizer() Randomizer { + return &DeterministicRandomizer{} } +// At returns a generator whose value sequence is selected by the supplied +// ExampleIdentity. The result shares the map of values currently being built +// in this run, but gets a new Randomizer with no consumed values. +func (r *ExampleGenerator) At(identity ExampleIdentity) *ExampleGenerator { + root := r.store() + if root.factory == nil { + return r + } + if identity.seed == "" { + panic("example identity is not initialized") + } + return &ExampleGenerator{ + exampleRandomizer: root.factory.NewRandomizer(identity), + factory: root.factory, + identity: identity, + root: root, + } +} + +// Member returns a generator whose repeatable sequence is selected by the +// current ExampleIdentity plus the named field. +func (r *ExampleGenerator) Member(name string) *ExampleGenerator { + if r.factory == nil { + return r + } + return r.structural(r.identity.Member(name)) +} + +// ArrayElement returns a generator whose repeatable sequence is selected by +// the current ExampleIdentity plus the array index. +func (r *ExampleGenerator) ArrayElement(index int) *ExampleGenerator { + if r.factory == nil { + return r + } + return r.structural(r.identity.ArrayElement(index)) +} + +// MapKey returns a generator whose repeatable sequence is selected by the +// current ExampleIdentity plus the map key index. +func (r *ExampleGenerator) MapKey(index int) *ExampleGenerator { + if r.factory == nil { + return r + } + return r.structural(r.identity.MapKey(index)) +} + +// MapValue returns a generator whose repeatable sequence is selected by the +// current ExampleIdentity plus the map value index. +func (r *ExampleGenerator) MapValue(index int) *ExampleGenerator { + if r.factory == nil { + return r + } + return r.structural(r.identity.MapValue(index)) +} + +// UnionMember returns a generator whose repeatable sequence is selected by the +// current ExampleIdentity plus the union branch name. +func (r *ExampleGenerator) UnionMember(name string) *ExampleGenerator { + if r.factory == nil { + return r + } + return r.structural(r.identity.UnionMember(name)) +} + +// ArrayLength returns a small positive array length. func (r *FakerRandomizer) ArrayLength() int { return r.Int()%3 + 2 } + +// Int returns the next int value. func (r *FakerRandomizer) Int() int { return r.rand.Int() } + +// Int32 returns the next int32 value. func (r *FakerRandomizer) Int32() int32 { return r.rand.Int31() } + +// Int64 returns the next int64 value. func (r *FakerRandomizer) Int64() int64 { return r.rand.Int63() } + +// String returns the next short sentence. func (r *FakerRandomizer) String() string { return r.faker.Sentence(2, false) } + +// Bool returns the next boolean value. func (r *FakerRandomizer) Bool() bool { return r.rand.Int()%2 == 0 } + +// Float32 returns the next float32 value. func (r *FakerRandomizer) Float32() float32 { return r.rand.Float32() } + +// Float64 returns the next float64 value. func (r *FakerRandomizer) Float64() float64 { return r.rand.Float64() } + +// UInt returns the next uint value. func (r *FakerRandomizer) UInt() uint { return uint(r.UInt64()) } + +// UInt32 returns the next uint32 value. func (r *FakerRandomizer) UInt32() uint32 { return r.rand.Uint32() } + +// UInt64 returns the next uint64 value. func (r *FakerRandomizer) UInt64() uint64 { return r.rand.Uint64() } + +// Email returns the next email address. func (r *FakerRandomizer) Email() string { return r.faker.Email() } + +// Hostname returns the next hostname. func (r *FakerRandomizer) Hostname() string { return r.faker.DomainName() + "." + r.faker.DomainSuffix() } + +// IPv4Address returns the next IPv4 address. func (r *FakerRandomizer) IPv4Address() net.IP { return r.faker.IPv4Address() } + +// IPv6Address returns the next IPv6 address. func (r *FakerRandomizer) IPv6Address() net.IP { return r.faker.IPv6Address() } + +// URL returns the next URL. func (r *FakerRandomizer) URL() string { return r.faker.URL() } + +// Characters returns the next string containing n characters. func (r *FakerRandomizer) Characters(n int) string { return r.faker.Characters(n) } + +// UUID returns the next random version 4 UUID. func (r *FakerRandomizer) UUID() string { uuid := make([]byte, 16) r.rand.Read(uuid) @@ -257,36 +312,121 @@ func (r *FakerRandomizer) UUID() string { uuid[8] = (uuid[8] & 0x3f) | 0x80 return fmt.Sprintf("%x-%x-%x-%x-%x", uuid[0:4], uuid[4:6], uuid[6:8], uuid[8:10], uuid[10:]) } + +// Name returns the next human name. func (r *FakerRandomizer) Name() string { return r.faker.Name() } -// NewDeterministicRandomizer builds a Randomizer that will return hard-coded -// values, removing all randomness from example generation. -func NewDeterministicRandomizer() Randomizer { - return &DeterministicRandomizer{} -} +// ArrayLength returns one. +func (DeterministicRandomizer) ArrayLength() int { return 1 } + +// Int returns one. +func (DeterministicRandomizer) Int() int { return 1 } + +// Int32 returns one. +func (DeterministicRandomizer) Int32() int32 { return 1 } + +// Int64 returns one. +func (DeterministicRandomizer) Int64() int64 { return 1 } + +// String returns a fixed string. +func (DeterministicRandomizer) String() string { return "abc123" } + +// Bool returns false. +func (DeterministicRandomizer) Bool() bool { return false } + +// Float32 returns one. +func (DeterministicRandomizer) Float32() float32 { return 1 } + +// Float64 returns one. +func (DeterministicRandomizer) Float64() float64 { return 1 } + +// UInt returns one. +func (DeterministicRandomizer) UInt() uint { return 1 } + +// UInt32 returns one. +func (DeterministicRandomizer) UInt32() uint32 { return 1 } + +// UInt64 returns one. +func (DeterministicRandomizer) UInt64() uint64 { return 1 } + +// Name returns a fixed human name. +func (DeterministicRandomizer) Name() string { return "Alice" } + +// Email returns a fixed email address. +func (DeterministicRandomizer) Email() string { return "alice@example.com" } + +// Hostname returns a fixed hostname. +func (DeterministicRandomizer) Hostname() string { return "example.com" } + +// IPv4Address returns the unspecified IPv4 address. +func (DeterministicRandomizer) IPv4Address() net.IP { return net.IPv4zero } + +// IPv6Address returns the unspecified IPv6 address. +func (DeterministicRandomizer) IPv6Address() net.IP { return net.IPv6zero } + +// URL returns a fixed URL. +func (DeterministicRandomizer) URL() string { return "https://example.com/foo" } -// DeterministicRandomizer returns hard-coded values, removing all randomness -// from example generation -type DeterministicRandomizer struct{} - -func (DeterministicRandomizer) ArrayLength() int { return 1 } -func (DeterministicRandomizer) Int() int { return 1 } -func (DeterministicRandomizer) Int32() int32 { return 1 } -func (DeterministicRandomizer) Int64() int64 { return 1 } -func (DeterministicRandomizer) String() string { return "abc123" } -func (DeterministicRandomizer) Bool() bool { return false } -func (DeterministicRandomizer) Float32() float32 { return 1 } -func (DeterministicRandomizer) Float64() float64 { return 1 } -func (DeterministicRandomizer) UInt() uint { return 1 } -func (DeterministicRandomizer) UInt32() uint32 { return 1 } -func (DeterministicRandomizer) UInt64() uint64 { return 1 } -func (DeterministicRandomizer) Name() string { return "Alice" } -func (DeterministicRandomizer) Email() string { return "alice@example.com" } -func (DeterministicRandomizer) Hostname() string { return "example.com" } -func (DeterministicRandomizer) IPv4Address() net.IP { return net.IPv4zero } -func (DeterministicRandomizer) IPv6Address() net.IP { return net.IPv6zero } -func (DeterministicRandomizer) URL() string { return "https://example.com/foo" } +// Characters returns n copies of "a". func (DeterministicRandomizer) Characters(n int) string { return strings.Repeat("a", n) } -func (DeterministicRandomizer) UUID() string { return "550e8400-e29b-41d4-a716-446655440000" } + +// UUID returns a fixed version 4 UUID. +func (DeterministicRandomizer) UUID() string { return "550e8400-e29b-41d4-a716-446655440000" } + +// NewRandomizer creates an independent faker value sequence selected by the +// supplied ExampleIdentity key. +func (f fakerRandomizerFactory) NewRandomizer(identity ExampleIdentity) Randomizer { + return NewFakerRandomizer(f.seed + identity.Seed()) +} + +// NewRandomizer creates an independent Randomizer whose methods return fixed +// values. The supplied ExampleIdentity does not change those values. +func (deterministicRandomizerFactory) NewRandomizer(ExampleIdentity) Randomizer { + return NewDeterministicRandomizer() +} + +// previouslySeen returns the value already being built for typ in this run. It +// uses the original type declaration so copied types find the same in-progress +// value and recursive definitions stop. +func (r *ExampleGenerator) previouslySeen(typ UserType) (*any, bool) { + s := r.store() + if s.seen == nil { + return nil, false + } + val, haveSeen := s.seen[typ.Origin()] + return val, haveSeen +} + +// haveSeen records the value currently being built for typ so a recursive use +// can return it before construction finishes. +func (r *ExampleGenerator) haveSeen(typ UserType, val *any) { + s := r.store() + if s.seen == nil { + s.seen = make(map[UserType]*any) + } + + s.seen[typ.Origin()] = val +} + +// store returns the first generator, which stores the RandomizerFactory and the +// map of values currently being built. It returns r when r has no parent. +func (r *ExampleGenerator) store() *ExampleGenerator { + if r.root != nil { + return r.root + } + return r +} + +// structural returns a generator for the sequence selected by the supplied +// ExampleIdentity. It shares this run's map of values currently being built. +func (r *ExampleGenerator) structural(identity ExampleIdentity) *ExampleGenerator { + if r.factory == nil { + return r + } + if r.exampleRandomizer == nil { + panic("example generator must be anchored before structural descent") + } + return r.At(identity) +} diff --git a/expr/random_factory_test.go b/expr/random_factory_test.go new file mode 100644 index 0000000000..212a3000a1 --- /dev/null +++ b/expr/random_factory_test.go @@ -0,0 +1,246 @@ +// This file verifies that immutable example configuration creates independent +// mutable value streams for each code generation run. +package expr_test + +import ( + "testing" + + "github.com/stretchr/testify/require" + + "goa.design/goa/v3/dsl" + "goa.design/goa/v3/expr" +) + +type ( + // customRandomizerFactory exercises the public factory contract without + // depending on Goa's built-in factory implementations. + customRandomizerFactory struct { + seed string + } + + recordingRandomizerFactory struct { + identities *[]expr.ExampleIdentity + } +) + +// NewRandomizer creates an independent seeded stream for identity. +func (f customRandomizerFactory) NewRandomizer(identity expr.ExampleIdentity) expr.Randomizer { + if identity.Seed() == "" { + panic("custom randomizer received an empty identity") + } + return expr.NewFakerRandomizerFactory(f.seed).NewRandomizer(identity) +} + +// NewRandomizer records the exact owner selected by example traversal and +// delegates value generation to Goa's deterministic factory. +func (f recordingRandomizerFactory) NewRandomizer(identity expr.ExampleIdentity) expr.Randomizer { + *f.identities = append(*f.identities, identity) + return expr.NewDeterministicRandomizerFactory().NewRandomizer(identity) +} + +func TestRandomizerFactoriesCreateIndependentStreams(t *testing.T) { + cases := []struct { + Name string + Factory expr.RandomizerFactory + }{ + {"faker", expr.NewFakerRandomizerFactory("seed")}, + {"deterministic", expr.NewDeterministicRandomizerFactory()}, + {"custom", customRandomizerFactory{seed: "seed"}}, + } + + for _, c := range cases { + t.Run(c.Name, func(t *testing.T) { + identity := expr.MethodPayloadExampleIdentity(exampleMethod("service", "method")) + first := expr.NewExampleGenerator(c.Factory).At(identity) + second := expr.NewExampleGenerator(c.Factory).At(identity) + + require.NotSame(t, first, second) + require.Equal(t, first.String(), second.String()) + require.Equal(t, first.Int(), second.Int()) + }) + } +} + +// TestReleasedStandaloneRandomizers checks the released concrete randomizer +// types, seed, and repeatable values. +func TestReleasedStandaloneRandomizers(t *testing.T) { + faker := expr.NewFakerRandomizer("seed") + concrete, ok := faker.(*expr.FakerRandomizer) + require.True(t, ok) + require.Equal(t, "seed", concrete.Seed) + require.Equal(t, expr.NewFakerRandomizer("seed").String(), faker.String()) + + deterministic := expr.NewDeterministicRandomizer() + _, ok = deterministic.(*expr.DeterministicRandomizer) + require.True(t, ok) + require.Equal(t, "abc123", deterministic.String()) +} + +func TestRandomizerFactoriesPreserveDerivedExampleStability(t *testing.T) { + factory := expr.NewFakerRandomizerFactory("seed") + identity := expr.MethodPayloadExampleIdentity(exampleMethod("service", "method")) + first := expr.NewExampleGenerator(factory).At(identity) + second := expr.NewExampleGenerator(factory).At(identity) + + require.Equal(t, first.Member("payload").String(), second.Member("payload").String()) + require.Equal(t, first.ArrayElement(0).Int(), second.ArrayElement(0).Int()) +} + +func TestExampleIdentitiesFrameComponents(t *testing.T) { + for _, delimiter := range []string{".", "/", ":"} { + t.Run(delimiter, func(t *testing.T) { + left := expr.MethodPayloadExampleIdentity(exampleMethod("a"+delimiter+"b", "c")) + right := expr.MethodPayloadExampleIdentity(exampleMethod("a", "b"+delimiter+"c")) + + require.NotEqual(t, left.Seed(), right.Seed()) + }) + } +} + +func TestExampleIdentitiesDistinguishSemanticAndStructuralKinds(t *testing.T) { + method := exampleMethod("service", "method") + payload := expr.MethodPayloadExampleIdentity(method) + result := expr.MethodResultExampleIdentity(method) + + require.NotEqual(t, payload.Seed(), result.Seed()) + require.NotEqual(t, payload.Member("0").Seed(), payload.ArrayElement(0).Seed()) + require.NotEqual(t, payload.Member("value").Seed(), payload.UnionMember("value").Seed()) + require.NotEqual(t, payload.MapKey(0).Seed(), payload.MapValue(0).Seed()) + errorIdentity := expr.MethodErrorExampleIdentity(method, &expr.ErrorExpr{Name: "failure"}) + require.NotEqual(t, result.Member("failure").Seed(), errorIdentity.Seed()) +} + +func TestHTTPResponseIdentitiesIgnoreTraversalOrderAndDistinguishErrors(t *testing.T) { + method := exampleMethod("service", "method") + endpoint := &expr.HTTPEndpointExpr{MethodExpr: method} + ok := &expr.HTTPResponseExpr{StatusCode: expr.StatusOK} + created := &expr.HTTPResponseExpr{StatusCode: expr.StatusCreated} + responses := []*expr.HTTPResponseExpr{ok, created} + before := map[int]string{ + ok.StatusCode: expr.ResponseBodyExampleIdentity(endpoint, responses[0]).Seed(), + created.StatusCode: expr.ResponseBodyExampleIdentity(endpoint, responses[1]).Seed(), + } + + responses[0], responses[1] = responses[1], responses[0] + require.Equal(t, before[created.StatusCode], expr.ResponseBodyExampleIdentity(endpoint, responses[0]).Seed()) + require.Equal(t, before[ok.StatusCode], expr.ResponseBodyExampleIdentity(endpoint, responses[1]).Seed()) + + firstError := &expr.HTTPErrorExpr{Name: "missing", Response: &expr.HTTPResponseExpr{StatusCode: expr.StatusNotFound}} + secondError := &expr.HTTPErrorExpr{Name: "gone", Response: &expr.HTTPResponseExpr{StatusCode: expr.StatusNotFound}} + require.NotEqual(t, + expr.ErrorResponseBodyExampleIdentity(endpoint, firstError).Seed(), + expr.ErrorResponseBodyExampleIdentity(endpoint, secondError).Seed(), + ) + require.NotEqual(t, + expr.ResponseBodyExampleIdentity(endpoint, &expr.HTTPResponseExpr{StatusCode: expr.StatusNotFound}).Seed(), + expr.ErrorResponseBodyExampleIdentity(endpoint, firstError).Seed(), + ) +} + +func TestHTTPBodyIdentitiesDistinguishHTTPAndJSONRPCMappings(t *testing.T) { + method := exampleMethod("service", "method") + httpEndpoint := &expr.HTTPEndpointExpr{MethodExpr: method} + jsonRPCEndpoint := &expr.HTTPEndpointExpr{ + MethodExpr: method, + Meta: expr.MetaExpr{"jsonrpc": {}}, + } + + require.NotEqual(t, + expr.RequestBodyExampleIdentity(httpEndpoint).Seed(), + expr.RequestBodyExampleIdentity(jsonRPCEndpoint).Seed(), + ) +} + +func TestGRPCMessageIdentitiesDistinguishExactMethodsAndRoles(t *testing.T) { + dashed := exampleMethod("service", "foo-bar") + underscore := exampleMethod("service", "foo_bar") + errorExpr := &expr.ErrorExpr{Name: "failure"} + + require.NotEqual(t, + expr.GRPCRequestMessageExampleIdentity(dashed).Seed(), + expr.GRPCRequestMessageExampleIdentity(underscore).Seed(), + ) + require.NotEqual(t, + expr.GRPCRequestMessageExampleIdentity(dashed).Seed(), + expr.GRPCResponseMessageExampleIdentity(dashed).Seed(), + ) + require.NotEqual(t, + expr.GRPCStreamingRequestMessageExampleIdentity(dashed).Seed(), + expr.GRPCStreamingResponseMessageExampleIdentity(dashed).Seed(), + ) + require.NotEqual(t, + expr.GRPCResponseMessageExampleIdentity(dashed).Seed(), + expr.GRPCErrorMessageExampleIdentity(dashed, errorExpr).Seed(), + ) +} + +func TestInlineMethodErrorsRetainMethodErrorIdentity(t *testing.T) { + root := expr.RunDSL(t, func() { + var authored = dsl.Type("AuthoredError", func() { + dsl.Attribute("message", dsl.String) + }) + dsl.Service("Values", func() { + dsl.Method("Read", func() { + dsl.Error("inline", dsl.String) + dsl.Error("authored", authored) + }) + }) + }) + method := root.Service("Values").Method("Read") + cases := []struct { + name string + error *expr.ErrorExpr + expected expr.ExampleIdentity + }{ + { + name: "inline", + error: method.Error("inline"), + expected: expr.MethodErrorExampleIdentity(method, method.Error("inline")), + }, + { + name: "authored", + error: method.Error("authored"), + expected: expr.UserTypeExampleIdentity(root.UserType("AuthoredError")), + }, + } + for _, test := range cases { + t.Run(test.name, func(t *testing.T) { + var identities []expr.ExampleIdentity + generator := expr.NewExampleGenerator(recordingRandomizerFactory{identities: &identities}) + test.error.AttributeExpr.Example(generator.At(expr.MethodPayloadExampleIdentity(method))) + + require.NotEmpty(t, identities) + require.Contains(t, identities, test.expected) + }) + } +} + +func TestConfiguredExampleGeneratorRequiresIdentity(t *testing.T) { + attribute := &expr.AttributeExpr{Type: expr.String} + + require.Panics(t, func() { + attribute.Example(expr.NewExampleGenerator(expr.NewFakerRandomizerFactory("seed"))) + }) +} + +func TestZeroExampleIdentityCannotCreateStructuralIdentity(t *testing.T) { + var identity expr.ExampleIdentity + + require.Panics(t, func() { + identity.Member("field") + }) +} + +func TestDisabledExampleGeneratorSuppressesAuthoredExamples(t *testing.T) { + attribute := &expr.AttributeExpr{ + Type: expr.String, + UserExamples: []*expr.ExampleExpr{{Value: "authored"}}, + } + + require.Nil(t, attribute.Example(&expr.ExampleGenerator{})) +} + +func exampleMethod(service, method string) *expr.MethodExpr { + svc := &expr.ServiceExpr{Name: service} + return &expr.MethodExpr{Name: method, Service: svc} +} diff --git a/expr/result_type.go b/expr/result_type.go index 5876bd5a58..70f323aca6 100644 --- a/expr/result_type.go +++ b/expr/result_type.go @@ -1,3 +1,5 @@ +// This file defines result types and views and records the original +// declaration used when a result type is copied. package expr import ( @@ -29,6 +31,9 @@ type ( ContentType string // Views list the supported views indexed by name. Views []*ViewExpr + // origin is the earliest result type declaration copied to create this + // result type. + origin UserType } // ViewExpr defines which fields to render when building a response. The view @@ -57,6 +62,7 @@ var ( Type: errorResultType, Description: "Error response result type", Validation: &ValidationExpr{Required: []string{"name", "id", "message", "temporary", "timeout", "fault"}}, + finalized: true, }, TypeName: "error", }, @@ -114,6 +120,13 @@ func NewResultTypeExpr(name, identifier string, fn func()) *ResultTypeExpr { } } +// IsErrorResult reports whether dataType is Goa's built-in service error type +// or a generator copy made from it. +func IsErrorResult(dataType DataType) bool { + userType, ok := dataType.(UserType) + return ok && userType.Origin() == ErrorResult +} + // CanonicalIdentifier returns the result type identifier sans suffix // which is what the DSL uses to store and lookup result types. func CanonicalIdentifier(identifier string) string { @@ -137,7 +150,18 @@ func (rt *ResultTypeExpr) Dup(att *AttributeExpr) UserType { UserTypeExpr: rt.UserTypeExpr.Dup(att).(*UserTypeExpr), Identifier: rt.Identifier, Views: rt.Views, + origin: rt.Origin(), + } +} + +// Origin returns the earliest result type declaration from which rt was +// copied. Result types override their embedded user-type origin so later copies +// still point to the original result declaration. +func (rt *ResultTypeExpr) Origin() UserType { + if rt.origin != nil { + return rt.origin } + return rt } // ID returns the identifier of the result type. @@ -148,6 +172,13 @@ func (rt *ResultTypeExpr) ID() string { // Name returns the result type name. func (rt *ResultTypeExpr) Name() string { return rt.TypeName } +// Rename changes the result type name and starts a new generated declaration +// origin at rt. +func (rt *ResultTypeExpr) Rename(name string) { + rt.UserTypeExpr.Rename(name) + rt.origin = nil +} + // View returns the view with the given name. func (rt *ResultTypeExpr) View(name string) *ViewExpr { for _, v := range rt.Views { @@ -291,14 +322,8 @@ func projectSingle(rt *ResultTypeExpr, view string, seen map[string]UserType) (* } id := rt.projectIdentifier(view) - ut := &UserTypeExpr{ - AttributeExpr: &AttributeExpr{ - Description: desc, - Validation: val, - }, - TypeName: typeName, - UID: id, - } + attribute := &AttributeExpr{Description: desc, Validation: val} + ut := projectedUserType(rt, typeName, id, attribute) ut.Type = Dup(v.Type) ut.UserExamples = v.UserExamples projected := &ResultTypeExpr{ @@ -340,17 +365,14 @@ func projectCollection(rt *ResultTypeExpr, view string, seen map[string]UserType // Build the projected collection with the results id := rt.projectIdentifier(view) + attribute := &AttributeExpr{ + Description: rt.TypeName + " is the result type for an array of " + e.TypeName + " (" + view + " view)", + Type: &Array{ElemType: &AttributeExpr{Type: pe}}, + UserExamples: rt.UserExamples, + } proj := &ResultTypeExpr{ - Identifier: id, - UserTypeExpr: &UserTypeExpr{ - AttributeExpr: &AttributeExpr{ - Description: rt.TypeName + " is the result type for an array of " + e.TypeName + " (" + view + " view)", - Type: &Array{ElemType: &AttributeExpr{Type: pe}}, - UserExamples: rt.UserExamples, - }, - TypeName: pe.TypeName + "Collection", - UID: id, - }, + Identifier: id, + UserTypeExpr: projectedUserType(rt, pe.TypeName+"Collection", id, attribute), Views: []*ViewExpr{{ AttributeExpr: DupAtt(pe.View(DefaultView).AttributeExpr), Name: DefaultView, @@ -367,6 +389,16 @@ func projectCollection(rt *ResultTypeExpr, view string, seen map[string]UserType return proj, nil } +// projectedUserType makes a synthesized result type use the same repeatable +// example sequence as source. A view-specific type authored in the design keeps +// its media-type-derived UID instead. +func projectedUserType(source UserType, name, uid string, attribute *AttributeExpr) *UserTypeExpr { + if identity, ok := GeneratedUserTypeExampleIdentity(source); ok { + return NewGeneratedUserType(name, attribute, identity) + } + return &UserTypeExpr{AttributeExpr: attribute, TypeName: name, UID: uid} +} + // projectRecursive computes the projected attribute for the field described // by at within a result type being projected with view. vat is the matching // view attribute. It always returns a fresh attribute: projected types are diff --git a/expr/result_types_root.go b/expr/result_types_root.go index e4f497606a..f54610744f 100644 --- a/expr/result_types_root.go +++ b/expr/result_types_root.go @@ -15,10 +15,13 @@ type ( var GeneratedResultTypes = new(ResultTypesRoot) // GeneratedResultType returns the generated result type expression with the given -// id, nil if there isn't one. +// id, nil if there isn't one. Identifiers are compared canonically so that a +// lookup by canonical identifier finds a type recorded with its authored +// suffix, such as "+json". func GeneratedResultType(id string) *ResultTypeExpr { + canonical := CanonicalIdentifier(id) for _, rt := range *GeneratedResultTypes { - if rt.Identifier == id { + if CanonicalIdentifier(rt.Identifier) == canonical { return rt } } diff --git a/expr/result_types_root_test.go b/expr/result_types_root_test.go new file mode 100644 index 0000000000..69aa1e3800 --- /dev/null +++ b/expr/result_types_root_test.go @@ -0,0 +1,27 @@ +package expr + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestGeneratedResultTypeMatchesCanonicalIdentifier verifies that a lookup by +// canonical identifier finds a generated result type recorded with its authored +// media type suffix. CollectionOf looks its cache up with a canonical +// identifier, so a suffixed identifier must not create a second type. +func TestGeneratedResultTypeMatchesCanonicalIdentifier(t *testing.T) { + ResetDSL(t) + + suffixed := "application/vnd.item+json; type=collection" + collection := NewResultTypeExpr("ItemCollection", suffixed, func() {}) + GeneratedResultTypes.Append(collection) + + canonical := CanonicalIdentifier(suffixed) + require.NotEqual(t, suffixed, canonical, "the identifier must lose its suffix to exercise the lookup") + + assert.Same(t, collection, GeneratedResultType(canonical)) + assert.Same(t, collection, GeneratedResultType(suffixed)) + assert.Nil(t, GeneratedResultType("application/vnd.other; type=collection")) +} diff --git a/expr/root.go b/expr/root.go index 5ba20b8580..ca07a61613 100644 --- a/expr/root.go +++ b/expr/root.go @@ -1,10 +1,16 @@ +// This file defines the evaluated design root and validates relationships +// between its API, services, generated types, and explicitly relocated user +// types before code generation begins. package expr import ( + "cmp" "fmt" "maps" + "reflect" "slices" "sort" + "strings" "goa.design/goa/v3/eval" goa "goa.design/goa/v3/pkg" @@ -25,8 +31,8 @@ type ( Services []*ServiceExpr // Interceptors contains the list of interceptors. Interceptors []*InterceptorExpr - // Errors contains the list of errors returned by all the API - // methods. + // Errors contains reusable error definitions. Services and methods + // select these errors by naming them in their own Error DSL. Errors []*ErrorExpr // Types contains the user types described in the DSL. Types []UserType @@ -52,6 +58,13 @@ type ( // External is an instance of the type being converted from or to. External any } + + // mountedHTTPRoute identifies one final route and the service that owns it. + mountedHTTPRoute struct { + service string + route *RouteExpr + path string + } ) // WalkSets returns the expressions in order of evaluation. @@ -87,6 +100,9 @@ func (r *RootExpr) WalkSets(walk eval.SetWalker) { walk(rtypes) // Services + for _, service := range r.Services { + service.design = r + } walk(eval.ToExpressionSet(r.Services)) // Methods (must be done after services) @@ -99,10 +115,10 @@ func (r *RootExpr) WalkSets(walk eval.SetWalker) { walk(methods) // HTTP services and endpoints - r.walkHTTPServices(r.API.HTTP.Services, walk) + r.walkHTTPServices(r.API.HTTP, r.API.HTTP.Services, walk) // JSON-RPC services and endpoints - r.walkHTTPServices(r.API.JSONRPC.Services, walk) + r.walkHTTPServices(r.API.JSONRPC, r.API.JSONRPC.Services, walk) // GRPC services and endpoints grpcsvcs := make(eval.ExpressionSet, len(r.API.GRPC.Services)) @@ -212,9 +228,253 @@ func (r *RootExpr) Validate() error { seen[name] = struct{}{} } } + for _, userType := range r.Types { + verr.Merge(userType.Attribute().validateDefaultValues(fmt.Sprintf("type %q", userType.Name()), userType)) + } + for _, resultType := range r.ResultTypes { + verr.Merge(resultType.Attribute().validateDefaultValues(fmt.Sprintf("result type %q", resultType.Name()), resultType)) + } + verr.Merge(r.validateErrorDefaults()) + verr.Merge(ValidateSharedErrorNames(r)) verr.Merge(r.validateRelocatedUserTypes()) + verr.Merge(validateTypeMappings("conversion", r.Conversions)) + verr.Merge(validateTypeMappings("creation", r.Creations)) + if r.API != nil { + verr.Merge(r.validateSharedHTTPRoutes()) + } + + return &verr +} + +// ValidateSharedErrorNames rejects an authored type with several static error +// names unless each value carries its selected name in an ErrorName field. The +// roots must include every design linked by one generation command. +func ValidateSharedErrorNames(roots ...*RootExpr) *eval.ValidationErrors { + type errorUse struct { + expression *ErrorExpr + userType UserType + names map[string]struct{} + carriesName bool + } + verr := new(eval.ValidationErrors) + uses := make(map[UserType]*errorUse) + collect := func(designError *ErrorExpr) { + userType, ok := designError.Type.(UserType) + if !ok { + return + } + use := uses[userType.Origin()] + if use == nil { + use = &errorUse{ + expression: designError, + userType: userType, + names: make(map[string]struct{}), + } + uses[userType.Origin()] = use + } + use.names[designError.Name] = struct{}{} + use.carriesName = use.carriesName || hasErrorNameAttribute(userType.Attribute()) + } + for _, root := range roots { + for _, designError := range root.Errors { + collect(designError) + } + for _, service := range root.Services { + for _, designError := range service.Errors { + collect(designError) + } + for _, method := range service.Methods { + for _, designError := range method.Errors { + collect(designError) + } + } + } + } + ordered := slices.Collect(maps.Values(uses)) + slices.SortFunc(ordered, func(left, right *errorUse) int { + if compared := cmp.Compare(left.userType.Name(), right.userType.Name()); compared != 0 { + return compared + } + leftNames := strings.Join(slices.Sorted(maps.Keys(left.names)), "\x00") + rightNames := strings.Join(slices.Sorted(maps.Keys(right.names)), "\x00") + return cmp.Compare(leftNames, rightNames) + }) + for _, use := range ordered { + if len(use.names) < 2 || use.carriesName { + continue + } + names := slices.Sorted(maps.Keys(use.names)) + verr.Add( + use.expression, + "type %q defines errors %s and must identify the attribute containing the error name with ErrorName", + use.userType.Name(), + strings.Join(names, ", "), + ) + } + return verr +} +// hasErrorNameAttribute reports whether the type stores its error name in one +// generated field. +func hasErrorNameAttribute(attribute *AttributeExpr) bool { + found := false + walkAttribute(attribute, func(_ string, nested *AttributeExpr) error { // nolint: errcheck + if _, ok := nested.Meta["struct:error:name"]; ok { + found = true + } + return nil + }) + return found +} + +// validateErrorDefaults checks each declared error once. Services and methods +// may reuse the same error expression, so pointer identity prevents repeated +// diagnostics for one declaration. +func (r *RootExpr) validateErrorDefaults() *eval.ValidationErrors { + errors := new(eval.ValidationErrors) + seen := make(map[*ErrorExpr]struct{}) + validate := func(designError *ErrorExpr) { + if _, ok := seen[designError]; ok { + return + } + seen[designError] = struct{}{} + errors.Merge(designError.AttributeExpr.validateDefaultValues( + fmt.Sprintf("error %q", designError.Name), designError, + )) + } + for _, designError := range r.Errors { + validate(designError) + } + for _, service := range r.Services { + for _, designError := range service.Errors { + validate(designError) + } + for _, method := range service.Methods { + for _, designError := range method.Errors { + validate(designError) + } + } + } + return errors +} + +// validateSharedHTTPRoutes rejects ordinary HTTP and JSON-RPC routes that +// would replace one another when mounted on the same server. +func (r *RootExpr) validateSharedHTTPRoutes() *eval.ValidationErrors { + var verr eval.ValidationErrors + if r.API.HTTP == nil || r.API.JSONRPC == nil { + return &verr + } + + servers := r.API.Servers + if len(servers) == 0 { + // Goa creates this server after validation when the design does not + // declare one. It hosts every service. + servers = []*ServerExpr{{Name: r.API.Name}} + } + + for _, server := range servers { + httpRoutes := routesMountedOnServer(server, r.API.HTTP.Services) + jsonrpcRoutes := routesMountedOnServer(server, r.API.JSONRPC.Services) + for _, httpRoute := range httpRoutes { + for _, jsonrpcRoute := range jsonrpcRoutes { + if httpRoute.route.Method != jsonrpcRoute.route.Method || + routePatternWithoutParameterNames(httpRoute.path) != + routePatternWithoutParameterNames(jsonrpcRoute.path) { + continue + } + verr.Add( + jsonrpcRoute.route, + "server %q mounts ordinary HTTP route %s %q from service %q "+ + "and JSON-RPC route %s %q from service %q; both routes match the same requests", + server.Name, + httpRoute.route.Method, + httpRoute.path, + httpRoute.service, + jsonrpcRoute.route.Method, + jsonrpcRoute.path, + jsonrpcRoute.service, + ) + } + } + } + return &verr +} + +// routesMountedOnServer returns every final route owned by services mounted on +// server. +func routesMountedOnServer(server *ServerExpr, services []*HTTPServiceExpr) []mountedHTTPRoute { + var routes []mountedHTTPRoute + for _, service := range services { + if !serverHostsService(server, service.Name()) { + continue + } + for _, endpoint := range service.HTTPEndpoints { + for _, route := range endpoint.Routes { + for _, routePath := range route.FullPaths() { + routes = append(routes, mountedHTTPRoute{ + service: service.Name(), + route: route, + path: routePath, + }) + } + } + } + } + return routes +} + +// serverHostsService reports whether server mounts service. An empty service +// list means that the server mounts every service in the design. +func serverHostsService(server *ServerExpr, service string) bool { + return len(server.Services) == 0 || slices.Contains(server.Services, service) +} + +// routePatternWithoutParameterNames returns the path form used to detect +// router replacement. Parameter names do not change which requests match. +func routePatternWithoutParameterNames(routePath string) string { + return HTTPWildcardRegex.ReplaceAllStringFunc(routePath, func(wildcard string) string { + if strings.HasPrefix(wildcard, "/{*") { + return "/{*wildcard}" + } + return "/{parameter}" + }) +} + +// validateTypeMappings rejects repeated declarations that would generate the +// same method on one user type. A reflected type includes its package path, so +// equally named external types from different packages remain distinct. +func validateTypeMappings(direction string, mappings []*TypeMap) *eval.ValidationErrors { + type mappingIdentity struct { + user UserType + external reflect.Type + } + var verr eval.ValidationErrors + seen := make(map[mappingIdentity]struct{}, len(mappings)) + for _, mapping := range mappings { + identity := mappingIdentity{ + user: mapping.User.Origin(), + external: reflect.TypeOf(mapping.External), + } + if _, ok := seen[identity]; ok { + if direction == "conversion" { + verr.Add( + mapping.User, + "conversion from user type %q to external type %q defined twice", + mapping.User.Name(), identity.external, + ) + } else { + verr.Add( + mapping.User, + "creation from external type %q to user type %q defined twice", + identity.external, mapping.User.Name(), + ) + } + continue + } + seen[identity] = struct{}{} + } return &verr } @@ -229,21 +489,21 @@ func (r *RootExpr) Validate() error { // types. func (r *RootExpr) validateRelocatedUserTypes() *eval.ValidationErrors { var verr eval.ValidationErrors - declared := make(map[string]struct{}, len(r.Types)) + declared := make(map[UserType]struct{}, len(r.Types)) for _, ut := range r.Types { - declared[ut.ID()] = struct{}{} + declared[ut.Origin()] = struct{}{} } for _, ut := range r.Types { pkgPath, ok := ut.Attribute().Meta.Last("struct:pkg:path") if !ok || pkgPath == "" { continue } - seen := make(map[string]struct{}) + seen := make(map[UserType]struct{}) r.walkUserTypeDependencies(ut, seen, "", func(dep UserType, path string) { - if dep.ID() == ut.ID() { + if dep.Origin() == ut.Origin() { return } - if _, ok := declared[dep.ID()]; !ok { + if _, ok := declared[dep.Origin()]; !ok { // Generated/derived user types (e.g. union branch wrappers) are // materialized alongside their owning types and do not require an // explicit struct:pkg:path. @@ -275,7 +535,7 @@ func (r *RootExpr) validateRelocatedUserTypes() *eval.ValidationErrors { // walkUserTypeDependencies traverses the attribute graph reachable from root and // invokes visit for each encountered user type. -func (r *RootExpr) walkUserTypeDependencies(root UserType, seen map[string]struct{}, path string, visit func(UserType, string)) { +func (r *RootExpr) walkUserTypeDependencies(root UserType, seen map[UserType]struct{}, path string, visit func(UserType, string)) { if root == nil || root.Attribute() == nil { return } @@ -287,16 +547,17 @@ func (r *RootExpr) walkUserTypeDependencies(root UserType, seen map[string]struc // // The path argument records the traversal path through objects, arrays, maps, // and unions and is intended for diagnostics. -func (r *RootExpr) walkAttributeUserTypes(att *AttributeExpr, seen map[string]struct{}, path string, visit func(UserType, string)) { +func (r *RootExpr) walkAttributeUserTypes(att *AttributeExpr, seen map[UserType]struct{}, path string, visit func(UserType, string)) { if att == nil || att.Type == Empty { return } switch t := att.Type.(type) { case UserType: - if _, ok := seen[t.ID()]; ok { + origin := t.Origin() + if _, ok := seen[origin]; ok { return } - seen[t.ID()] = struct{}{} + seen[origin] = struct{}{} visit(t, path) r.walkAttributeUserTypes(t.Attribute(), seen, path, visit) case *Object: @@ -341,7 +602,7 @@ func (r *RootExpr) Finalize() { } // walkHTTPServices walks the HTTP services and endpoints. -func (r *RootExpr) walkHTTPServices(svcs []*HTTPServiceExpr, walk eval.SetWalker) { +func (r *RootExpr) walkHTTPServices(root eval.Expression, svcs []*HTTPServiceExpr, walk eval.SetWalker) { sort.SliceStable(svcs, func(i, j int) bool { return svcs[j].ParentName == svcs[i].Name() }) @@ -357,7 +618,7 @@ func (r *RootExpr) walkHTTPServices(svcs []*HTTPServiceExpr, walk eval.SetWalker httpsvrs = append(httpsvrs, s) } } - walk(eval.ExpressionSet{r.API.HTTP}) + walk(eval.ExpressionSet{root}) walk(httpsvcs) walk(httpepts) walk(httpsvrs) diff --git a/expr/root_test.go b/expr/root_test.go index a177ad2195..11f73b12fa 100644 --- a/expr/root_test.go +++ b/expr/root_test.go @@ -1,13 +1,70 @@ +// This file verifies root validation, including shared HTTP routes and +// exact-origin dependency traversal for explicitly relocated user types. package expr import ( "errors" "fmt" + "strings" "testing" "goa.design/goa/v3/eval" ) +type rootExternalType struct { + Value string +} + +func TestRelocatedDependenciesUseDeclarationOrigin(t *testing.T) { + dependency := &UserTypeExpr{ + TypeName: "Dependency", + UID: "shared-semantic-id", + AttributeExpr: &AttributeExpr{Type: String}, + } + relocated := &UserTypeExpr{ + TypeName: "Relocated", + UID: "shared-semantic-id", + AttributeExpr: &AttributeExpr{ + Meta: MetaExpr{"struct:pkg:path": {"types"}}, + Type: &Object{&NamedAttributeExpr{ + Name: "dependency", + Attribute: &AttributeExpr{Type: dependency}, + }}, + }, + } + root := &RootExpr{Types: []UserType{relocated, dependency}} + + errors := root.validateRelocatedUserTypes() + if len(errors.Errors) != 1 { + t.Fatalf("expected one relocated dependency error, got %d", len(errors.Errors)) + } + if message := errors.Errors[0].Error(); !strings.Contains(message, "Dependency") { + t.Errorf("expected dependency name in error, got %q", message) + } +} + +func TestRelocatedDependencyWalkStopsAtExactOriginCopy(t *testing.T) { + relocated := &UserTypeExpr{ + TypeName: "Relocated", + UID: "relocated", + AttributeExpr: &AttributeExpr{ + Meta: MetaExpr{"struct:pkg:path": {"types"}}, + Type: String, + }, + } + copy := relocated.Dup(DupAtt(relocated.Attribute())) + relocated.AttributeExpr.Type = &Object{&NamedAttributeExpr{ + Name: "self", + Attribute: &AttributeExpr{Type: copy}, + }} + root := &RootExpr{Types: []UserType{relocated}} + + errors := root.validateRelocatedUserTypes() + if len(errors.Errors) != 0 { + t.Errorf("expected exact origin copy to be treated as recursion, got %v", errors) + } +} + func TestRootExprValidate(t *testing.T) { cases := map[string]struct { api *APIExpr @@ -46,6 +103,154 @@ func TestRootExprValidate(t *testing.T) { } } +func TestRootExprValidateMixedHTTPRoutes(t *testing.T) { + tests := []struct { + name string + httpMethod string + httpPath string + jsonrpcMethod string + jsonrpcPath string + servers []*ServerExpr + wantError bool + }{ + { + name: "same route on the default server", + httpMethod: "POST", + httpPath: "/tasks", + jsonrpcMethod: "POST", + jsonrpcPath: "/tasks", + wantError: true, + }, + { + name: "wildcards with different names", + httpMethod: "POST", + httpPath: "/tasks/{taskID}", + jsonrpcMethod: "POST", + jsonrpcPath: "/tasks/{id}", + wantError: true, + }, + { + name: "catch-all wildcards with different names", + httpMethod: "POST", + httpPath: "/assets/{*assetPath}", + jsonrpcMethod: "POST", + jsonrpcPath: "/assets/{*path}", + wantError: true, + }, + { + name: "different wildcard kinds", + httpMethod: "POST", + httpPath: "/assets/{asset}", + jsonrpcMethod: "POST", + jsonrpcPath: "/assets/{*path}", + }, + { + name: "different methods", + httpMethod: "GET", + httpPath: "/tasks", + jsonrpcMethod: "POST", + jsonrpcPath: "/tasks", + }, + { + name: "different paths", + httpMethod: "POST", + httpPath: "/tasks", + jsonrpcMethod: "POST", + jsonrpcPath: "/rpc", + }, + { + name: "same route on different servers", + httpMethod: "POST", + httpPath: "/tasks", + jsonrpcMethod: "POST", + jsonrpcPath: "/tasks", + servers: []*ServerExpr{ + {Name: "http", Services: []string{"http_service"}}, + {Name: "jsonrpc", Services: []string{"jsonrpc_service"}}, + }, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + root := mixedHTTPRoot( + test.httpMethod, + test.httpPath, + test.jsonrpcMethod, + test.jsonrpcPath, + test.servers, + ) + err := root.Validate() + var validationErrors *eval.ValidationErrors + errors.As(err, &validationErrors) + if !test.wantError { + if len(validationErrors.Errors) > 0 { + t.Errorf("expected routes to coexist, got %v", err) + } + return + } + if len(validationErrors.Errors) == 0 { + t.Errorf("expected conflicting routes to be rejected") + return + } + for _, text := range []string{ + "server \"test\"", + "ordinary HTTP route " + test.httpMethod, + test.httpPath, + "JSON-RPC route " + test.jsonrpcMethod, + test.jsonrpcPath, + } { + if !strings.Contains(err.Error(), text) { + t.Errorf("expected error to contain %q, got %v", text, err) + } + } + }) + } +} + +// TestRootExprValidateRejectsDuplicateTypeMappings catches two identical +// conversion or creation declarations that would emit the same receiver method. +func TestRootExprValidateRejectsDuplicateTypeMappings(t *testing.T) { + user := &UserTypeExpr{ + TypeName: "Value", + UID: "value", + AttributeExpr: &AttributeExpr{Type: String}, + } + for _, test := range []struct { + name string + conversions []*TypeMap + creations []*TypeMap + }{ + { + name: "conversion", + conversions: []*TypeMap{ + {User: user, External: rootExternalType{}}, + {User: user, External: rootExternalType{}}, + }, + }, + { + name: "creation", + creations: []*TypeMap{ + {User: user, External: rootExternalType{}}, + {User: user, External: rootExternalType{}}, + }, + }, + } { + t.Run(test.name, func(t *testing.T) { + root := &RootExpr{ + API: &APIExpr{Name: "test"}, + Types: []UserType{user}, + Conversions: test.conversions, + Creations: test.creations, + } + err := root.Validate() + if err == nil || !strings.Contains(err.Error(), test.name+" from") || !strings.Contains(err.Error(), "defined twice") { + t.Fatalf("expected precise duplicate %s error, got %v", test.name, err) + } + }) + } +} + func TestMetaExpr_Last(t *testing.T) { tt := map[string]struct { meta MetaExpr @@ -98,3 +303,39 @@ func TestMetaExpr_Last(t *testing.T) { }) } } + +// mixedHTTPRoot returns a design with one ordinary HTTP route and one +// JSON-RPC route. +func mixedHTTPRoot( + httpMethod, httpPath, jsonrpcMethod, jsonrpcPath string, + servers []*ServerExpr, +) *RootExpr { + api := NewAPIExpr("test", func() {}) + api.Servers = servers + root := &RootExpr{API: api} + + addMixedHTTPRoute(root, api.HTTP, "http_service", httpMethod, httpPath) + addMixedHTTPRoute(root, &api.JSONRPC.HTTPExpr, "jsonrpc_service", jsonrpcMethod, jsonrpcPath) + return root +} + +// addMixedHTTPRoute adds one service method to transport for route validation. +func addMixedHTTPRoute(root *RootExpr, transport *HTTPExpr, serviceName, method, routePath string) { + service := &ServiceExpr{Name: serviceName} + methodExpr := &MethodExpr{ + Name: "run", + Payload: &AttributeExpr{Type: Empty}, + Result: &AttributeExpr{Type: Empty}, + Service: service, + Stream: NoStreamKind, + } + service.Methods = []*MethodExpr{methodExpr} + root.Services = append(root.Services, service) + httpService := transport.ServiceFor(service, transport) + endpoint := httpService.EndpointFor(methodExpr) + endpoint.Routes = []*RouteExpr{{ + Method: method, + Path: routePath, + Endpoint: endpoint, + }} +} diff --git a/expr/security.go b/expr/security.go index 6ae1c45c95..7ef6d55e4e 100644 --- a/expr/security.go +++ b/expr/security.go @@ -83,6 +83,9 @@ type ( Flows []*FlowExpr // Meta is a list of key/value pairs Meta MetaExpr + // authored points to the security scheme copied for a transport. It is + // nil while this value is the scheme declared by the design. + authored *SchemeExpr } // FlowExpr describes a specific OAuth2 flow. @@ -141,10 +144,20 @@ func DupScheme(sch *SchemeExpr) *SchemeExpr { Scopes: sch.Scopes, Flows: sch.Flows, Meta: sch.Meta, + authored: sch.AuthoredScheme(), } return &dup } +// AuthoredScheme returns the security scheme declared by the design. It +// returns s when s has not been copied for a transport. +func (s *SchemeExpr) AuthoredScheme() *SchemeExpr { + if s.authored != nil { + return s.authored + } + return s +} + // HasNoSecurity returns true if the security requirements explicitly disable // security. func HasNoSecurity(reqs []*SecurityExpr) bool { diff --git a/expr/service.go b/expr/service.go index 43887d95d6..fe713efef6 100644 --- a/expr/service.go +++ b/expr/service.go @@ -1,8 +1,11 @@ +// This file defines services and their errors. It also distinguishes errors +// named in the design from errors created for one method. package expr import ( "errors" "fmt" + "strings" "goa.design/goa/v3/eval" ) @@ -34,6 +37,8 @@ type ( // Meta is a set of key/value pairs with semantic that is // specific to each generator. Meta MetaExpr + // design points to the root containing this service. + design *RootExpr } // ErrorExpr defines an error response. It consists of a named @@ -65,14 +70,14 @@ func (s *ServiceExpr) EvalName() string { return fmt.Sprintf("service %#v", s.Name) } -// Error returns the error with the given name if any. +// Error returns the error with the given name declared by the service, if any. func (s *ServiceExpr) Error(name string) *ErrorExpr { for _, erro := range s.Errors { if erro.Name == name { return erro } } - return Root.Error(name) + return nil } // Hash returns a unique hash value for s. @@ -91,9 +96,59 @@ func (s *ServiceExpr) Validate() error { } } } + verr.Merge(s.validateInlineMethodErrors()) + return verr +} + +// validateInlineMethodErrors rejects two inline errors that request one public +// Go error name but define different values. +func (s *ServiceExpr) validateInlineMethodErrors() *eval.ValidationErrors { + verr := new(eval.ValidationErrors) + seen := make(map[string]*ErrorExpr) + for _, serviceError := range s.Errors { + if standardErrorUsesGeneratedConstructor(serviceError) { + seen[serviceError.Name] = serviceError + } + } + for _, method := range s.Methods { + for _, methodError := range method.Errors { + if !standardErrorUsesGeneratedConstructor(methodError) { + continue + } + if previous := seen[methodError.Name]; previous != nil { + if !equivalentErrorAttributes(previous.AttributeExpr, methodError.AttributeExpr) { + if settings := differingErrorQualifierSettings(previous.AttributeExpr, methodError.AttributeExpr); len(settings) > 0 { + verr.Add( + methodError, + "error %q cannot use one generated constructor because its %s setting differs in service %q", + methodError.Name, + strings.Join(settings, ", "), + s.Name, + ) + } else { + verr.Add( + methodError, + "inline error %q must define the same value contract in every method of service %q", + methodError.Name, + s.Name, + ) + } + } + continue + } + seen[methodError.Name] = methodError + } + } return verr } +// standardErrorUsesGeneratedConstructor reports whether Goa generates the +// shared Make function whose behavior repeated declarations could change. +func standardErrorUsesGeneratedConstructor(errorExpression *ErrorExpr) bool { + userType, authored := errorExpression.Type.(UserType) + return !authored || IsErrorResult(userType) +} + // Finalize finalizes all the service methods and errors. func (s *ServiceExpr) Finalize() { for _, e := range s.Errors { @@ -134,7 +189,7 @@ func (e *ErrorExpr) Finalize() { att := e.AttributeExpr switch dt := att.Type.(type) { case UserType: - if dt != ErrorResult { + if !IsErrorResult(dt) { // If this type contains an attribute with "struct:error:name" meta // then no need to do anything. if IsObject(dt) { @@ -158,3 +213,37 @@ func (e *ErrorExpr) Finalize() { e.AttributeExpr = &AttributeExpr{Type: ut} } } + +// finalizeMethodType wraps an inline method error and assigns the repeatable +// example key used by service and transport generators for that method error. +func (e *ErrorExpr) finalizeMethodType(method *MethodExpr) { + e.AttributeExpr = &AttributeExpr{Type: newGeneratedUserType( + e.Name, + e.AttributeExpr, + MethodErrorExampleIdentity(method, e), + previousInlineMethodErrorOrigin(method, e.Name), + )} +} + +// previousInlineMethodErrorOrigin returns the declaration already created for +// the same inline error by an earlier method in this service. +func previousInlineMethodErrorOrigin(method *MethodExpr, name string) UserType { + for _, previousMethod := range method.Service.Methods { + if previousMethod == method { + return nil + } + for _, previousError := range previousMethod.Errors { + if previousError.Name != name { + continue + } + userType, ok := previousError.Type.(UserType) + if !ok { + continue + } + if _, generated := GeneratedUserTypeExampleIdentity(userType); generated { + return userType.Origin() + } + } + } + return nil +} diff --git a/expr/service_test.go b/expr/service_test.go index 6cf39903aa..8c92d5bf71 100644 --- a/expr/service_test.go +++ b/expr/service_test.go @@ -4,7 +4,10 @@ import ( "testing" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "goa.design/goa/v3/dsl" + "goa.design/goa/v3/eval" "goa.design/goa/v3/expr" "goa.design/goa/v3/expr/testdata" ) @@ -45,14 +48,137 @@ func TestServiceExprMethod(t *testing.T) { } } +func TestEquivalentInlineMethodErrorsShareOrigin(t *testing.T) { + root := expr.RunDSL(t, func() { + dsl.Service("secured", func() { + for _, method := range []string{"read", "write"} { + dsl.Method(method, func() { + dsl.Error("invalid_scopes", dsl.String) + }) + } + }) + }) + service := root.Service("secured") + first := service.Method("read").Error("invalid_scopes").Type.(expr.UserType) + second := service.Method("write").Error("invalid_scopes").Type.(expr.UserType) + + require.NotSame(t, first, second) + require.Same(t, first.Origin(), second.Origin()) + firstIdentity, ok := expr.GeneratedUserTypeExampleIdentity(first) + require.True(t, ok) + secondIdentity, ok := expr.GeneratedUserTypeExampleIdentity(second) + require.True(t, ok) + require.NotEqual(t, firstIdentity, secondIdentity) +} + +func TestIncompatibleInlineMethodErrorsAreRejected(t *testing.T) { + expr.ResetDSL(t) + design := func() { + dsl.Service("secured", func() { + dsl.Method("read", func() { + dsl.Error("invalid_scopes", dsl.String) + }) + dsl.Method("write", func() { + dsl.Error("invalid_scopes", dsl.Int) + }) + }) + } + require.True(t, eval.Execute(design, nil)) + err := eval.RunDSL() + require.ErrorContains(t, err, `inline error "invalid_scopes" must define the same value contract in every method of service "secured"`) +} + +func TestRepeatedStandardErrorsMustUseSameQualifiers(t *testing.T) { + qualifiers := []struct { + name string + apply func() + }{ + {name: "temporary", apply: dsl.Temporary}, + {name: "timeout", apply: func() { dsl.Timeout() }}, + {name: "fault", apply: dsl.Fault}, + } + for _, qualifier := range qualifiers { + t.Run("service reference inherits "+qualifier.name, func(t *testing.T) { + root := expr.RunDSL(t, func() { + dsl.Service("jobs", func() { + dsl.Error("busy", qualifier.apply) + dsl.Method("run", func() { + dsl.Error("busy") + }) + }) + }) + service := root.Service("jobs") + require.Same(t, service.Error("busy"), service.Method("run").Error("busy")) + }) + + t.Run("two methods "+qualifier.name, func(t *testing.T) { + expr.ResetDSL(t) + design := func() { + dsl.Service("jobs", func() { + dsl.Method("start", func() { + dsl.Error("busy", qualifier.apply) + }) + dsl.Method("resume", func() { + dsl.Error("busy") + }) + }) + } + require.True(t, eval.Execute(design, nil)) + err := eval.RunDSL() + require.ErrorContains(t, err, qualifier.name+" setting differs") + }) + + t.Run("matching "+qualifier.name, func(t *testing.T) { + expr.RunDSL(t, func() { + dsl.Service("jobs", func() { + dsl.Error("busy", qualifier.apply) + dsl.Method("run", func() { + dsl.Error("busy", qualifier.apply) + }) + }) + }) + }) + } +} + +func TestAPIErrorDoesNotAddErrorsToServicesOrMethods(t *testing.T) { + root := expr.RunDSL(t, func() { + dsl.API("jobs", func() { + dsl.Error("busy", dsl.Temporary) + }) + dsl.Service("jobs", func() { + dsl.Method("run", func() {}) + dsl.Method("retry", func() { + dsl.Error("busy") + }) + }) + }) + service := root.Service("jobs") + + require.Empty(t, service.Errors) + require.Empty(t, service.Method("run").Errors) + require.Equal(t, []*expr.ErrorExpr{root.Error("busy")}, service.Method("retry").Errors) +} + +func TestRepeatedAuthoredErrorTypesDoNotShareGeneratedConstructors(t *testing.T) { + custom := dsl.Type("CustomError", func() { + dsl.Attribute("message", dsl.String) + }) + expr.RunDSL(t, func() { + dsl.Service("jobs", func() { + dsl.Error("busy", custom, dsl.Temporary) + dsl.Method("run", func() { + dsl.Error("busy", custom) + }) + }) + }) +} + func TestServiceExprError(t *testing.T) { var ( errorFoo = &expr.ErrorExpr{ Name: "foo", } - errorBar = &expr.ErrorExpr{ - Name: "bar", - } ) cases := map[string]struct { name string @@ -62,19 +188,12 @@ func TestServiceExprError(t *testing.T) { name: "foo", expected: errorFoo, }, - "exist in root": { - name: "bar", - expected: errorBar, - }, "not exist": { name: "qux", expected: nil, }, } - expr.Root.Errors = []*expr.ErrorExpr{ - errorBar, - } s := expr.ServiceExpr{ Errors: []*expr.ErrorExpr{ errorFoo, @@ -114,12 +233,14 @@ func TestErrorExprValidate(t *testing.T) { }{ {"no error", testdata.ValidErrorsDSL, ""}, {"invalid-struct-error-name-meta", testdata.InvalidStructErrorNameDSL, - `attribute: error name "a" must be required in type "ServiceError" + `attribute: type "ErrorType" defines errors error1, error2 and must identify the attribute containing the error name with ErrorName +attribute: error name "a" must be required in type "ServiceError" attribute: duplicate error names in type "Error" attribute: error name "a" must be a string in type "Error" -attribute: error name "a" must be required in type "Error" -attribute: type "ErrorType" is used to define multiple errors and must identify the attribute containing the error name with ErrorName -attribute: type "ErrorType" is used to define multiple errors and must identify the attribute containing the error name with ErrorName`, +attribute: error name "a" must be required in type "Error"`, + }, + {"shared error names across services", testdata.InvalidSharedErrorNamesDSL, + `attribute: type "SharedError" defines errors first_error, second_error and must identify the attribute containing the error name with ErrorName`, }, } for _, tc := range cases { diff --git a/expr/streaming_response_mapping_test.go b/expr/streaming_response_mapping_test.go new file mode 100644 index 0000000000..9aaf07e45d --- /dev/null +++ b/expr/streaming_response_mapping_test.go @@ -0,0 +1,101 @@ +// This file checks that a method which returns many results cannot put result +// fields in HTTP headers or cookies. One connection has only one HTTP response, +// so it cannot carry different values for each result. +package expr_test + +import ( + "testing" + + "github.com/stretchr/testify/require" + + . "goa.design/goa/v3/dsl" + "goa.design/goa/v3/expr" +) + +// TestStreamingSuccessResponseRejectsHeadersAndCookies checks HTTP methods over +// SSE and WebSocket connections and JSON-RPC methods over SSE. +func TestStreamingSuccessResponseRejectsHeadersAndCookies(t *testing.T) { + transports := []struct { + name string + dsl func(func()) + }{ + {name: "HTTP server-sent events", dsl: httpStreamingResponseMappingDSL(true)}, + {name: "HTTP WebSocket", dsl: httpStreamingResponseMappingDSL(false)}, + {name: "JSON-RPC server-sent events", dsl: jsonRPCStreamingResponseMappingDSL(true)}, + } + mappings := []struct { + name string + apply func() + error string + }{ + {name: "header", apply: func() { Header("metadata:X-Metadata") }, error: "streaming success response cannot map result attributes to HTTP headers"}, + {name: "cookie", apply: func() { Cookie("metadata:session") }, error: "streaming success response cannot map result attributes to HTTP cookies"}, + } + + for _, transport := range transports { + for _, mapping := range mappings { + t.Run(transport.name+" "+mapping.name, func(t *testing.T) { + err := expr.RunInvalidDSL(t, func() { + transport.dsl(mapping.apply) + }) + require.ErrorContains(t, err, mapping.error) + }) + } + } +} + +// httpStreamingResponseMappingDSL creates an HTTP method which returns many +// results and places one result field in its HTTP response. +func httpStreamingResponseMappingDSL(sse bool) func(func()) { + return func(mapping func()) { + Service("stream", func() { + Method("watch", func() { + StreamingResult(streamingMappedResult()) + HTTP(func() { + GET("/watch") + if sse { + ServerSentEvents(func() {}) + } + Response(func() { + mapping() + }) + }) + }) + }) + } +} + +// jsonRPCStreamingResponseMappingDSL creates a JSON-RPC method which returns +// many results and places one result field in its HTTP response. +func jsonRPCStreamingResponseMappingDSL(sse bool) func(func()) { + return func(mapping func()) { + Service("stream", func() { + JSONRPC(func() { + if sse { + POST("/watch") + } else { + GET("/watch") + } + }) + Method("watch", func() { + StreamingResult(streamingMappedResult()) + JSONRPC(func() { + if sse { + ServerSentEvents(func() {}) + } + Response(func() { + mapping() + }) + }) + }) + }) + } +} + +// streamingMappedResult defines the two fields used by these tests. +func streamingMappedResult() func() { + return func() { + Attribute("value", String) + Attribute("metadata", String) + } +} diff --git a/expr/testdata/endpoint_dsls.go b/expr/testdata/endpoint_dsls.go index ae54cab42c..162956e30f 100644 --- a/expr/testdata/endpoint_dsls.go +++ b/expr/testdata/endpoint_dsls.go @@ -1,3 +1,5 @@ +// This file defines reusable endpoint designs that exercise HTTP and gRPC +// preparation, validation, inheritance, streaming, and metadata behavior. package testdata import ( @@ -460,6 +462,20 @@ var EndpointPayloadMissingRequired = func() { }) } +var EndpointMultipartWithoutBody = func() { + Service("Service", func() { + Method("Method", func() { + Payload(func() { + Attribute("id", String) + }) + HTTP(func() { + POST("/{id}") + MultipartRequest() + }) + }) + }) +} + var StreamingEndpointRequestBody = func() { var PT = Type("Payload", func() { Attribute("foo", String) @@ -625,6 +641,35 @@ var GRPCEndpointWithAnyType = func() { }) } +var GRPCEndpointWithMixedResults = func() { + Service("Service", func() { + Method("Method", func() { + Result(String) + StreamingResult(Int) + GRPC(func() {}) + }) + }) +} + +var GRPCEndpointWithMatchingMixedResults = func() { + Service("Service", func() { + Method("Method", func() { + Result(String) + StreamingResult(String) + GRPC(func() {}) + }) + }) +} + +var GRPCEndpointWithStreamingResult = func() { + Service("Service", func() { + Method("Method", func() { + StreamingResult(String) + GRPC(func() {}) + }) + }) +} + var GRPCEndpointWithUntaggedFields = func() { var Req = Type("Req", func() { Attribute("req_not_field", String) @@ -714,6 +759,46 @@ var GRPCEndpointWithExtendedTypes = func() { }) } +var GRPCEndpointWithCompositeMetadata = func() { + objectValue := Type("MetadataObject", func() { + Attribute("name", String) + }) + Service("Service", func() { + Method("Method", func() { + Payload(func() { + Attribute("object", objectValue) + Attribute("mapping", MapOf(String, String)) + OneOf("choice", func() { + Attribute("text", String) + Attribute("count", Int) + }) + }) + Result(func() { + Attribute("object", objectValue) + Attribute("mapping", MapOf(String, String)) + OneOf("choice", func() { + Attribute("text", String) + Attribute("count", Int) + }) + }) + GRPC(func() { + Metadata(func() { + Attribute("object") + Attribute("mapping") + Attribute("choice") + }) + Response(func() { + Headers(func() { Attribute("object") }) + Trailers(func() { + Attribute("mapping") + Attribute("choice") + }) + }) + }) + }) + }) +} + var GRPCEndpointWithInheritErrorDSL = func() { API("API", func() { Error("not_found") diff --git a/expr/testdata/method_validate_dsls.go b/expr/testdata/method_validate_dsls.go index bed8ef7f09..b1c297fd4b 100644 --- a/expr/testdata/method_validate_dsls.go +++ b/expr/testdata/method_validate_dsls.go @@ -76,3 +76,79 @@ var InvalidSecuritySchemesDSL = func() { }) }) } + +var InvalidSecurityFieldTypeDSL = func() { + Service("InvalidSecurityFieldType", func() { + Method("Authenticate", func() { + Security(BasicAuth) + Payload(func() { + Username("username", String, func() { + Meta("struct:field:type", "CustomUsername") + }) + Password("password", String) + }) + }) + }) +} + +var InvalidSecurityFieldDataTypesDSL = func() { + basic := BasicAuthSecurity("basic") + apiKey := APIKeySecurity("api_key") + bearer := BearerSecurity("bearer") + jwt := JWTSecurity("jwt") + oauth := OAuth2Security("oauth") + + Service("InvalidSecurityFieldDataTypes", func() { + Method("Authenticate", func() { + Security(basic, apiKey, bearer, jwt, oauth) + Payload(func() { + Username("username", Int) + Password("password", Boolean) + APIKey("api_key", "api_key", Bytes) + BearerToken("bearer", ArrayOf(String)) + Token("jwt", MapOf(String, String)) + AccessToken("oauth", func() { + Attribute("value", String) + }) + }) + }) + }) +} + +var InvalidSecurityFieldDefaultDSL = func() { + jwt := JWTSecurity("jwt") + + Service("InvalidSecurityFieldDefault", func() { + Method("Authenticate", func() { + Security(jwt) + Payload(func() { + Token("token", String, func() { + Default("generated credential") + }) + }) + }) + }) +} + +var ValidNamedSecurityFieldTypesDSL = func() { + credential := Type("Credential", String) + basic := BasicAuthSecurity("basic") + apiKey := APIKeySecurity("api_key") + bearer := BearerSecurity("bearer") + jwt := JWTSecurity("jwt") + oauth := OAuth2Security("oauth") + + Service("ValidNamedSecurityFieldTypes", func() { + Method("Authenticate", func() { + Security(basic, apiKey, bearer, jwt, oauth) + Payload(func() { + Username("username", credential) + Password("password", credential) + APIKey("api_key", "api_key", credential) + BearerToken("bearer", credential) + Token("jwt", credential) + AccessToken("oauth", credential) + }) + }) + }) +} diff --git a/expr/testdata/mixed_jsonrpc_transports.go b/expr/testdata/mixed_jsonrpc_transports.go deleted file mode 100644 index 3cf13ed25c..0000000000 --- a/expr/testdata/mixed_jsonrpc_transports.go +++ /dev/null @@ -1,151 +0,0 @@ -package testdata - -import ( - . "goa.design/goa/v3/dsl" -) - -// MixedJSONRPCTransportsAPI defines an API with mixed JSON-RPC transports. -var MixedJSONRPCTransportsAPI = func() { - API("MixedTransports", func() { - Title("Mixed JSON-RPC Transports API") - Description("API demonstrating mixed HTTP and SSE JSON-RPC transports") - }) - - Service("MixedService", func() { - Description("Service with both HTTP and SSE JSON-RPC methods") - - // Regular HTTP method - Method("GetUser", func() { - Payload(func() { - ID("id", String, "User ID") - Required("id") - }) - Result(func() { - ID("id", String, "User ID") - Field(1, "name", String) - Field(2, "email", String) - Required("id") - }) - HTTP(func() { - POST("/users/{id}") - }) - JSONRPC(func() { - }) - }) - - // SSE streaming method - Method("WatchUsers", func() { - Payload(func() { - ID("request_id", String, "Request ID") - Field(1, "filter", String, "Filter expression") - Required("request_id") - }) - StreamingResult(func() { - Field(1, "user_id", String) - Field(2, "event", String) - Field(3, "timestamp", String) - }) - HTTP(func() { - POST("/users/watch") - ServerSentEvents() // Enable SSE for this method - }) - JSONRPC(func() { - }) - }) - - // Another regular HTTP method - Method("CreateUser", func() { - Payload(func() { - Field(1, "name", String) - Field(2, "email", String) - Required("name", "email") - }) - Result(func() { - Field(1, "id", String, "Created user ID") - }) - HTTP(func() { - POST("/users") - }) - JSONRPC(func() { - // Notification - no ID needed - }) - }) - - // Configure JSON-RPC endpoint - JSONRPC(func() { - Path("/api/rpc") - }) - }) -} - -// ValidWebSocketOnlyAPI shows WebSocket cannot mix with other transports. -var ValidWebSocketOnlyAPI = func() { - API("WebSocketOnly", func() { - Title("WebSocket Only API") - }) - - Service("WebSocketService", func() { - Description("Service with only WebSocket JSON-RPC methods") - - Method("Connect", func() { - Payload(func() { - ID("token", String, "Request token used as ID") - Required("token") - }) - StreamingPayload(func() { - Field(1, "message", String) - }) - StreamingResult(func() { - Field(1, "response", String) - }) - HTTP(func() { - GET("/ws") - }) - JSONRPC(func() { - }) - }) - - JSONRPC(func() { - Path("/ws") - }) - }) -} - -// InvalidMixedWebSocketAPI shows invalid mixing of WebSocket with other transports. -var InvalidMixedWebSocketAPI = func() { - API("InvalidMixed", func() { - Title("Invalid Mixed API") - }) - - Service("InvalidService", func() { - Description("Service incorrectly mixing WebSocket with HTTP") - - // WebSocket method - Method("Stream", func() { - StreamingPayload(String) - StreamingResult(String) - HTTP(func() { - GET("/stream") - }) - JSONRPC(func() { - // Streaming methods typically don't use ID - }) - }) - - // Regular HTTP method - THIS SHOULD CAUSE VALIDATION ERROR - Method("Get", func() { - Payload(String) - Result(String) - HTTP(func() { - POST("/get") - }) - JSONRPC(func() { - // This method mixes with WebSocket - should error - }) - }) - - JSONRPC(func() { - Path("/invalid") - }) - }) -} \ No newline at end of file diff --git a/expr/testdata/service_validate_dsls.go b/expr/testdata/service_validate_dsls.go index 6f0085ba72..dfce873935 100644 --- a/expr/testdata/service_validate_dsls.go +++ b/expr/testdata/service_validate_dsls.go @@ -65,3 +65,17 @@ var ServiceErrorDSL = func() { Method("Method", func() {}) }) } + +var InvalidSharedErrorNamesDSL = func() { + var SharedError = Type("SharedError", func() { + Attribute("message", String) + }) + Service("First", func() { + Error("first_error", SharedError) + Method("Run", func() {}) + }) + Service("Second", func() { + Error("second_error", SharedError) + Method("Run", func() {}) + }) +} diff --git a/expr/transport_error_contract_test.go b/expr/transport_error_contract_test.go new file mode 100644 index 0000000000..904566af65 --- /dev/null +++ b/expr/transport_error_contract_test.go @@ -0,0 +1,493 @@ +// This file verifies that reusable transport error mappings never replace the +// service error contract selected by an endpoint method. +package expr_test + +import ( + "testing" + + "github.com/stretchr/testify/require" + + . "goa.design/goa/v3/dsl" + "goa.design/goa/v3/expr" +) + +func TestHTTPInheritedErrorMappingUsesMethodError(t *testing.T) { + root := expr.RunDSL(t, equivalentHTTPErrorMappingDSL) + endpoint := root.API.HTTP.Services[0].HTTPEndpoints[0] + + require.Same(t, endpoint.MethodExpr.Error("bad_request"), endpoint.HTTPErrors[0].ErrorExpr) +} + +func TestHTTPInheritedErrorMappingRejectsIncompatibleError(t *testing.T) { + for _, test := range []struct { + name string + dsl func() + }{ + {"different type", incompatibleHTTPErrorMappingDSL}, + {"different validation", incompatibleHTTPErrorValidationDSL}, + {"different named type", incompatibleHTTPNamedErrorMappingDSL}, + } { + t.Run(test.name, func(t *testing.T) { + err := expr.RunInvalidDSL(t, test.dsl) + require.ErrorContains(t, err, `HTTP error mapping "bad_request"`) + require.ErrorContains(t, err, `method "Show" of service "Errors"`) + require.ErrorContains(t, err, "must define the same error attribute") + }) + } +} + +func TestHTTPInheritedErrorMappingAcceptsServiceReference(t *testing.T) { + root := expr.RunDSL(t, inheritedHTTPObjectErrorMappingDSL) + endpoint := root.API.HTTP.Services[0].HTTPEndpoints[0] + + require.Same(t, root.Error("bad_request"), endpoint.HTTPErrors[0].ErrorExpr) +} + +func TestGRPCInheritedErrorMappingUsesMethodError(t *testing.T) { + root := expr.RunDSL(t, equivalentGRPCErrorMappingDSL) + endpoint := root.API.GRPC.Services[0].GRPCEndpoints[0] + + require.Same(t, endpoint.MethodExpr.Error("bad_request"), endpoint.GRPCErrors[0].ErrorExpr) +} + +func TestServiceErrorMappingsUseMethodError(t *testing.T) { + root := expr.RunDSL(t, equivalentServiceErrorMappingDSL) + httpEndpoint := root.API.HTTP.Services[0].HTTPEndpoints[0] + grpcEndpoint := root.API.GRPC.Services[0].GRPCEndpoints[0] + methodError := httpEndpoint.MethodExpr.Error("bad_request") + + require.Same(t, methodError, httpEndpoint.HTTPErrors[0].ErrorExpr) + require.Same(t, methodError, grpcEndpoint.GRPCErrors[0].ErrorExpr) +} + +func TestHTTPInheritedErrorMappingAcceptsEquivalentValidationOrder(t *testing.T) { + root := expr.RunDSL(t, equivalentHTTPErrorValidationOrderDSL) + endpoint := root.API.HTTP.Services[0].HTTPEndpoints[0] + + require.Same(t, endpoint.MethodExpr.Error("bad_request"), endpoint.HTTPErrors[0].ErrorExpr) +} + +func TestHTTPInheritedErrorMappingUsesEffectiveInheritedContract(t *testing.T) { + root := expr.RunDSL(t, equivalentHTTPInheritedErrorMappingDSL) + endpoint := root.API.HTTP.Services[0].HTTPEndpoints[0] + + require.Same(t, endpoint.MethodExpr.Error("bad_request"), endpoint.HTTPErrors[0].ErrorExpr) +} + +func TestGRPCInheritedErrorMappingUsesEffectiveInheritedContract(t *testing.T) { + root := expr.RunDSL(t, equivalentGRPCInheritedErrorMappingDSL) + endpoint := root.API.GRPC.Services[0].GRPCEndpoints[0] + + require.Same(t, endpoint.MethodExpr.Error("bad_request"), endpoint.GRPCErrors[0].ErrorExpr) +} + +func TestInheritedErrorMappingRejectsDifferentEffectiveBases(t *testing.T) { + for _, test := range []struct { + name string + dsl func() + }{ + {"HTTP", incompatibleHTTPInheritedErrorMappingDSL}, + {"gRPC", incompatibleGRPCInheritedErrorMappingDSL}, + } { + t.Run(test.name, func(t *testing.T) { + err := expr.RunInvalidDSL(t, test.dsl) + require.ErrorContains(t, err, `error mapping "bad_request"`) + require.ErrorContains(t, err, "must define the same error attribute") + }) + } +} + +func TestGRPCInheritedErrorMappingRejectsIncompatibleError(t *testing.T) { + err := expr.RunInvalidDSL(t, incompatibleGRPCErrorMappingDSL) + require.ErrorContains(t, err, `gRPC error mapping "bad_request"`) + require.ErrorContains(t, err, `method "Show" of service "Errors"`) + require.ErrorContains(t, err, "must define the same error attribute") +} + +func TestInheritedErrorMappingRetainsQualifiers(t *testing.T) { + qualifiers := []struct { + name string + apply func() + }{ + {name: "temporary", apply: Temporary}, + {name: "timeout", apply: func() { Timeout() }}, + {name: "fault", apply: Fault}, + } + for _, transport := range []string{"HTTP", "gRPC"} { + for _, qualifier := range qualifiers { + t.Run(transport+" "+qualifier.name, func(t *testing.T) { + root := expr.RunDSL(t, qualifierErrorMappingDSL(transport, qualifier.apply)) + if transport == "HTTP" { + require.Same(t, root.Error("busy"), root.API.HTTP.Services[0].HTTPEndpoints[0].HTTPErrors[0].ErrorExpr) + } else { + require.Same(t, root.Error("busy"), root.API.GRPC.Services[0].GRPCEndpoints[0].GRPCErrors[0].ErrorExpr) + } + }) + } + } +} + +func TestInheritedErrorMappingRejectsExplicitQualifierOverride(t *testing.T) { + for _, transport := range []string{"HTTP", "gRPC"} { + t.Run(transport, func(t *testing.T) { + err := expr.RunInvalidDSL(t, explicitQualifierOverrideDSL(transport)) + require.ErrorContains(t, err, transport+` error mapping "busy"`) + require.ErrorContains(t, err, "temporary setting differs") + }) + } +} + +func TestServiceTransportErrorResponseMayDefineUnusedDefault(t *testing.T) { + for _, test := range []struct { + name string + dsl func() + }{ + { + name: "HTTP", + dsl: serviceDefaultErrorResponseDSL("HTTP"), + }, + { + name: "gRPC", + dsl: serviceDefaultErrorResponseDSL("gRPC"), + }, + } { + t.Run(test.name, func(t *testing.T) { + root := expr.RunDSL(t, test.dsl) + if test.name == "HTTP" { + service := root.API.HTTP.Service("Jobs") + selected := service.Endpoint("Selected") + require.Len(t, selected.HTTPErrors, 1) + require.Same(t, root.Error("busy"), selected.HTTPErrors[0].ErrorExpr) + require.Empty(t, service.Endpoint("Unselected").HTTPErrors) + } else { + service := root.API.GRPC.Service("Jobs") + selected := service.Endpoint("Selected") + require.Len(t, selected.GRPCErrors, 1) + require.Same(t, root.Error("busy"), selected.GRPCErrors[0].ErrorExpr) + require.Empty(t, service.Endpoint("Unselected").GRPCErrors) + } + }) + } +} + +func TestMethodTransportErrorResponseRequiresSelectedError(t *testing.T) { + for _, test := range []struct { + name string + dsl func() + }{ + { + name: "HTTP method", + dsl: unselectedErrorResponseDSL( + func() {}, + func() { + HTTP(func() { + POST("/run") + Response(StatusServiceUnavailable, "busy") + }) + }, + ), + }, + { + name: "gRPC method", + dsl: unselectedErrorResponseDSL( + func() {}, + func() { GRPC(func() { Response("busy", CodeUnavailable) }) }, + ), + }, + } { + t.Run(test.name, func(t *testing.T) { + err := expr.RunInvalidDSL(t, test.dsl) + require.ErrorContains(t, err, "does not match an error defined in") + }) + } +} + +func qualifierErrorMappingDSL(transport string, qualifier func()) func() { + return func() { + API("errors", func() { + Error("busy", qualifier) + if transport == "HTTP" { + HTTP(func() { Response(StatusServiceUnavailable, "busy") }) + } else { + GRPC(func() { Response("busy", CodeUnavailable) }) + } + }) + Service("Jobs", func() { + Method("Run", func() { + Error("busy") + if transport == "HTTP" { + HTTP(func() { POST("/run") }) + } else { + GRPC(func() {}) + } + }) + }) + } +} + +func explicitQualifierOverrideDSL(transport string) func() { + return func() { + API("errors", func() { + Error("busy", Temporary) + if transport == "HTTP" { + HTTP(func() { Response(StatusServiceUnavailable, "busy") }) + } else { + GRPC(func() { Response("busy", CodeUnavailable) }) + } + }) + Service("Jobs", func() { + Method("Run", func() { + Error("busy", ErrorResult) + if transport == "HTTP" { + HTTP(func() { POST("/run") }) + } else { + GRPC(func() {}) + } + }) + }) + } +} + +func unselectedErrorResponseDSL(serviceTransport, methodTransport func()) func() { + return func() { + API("errors", func() { + Error("busy", Temporary) + }) + Service("Jobs", func() { + serviceTransport() + Method("Run", func() { + methodTransport() + }) + }) + } +} + +func serviceDefaultErrorResponseDSL(transport string) func() { + return func() { + API("errors", func() { + Error("busy", Temporary) + }) + Service("Jobs", func() { + if transport == "HTTP" { + HTTP(func() { Response(StatusServiceUnavailable, "busy") }) + } else { + GRPC(func() { Response("busy", CodeUnavailable) }) + } + Method("Selected", func() { + Error("busy") + if transport == "HTTP" { + HTTP(func() { POST("/selected") }) + } else { + GRPC(func() {}) + } + }) + Method("Unselected", func() { + if transport == "HTTP" { + HTTP(func() { POST("/unselected") }) + } else { + GRPC(func() {}) + } + }) + }) + } +} + +var equivalentHTTPErrorMappingDSL = func() { + API("errors", func() { + Error("bad_request", String) + HTTP(func() { Response(StatusBadRequest, "bad_request") }) + }) + Service("Errors", func() { + Method("Show", func() { + Error("bad_request", String) + HTTP(func() { GET("/") }) + }) + }) +} + +var incompatibleHTTPErrorMappingDSL = func() { + API("errors", func() { + Error("bad_request", String) + HTTP(func() { Response(StatusBadRequest, "bad_request") }) + }) + Service("Errors", func() { + Method("Show", func() { + Error("bad_request", Int) + HTTP(func() { GET("/") }) + }) + }) +} + +var incompatibleHTTPErrorValidationDSL = func() { + API("errors", func() { + Error("bad_request", String, func() { MinLength(2) }) + HTTP(func() { Response(StatusBadRequest, "bad_request") }) + }) + Service("Errors", func() { + Method("Show", func() { + Error("bad_request", String, func() { MinLength(3) }) + HTTP(func() { GET("/") }) + }) + }) +} + +var equivalentHTTPErrorValidationOrderDSL = func() { + API("errors", func() { + Error("bad_request", String, func() { Enum("first", "second") }) + HTTP(func() { Response(StatusBadRequest, "bad_request") }) + }) + Service("Errors", func() { + Method("Show", func() { + Error("bad_request", String, func() { Enum("second", "first") }) + HTTP(func() { GET("/") }) + }) + }) +} + +var incompatibleHTTPNamedErrorMappingDSL = func() { + firstError := Type("FirstError", func() { Attribute("message", String) }) + secondError := Type("SecondError", func() { Attribute("message", String) }) + API("errors", func() { + Error("bad_request", firstError) + HTTP(func() { Response(StatusBadRequest, "bad_request") }) + }) + Service("Errors", func() { + Method("Show", func() { + Error("bad_request", secondError) + HTTP(func() { GET("/") }) + }) + }) +} + +var inheritedHTTPObjectErrorMappingDSL = func() { + stringError := Type("StringError", func() { Attribute("header") }) + API("errors", func() { + Error("bad_request", stringError) + HTTP(func() { + Response("bad_request", StatusBadRequest, func() { Header("header") }) + }) + }) + Service("Errors", func() { + Error("bad_request") + Method("Show", func() { HTTP(func() { GET("/") }) }) + }) +} + +var equivalentGRPCErrorMappingDSL = func() { + API("errors", func() { + Error("bad_request", String) + GRPC(func() { Response("bad_request", CodeInvalidArgument) }) + }) + Service("Errors", func() { + Method("Show", func() { + Error("bad_request", String) + GRPC(func() {}) + }) + }) +} + +var incompatibleGRPCErrorMappingDSL = func() { + API("errors", func() { + Error("bad_request", String) + GRPC(func() { Response("bad_request", CodeInvalidArgument) }) + }) + Service("Errors", func() { + Method("Show", func() { + Error("bad_request", Int) + GRPC(func() {}) + }) + }) +} + +var equivalentServiceErrorMappingDSL = func() { + Service("Errors", func() { + Error("bad_request", String) + HTTP(func() { Response(StatusBadRequest, "bad_request") }) + GRPC(func() { Response("bad_request", CodeInvalidArgument) }) + Method("Show", func() { + Error("bad_request", String) + HTTP(func() { GET("/") }) + GRPC(func() {}) + }) + }) +} + +var equivalentHTTPInheritedErrorMappingDSL = func() { + base := Type("HTTPErrorBase", func() { + Attribute("message", String, func() { + Default("invalid") + Meta("struct:field:name", "Message") + }) + Required("message") + }) + API("errors", func() { + Error("bad_request", &expr.Object{}, func() { Extend(base) }) + HTTP(func() { Response(StatusBadRequest, "bad_request") }) + }) + Service("Errors", func() { + Method("Show", func() { + Error("bad_request", &expr.Object{}, func() { + Attribute("message", String, func() { + Default("invalid") + Meta("struct:field:name", "Message") + }) + Required("message") + }) + HTTP(func() { GET("/") }) + }) + }) +} + +var equivalentGRPCInheritedErrorMappingDSL = func() { + base := Type("GRPCErrorBase", func() { + Attribute("message", String, func() { Meta("rpc:tag", "1") }) + Required("message") + }) + API("errors", func() { + Error("bad_request", &expr.Object{}, func() { Extend(base) }) + GRPC(func() { Response("bad_request", CodeInvalidArgument) }) + }) + Service("Errors", func() { + Method("Show", func() { + Error("bad_request", &expr.Object{}, func() { + Attribute("message", String, func() { Meta("rpc:tag", "1") }) + Required("message") + }) + GRPC(func() {}) + }) + }) +} + +var incompatibleHTTPInheritedErrorMappingDSL = func() { + stringBase := Type("HTTPStringErrorBase", func() { Attribute("value", String) }) + integerBase := Type("HTTPIntegerErrorBase", func() { Attribute("value", Int) }) + API("errors", func() { + Error("bad_request", &expr.Object{}, func() { Extend(stringBase) }) + HTTP(func() { Response(StatusBadRequest, "bad_request") }) + }) + Service("Errors", func() { + Method("Show", func() { + Error("bad_request", &expr.Object{}, func() { Extend(integerBase) }) + HTTP(func() { GET("/") }) + }) + }) +} + +var incompatibleGRPCInheritedErrorMappingDSL = func() { + stringBase := Type("GRPCStringErrorBase", func() { + Attribute("value", String, func() { Meta("rpc:tag", "1") }) + }) + integerBase := Type("GRPCIntegerErrorBase", func() { + Attribute("value", Int, func() { Meta("rpc:tag", "1") }) + }) + API("errors", func() { + Error("bad_request", &expr.Object{}, func() { Extend(stringBase) }) + GRPC(func() { Response("bad_request", CodeInvalidArgument) }) + }) + Service("Errors", func() { + Method("Show", func() { + Error("bad_request", &expr.Object{}, func() { Extend(integerBase) }) + GRPC(func() {}) + }) + }) +} diff --git a/expr/transport_error_response_dsl_test.go b/expr/transport_error_response_dsl_test.go new file mode 100644 index 0000000000..3fe5b38d41 --- /dev/null +++ b/expr/transport_error_response_dsl_test.go @@ -0,0 +1,299 @@ +// This file verifies that transport error response callbacks describe the +// declared error value while successful response callbacks describe the method +// result. +package expr_test + +import ( + "testing" + + "github.com/stretchr/testify/require" + + . "goa.design/goa/v3/dsl" + "goa.design/goa/v3/expr" +) + +func TestHTTPErrorResponseCallbackUsesDeclaredError(t *testing.T) { + for _, test := range []struct { + name string + dsl func() + }{ + {name: "method", dsl: methodHTTPErrorResponseDSL}, + {name: "service", dsl: serviceHTTPErrorResponseDSL}, + {name: "API", dsl: apiHTTPErrorResponseDSL}, + } { + t.Run(test.name, func(t *testing.T) { + root := expr.RunDSL(t, test.dsl) + response := root.API.HTTP.Services[0].HTTPEndpoints[0].HTTPErrors[0].Response + + require.Equal(t, StatusUnprocessableEntity, response.StatusCode) + require.Equal(t, "The problem response.", response.Description) + require.Equal(t, "application/problem+json", response.ContentType) + require.Equal(t, []string{"detail"}, response.Body.Meta["origin:attribute"]) + require.NotNil(t, response.Headers.Find("detail")) + require.NotNil(t, response.Cookies.Find("detail")) + require.Equal(t, []string{"30"}, response.Cookies.Meta["cookie:max-age"]) + }) + } +} + +func TestHTTPMethodErrorResponseCallbackAcceptsTag(t *testing.T) { + root := expr.RunDSL(t, func() { + Service("Errors", func() { + Method("Run", func() { + Error("problem", transportErrorType()) + HTTP(func() { + POST("/") + Response("problem", StatusUnprocessableEntity, func() { + Tag("detail", "problem") + }) + }) + }) + }) + }) + + response := root.API.HTTP.Services[0].HTTPEndpoints[0].HTTPErrors[0].Response + require.Equal(t, [2]string{"detail", "problem"}, response.Tag) +} + +func TestGRPCErrorResponseCallbackUsesDeclaredError(t *testing.T) { + for _, test := range []struct { + name string + dsl func() + }{ + {name: "method", dsl: methodGRPCErrorResponseDSL}, + {name: "service", dsl: serviceGRPCErrorResponseDSL}, + {name: "API", dsl: apiGRPCErrorResponseDSL}, + } { + t.Run(test.name, func(t *testing.T) { + root := expr.RunDSL(t, test.dsl) + response := root.API.GRPC.Services[0].GRPCEndpoints[0].GRPCErrors[0].Response + + require.Equal(t, CodeInvalidArgument, response.StatusCode) + require.Equal(t, "The problem response.", response.Description) + require.NotNil(t, response.Message.Find("detail")) + require.NotNil(t, response.Headers.Find("header")) + require.NotNil(t, response.Trailers.Find("trailer")) + }) + } +} + +func TestJSONRPCErrorResponseCallbackUsesDeclaredError(t *testing.T) { + root := expr.RunDSL(t, func() { + API("Errors", func() { + JSONRPC(func() { + Response("problem", func() { + Code(7001) + Description("The problem response.") + ContentType("application/problem+json") + Body("detail") + }) + }) + Error("problem", transportErrorType()) + }) + Service("Errors", func() { + Error("problem") + JSONRPC(func() { POST("/rpc") }) + Method("Run", func() { + Error("problem") + JSONRPC(func() {}) + }) + }) + }) + + response := root.API.JSONRPC.Services[0].HTTPEndpoints[0].HTTPErrors[0].Response + require.Equal(t, 7001, response.StatusCode) + require.Equal(t, "The problem response.", response.Description) + require.Equal(t, "application/problem+json", response.ContentType) + require.Equal(t, []string{"detail"}, response.Body.Meta["origin:attribute"]) +} + +func TestErrorResponseCallbackWithUnknownErrorDoesNotPanic(t *testing.T) { + for _, test := range []struct { + name string + dsl func() + error string + }{ + { + name: "HTTP", + error: "Body is set but Error missing is not defined", + dsl: func() { + Service("Errors", func() { + Method("Run", func() { + HTTP(func() { + POST("/") + Response("missing", StatusBadRequest, func() { Body("detail") }) + }) + }) + }) + }, + }, + { + name: "gRPC", + error: `Error "missing" does not match an error defined in the method`, + dsl: func() { + Service("Errors", func() { + Method("Run", func() { + GRPC(func() { + Response("missing", CodeInvalidArgument, func() { + Message(func() { Field(1, "detail", String) }) + }) + }) + }) + }) + }, + }, + } { + t.Run(test.name, func(t *testing.T) { + require.NotPanics(t, func() { + err := expr.RunInvalidDSL(t, test.dsl) + require.ErrorContains(t, err, test.error) + }) + }) + } +} + +func TestSuccessResponseCallbackUsesMethodResult(t *testing.T) { + root := expr.RunDSL(t, func() { + Service("Success", func() { + Method("Run", func() { + Result(func() { + Attribute("value", String) + }) + HTTP(func() { + POST("/") + Response(StatusOK, func() { + Tag("value", "complete") + Body("value") + }) + Response(StatusAccepted) + }) + }) + }) + }) + + response := root.API.HTTP.Services[0].HTTPEndpoints[0].Responses[0] + require.Equal(t, [2]string{"value", "complete"}, response.Tag) + require.Equal(t, []string{"value"}, response.Body.Meta["origin:attribute"]) +} + +func TestAPIErrorMappingsDoNotDeclareMethodErrors(t *testing.T) { + root := expr.RunDSL(t, func() { + API("Errors", func() { + Error("busy", String) + HTTP(func() { Response("busy", StatusServiceUnavailable) }) + JSONRPC(func() { Response("busy", 7001) }) + GRPC(func() { Response("busy", CodeUnavailable) }) + }) + Service("plain", func() { + Method("Run", func() { + HTTP(func() { POST("/run") }) + GRPC(func() {}) + }) + }) + Service("rpc", func() { + JSONRPC(func() { POST("/rpc") }) + Method("Run", func() { JSONRPC(func() {}) }) + }) + }) + + require.Empty(t, root.API.HTTP.Service("plain").Endpoint("Run").HTTPErrors) + require.Empty(t, root.API.GRPC.Service("plain").Endpoint("Run").GRPCErrors) + require.Empty(t, root.API.JSONRPC.Service("rpc").Endpoint("Run").HTTPErrors) +} + +func methodHTTPErrorResponseDSL() { + Service("Errors", func() { + Method("Run", func() { + Error("problem", transportErrorType()) + HTTP(func() { + POST("/") + Response("problem", errorHTTPResponse) + }) + }) + }) +} + +func serviceHTTPErrorResponseDSL() { + Service("Errors", func() { + Error("problem", transportErrorType()) + HTTP(func() { Response("problem", errorHTTPResponse) }) + Method("Run", func() { + Error("problem") + HTTP(func() { POST("/") }) + }) + }) +} + +func apiHTTPErrorResponseDSL() { + API("Errors", func() { + HTTP(func() { Response("problem", errorHTTPResponse) }) + Error("problem", transportErrorType()) + }) + Service("Errors", func() { + Error("problem") + Method("Run", func() { + Error("problem") + HTTP(func() { POST("/") }) + }) + }) +} + +func methodGRPCErrorResponseDSL() { + Service("Errors", func() { + Method("Run", func() { + Error("problem", transportErrorType()) + GRPC(func() { Response("problem", errorGRPCResponse) }) + }) + }) +} + +func serviceGRPCErrorResponseDSL() { + Service("Errors", func() { + Error("problem", transportErrorType()) + GRPC(func() { Response("problem", errorGRPCResponse) }) + Method("Run", func() { + Error("problem") + GRPC(func() {}) + }) + }) +} + +func apiGRPCErrorResponseDSL() { + API("Errors", func() { + GRPC(func() { Response("problem", errorGRPCResponse) }) + Error("problem", transportErrorType()) + }) + Service("Errors", func() { + Error("problem") + Method("Run", func() { + Error("problem") + GRPC(func() {}) + }) + }) +} + +func transportErrorType() func() { + return func() { + Field(1, "detail", String) + Field(2, "header", String) + Field(3, "trailer", String) + } +} + +func errorHTTPResponse() { + Code(StatusUnprocessableEntity) + Description("The problem response.") + ContentType("application/problem+json") + Header("detail:X-Detail") + Cookie("detail:problem") + CookieMaxAge(30) + Body("detail") +} + +func errorGRPCResponse() { + Code(CodeInvalidArgument) + Description("The problem response.") + Message(func() { Attribute("detail") }) + Headers(func() { Attribute("header") }) + Trailers(func() { Attribute("trailer") }) +} diff --git a/expr/types.go b/expr/types.go index ea789c1e6e..5ef84bd451 100644 --- a/expr/types.go +++ b/expr/types.go @@ -1,10 +1,11 @@ +// This file defines Goa's core design data types and the structural operations +// used by evaluation, validation, and code generation. package expr import ( "fmt" "reflect" "sort" - "strconv" "goa.design/goa/v3/eval" ) @@ -78,6 +79,9 @@ type ( CompositeExpr // ID returns the identifier for the user type. ID() string + // Origin returns the first user type declaration from which this value + // was copied. An authored value returns itself. + Origin() UserType // Rename changes the type name to the given value. Rename(string) // SetAttribute updates the underlying attribute. @@ -181,6 +185,7 @@ var Empty = &UserTypeExpr{ AttributeExpr: &AttributeExpr{ Description: "Empty represents empty values", Type: &Object{}, + finalized: true, }, } @@ -424,7 +429,7 @@ func (a *Array) Example(r *ExampleGenerator) any { for i := range count { // Derive the element value stream from the index so elements get // distinct yet design-stable values. - res[i] = a.ElemType.Example(r.Derived(strconv.Itoa(i))) + res[i] = a.ElemType.Example(r.ArrayElement(i)) if res[i] == nil { // Handle the case of recursive data structures res[i] = make(map[string]any) @@ -544,7 +549,7 @@ func (o *Object) Example(r *ExampleGenerator) any { for _, nat := range *o { // Derive the field value stream from the field name so a field // example only changes when the field itself changes. - if v := nat.Attribute.Example(r.Derived(nat.Name)); v != nil { + if v := nat.Attribute.Example(r.Member(nat.Name)); v != nil { res[nat.Name] = v } } @@ -590,8 +595,8 @@ func (m *Map) Example(r *ExampleGenerator) any { for i := range count { // Derive per-entry value streams from the entry index so entries // get distinct yet design-stable keys and values. - k := m.KeyType.Example(r.Derived("key" + strconv.Itoa(i))) - v := m.ElemType.Example(r.Derived("val" + strconv.Itoa(i))) + k := m.KeyType.Example(r.MapKey(i)) + v := m.ElemType.Example(r.MapValue(i)) if k != nil && v != nil { pair[k] = v } @@ -676,7 +681,7 @@ func (u *Union) Example(r *ExampleGenerator) any { nat := u.Values[r.Int()%len(u.Values)] return map[string]any{ u.GetTypeKey(): nat.Name, - u.GetValueKey(): nat.Attribute.Example(r.Derived(nat.Name)), + u.GetValueKey(): nat.Attribute.Example(r.UnionMember(nat.Name)), } } diff --git a/expr/types_test.go b/expr/types_test.go index 66e6358f60..db819003bf 100644 --- a/expr/types_test.go +++ b/expr/types_test.go @@ -1,3 +1,5 @@ +// This file verifies expression type conversion, compatibility, and example +// behavior, including the tagged representation produced for union values. package expr import "testing" @@ -919,7 +921,12 @@ func TestUnionExampleAndCompatibilityUseTaggedEnvelope(t *testing.T) { }, } - example := union.Example(NewRandom("test")) + example := union.Example(NewExampleGenerator(NewFakerRandomizerFactory("test")).At( + MethodPayloadExampleIdentity(&MethodExpr{ + Name: "union", + Service: &ServiceExpr{Name: "test"}, + }), + )) envelope, ok := example.(map[string]any) if !ok { t.Fatalf("expected tagged envelope, got %T", example) diff --git a/expr/user_type.go b/expr/user_type.go index 0d81160f7e..7f9504b327 100644 --- a/expr/user_type.go +++ b/expr/user_type.go @@ -1,22 +1,52 @@ +// This file defines user types and records the original declaration from +// which each copied type was created. package expr type ( - // UserTypeExpr describes user defined types. While a given design must - // ensure that the names are unique the code used to generate code can - // create multiple user types that share the same name (for example because - // generated in different packages). UID is always unique and makes it - // possible to avoid infinite recursions when traversing the data structures - // described by the attribute expression e.g. when computing example values. + // UserTypeExpr describes a type declared in a Goa design or created by a + // generator. One design cannot declare two types with the same name, but + // generators may create same-named types in different Go packages. UID keeps + // authored examples and result-type behavior tied to the declared type. + // Generated types use exampleIdentity instead. Origin points to the first + // UserTypeExpr from which a copied type was made. UserTypeExpr struct { // The embedded attribute expression. *AttributeExpr // Name of type TypeName string - // UID of type + // UID identifies an authored type across copies of its expression. UID string + // origin is the earliest declaration copied to create this type. + origin UserType + // exampleIdentity selects the repeatable example sequence for a type created + // by a transport generator. Authored types leave it empty and use ID. + exampleIdentity ExampleIdentity } ) +// NewGeneratedUserType creates a user type for generated transport data. +// The supplied ExampleIdentity selects the generated type's ID and repeatable +// example sequence. Copies of a request or response type therefore do not use +// examples belonging to the authored service type. +func NewGeneratedUserType(name string, attribute *AttributeExpr, identity ExampleIdentity) *UserTypeExpr { + return newGeneratedUserType(name, attribute, identity, nil) +} + +// newGeneratedUserType creates one generated wrapper. origin identifies a +// prior wrapper that represents the same generated Go declaration. +func newGeneratedUserType(name string, attribute *AttributeExpr, identity ExampleIdentity, origin UserType) *UserTypeExpr { + if identity.seed == "" { + panic("generated user type requires an example identity") + } + return &UserTypeExpr{ + AttributeExpr: attribute, + TypeName: name, + UID: "generated:" + identity.Seed(), + origin: origin, + exampleIdentity: identity, + } +} + // ID returns the unique identifier for the user type. func (u *UserTypeExpr) ID() string { if u.UID != "" { @@ -25,6 +55,14 @@ func (u *UserTypeExpr) ID() string { return u.Name() } +// Origin returns the earliest user type declaration from which u was copied. +func (u *UserTypeExpr) Origin() UserType { + if u.origin != nil { + return u.origin + } + return u +} + // Kind implements DataKind. func (*UserTypeExpr) Kind() Kind { return UserTypeKind } @@ -45,6 +83,7 @@ func (u *UserTypeExpr) Rename(n string) { u.AddMeta("name:original", u.TypeName) delete(u.Meta, "struct:type:name") u.TypeName = n + u.origin = nil } // IsCompatible returns true if u describes the (Go) type of val. @@ -69,9 +108,11 @@ func (u *UserTypeExpr) Dup(att *AttributeExpr) UserType { return u } return &UserTypeExpr{ - AttributeExpr: att, - TypeName: u.TypeName, - UID: u.UID, + AttributeExpr: att, + TypeName: u.TypeName, + UID: u.UID, + origin: u.Origin(), + exampleIdentity: u.exampleIdentity, } } @@ -90,16 +131,16 @@ func (u *UserTypeExpr) Example(r *ExampleGenerator) any { } func (u *UserTypeExpr) recExample(r *ExampleGenerator) *any { - if ex, ok := r.PreviouslySeen(u.ID()); ok { + if ex, ok := r.previouslySeen(u); ok { return ex } var ex any pex := &ex - r.HaveSeen(u.ID(), pex) + r.haveSeen(u, pex) // Anchor the value stream to the type identity so the example depends // only on the type definition, not on how many examples were computed // before it nor on which design path reached the type first. - actual := u.AttributeExpr.Example(r.Rebased(u.ID())) + actual := u.AttributeExpr.Example(r.At(UserTypeExampleIdentity(u))) *pex = actual return pex } diff --git a/expr/user_type_example_test.go b/expr/user_type_example_test.go index b9fc3b5425..282c8c1736 100644 --- a/expr/user_type_example_test.go +++ b/expr/user_type_example_test.go @@ -1,8 +1,12 @@ +// This file verifies authored and generated user types retain independent +// example ownership while recursive copies share one declaration origin. package expr_test import ( "testing" + "github.com/stretchr/testify/require" + "goa.design/goa/v3/expr" ) @@ -34,7 +38,9 @@ func TestUserTypeWithUserExample(t *testing.T) { // Test with both randomizers to ensure user examples always take precedence t.Run("with faker randomizer", func(t *testing.T) { - exampleGen := expr.NewRandom("test") + exampleGen := expr.NewExampleGenerator(expr.NewFakerRandomizerFactory("test")).At( + expr.UserTypeExampleIdentity(urlType), + ) example := attr.Example(exampleGen) if example != customURL { t.Errorf("Attribute with user example should return %q, got %q", customURL, example) @@ -42,10 +48,9 @@ func TestUserTypeWithUserExample(t *testing.T) { }) t.Run("with deterministic randomizer", func(t *testing.T) { - gen := expr.NewDeterministicRandomizer() - exampleGen := &expr.ExampleGenerator{ - Randomizer: gen, - } + exampleGen := expr.NewExampleGenerator(expr.NewDeterministicRandomizerFactory()).At( + expr.UserTypeExampleIdentity(urlType), + ) example := attr.Example(exampleGen) if example != customURL { t.Errorf("Attribute with user example should return %q, got %q", customURL, example) @@ -81,10 +86,9 @@ func TestUserTypeFormatWithCustomExample(t *testing.T) { } // Test with deterministic randomizer (as reported in the issue) - gen := expr.NewDeterministicRandomizer() - exampleGen := &expr.ExampleGenerator{ - Randomizer: gen, - } + exampleGen := expr.NewExampleGenerator(expr.NewDeterministicRandomizerFactory()).At( + expr.UserTypeExampleIdentity(urlType), + ) // The bug was that this would return "https://example.com/foo" instead of the custom example example := attr.Example(exampleGen) @@ -117,10 +121,9 @@ func TestIssue3716Regression(t *testing.T) { } // When generating an example for the object - gen := expr.NewDeterministicRandomizer() - exampleGen := &expr.ExampleGenerator{ - Randomizer: gen, - } + exampleGen := expr.NewExampleGenerator(expr.NewDeterministicRandomizerFactory()).At( + expr.MethodPayloadExampleIdentity(exampleMethod("issue-3716", "object")), + ) example := obj.Example(exampleGen) objExample, ok := example.(map[string]any) @@ -162,10 +165,9 @@ func TestUserTypeWithOwnExample(t *testing.T) { } // Use deterministic randomizer - gen := expr.NewDeterministicRandomizer() - exampleGen := &expr.ExampleGenerator{ - Randomizer: gen, - } + exampleGen := expr.NewExampleGenerator(expr.NewDeterministicRandomizerFactory()).At( + expr.UserTypeExampleIdentity(urlType), + ) // The UserType itself should return its custom example example := urlType.Example(exampleGen) @@ -174,3 +176,58 @@ func TestUserTypeWithOwnExample(t *testing.T) { t.Errorf("UserType with custom example should return %q, got %q", customExample, example) } } + +func TestUserTypeExamplesIgnoreStringIDCollisions(t *testing.T) { + method := exampleMethod("service", "method") + owner := expr.MethodPayloadExampleIdentity(method) + generated := expr.NewGeneratedUserType("Generated", &expr.AttributeExpr{Type: &expr.Object{ + {Name: "generated", Attribute: &expr.AttributeExpr{Type: expr.String}}, + }}, owner) + generatedOwner, ok := expr.GeneratedUserTypeExampleIdentity(generated) + require.True(t, ok) + require.Equal(t, owner, generatedOwner) + authored := &expr.UserTypeExpr{ + TypeName: "Authored", + UID: generated.ID(), + AttributeExpr: &expr.AttributeExpr{Type: &expr.Object{ + {Name: "authored", Attribute: &expr.AttributeExpr{Type: expr.String}}, + }}, + } + _, ok = expr.GeneratedUserTypeExampleIdentity(authored) + require.False(t, ok) + + cases := []struct { + name string + first *expr.UserTypeExpr + firstField string + second *expr.UserTypeExpr + secondField string + }{ + { + name: "authored then generated", + first: authored, + firstField: "authored", + second: generated, + secondField: "generated", + }, + { + name: "generated then authored", + first: generated, + firstField: "generated", + second: authored, + secondField: "authored", + }, + } + for _, test := range cases { + t.Run(test.name, func(t *testing.T) { + generator := expr.NewExampleGenerator(expr.NewFakerRandomizerFactory("test")) + first := test.first.Example(generator.At(expr.UserTypeExampleIdentity(test.first))).(map[string]any) + second := test.second.Example(generator.At(expr.UserTypeExampleIdentity(test.second))).(map[string]any) + + require.Contains(t, first, test.firstField) + require.NotContains(t, first, test.secondField) + require.Contains(t, second, test.secondField) + require.NotContains(t, second, test.firstField) + }) + } +} diff --git a/expr/user_type_test.go b/expr/user_type_test.go index a373a66d91..614fb8b55e 100644 --- a/expr/user_type_test.go +++ b/expr/user_type_test.go @@ -1,6 +1,45 @@ package expr -import "testing" +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestUserTypeOrigin(t *testing.T) { + original := &UserTypeExpr{ + AttributeExpr: &AttributeExpr{Type: String}, + TypeName: "Value", + } + copy := original.Dup(DupAtt(original.Attribute())).(*UserTypeExpr) + copyOfCopy := copy.Dup(DupAtt(copy.Attribute())).(*UserTypeExpr) + require.Same(t, original, original.Origin()) + require.Same(t, original, copy.Origin()) + require.Same(t, original, copyOfCopy.Origin()) + + copy.Rename("RenamedValue") + renamedCopy := copy.Dup(DupAtt(copy.Attribute())).(*UserTypeExpr) + require.Same(t, copy, copy.Origin()) + require.Same(t, copy, renamedCopy.Origin()) + require.Same(t, original, copyOfCopy.Origin()) +} + +func TestIndependentUserTypesHaveDistinctOrigins(t *testing.T) { + first := &UserTypeExpr{AttributeExpr: &AttributeExpr{Type: String}, TypeName: "Value"} + second := &UserTypeExpr{AttributeExpr: &AttributeExpr{Type: String}, TypeName: "Value"} + require.NotSame(t, first.Origin(), second.Origin()) +} + +func TestResultTypeOriginPreservesDynamicType(t *testing.T) { + original := NewResultTypeExpr("Value", "application/vnd.value", nil) + copy := original.Dup(DupAtt(original.Attribute())).(*ResultTypeExpr) + require.Same(t, original, copy.Origin()) + + copy.Rename("RenamedValue") + renamedCopy := copy.Dup(DupAtt(copy.Attribute())).(*ResultTypeExpr) + require.Same(t, copy, copy.Origin()) + require.Same(t, copy, renamedCopy.Origin()) +} func TestUserTypeExprName(t *testing.T) { var ( diff --git a/go.mod b/go.mod index 2fa848db8a..1baca9f894 100644 --- a/go.mod +++ b/go.mod @@ -12,6 +12,7 @@ require ( github.com/manveru/faker v0.0.0-20171103152722-9fbc68a78c4d github.com/pkg/errors v0.9.1 github.com/stretchr/testify v1.12.1 + golang.org/x/mod v0.40.0 golang.org/x/text v0.41.0 golang.org/x/tools v0.49.0 google.golang.org/genproto/googleapis/rpc v0.0.0-20260819154853-08b0e4226688 @@ -28,7 +29,6 @@ require ( github.com/oasdiff/yaml3 v0.0.14 // indirect github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 // indirect go.yaml.in/yaml/v3 v3.0.5 // indirect - golang.org/x/mod v0.40.0 // indirect golang.org/x/net v0.58.0 // indirect golang.org/x/sync v0.22.0 // indirect golang.org/x/sys v0.47.0 // indirect diff --git a/grpc/codegen/client.go b/grpc/codegen/client.go index 9e63a6d1a6..64864d3148 100644 --- a/grpc/codegen/client.go +++ b/grpc/codegen/client.go @@ -1,7 +1,8 @@ +// This file renders gRPC clients and codecs per service; each returned file +// owns the generated-type imports used by its conversions. package codegen import ( - "path" "path/filepath" "goa.design/goa/v3/codegen" @@ -9,22 +10,21 @@ import ( "goa.design/goa/v3/expr" ) -// ClientFiles returns the client files that contain client methods to call the -// corresponding service methods along with the encoding and decoding logic. -func ClientFiles(genpkg string, services *ServicesData) []*codegen.File { - svcLen := len(services.Root.API.GRPC.Services) +// clientFiles returns the planned client methods and their encoders and decoders. +func clientFiles(services *ServicesData) []*codegen.File { + svcLen := len(services.servicePlans) fw := make([]*codegen.File, 2*svcLen) - for i, svc := range services.Root.API.GRPC.Services { - fw[i] = clientFile(genpkg, svc, services) + for i, servicePlan := range services.servicePlans { + fw[i] = addEndpointImports(clientFile(servicePlan.expression, services), services) } - for i, svc := range services.Root.API.GRPC.Services { - fw[i+svcLen] = clientEncodeDecode(genpkg, svc, services) + for i, servicePlan := range services.servicePlans { + fw[i+svcLen] = addEndpointImports(clientEncodeDecode(servicePlan.expression, services), services) } return fw } // clientFile returns the file implementing the gRPC client. -func clientFile(genpkg string, svc *expr.GRPCServiceExpr, services *ServicesData) *codegen.File { +func clientFile(svc *expr.GRPCServiceExpr, services *ServicesData) *codegen.File { var ( fpath string sections []*codegen.SectionTemplate @@ -34,18 +34,8 @@ func clientFile(genpkg string, svc *expr.GRPCServiceExpr, services *ServicesData { svcName := data.Service.PathName fpath = filepath.Join(codegen.Gendir, "grpc", svcName, "client", "client.go") - imports := []*codegen.ImportSpec{ - {Path: "context"}, - {Path: "google.golang.org/grpc"}, - codegen.GoaImport(""), - codegen.GoaNamedImport("grpc", "goagrpc"), - codegen.GoaNamedImport("grpc/pb", "goapb"), - {Path: path.Join(genpkg, svcName), Name: data.Service.PkgName}, - {Path: path.Join(genpkg, svcName, "views"), Name: data.Service.ViewsPkg}, - {Path: path.Join(genpkg, "grpc", svcName, pbPkgName), Name: data.PkgName}, - } sections = []*codegen.SectionTemplate{ - codegen.Header(svc.Name()+" gRPC client", "client", imports), + codegen.Header(svc.Name()+" gRPC client", "client", nil), } sections = append(sections, &codegen.SectionTemplate{ Name: "client-struct", @@ -111,7 +101,7 @@ func clientFile(genpkg string, svc *expr.GRPCServiceExpr, services *ServicesData // clientEncodeDecode returns the file containing the gRPC client encoding and // decoding logic. -func clientEncodeDecode(genpkg string, svc *expr.GRPCServiceExpr, services *ServicesData) *codegen.File { +func clientEncodeDecode(svc *expr.GRPCServiceExpr, services *ServicesData) *codegen.File { var ( fpath string sections []*codegen.SectionTemplate @@ -121,23 +111,11 @@ func clientEncodeDecode(genpkg string, svc *expr.GRPCServiceExpr, services *Serv { svcName := data.Service.PathName fpath = filepath.Join(codegen.Gendir, "grpc", svcName, "client", "encode_decode.go") - imports := []*codegen.ImportSpec{ - {Path: "fmt"}, - {Path: "context"}, - {Path: "strconv"}, - {Path: "unicode/utf8"}, - {Path: "google.golang.org/grpc"}, - {Path: "google.golang.org/grpc/metadata"}, - codegen.GoaImport(""), - codegen.GoaNamedImport("grpc", "goagrpc"), - {Path: path.Join(genpkg, svcName), Name: data.Service.PkgName}, - {Path: path.Join(genpkg, svcName, "views"), Name: data.Service.ViewsPkg}, - {Path: path.Join(genpkg, "grpc", svcName, pbPkgName), Name: data.PkgName}, - } - sections = []*codegen.SectionTemplate{codegen.Header(svc.Name()+" gRPC client encoders and decoders", "client", imports)} - fm := transTmplFuncs(svc, services) + sections = []*codegen.SectionTemplate{codegen.Header(svc.Name()+" gRPC client encoders and decoders", "client", nil)} + fm := transTmplFuncs(data) + fm["hasInitArg"] = hasInitArg fm["metadataEncodeDecodeData"] = metadataEncodeDecodeData - fm["typeConversionData"] = typeConversionData + fm["typeStringExpressionData"] = typeStringExpressionData fm["isBearer"] = isBearer for _, e := range data.Endpoints { sections = append(sections, &codegen.SectionTemplate{ @@ -148,7 +126,7 @@ func clientEncodeDecode(genpkg string, svc *expr.GRPCServiceExpr, services *Serv if e.PayloadRef != "" { sections = append(sections, &codegen.SectionTemplate{ Name: "request-encoder", - Source: grpcTemplates.Read(grpcRequestEncoderT, grpcConvertTypeToStringP, "string_conversion"), + Source: grpcTemplates.Read(grpcRequestEncoderT, grpcTypeToStringExpressionP), Data: e, FuncMap: fm, }) @@ -166,6 +144,18 @@ func clientEncodeDecode(genpkg string, svc *expr.GRPCServiceExpr, services *Serv return &codegen.File{Path: fpath, SectionTemplates: sections} } +// hasInitArg reports whether a generated constructor consumes the named +// source variable. Templates use it to avoid declaring an unused variable for +// an empty protobuf message that only carries response metadata. +func hasInitArg(args []*InitArgData, name string) bool { + for _, arg := range args { + if arg.Name == name { + return true + } + } + return false +} + // isBearer returns true if the security scheme uses a Bearer scheme. func isBearer(schemes []*service.SchemeData) bool { for _, s := range schemes { diff --git a/grpc/codegen/client_cli.go b/grpc/codegen/client_cli.go index 22c1d0fe53..4e230f9355 100644 --- a/grpc/codegen/client_cli.go +++ b/grpc/codegen/client_cli.go @@ -1,139 +1,159 @@ +// This file renders gRPC command parsers and per-service payload builders, +// including relocated payload imports in the builder that references them. package codegen import ( + "fmt" "path" "path/filepath" "goa.design/goa/v3/codegen" "goa.design/goa/v3/codegen/cli" - "goa.design/goa/v3/expr" ) -// ClientCLIFiles returns the CLI files to generate a command-line client that -// makes gRPC requests. -func ClientCLIFiles(genpkg string, services *ServicesData) []*codegen.File { - if len(services.Root.API.GRPC.Services) == 0 { +type ( + // commandData adds the exact gRPC client constructor to the shared command + // data used by transport command-line generators. + commandData struct { + *cli.CommandData + // ClientInit is the client constructor called by ParseEndpoint. + ClientInit *codegen.NameDeclaration + } +) + +// clientCLIFiles returns the planned command-line client files. +func clientCLIFiles(services *ServicesData) []*codegen.File { + if len(services.servicePlans) == 0 { return nil } var ( - data = make([]*cli.CommandData, 0, len(services.Root.API.GRPC.Services)) - svcs = make([]*expr.GRPCServiceExpr, 0, len(services.Root.API.GRPC.Services)) + data = make([]*commandData, 0, len(services.servicePlans)) + svcs = make([]*grpcServicePlan, 0, len(services.servicePlans)) ) - for _, svc := range services.Root.API.GRPC.Services { + for _, servicePlan := range services.servicePlans { + svc := servicePlan.expression if len(svc.GRPCEndpoints) == 0 { continue } sd := services.Get(svc.Name()) - command := cli.BuildCommandData(sd.Service) - for _, e := range sd.Endpoints { - flags, buildFunction := buildFlags(e) + command := &commandData{ + CommandData: cli.BuildCommandData(sd.Service), + ClientInit: sd.ClientInitDeclaration, + } + for index, e := range sd.Endpoints { + flags, buildFunction := buildFlags(e, services.cliPlan.builders[svc.GRPCEndpoints[index]]) subcmd := cli.BuildSubcommandData(sd.Service, e.Method, buildFunction, flags) - command.Subcommands = append(command.Subcommands, subcmd) + command.CommandData.Subcommands = append(command.CommandData.Subcommands, subcmd) } command.Example = command.Subcommands[0].Example data = append(data, command) - svcs = append(svcs, svc) + svcs = append(svcs, servicePlan) } - files := make([]*codegen.File, 0, len(services.Root.API.Servers)+len(svcs)) - for _, svr := range services.Root.API.Servers { - files = append(files, endpointParser(genpkg, services, svr, data)) + files := make([]*codegen.File, 0, len(services.cliPlan.servers)+len(svcs)) + for _, serverPlan := range services.cliPlan.servers { + serverData := make([]*commandData, 0, len(serverPlan.expression.Services)) + for _, serviceName := range serverPlan.expression.Services { + for _, command := range data { + if command.ServiceName == serviceName { + serverData = append(serverData, command) + break + } + } + } + files = append(files, endpointParser(services, serverPlan, serverData)) } for i, svc := range svcs { - files = append(files, payloadBuilders(genpkg, svc, data[i], services)) + files = append(files, payloadBuilders(svc, data[i].CommandData, services)) } return files } // endpointParser returns the file that implements the command line parser that // builds the client endpoint and payload necessary to perform a request. -func endpointParser(genpkg string, services *ServicesData, svr *expr.ServerExpr, data []*cli.CommandData) *codegen.File { - pkg := codegen.SnakeCase(codegen.Goify(svr.Name, true)) +func endpointParser(services *ServicesData, serverPlan *grpcCLIServerPlan, data []*commandData) *codegen.File { + genpkg := services.GenPkg() + pkg := codegen.SnakeCase(codegen.Goify(serverPlan.name, true)) + outputPackage := path.Join(genpkg, "grpc", "cli", pkg) fpath := filepath.Join(codegen.Gendir, "grpc", "cli", pkg, "cli.go") - title := svr.Name + " gRPC client CLI support package" - specs := []*codegen.ImportSpec{ - {Path: "context"}, - {Path: "flag"}, - {Path: "fmt"}, - {Path: "os"}, - {Path: "strconv"}, - {Path: "unicode/utf8"}, - codegen.GoaImport(""), - codegen.GoaNamedImport("grpc", "goagrpc"), - {Path: "google.golang.org/grpc", Name: "grpc"}, + title := serverPlan.name + " gRPC client CLI support package" + + parser := serverPlan.parser + if parser == nil { + panic(fmt.Sprintf("gRPC command parser names are missing for server %q", serverPlan.name)) } - // Add structpb import if Any type is used - needsAnyPb := false - for _, svc := range services.Root.API.GRPC.Services { - if usesAnyType(svc.GRPCEndpoints, false) { - needsAnyPb = true - break + plannedData := make([]*commandData, len(data)) + plannedCommands := make([]*cli.CommandData, len(data)) + for index, command := range data { + commandNames := parser.Commands[command.ServiceName] + if commandNames == nil { + panic(fmt.Sprintf("gRPC command names are missing for service %q", command.ServiceName)) } - } - if needsAnyPb { - specs = append(specs, - &codegen.ImportSpec{Path: "google.golang.org/protobuf/types/known/structpb", Name: "structpb"}, - ) - } - for _, svc := range services.Root.API.GRPC.Services { - sd := services.Get(svc.Name()) - if sd == nil { - continue + commandCopy := *command.CommandData + sd := services.Get(command.ServiceName) + clientPath := path.Join(genpkg, "grpc", sd.Service.PathName, "client") + commandCopy.PkgName = services.PackageImport(outputPackage, clientPath).Name + if command.Interceptors != nil { + interceptors := *command.Interceptors + interceptors.PkgName = services.ServiceImport(outputPackage, command.ServiceName).Name + commandCopy.Interceptors = &interceptors + } + commandCopy.UsageDeclaration = commandNames.Usage + commandCopy.Subcommands = make([]*cli.SubcommandData, len(command.Subcommands)) + for methodIndex, subcommand := range command.Subcommands { + usage := commandNames.Methods[subcommand.MethodName] + if usage == nil { + panic(fmt.Sprintf("gRPC method help name is missing for %q.%q", command.ServiceName, subcommand.Name)) + } + subcommandCopy := *subcommand + subcommandCopy.UsageDeclaration = usage + commandCopy.Subcommands[methodIndex] = &subcommandCopy } - svcName := sd.Service.PathName - specs = append(specs, - &codegen.ImportSpec{Path: path.Join(genpkg, "grpc", svcName, "client"), Name: sd.Service.PkgName + "c"}, - &codegen.ImportSpec{Path: path.Join(genpkg, "grpc", svcName, pbPkgName), Name: svcName + pbPkgName}) - // Add interceptors import if service has client interceptors - if len(sd.Service.ClientInterceptors) > 0 { - specs = append(specs, &codegen.ImportSpec{ - Path: genpkg + "/" + sd.Service.PathName, - Name: sd.Service.PkgName, - }) + plannedData[index] = &commandData{ + CommandData: &commandCopy, + ClientInit: command.ClientInit, } + plannedCommands[index] = &commandCopy } - + parser.PlanVariables(plannedCommands, nil) parseSection := &codegen.SectionTemplate{ Name: "parse-endpoint-grpc", Source: grpcTemplates.Read(grpcParseEndpointT), Data: struct { - FlagsCode string - Commands []*cli.CommandData + Declaration *codegen.NameDeclaration + FlagsCode string + Commands []*commandData + Variables *cli.ParserVariablesData }{ - cli.FlagsCode(data), - data, + parser.Declarations.ParseEndpoint, + parser.FlagsCode(plannedCommands), + plannedData, + parser.Variables, }, } - return cli.EndpointParserFile(fpath, title, specs, data, parseSection) + return addEndpointImports(parser.EndpointParserFile(fpath, title, nil, plannedCommands, parseSection), services) } // payloadBuilders returns the file that contains the payload constructors that // use flag values as arguments. -func payloadBuilders(genpkg string, svc *expr.GRPCServiceExpr, data *cli.CommandData, services *ServicesData) *codegen.File { +func payloadBuilders(servicePlan *grpcServicePlan, data *cli.CommandData, services *ServicesData) *codegen.File { + svc := servicePlan.expression sd := services.Get(svc.Name()) svcName := sd.Service.PathName fpath := filepath.Join(codegen.Gendir, "grpc", svcName, "client", "cli.go") title := svc.Name() + " gRPC client CLI support package" - specs := []*codegen.ImportSpec{ - {Path: "encoding/json"}, - {Path: "fmt"}, - {Path: "strconv"}, - {Path: "unicode/utf8"}, - codegen.GoaImport(""), - {Path: path.Join(genpkg, svcName), Name: sd.Service.PkgName}, - {Path: path.Join(genpkg, "grpc", svcName, pbPkgName), Name: sd.PkgName}, - } - // Add structpb import if Any type is used - if usesAnyType(svc.GRPCEndpoints, false) { - specs = append(specs, - &codegen.ImportSpec{Path: "google.golang.org/protobuf/types/known/structpb", Name: "structpb"}, - ) - } - return cli.PayloadBuildersFile(fpath, title, specs, data) + return addEndpointImports(cli.PayloadBuildersFile(fpath, title, nil, data), services) } -func buildFlags(e *EndpointData) ([]*cli.FlagData, *cli.BuildFunctionData) { +func buildFlags(e *EndpointData, declaration *codegen.NameDeclaration) ([]*cli.FlagData, *cli.BuildFunctionData) { if e.Request != nil { - return makeFlags(e, e.Request.CLIArgs) + flags, buildFunction := makeFlags(e, e.Request.CLIArgs) + if buildFunction != nil { + if declaration == nil { + panic(fmt.Sprintf("gRPC payload builder name is missing for %q.%q", e.ServiceName, e.Method.Name)) + } + buildFunction.Name = declaration.Name() + } + return flags, buildFunction } return nil, nil } @@ -143,28 +163,31 @@ func makeFlags(e *EndpointData, args []*InitArgData) ([]*cli.FlagData, *cli.Buil pInitArgs := make([]*codegen.InitArgData, len(args)) for i, arg := range args { pInitArgs[i] = &codegen.InitArgData{ - Name: arg.Name, - FieldName: arg.FieldName, - FieldType: arg.FieldType, - Type: arg.Type, + Name: arg.Name, + FieldName: arg.FieldName, + FieldType: arg.FieldType, + FieldTypeRef: arg.FieldTypeRef, + Type: arg.Type, + Pointer: arg.Pointer, + FieldPointer: arg.Pointer, } fargs[i] = &cli.FlagArgData{ Name: arg.Name, TypeName: arg.TypeName, + Plan: arg.CLIPlan, TypeRef: arg.TypeRef, FieldName: arg.FieldName, Description: arg.Description, Required: arg.Required, Example: arg.Example, DefaultValue: arg.DefaultValue, - Validate: arg.Validate, } } var pinit *cli.PayloadInitData if e.Method.PayloadRef != "" && e.Request.ServerConvert != nil { pinit = &cli.PayloadInitData{ - Code: e.Request.ServerConvert.Init.Code, + Code: e.Request.CLIInitCode, ReturnIsStruct: e.Request.ServerConvert.Init.ReturnIsStruct, ReturnTypePkg: e.Request.ServerConvert.Init.ReturnTypePkg, Args: pInitArgs, diff --git a/grpc/codegen/client_cli_test.go b/grpc/codegen/client_cli_test.go index c2b906c865..bb318559f3 100644 --- a/grpc/codegen/client_cli_test.go +++ b/grpc/codegen/client_cli_test.go @@ -2,12 +2,14 @@ package codegen import ( "bytes" - "goa.design/goa/v3/codegen/testutil" "testing" "github.com/stretchr/testify/require" "goa.design/goa/v3/codegen" + "goa.design/goa/v3/codegen/cli" + "goa.design/goa/v3/codegen/testutil" + "goa.design/goa/v3/dsl" "goa.design/goa/v3/grpc/codegen/testdata" ) @@ -17,13 +19,14 @@ func TestClientCLIFiles(t *testing.T) { DSL func() }{ {"payload-with-validations", testdata.PayloadWithValidationsDSL}, + {"payload-with-message", testdata.PayloadWithMessageDSL}, } for _, c := range cases { t.Run(c.Name, func(t *testing.T) { root := RunGRPCDSL(t, c.DSL) services := CreateGRPCServices(root) - fs := ClientCLIFiles("", services) + fs := clientCLIFiles(services) require.Greater(t, len(fs), 1, "expected at least 2 files") require.NotEmpty(t, fs[1].SectionTemplates) var buf bytes.Buffer @@ -35,3 +38,79 @@ func TestClientCLIFiles(t *testing.T) { }) } } + +// TestClientCLIFlagPresenceGolden shows how generated gRPC commands preserve +// explicit empty metadata while applying every authored zero-valued default. +func TestClientCLIFlagPresenceGolden(t *testing.T) { + root := RunGRPCDSL(t, func() { + dsl.Service("FlagPresence", func() { + dsl.Method("check", func() { + dsl.Payload(func() { + dsl.Field(1, "required", dsl.String) + dsl.Field(2, "optional", dsl.String) + dsl.Field(3, "empty", dsl.String, func() { + dsl.Default("") + }) + dsl.Field(4, "disabled", dsl.Boolean, func() { + dsl.Default(false) + }) + dsl.Field(5, "zero", dsl.Int, func() { + dsl.Default(0) + }) + dsl.Required("required") + }) + dsl.GRPC(func() { + dsl.Metadata(func() { + dsl.Attribute("required") + dsl.Attribute("optional") + dsl.Attribute("empty") + dsl.Attribute("disabled") + dsl.Attribute("zero") + }) + }) + }) + }) + }) + services := CreateGRPCServices(root) + files := clientCLIFiles(services) + require.Len(t, files, 2) + + parser := codegen.SectionsCode(t, files[0].Section("parse-endpoint-grpc")) + testutil.AssertGo(t, "testdata/golden/client_cli_flag-presence-parse.go.golden", parser) + builder := codegen.SectionsCode(t, files[1].Section("cli-build-payload")) + testutil.AssertGo(t, "testdata/golden/client_cli_flag-presence-build.go.golden", builder) +} + +// TestReleasedGRPCNamesMatchDeclarations verifies released plugins can read +// final method and validation names without choosing those names themselves. +func TestReleasedGRPCNamesMatchDeclarations(t *testing.T) { + root := RunGRPCDSL(t, testdata.PayloadWithValidationsDSL) + services := CreateGRPCServices(root) + files := clientCLIFiles(services) + require.Greater(t, len(files), 1) + + build, ok := files[1].SectionTemplates[1].Data.(*cli.BuildFunctionData) + require.True(t, ok) + servicePlan := services.servicePlans[0] + declaration := services.cliPlan.builders[servicePlan.expression.GRPCEndpoints[0]] + require.NotNil(t, declaration) + require.Equal(t, declaration.Name(), build.Name) + + service := services.GRPCServices["PayloadWithValidation"] + require.NotNil(t, service) + require.NotEmpty(t, service.Endpoints) + endpoint := service.Endpoints[0] + require.Equal(t, endpoint.ProtoMethodName, endpoint.ClientMethodName) + + validationRoot := RunGRPCDSL(t, testdata.ElemValidationDSL) + validationService := CreateGRPCServices(validationRoot).GRPCServices["ServiceElemValidation"] + require.NotNil(t, validationService) + require.NotEmpty(t, validationService.Endpoints) + request := validationService.Endpoints[0].Request + require.NotNil(t, request) + require.NotNil(t, request.ServerConvert) + validation := request.ServerConvert.Validation + require.NotNil(t, validation) + require.NotNil(t, validation.Declaration) + require.Equal(t, validation.Declaration.Name(), validation.Name) +} diff --git a/grpc/codegen/client_test.go b/grpc/codegen/client_test.go index 464056963f..25334f516b 100644 --- a/grpc/codegen/client_test.go +++ b/grpc/codegen/client_test.go @@ -32,7 +32,7 @@ func TestClientEndpointInit(t *testing.T) { t.Run(c.Name, func(t *testing.T) { root := RunGRPCDSL(t, c.DSL) services := CreateGRPCServices(root) - fs := ClientFiles("", services) + fs := clientFiles(services) require.Len(t, fs, 2) sections := fs[0].Section("client-endpoint-init") if len(sections) == 0 { @@ -59,12 +59,13 @@ func TestRequestEncoder(t *testing.T) { {"request-encoder-payload-with-metadata", testdata.MessageWithMetadataDSL}, {"request-encoder-payload-with-validate", testdata.MessageWithValidateDSL}, {"request-encoder-payload-with-security-attributes", testdata.MessageWithSecurityAttrsDSL}, + {"request-encoder-named-security-metadata", testdata.NamedSecurityMetadataDSL}, } for _, c := range cases { t.Run(c.Name, func(t *testing.T) { root := RunGRPCDSL(t, c.DSL) services := CreateGRPCServices(root) - fs := ClientFiles("", services) + fs := clientFiles(services) require.Len(t, fs, 2) sections := fs[1].Section("request-encoder") require.NotEmpty(t, sections) @@ -95,7 +96,7 @@ func TestResponseDecoder(t *testing.T) { t.Run(c.Name, func(t *testing.T) { root := RunGRPCDSL(t, c.DSL) services := CreateGRPCServices(root) - fs := ClientFiles("", services) + fs := clientFiles(services) require.Len(t, fs, 2) sections := fs[1].Section("response-decoder") require.NotEmpty(t, sections) diff --git a/grpc/codegen/client_types_test.go b/grpc/codegen/client_types_test.go index 4a65fb5039..2a5df9fda4 100644 --- a/grpc/codegen/client_types_test.go +++ b/grpc/codegen/client_types_test.go @@ -1,16 +1,182 @@ +// This file checks the generated client and server conversion functions. package codegen import ( "bytes" - "goa.design/goa/v3/codegen/testutil" + "strings" "testing" "github.com/stretchr/testify/require" "goa.design/goa/v3/codegen" + "goa.design/goa/v3/codegen/testutil" + "goa.design/goa/v3/dsl" "goa.design/goa/v3/grpc/codegen/testdata" ) +// TestTypeFilesShareRepeatedConversions checks that two endpoints using the +// same payload call one conversion function in each generated package. +func TestTypeFilesShareRepeatedConversions(t *testing.T) { + root := RunGRPCDSL(t, testdata.PayloadWithMultipleUseTypesDSL) + services := CreateGRPCServices(root) + client := codegen.SectionsCode(t, clientTypeFiles(services)[0].SectionTemplates[1:]) + server := codegen.SectionsCode(t, serverTypeFiles(services)[0].SectionTemplates[1:]) + + require.Equal(t, 1, strings.Count(client, "func NewProtoDupePayload(")) + require.Equal(t, 1, strings.Count(server, "func NewDupePayload(")) + require.NotContains(t, server, "func NewMethodPayloadDuplicateAPayload(") + require.NotContains(t, server, "func NewMethodPayloadDuplicateBPayload(") +} + +// TestTypeFilesShareRepeatedValidations checks that two endpoints using the +// same protobuf message and validation rules emit one validator per package. +func TestTypeFilesShareRepeatedValidations(t *testing.T) { + root := RunGRPCDSL(t, testdata.PayloadWithMultipleUseTypesDSL) + services := CreateGRPCServices(root) + client := codegen.SectionsCode(t, clientTypeFiles(services)[0].SectionTemplates[1:]) + server := codegen.SectionsCode(t, serverTypeFiles(services)[0].SectionTemplates[1:]) + + require.Equal(t, 1, strings.Count(client, "func ValidateDupePayload(")) + require.Equal(t, 1, strings.Count(server, "func ValidateDupePayload(")) + require.NotContains(t, client, "func ValidateDupePayload2(") + require.NotContains(t, server, "func ValidateDupePayload2(") +} + +// TestRepeatedWrapperValidationUsesAuthoredRules checks that protobuf arrays +// keep length checks without inventing a missing-field check they cannot prove. +func TestRepeatedWrapperValidationUsesAuthoredRules(t *testing.T) { + withoutLength := CreateGRPCServices(RunGRPCDSL(t, testdata.PayloadWithNestedTypesDSL)) + withoutLengthCode := codegen.SectionsCode(t, serverTypeFiles(withoutLength)[0].SectionTemplates[1:]) + require.NotContains(t, withoutLengthCode, "func ValidateArrayOfString(") + require.NotContains(t, withoutLengthCode, `MissingFieldError("field"`) + require.Contains(t, withoutLengthCode, "var tv []string") + require.Contains(t, withoutLengthCode, "if val != nil {") + require.Less(t, + strings.Index(withoutLengthCode, "if val != nil {"), + strings.Index(withoutLengthCode, "len(val.Field)"), + ) + + withLength := CreateGRPCServices(RunGRPCDSL(t, testdata.ElemValidationDSL)) + withLengthCode := codegen.SectionsCode(t, serverTypeFiles(withLength)[0].SectionTemplates[1:]) + require.Contains(t, withLengthCode, "func validatetest_20_api_ServiceElemValidation_ArrayOfString_At_val(") + require.Contains(t, withLengthCode, `InvalidLengthError("val.field"`) + require.NotContains(t, withLengthCode, `MissingFieldError("field", "val")`) +} + +// TestCLIConversionsShareTypePair checks that command-line payload builders +// for the same protobuf message and Goa type keep the same conversion plan. +func TestCLIConversionsShareTypePair(t *testing.T) { + root := RunGRPCDSL(t, testdata.PayloadWithMultipleUseTypesDSL) + services := CreateGRPCServices(root) + grpcService := root.API.GRPC.Services[0] + first := services.symbols[grpcService].endpoints[grpcService.GRPCEndpoints[0]].cliPayload + second := services.symbols[grpcService].endpoints[grpcService.GRPCEndpoints[1]].cliPayload + + require.Same(t, first, second) +} + +// TestRequestMetadataKeepsDistinctConversions checks that the same Goa type +// gets separate constructors when endpoint metadata produces different +// protobuf messages and different function arguments. +func TestRequestMetadataKeepsDistinctConversions(t *testing.T) { + root := RunGRPCDSL(t, func() { + payload := dsl.Type("SharedPayload", func() { + dsl.Field(1, "value", dsl.String) + dsl.Field(2, "token", dsl.String) + }) + dsl.Service("MetadataConversions", func() { + dsl.Method("Plain", func() { + dsl.Payload(payload) + dsl.GRPC(func() {}) + }) + dsl.Method("WithMetadata", func() { + dsl.Payload(payload) + dsl.GRPC(func() { + dsl.Metadata(func() { + dsl.Attribute("token") + }) + }) + }) + }) + }) + services := CreateGRPCServices(root) + grpcService := root.API.GRPC.Services[0] + plain := services.symbols[grpcService].endpoints[grpcService.GRPCEndpoints[0]].serverInits[grpcInitKey{role: grpcRequestInit}] + metadata := services.symbols[grpcService].endpoints[grpcService.GRPCEndpoints[1]].serverInits[grpcInitKey{role: grpcRequestInit}] + + require.NotSame(t, plain, metadata) + require.NotSame(t, plain.declaration, metadata.declaration) +} + +// TestOneUseConversionsKeepReleasedNames checks that conversions used by one +// method keep the names generated by released Goa versions. +func TestOneUseConversionsKeepReleasedNames(t *testing.T) { + root := RunGRPCDSL(t, testdata.PayloadWithMixedAttributesDSL) + services := CreateGRPCServices(root) + server := codegen.SectionsCode(t, serverTypeFiles(services)[0].SectionTemplates[1:]) + + require.Contains(t, server, "func NewUnaryMethodPayload(") + require.Contains(t, server, "func NewStreamingMethodStreamingRequestAPayload(") + require.NotContains(t, server, "func NewAPayloadFromProto") +} + +// TestReleasedConversionNameCollisionsUseDeclaredNames checks that two old +// names which become equal get stable suffixes in definitions and calls. +func TestReleasedConversionNameCollisionsUseDeclaredNames(t *testing.T) { + root := RunGRPCDSL(t, func() { + first := dsl.Type("FirstPayload", func() { + dsl.Field(1, "first", dsl.String) + }) + second := dsl.Type("SecondPayload", func() { + dsl.Field(1, "second", dsl.String) + }) + dsl.Service("CollidingConversions", func() { + dsl.Method("foo-bar", func() { + dsl.Payload(first) + dsl.GRPC(func() {}) + }) + dsl.Method("foo_bar", func() { + dsl.Payload(second) + dsl.GRPC(func() {}) + }) + }) + }) + services := CreateGRPCServices(root) + serverTypes := codegen.SectionsCode(t, serverTypeFiles(services)[0].SectionTemplates[1:]) + server := codegen.SectionsCode(t, serverFiles(services)[1].Section("request-decoder")) + + require.Equal(t, 1, strings.Count(serverTypes, "func NewFooBarPayload(")) + require.Equal(t, 1, strings.Count(serverTypes, "func NewFooBarPayload2(")) + require.Contains(t, server, "NewFooBarPayload(") + require.Contains(t, server, "NewFooBarPayload2(") +} + +// TestLegacyMetadataConversionKeepsReleasedName checks that legacy request +// metadata conversion keeps its released method-specific name. +func TestLegacyMetadataConversionKeepsReleasedName(t *testing.T) { + root := RunGRPCDSL(t, testdata.BidirectionalStreamingRPCWithPayloadLegacyCompatDSL) + services := CreateGRPCServices(root) + server := codegen.SectionsCode(t, serverTypeFiles(services)[0].SectionTemplates[1:]) + + require.Contains(t, server, "func NewMethodBidirectionalStreamingRPCWithPayloadLegacyCompatPayloadFromMetadata(") +} + +// TestTransformHelperNamesDescribeNestedTypes checks that each helper name +// identifies the nested value it converts and its direction. +func TestTransformHelperNamesDescribeNestedTypes(t *testing.T) { + root := RunGRPCDSL(t, testdata.PayloadWithNestedTypesDSL) + services := CreateGRPCServices(root) + client := codegen.SectionsCode(t, clientTypeFiles(services)[0].SectionTemplates[1:]) + server := codegen.SectionsCode(t, serverTypeFiles(services)[0].SectionTemplates[1:]) + + require.Contains(t, client, "func transformAParamsToProtoAParams(") + require.Contains(t, client, "func transformBParamsToProtoBParams(") + require.Contains(t, server, "func transformProtoAParamsToAParams(") + require.Contains(t, server, "func transformProtoBParamsToBParams(") + require.NotRegexp(t, `func transform\w+\d+\(`, client) + require.NotRegexp(t, `func transform\w+\d+\(`, server) +} + func TestClientTypeFiles(t *testing.T) { cases := []struct { Name string @@ -26,12 +192,15 @@ func TestClientTypeFiles(t *testing.T) { {"client-struct-meta-type", testdata.StructMetaTypeDSL}, {"client-struct-field-name-meta-type", testdata.StructFieldNameMetaTypeDSL}, {"client-default-fields", testdata.DefaultFieldsDSL}, + {"client-result-with-views", testdata.MessageResultTypeWithViewsDSL}, + {"client-result-with-explicit-view", testdata.MessageResultTypeWithExplicitViewDSL}, + {"client-streaming-result-with-views", testdata.ServerStreamingResultWithViewsDSL}, } for _, c := range cases { t.Run(c.Name, func(t *testing.T) { root := RunGRPCDSL(t, c.DSL) services := CreateGRPCServices(root) - fs := ClientTypeFiles("", services) + fs := clientTypeFiles(services) require.Len(t, fs, 1) var buf bytes.Buffer for _, s := range fs[0].SectionTemplates[1:] { diff --git a/grpc/codegen/compatibility.go b/grpc/codegen/compatibility.go new file mode 100644 index 0000000000..beffc37d10 --- /dev/null +++ b/grpc/codegen/compatibility.go @@ -0,0 +1,59 @@ +// This file keeps released gRPC generator entry points available to plugins +// while all rendering uses the one service plan retained by Goa. +package codegen + +import ( + "fmt" + + "goa.design/goa/v3/codegen" +) + +// ClientFiles returns the planned client files. genpkg must match the package +// used to create services. +func ClientFiles(genpkg string, services *ServicesData) []*codegen.File { + requireGeneratedPackage(genpkg, services) + return clientFiles(services) +} + +// ClientCLIFiles returns the planned command-line client files. genpkg must +// match the package used to create services. +func ClientCLIFiles(genpkg string, services *ServicesData) []*codegen.File { + requireGeneratedPackage(genpkg, services) + return clientCLIFiles(services) +} + +// ProtoFiles returns the planned protobuf files. genpkg must match the package +// used to create services. +func ProtoFiles(genpkg string, services *ServicesData) []*codegen.File { + requireGeneratedPackage(genpkg, services) + return protoFiles(services) +} + +// ServerFiles returns the planned server files. genpkg must match the package +// used to create services. +func ServerFiles(genpkg string, services *ServicesData) []*codegen.File { + requireGeneratedPackage(genpkg, services) + return serverFiles(services) +} + +// ServerTypeFiles returns the planned server conversion files. genpkg must +// match the package used to create services. +func ServerTypeFiles(genpkg string, services *ServicesData) []*codegen.File { + requireGeneratedPackage(genpkg, services) + return serverTypeFiles(services) +} + +// ClientTypeFiles returns the planned client conversion files. genpkg must +// match the package used to create services. +func ClientTypeFiles(genpkg string, services *ServicesData) []*codegen.File { + requireGeneratedPackage(genpkg, services) + return clientTypeFiles(services) +} + +// requireGeneratedPackage rejects a package argument that does not describe +// the service data supplied by the same generation run. +func requireGeneratedPackage(genpkg string, services *ServicesData) { + if genpkg != services.GenPkg() { + panic(fmt.Sprintf("gRPC generation package %q does not match planned package %q", genpkg, services.GenPkg())) + } +} diff --git a/grpc/codegen/compatibility_test.go b/grpc/codegen/compatibility_test.go new file mode 100644 index 0000000000..7fb4f18d1b --- /dev/null +++ b/grpc/codegen/compatibility_test.go @@ -0,0 +1,39 @@ +// This file pins released gRPC generator entry points that plugins call after +// Goa has built the service data for one generated package. +package codegen + +import ( + "testing" + + "github.com/stretchr/testify/require" + + "goa.design/goa/v3/codegen" + "goa.design/goa/v3/grpc/codegen/testdata" +) + +var ( + _ func(string, *ServicesData) []*codegen.File = ClientFiles + _ func(string, *ServicesData) []*codegen.File = ClientCLIFiles + _ func(string, *ServicesData) []*codegen.File = ProtoFiles + _ func(string, *ServicesData) []*codegen.File = ServerFiles + _ func(string, *ServicesData) []*codegen.File = ServerTypeFiles + _ func(string, *ServicesData) []*codegen.File = ClientTypeFiles +) + +// TestReleasedFileFunctionsUsePlannedPackage checks that the compatibility +// entry points render one retained plan and reject a different package. +func TestReleasedFileFunctionsUsePlannedPackage(t *testing.T) { + services := CreateGRPCServices(RunGRPCDSL(t, testdata.UnaryRPCsDSL)) + genpkg := services.GenPkg() + require.Len(t, ClientFiles(genpkg, services), len(clientFiles(services))) + require.Len(t, ClientCLIFiles(genpkg, services), len(clientCLIFiles(services))) + require.Len(t, ProtoFiles(genpkg, services), len(protoFiles(services))) + require.Len(t, ServerFiles(genpkg, services), len(serverFiles(services))) + require.Len(t, ServerTypeFiles(genpkg, services), len(serverTypeFiles(services))) + require.Len(t, ClientTypeFiles(genpkg, services), len(clientTypeFiles(services))) + require.PanicsWithValue( + t, + `gRPC generation package "other.local/gen" does not match planned package "generated.local/gen"`, + func() { ClientFiles("other.local/gen", services) }, + ) +} diff --git a/grpc/codegen/example_cli.go b/grpc/codegen/example_cli.go index c06c228701..300c36dda2 100644 --- a/grpc/codegen/example_cli.go +++ b/grpc/codegen/example_cli.go @@ -1,70 +1,147 @@ +// This file writes runnable gRPC command-line examples with the package names +// already chosen for this generation. package codegen import ( - "os" "path" "path/filepath" "goa.design/goa/v3/codegen" + "goa.design/goa/v3/codegen/cli" "goa.design/goa/v3/codegen/example" + "goa.design/goa/v3/codegen/service" "goa.design/goa/v3/expr" ) -// ExampleCLIFiles returns an example gRPC client tool implementation. -func ExampleCLIFiles(genpkg string, services *ServicesData) []*codegen.File { +// exampleCLIFiles returns an example gRPC client tool implementation. +func exampleCLIFiles(root *example.Root, services *ServicesData) []*codegen.File { var files []*codegen.File - for _, svr := range services.Root.API.Servers { - if f := exampleCLI(genpkg, services, svr); f != nil { + for _, server := range root.Servers { + if f := exampleCLI(services, server); f != nil { files = append(files, f) } } return files } -// exampleCLI returns an example client tool HTTP implementation for the given -// server expression. -func exampleCLI(genpkg string, services *ServicesData, svr *expr.ServerExpr) *codegen.File { - svrdata := example.Servers.Get(svr, services.Root) - mainPath := filepath.Join("cmd", svrdata.Dir+"-cli", "grpc.go") - if _, err := os.Stat(mainPath); !os.IsNotExist(err) { - return nil // file already exists, skip it. - } - rootPath := example.RootPath(genpkg) - - specs := []*codegen.ImportSpec{ - {Path: "context"}, - {Path: "encoding/json"}, - {Path: "flag"}, - {Path: "fmt"}, - {Path: "google.golang.org/grpc"}, - {Path: "google.golang.org/grpc/credentials/insecure"}, - {Path: "os"}, - {Path: "time"}, - codegen.GoaImport(""), - codegen.GoaNamedImport("grpc", "goagrpc"), - {Path: rootPath + "/interceptors"}, - {Path: path.Join(genpkg, "grpc", "cli", svrdata.Dir), Name: "cli"}, +// exampleCLI writes the gRPC command-line program for server. +func exampleCLI(services *ServicesData, server *example.Data) *codegen.File { + genpkg := services.GenPkg() + mainPath := filepath.Join("cmd", server.Dir+"-cli", "grpc.go") + rootPath := path.Dir(genpkg) + outputPackage := path.Join(rootPath, "cmd", server.Dir+"-cli") + parser := services.cliPlan.parser(server.Name) + if parser == nil { + return nil } var svcData []*ServiceData - for _, svc := range svr.Services { + hasClientInterceptors := false + for _, svc := range server.Services { if data := services.Get(svc); data != nil { - svcData = append(svcData, data) + if len(data.Endpoints) == 0 { + continue + } + svcData = append(svcData, services.exampleServiceData(data, outputPackage, false)) + hasClientInterceptors = hasClientInterceptors || len(data.Service.ClientInterceptors) > 0 } } + cliImport := services.PackageImport(outputPackage, path.Join(genpkg, "grpc", "cli", server.Dir)) + var interceptorsPkg string + if hasClientInterceptors { + interceptorImport := services.PackageImport(outputPackage, rootPath+"/interceptors") + interceptorsPkg = interceptorImport.Name + } sections := []*codegen.SectionTemplate{ - codegen.Header("", "main", specs), + codegen.Header("", "main", nil), { Name: "do-grpc-cli", Source: grpcTemplates.Read(grpcDoGRPCCLIT), Data: map[string]any{ - "DefaultTransport": svrdata.DefaultTransport(), + "DefaultTransport": server.DefaultClientTransport(), "Services": svcData, - "InterceptorsPkg": "interceptors", + "InterceptorsPkg": interceptorsPkg, + "CLIPkg": cliImport.Name, + "Parser": parser.Declarations, + }, + FuncMap: map[string]any{ + "hasAnyInputStreams": cliHasAnyInputStreams, + "hasInputStreams": cliHasInputStreams, + "hasRunnable": cliHasRunnableCommands, + "hasRunnableService": cliHasRunnableService, + "kebab": codegen.KebabCase, + "streamsInput": cliStreamsInput, + "streamsOutput": cliStreamsOutput, }, }, } - return &codegen.File{Path: mainPath, SectionTemplates: sections, SkipExist: true} + return addEndpointImports(&codegen.File{Path: mainPath, SectionTemplates: sections, SkipExist: true}, services) +} + +// cliStreamsInput reports whether an example command would need to send more +// payload values after the endpoint call starts. +func cliStreamsInput(method *service.MethodData) bool { + return method.StreamKind == expr.ClientStreamKind || method.StreamKind == expr.BidirectionalStreamKind +} + +// cliStreamsOutput reports whether an example command receives a sequence of +// results from the server. +func cliStreamsOutput(method *service.MethodData) bool { + return method.StreamKind == expr.ServerStreamKind +} + +// cliHasInputStreams reports whether a service has commands that the example +// client must reject before parsing an endpoint. +func cliHasInputStreams(data *ServiceData) bool { + for _, endpoint := range data.Endpoints { + if cliStreamsInput(endpoint.Method) { + return true + } + } + return false +} + +// cliHasAnyInputStreams reports whether any service has a command that the +// example client must reject before parsing an endpoint. +func cliHasAnyInputStreams(services []*ServiceData) bool { + for _, data := range services { + if cliHasInputStreams(data) { + return true + } + } + return false +} + +// cliHasRunnableCommands reports whether the example client can invoke at +// least one generated endpoint. +func cliHasRunnableCommands(services []*ServiceData) bool { + for _, data := range services { + if cliHasRunnableService(data) { + return true + } + } + return false +} + +// cliHasRunnableService reports whether the example client can invoke at +// least one endpoint in the service. +func cliHasRunnableService(data *ServiceData) bool { + for _, endpoint := range data.Endpoints { + if !cliStreamsInput(endpoint.Method) { + return true + } + } + return false +} + +// parser returns the command parser saved for the named server. +func (p *grpcCLIPlan) parser(serverName string) *cli.ParserPlan { + for _, server := range p.servers { + if server.name == serverName { + return server.parser + } + } + return nil } diff --git a/grpc/codegen/example_cli_test.go b/grpc/codegen/example_cli_test.go index 1862de04b1..bffa397a1c 100644 --- a/grpc/codegen/example_cli_test.go +++ b/grpc/codegen/example_cli_test.go @@ -1,3 +1,4 @@ +// This file verifies generated gRPC command-line client examples. package codegen import ( @@ -7,9 +8,7 @@ import ( "github.com/stretchr/testify/require" "goa.design/goa/v3/codegen" - "goa.design/goa/v3/codegen/example" ctestdata "goa.design/goa/v3/codegen/example/testdata" - "goa.design/goa/v3/codegen/service" "goa.design/goa/v3/codegen/testutil" "goa.design/goa/v3/grpc/codegen/testdata" ) @@ -20,23 +19,28 @@ func TestExampleCLIFiles(t *testing.T) { DSL func() PkgPath string }{ - {"no-server", ctestdata.NoServerDSL, ""}, - {"server-hosting-service-subset", ctestdata.ServerHostingServiceSubsetDSL, ""}, - {"server-hosting-multiple-services", ctestdata.ServerHostingMultipleServicesDSL, ""}, + {"no-server", ctestdata.NoServerDSL, "generated.local/gen"}, + {"server-hosting-service-subset", ctestdata.ServerHostingServiceSubsetDSL, "generated.local/gen"}, + {"server-hosting-multiple-services", ctestdata.ServerHostingMultipleServicesDSL, "generated.local/gen"}, {"no-server-pkgpath", ctestdata.NoServerDSL, "my/pkg/path"}, {"server-hosting-service-subset-pkgpath", ctestdata.ServerHostingServiceSubsetDSL, "my/pkg/path"}, {"server-hosting-multiple-services-pkgpath", ctestdata.ServerHostingMultipleServicesDSL, "my/pkg/path"}, - {"interceptors", testdata.InterceptorsDSL, ""}, + {"interceptors", testdata.InterceptorsDSL, "generated.local/gen"}, + {"server-streaming", testdata.ServerStreamingRPCDSL, "generated.local/gen"}, + {"client-streaming", testdata.ClientStreamingRPCDSL, "generated.local/gen"}, + {"bidirectional-streaming", testdata.BidirectionalStreamingRPCDSL, "generated.local/gen"}, } for _, c := range cases { t.Run(c.Name, func(t *testing.T) { - // reset global variable - example.Servers = make(example.ServersData) root := codegen.RunDSL(t, c.DSL) - services := NewServicesData(service.NewServicesData(root)) - fs := ExampleCLIFiles(c.PkgPath, services) + examples := createExamplePlan(root, c.PkgPath) + fs := examples.CLIFiles() require.Greater(t, len(fs), 0) require.Greater(t, len(fs[0].SectionTemplates), 0) + header := sectionCode(t, fs[0].SectionTemplates[0]) + for _, absent := range []string{`"os"`, `"time"`, `goa "goa.design/goa/v3/pkg"`, `goagrpc "goa.design/goa/v3/grpc"`} { + require.NotContains(t, header, absent) + } var buf bytes.Buffer for _, s := range fs[0].SectionTemplates { require.NoError(t, s.Write(&buf)) diff --git a/grpc/codegen/example_identity_test.go b/grpc/codegen/example_identity_test.go new file mode 100644 index 0000000000..a768e4c00d --- /dev/null +++ b/grpc/codegen/example_identity_test.go @@ -0,0 +1,12 @@ +// This file supplies exact semantic owners to protobuf-shaping unit tests. +package codegen + +import "goa.design/goa/v3/expr" + +// testGRPCMessageExampleIdentity returns a distinct request-message owner for +// the named test case without introducing production fallback identity rules. +func testGRPCMessageExampleIdentity(name string) expr.ExampleIdentity { + service := &expr.ServiceExpr{Name: "test"} + method := &expr.MethodExpr{Name: name, Service: service} + return expr.GRPCRequestMessageExampleIdentity(method) +} diff --git a/grpc/codegen/example_server.go b/grpc/codegen/example_server.go index c2d5bcc417..53a11b59fc 100644 --- a/grpc/codegen/example_server.go +++ b/grpc/codegen/example_server.go @@ -1,20 +1,20 @@ +// This file writes runnable gRPC servers with the package names already chosen +// for this generation. package codegen import ( - "os" "path" "path/filepath" "goa.design/goa/v3/codegen" "goa.design/goa/v3/codegen/example" - "goa.design/goa/v3/expr" ) -// ExampleServerFiles returns an example gRPC server implementation. -func ExampleServerFiles(genpkg string, services *ServicesData) []*codegen.File { +// exampleServerFiles returns an example gRPC server implementation. +func exampleServerFiles(root *example.Root, services *ServicesData) []*codegen.File { var fw []*codegen.File - for _, svr := range services.Root.API.Servers { - if m := exampleServer(genpkg, services, svr); m != nil { + for _, server := range root.Servers { + if m := exampleServer(services, server); m != nil { fw = append(fw, m) } } @@ -22,64 +22,25 @@ func ExampleServerFiles(genpkg string, services *ServicesData) []*codegen.File { } // exampleServer returns an example gRPC server implementation. -func exampleServer(genpkg string, services *ServicesData, svr *expr.ServerExpr) *codegen.File { +func exampleServer(services *ServicesData, server *example.Data) *codegen.File { var ( mainPath string - - svrdata = example.Servers.Get(svr, services.Root) + genpkg = services.GenPkg() ) - mainPath = filepath.Join("cmd", svrdata.Dir, "grpc.go") - if _, err := os.Stat(mainPath); !os.IsNotExist(err) { - return nil // file already exists, skip it. - } - - var scope = codegen.NewNameScope() - - specs := []*codegen.ImportSpec{ - {Path: "context"}, - {Path: "fmt"}, - {Path: "net"}, - {Path: "net/url"}, - {Path: "sync"}, - codegen.GoaNamedImport("grpc", "goagrpc"), - {Path: "goa.design/clue/debug"}, - {Path: "goa.design/clue/log"}, - {Path: "google.golang.org/grpc"}, - {Path: "google.golang.org/grpc/reflection"}, - } - for _, svc := range services.Root.API.GRPC.Services { - sd := services.Get(svc.Name()) - svcName := sd.Service.PathName - specs = append(specs, - &codegen.ImportSpec{ - Path: path.Join(genpkg, "grpc", svcName, "server"), - Name: scope.Unique(sd.Service.PkgName + "svr"), - }, - &codegen.ImportSpec{ - Path: path.Join(genpkg, svcName), - Name: scope.Unique(sd.Service.PkgName), - }, - &codegen.ImportSpec{ - Path: path.Join(genpkg, "grpc", svcName, pbPkgName), - Name: scope.Unique(svcName + pbPkgName), - }) - } - - rootPath := example.RootPath(genpkg) - apiPkg := example.APIPkg(services.Root, scope) - specs = append(specs, &codegen.ImportSpec{Path: rootPath, Name: apiPkg}) + mainPath = filepath.Join("cmd", server.Dir, "grpc.go") + outputPackage := path.Join(path.Dir(genpkg), "cmd", server.Dir) var ( sections []*codegen.SectionTemplate ) var svcdata []*ServiceData - for _, svc := range svr.Services { + for _, svc := range server.Services { if data := services.Get(svc); data != nil { - svcdata = append(svcdata, data) + svcdata = append(svcdata, services.exampleServiceData(data, outputPackage, true)) } } sections = []*codegen.SectionTemplate{ - codegen.Header("", "main", specs), + codegen.Header("", "main", nil), { Name: "server-grpc-start", Source: grpcTemplates.Read(grpcServerGRPCStartT), @@ -110,7 +71,7 @@ func exampleServer(genpkg string, services *ServicesData, svr *expr.ServerExpr) }, }, } - return &codegen.File{Path: mainPath, SectionTemplates: sections, SkipExist: true} + return addEndpointImports(&codegen.File{Path: mainPath, SectionTemplates: sections, SkipExist: true}, services) } // needStream returns true if at least one method in the defined services diff --git a/grpc/codegen/example_server_test.go b/grpc/codegen/example_server_test.go index 7d553a3b5e..518da17e32 100644 --- a/grpc/codegen/example_server_test.go +++ b/grpc/codegen/example_server_test.go @@ -1,15 +1,15 @@ +// This file verifies generated gRPC server examples. package codegen import ( "bytes" "path/filepath" + "strings" "testing" "github.com/stretchr/testify/require" "goa.design/goa/v3/codegen" - "goa.design/goa/v3/codegen/example" ctestdata "goa.design/goa/v3/codegen/example/testdata" - "goa.design/goa/v3/codegen/service" "goa.design/goa/v3/codegen/testutil" ) @@ -24,18 +24,25 @@ func TestExampleServerFiles(t *testing.T) { } for _, c := range cases { t.Run(c.Name, func(t *testing.T) { - // reset global variable - example.Servers = make(example.ServersData) root := codegen.RunDSL(t, c.DSL) - services := NewServicesData(service.NewServicesData(root)) - fs := ExampleServerFiles("", services) + examples := createExamplePlan(root, "generated.local/gen") + fs := examples.ServerFiles() require.Greater(t, len(fs), 0) require.Greater(t, len(fs[0].SectionTemplates), 0) + header := sectionCode(t, fs[0].SectionTemplates[0]) + require.NotContains(t, header, `goagrpc "goa.design/goa/v3/grpc"`) + require.NotContains(t, header, `"generated.local"`) var buf bytes.Buffer for _, s := range fs[0].SectionTemplates[1:] { require.NoError(t, s.Write(&buf)) } code := codegen.FormatTestCode(t, "package foo\n"+buf.String()) + if strings.Contains(code, "GetServiceInfo") { + t.Errorf("generated server discovers methods at runtime:\n%s", code) + } + if !strings.Contains(code, "serving gRPC method") { + t.Errorf("generated server does not log its planned methods:\n%s", code) + } golden := filepath.Join("testdata", "server-"+c.Name+".golden") testutil.AssertGo(t, golden, code) }) diff --git a/grpc/codegen/idempotency_test.go b/grpc/codegen/idempotency_test.go index 441dff6031..eb076d4063 100644 --- a/grpc/codegen/idempotency_test.go +++ b/grpc/codegen/idempotency_test.go @@ -15,14 +15,14 @@ func TestIdempotentRPCCodegen(t *testing.T) { root := RunGRPCDSL(t, testdata.IdempotentRPCsDSL) services := CreateGRPCServices(root) - protoFiles := ProtoFiles("", services) + protoFiles := protoFiles(services) require.Len(t, protoFiles, 1) protoCode := sectionCode(t, protoFiles[0].SectionTemplates[1:]...) assert.Equal(t, 2, strings.Count(protoCode, "option idempotency_level = IDEMPOTENT;")) protoPath := codegen.CreateTempFile(t, protoCode) - assert.NoError(t, protoc(defaultProtocCmd, protoPath, nil)) + assert.NoError(t, protoc(defaultProtocCmd, protoPath)) - clientFiles := ClientFiles("", services) + clientFiles := clientFiles(services) require.Len(t, clientFiles, 2) clientCode := codegen.SectionsCode(t, clientFiles[0].Section("client-endpoint-init")) assert.Contains(t, clientCode, `goa.RetryEndpoint(endpoint, "busy")`) diff --git a/grpc/codegen/import_plan.go b/grpc/codegen/import_plan.go new file mode 100644 index 0000000000..65a9619c64 --- /dev/null +++ b/grpc/codegen/import_plan.go @@ -0,0 +1,735 @@ +// This file records every import in the generated gRPC package that writes +// the reference. Package-local planning keeps an unrelated transport or +// executable from changing a gRPC qualifier. +package codegen + +import ( + "fmt" + "path" + + "goa.design/goa/v3/codegen" + "goa.design/goa/v3/codegen/cli" + "goa.design/goa/v3/codegen/example" + "goa.design/goa/v3/codegen/service" + "goa.design/goa/v3/expr" +) + +type ( + // grpcFileImportInput lists the packages and service types emitted by one + // generated gRPC file. + grpcFileImportInput struct { + required []*codegen.ImportSpec + generated []*codegen.ImportSpec + design []*codegen.ImportSpec + typeDefinitions []*expr.AttributeExpr + typeReferences []*expr.AttributeExpr + } + + // grpcImportFacts records the template branches that add fixed imports to + // one service's generated files. + grpcImportFacts struct { + hasEndpoints bool + hasErrors bool + hasStreaming bool + serverFileUsesErrors bool + streamsPayload bool + hasViewedResult bool + hasViewedClientStream bool + hasUnaryViewedResult bool + hasCallerViewedStream bool + clientCodecUsesMetadata bool + serverCodecUsesGoa bool + responseMetadataUsesGoa bool + metadataUsesStrconv bool + requestMetadataUsesAny bool + responseMetadataUsesAny bool + usesBearerMetadata bool + usesNonBasicMetadata bool + } + + // grpcMetadataImportFacts records which fixed packages are used by the + // generated code for one metadata group. + grpcMetadataImportFacts struct { + decodeUsesGoa bool + usesStrconv bool + usesAny bool + } +) + +// collectGRPCImportFacts evaluates the service branches that determine fixed +// imports once, before package names become final. +func collectGRPCImportFacts(servicePlan *grpcServicePlan) grpcImportFacts { + serviceExpr := servicePlan.expression + facts := grpcImportFacts{hasEndpoints: len(serviceExpr.GRPCEndpoints) > 0} + for index, endpoint := range serviceExpr.GRPCEndpoints { + method := endpoint.MethodExpr + facts.hasStreaming = facts.hasStreaming || method.IsStreaming() + if len(endpoint.GRPCErrors) > 0 { + facts.hasErrors = true + facts.serverFileUsesErrors = true + } + if usesStreamEnvelope(endpoint) { + facts.serverFileUsesErrors = true + facts.serverCodecUsesGoa = true + } + if method.IsPayloadStreaming() && !isEmpty(endpoint.Request.Type) { + facts.streamsPayload = true + } + if method.Payload.Type != expr.Empty || method.Result.Type != expr.Empty || method.IsStreaming() { + facts.clientCodecUsesMetadata = true + } + + viewed := grpcMethodHasViewedResult(method) + facts.hasViewedResult = facts.hasViewedResult || viewed + facts.hasViewedClientStream = facts.hasViewedClientStream || viewed && method.IsResultStreaming() + facts.hasUnaryViewedResult = facts.hasUnaryViewedResult || viewed && !method.IsResultStreaming() + callerView := grpcMethodUsesCallerSelectedView(method) + facts.hasCallerViewedStream = facts.hasCallerViewedStream || callerView && method.IsResultStreaming() + facts.serverCodecUsesGoa = facts.serverCodecUsesGoa || callerView + + plannedMetadata := servicePlan.endpoints[index].metadata + request := collectGRPCMetadataImportFacts(plannedMetadata[endpoint.Metadata]) + header := collectGRPCMetadataImportFacts(plannedMetadata[endpoint.Response.Headers]) + trailer := collectGRPCMetadataImportFacts(plannedMetadata[endpoint.Response.Trailers]) + facts.serverCodecUsesGoa = facts.serverCodecUsesGoa || request.decodeUsesGoa + facts.responseMetadataUsesGoa = facts.responseMetadataUsesGoa || header.decodeUsesGoa || trailer.decodeUsesGoa + facts.metadataUsesStrconv = facts.metadataUsesStrconv || request.usesStrconv || header.usesStrconv || trailer.usesStrconv + facts.requestMetadataUsesAny = facts.requestMetadataUsesAny || request.usesAny + facts.responseMetadataUsesAny = facts.responseMetadataUsesAny || header.usesAny || trailer.usesAny + + for _, requirement := range endpoint.Requirements { + for _, authored := range requirement.Schemes { + scheme := service.BuildSchemeData(authored, method) + if scheme == nil { + continue + } + facts.usesBearerMetadata = facts.usesBearerMetadata || scheme.Type == "Bearer" || scheme.Type == "JWT" || scheme.Type == "OAuth2" + facts.usesNonBasicMetadata = facts.usesNonBasicMetadata || scheme.Type != "Basic" + } + } + } + return facts +} + +// collectGRPCMetadataImportFacts evaluates the template branches used to +// encode and decode one metadata group. +func collectGRPCMetadataImportFacts(metadata []*grpcMetadataPlan) grpcMetadataImportFacts { + var facts grpcMetadataImportFacts + for _, field := range metadata { + kind := grpcMetadataElementKind(field.wire) + facts.decodeUsesGoa = facts.decodeUsesGoa || field.required || kind != expr.StringKind && kind != expr.AnyKind + facts.usesStrconv = facts.usesStrconv || grpcMetadataKindUsesStrconv(kind) + facts.usesAny = facts.usesAny || kind == expr.AnyKind + } + return facts +} + +// planGRPCImports records imports for each generated client, server, and +// command-parser package before Generation.Freeze chooses their names. +func planGRPCImports(generation *codegen.Generation, plan *Plan) error { + for _, servicePlan := range plan.servicesPlan { + service := servicePlan.expression + facts := collectGRPCImportFacts(servicePlan) + pathName := servicePlan.packages.pathName + clientPath := path.Join(generation.GenPkg(), "grpc", pathName, "client") + serverPath := path.Join(generation.GenPkg(), "grpc", pathName, "server") + protobufPath := path.Join(generation.GenPkg(), "grpc", pathName, pbPkgName) + client := generation.Package(clientPath) + server := generation.Package(serverPath) + protobufImport := codegen.NewImport(pathName+"pb", protobufPath) + allReferences := grpcEndpointAttributes(service.GRPCEndpoints...) + errorReferences := grpcErrorAttributes(service.GRPCEndpoints...) + codecReferences := grpcCodecAttributes(service.GRPCEndpoints...) + payloadReferences := grpcPayloadAttributes(service.GRPCEndpoints...) + streamReferences := grpcStreamAttributes(service.GRPCEndpoints...) + serverDefinitions := append(append([]*expr.AttributeExpr(nil), streamReferences...), errorReferences...) + hasReferences := grpcAttributesHaveValues(allReferences) + hasCodecReferences := grpcAttributesHaveValues(codecReferences) + hasPayloadReferences := grpcAttributesHaveValues(payloadReferences) + + clientFileRequired := []*codegen.ImportSpec{codegen.SimpleImport("google.golang.org/grpc")} + if facts.hasEndpoints { + clientFileRequired = append(clientFileRequired, + codegen.SimpleImport("context"), + codegen.GoaImport(""), + codegen.GoaNamedImport("grpc", "goagrpc"), + codegen.GoaNamedImport("grpc/pb", "goapb"), + ) + } + clientFileGenerated := []*codegen.ImportSpec{protobufImport} + if facts.hasStreaming { + clientFileGenerated = append(clientFileGenerated, servicePlan.packages.service) + } + if facts.hasViewedClientStream { + clientFileGenerated = append(clientFileGenerated, servicePlan.packages.views) + } + if err := recordGRPCFileImports( + plan, + path.Join(codegen.Gendir, "grpc", pathName, "client", "client.go"), + client, + grpcFileImportInput{ + required: clientFileRequired, + generated: clientFileGenerated, + typeDefinitions: streamReferences, + }, + ); err != nil { + return err + } + + var clientCodecRequired []*codegen.ImportSpec + if facts.hasEndpoints { + clientCodecRequired = append(clientCodecRequired, + codegen.SimpleImport("context"), + codegen.GoaNamedImport("grpc", "goagrpc"), + codegen.SimpleImport("google.golang.org/grpc"), + ) + } + if facts.clientCodecUsesMetadata { + clientCodecRequired = append(clientCodecRequired, codegen.SimpleImport("google.golang.org/grpc/metadata")) + } + if facts.responseMetadataUsesGoa { + clientCodecRequired = append(clientCodecRequired, codegen.GoaImport("")) + } + if facts.metadataUsesStrconv { + clientCodecRequired = append(clientCodecRequired, codegen.SimpleImport("strconv")) + } + if facts.requestMetadataUsesAny { + clientCodecRequired = append(clientCodecRequired, codegen.SimpleImport("fmt")) + } + if facts.usesBearerMetadata { + clientCodecRequired = append(clientCodecRequired, codegen.SimpleImport("strings")) + } + clientCodecRequired = append(clientCodecRequired, grpcMetadataValidationImportSpecs(servicePlan, true)...) + var clientCodecGenerated []*codegen.ImportSpec + if facts.hasEndpoints { + clientCodecGenerated = append(clientCodecGenerated, protobufImport) + } + if hasCodecReferences { + clientCodecGenerated = append(clientCodecGenerated, servicePlan.packages.service) + } + if facts.hasUnaryViewedResult { + clientCodecGenerated = append(clientCodecGenerated, servicePlan.packages.views) + } + if err := recordGRPCFileImports( + plan, + path.Join(codegen.Gendir, "grpc", pathName, "client", "encode_decode.go"), + client, + grpcFileImportInput{ + required: clientCodecRequired, + generated: clientCodecGenerated, + typeReferences: codecReferences, + }, + ); err != nil { + return err + } + + clientTypesRequired := grpcValidationRuntimeImportSpecs(plan.protobuf[service], validateClient) + if servicePlan.usesAnyInErrors { + clientTypesRequired = append(clientTypesRequired, + codegen.SimpleImport("fmt"), + codegen.SimpleImport("google.golang.org/protobuf/types/known/structpb"), + ) + } + var clientTypesGenerated []*codegen.ImportSpec + if hasReferences { + clientTypesGenerated = append(clientTypesGenerated, servicePlan.packages.service) + } + if facts.hasEndpoints { + clientTypesGenerated = append(clientTypesGenerated, protobufImport) + } + if facts.hasViewedResult { + clientTypesGenerated = append(clientTypesGenerated, servicePlan.packages.views) + } + var clientTypesDesign []*codegen.ImportSpec + if hasReferences { + clientTypesDesign = servicePlan.protoGoImports + } + if err := recordGRPCFileImports( + plan, + path.Join(codegen.Gendir, "grpc", pathName, "client", "types.go"), + client, + grpcFileImportInput{ + required: clientTypesRequired, + generated: clientTypesGenerated, + design: clientTypesDesign, + typeReferences: allReferences, + }, + ); err != nil { + return err + } + + if facts.hasEndpoints { + clientCLIRequired := grpcPayloadBuilderImportPreferences(servicePlan, plan.protobuf[service]) + var clientCLIGenerated []*codegen.ImportSpec + if hasPayloadReferences { + clientCLIGenerated = append(clientCLIGenerated, servicePlan.packages.service) + } + if grpcServiceHasCLIMessage(plan.protobuf[service]) { + clientCLIGenerated = append(clientCLIGenerated, protobufImport) + } + if err := recordGRPCFileImports( + plan, + path.Join(codegen.Gendir, "grpc", pathName, "client", "cli.go"), + client, + grpcFileImportInput{ + required: clientCLIRequired, + generated: clientCLIGenerated, + typeReferences: payloadReferences, + }, + ); err != nil { + return err + } + } + + var serverFileRequired []*codegen.ImportSpec + if facts.hasEndpoints { + serverFileRequired = append(serverFileRequired, + codegen.SimpleImport("context"), + codegen.GoaImport(""), + codegen.GoaNamedImport("grpc", "goagrpc"), + ) + } + if facts.hasErrors { + serverFileRequired = append(serverFileRequired, codegen.SimpleImport("google.golang.org/grpc/codes")) + } + if facts.serverFileUsesErrors { + serverFileRequired = append(serverFileRequired, codegen.SimpleImport("errors")) + } + if facts.streamsPayload { + serverFileRequired = append(serverFileRequired, codegen.SimpleImport("io")) + } + if facts.hasCallerViewedStream { + serverFileRequired = append(serverFileRequired, codegen.SimpleImport("google.golang.org/grpc/metadata")) + } + serverFileGenerated := []*codegen.ImportSpec{servicePlan.packages.service, protobufImport} + if err := recordGRPCFileImports( + plan, + path.Join(codegen.Gendir, "grpc", pathName, "server", "server.go"), + server, + grpcFileImportInput{ + required: serverFileRequired, + generated: serverFileGenerated, + typeDefinitions: serverDefinitions, + }, + ); err != nil { + return err + } + + var serverCodecRequired []*codegen.ImportSpec + if facts.hasEndpoints { + serverCodecRequired = append(serverCodecRequired, + codegen.SimpleImport("context"), + codegen.GoaNamedImport("grpc", "goagrpc"), + codegen.SimpleImport("google.golang.org/grpc/metadata"), + ) + } + if facts.serverCodecUsesGoa { + serverCodecRequired = append(serverCodecRequired, codegen.GoaImport("")) + } + if facts.metadataUsesStrconv { + serverCodecRequired = append(serverCodecRequired, codegen.SimpleImport("strconv")) + } + if facts.usesNonBasicMetadata { + serverCodecRequired = append(serverCodecRequired, codegen.SimpleImport("strings")) + } + if facts.responseMetadataUsesAny { + serverCodecRequired = append(serverCodecRequired, codegen.SimpleImport("fmt")) + } + serverCodecRequired = append(serverCodecRequired, grpcMetadataValidationImportSpecs(servicePlan, false)...) + var serverCodecGenerated []*codegen.ImportSpec + if facts.hasEndpoints { + serverCodecGenerated = append(serverCodecGenerated, protobufImport) + } + if hasCodecReferences { + serverCodecGenerated = append(serverCodecGenerated, servicePlan.packages.service) + } + if facts.hasViewedResult { + serverCodecGenerated = append(serverCodecGenerated, servicePlan.packages.views) + } + if err := recordGRPCFileImports( + plan, + path.Join(codegen.Gendir, "grpc", pathName, "server", "encode_decode.go"), + server, + grpcFileImportInput{ + required: serverCodecRequired, + generated: serverCodecGenerated, + typeReferences: codecReferences, + }, + ); err != nil { + return err + } + + serverTypesRequired := grpcValidationRuntimeImportSpecs(plan.protobuf[service], validateServer) + if servicePlan.usesAnyInErrors { + serverTypesRequired = append(serverTypesRequired, + codegen.SimpleImport("fmt"), + codegen.SimpleImport("google.golang.org/protobuf/types/known/structpb"), + ) + } + var serverTypesGenerated []*codegen.ImportSpec + if hasReferences { + serverTypesGenerated = append(serverTypesGenerated, servicePlan.packages.service) + } + if facts.hasEndpoints { + serverTypesGenerated = append(serverTypesGenerated, protobufImport) + } + if facts.hasViewedResult { + serverTypesGenerated = append(serverTypesGenerated, servicePlan.packages.views) + } + var serverTypesDesign []*codegen.ImportSpec + if hasReferences { + serverTypesDesign = servicePlan.protoGoImports + } + if err := recordGRPCFileImports( + plan, + path.Join(codegen.Gendir, "grpc", pathName, "server", "types.go"), + server, + grpcFileImportInput{ + required: serverTypesRequired, + generated: serverTypesGenerated, + design: serverTypesDesign, + typeReferences: allReferences, + }, + ); err != nil { + return err + } + } + + for _, serverPlan := range plan.cli.servers { + serverName := codegen.SnakeCase(codegen.Goify(serverPlan.name, true)) + outputPath := path.Join(generation.GenPkg(), "grpc", "cli", serverName) + output := generation.Package(outputPath) + required := []*codegen.ImportSpec{ + codegen.SimpleImport("flag"), + codegen.SimpleImport("fmt"), + codegen.SimpleImport("os"), + codegen.GoaImport(""), + codegen.NewImport("grpc", "google.golang.org/grpc"), + } + var generated []*codegen.ImportSpec + for _, serviceName := range serverPlan.expression.Services { + servicePlan := grpcServicePlanByName(plan.servicesPlan, serviceName) + if servicePlan == nil { + continue + } + pathName := servicePlan.packages.pathName + generated = append(generated, + codegen.NewImport(servicePlan.packages.service.Name+"c", path.Join(generation.GenPkg(), "grpc", pathName, "client")), + ) + if len(servicePlan.source.ServiceExpr.ClientInterceptors) > 0 { + generated = append(generated, servicePlan.packages.service) + } + } + if err := recordGRPCFileImports( + plan, + path.Join(codegen.Gendir, "grpc", "cli", serverName, "cli.go"), + output, + grpcFileImportInput{required: required, generated: generated}, + ); err != nil { + return err + } + } + return nil +} + +// recordGRPCFileImports registers the imports used by one generated file and +// saves their paths so the header can use the chosen names after freeze. +func recordGRPCFileImports( + plan *Plan, + filePath string, + output *codegen.GeneratedPackage, + input grpcFileImportInput, +) error { + key := grpcFilePathKey(filePath) + if plan.fileImports[key] != nil { + return fmt.Errorf("gRPC file %q already has an import plan", filePath) + } + imports := codegen.NewGeneratedImportPlan(output) + if err := imports.Require(input.required...); err != nil { + return err + } + if err := imports.AddGenerated(input.generated...); err != nil { + return err + } + if err := imports.AddDesign(input.design...); err != nil { + return err + } + if err := imports.AddTypeExpressions(input.typeDefinitions...); err != nil { + return err + } + if err := imports.AddRecursiveTypeReferences(input.typeReferences...); err != nil { + return err + } + plan.fileImports[key] = imports + return nil +} + +// planGRPCExampleImports adds the gRPC files' imports to the executable +// packages already claimed by the shared example planner. +func planGRPCExampleImports(generation *codegen.Generation, plan *Plan, root *example.Root) error { + rootPath := path.Dir(generation.GenPkg()) + for _, server := range root.Servers { + serverPath := path.Join(rootPath, "cmd", server.Dir) + serverPackage, err := generation.ClaimOutputPackage(serverPath, path.Join("cmd", server.Dir)) + if err != nil { + return err + } + serverImports := grpcFileImportInput{required: []*codegen.ImportSpec{ + codegen.SimpleImport("context"), + codegen.SimpleImport("fmt"), + codegen.SimpleImport("net"), + codegen.SimpleImport("net/url"), + codegen.SimpleImport("sync"), + codegen.SimpleImport("goa.design/clue/debug"), + codegen.SimpleImport("goa.design/clue/log"), + codegen.SimpleImport("google.golang.org/grpc"), + codegen.SimpleImport("google.golang.org/grpc/reflection"), + }} + for _, serviceName := range server.Services { + servicePlan := grpcServicePlanByName(plan.servicesPlan, serviceName) + if servicePlan == nil { + continue + } + pathName := servicePlan.packages.pathName + serverImports.generated = append(serverImports.generated, + codegen.NewImport(servicePlan.packages.service.Name+"svr", path.Join(generation.GenPkg(), "grpc", pathName, "server")), + codegen.NewImport(pathName+"pb", path.Join(generation.GenPkg(), "grpc", pathName, pbPkgName)), + ) + if len(servicePlan.expression.GRPCEndpoints) > 0 { + serverImports.generated = append(serverImports.generated, servicePlan.packages.service) + } + } + if err := recordGRPCFileImports( + plan, + path.Join("cmd", server.Dir, "grpc.go"), + serverPackage, + serverImports, + ); err != nil { + return err + } + + if plan.cli.parser(server.Name) == nil { + continue + } + clientPath := path.Join(rootPath, "cmd", server.Dir+"-cli") + clientPackage, err := generation.ClaimOutputPackage(clientPath, path.Join("cmd", server.Dir+"-cli")) + if err != nil { + return err + } + clientImports := grpcFileImportInput{required: []*codegen.ImportSpec{ + codegen.SimpleImport("context"), + codegen.SimpleImport("errors"), + codegen.SimpleImport("fmt"), + codegen.SimpleImport("io"), + codegen.SimpleImport("google.golang.org/grpc"), + codegen.SimpleImport("google.golang.org/grpc/credentials/insecure"), + }, generated: []*codegen.ImportSpec{codegen.NewImport( + "cli", + path.Join(generation.GenPkg(), "grpc", "cli", server.Dir), + )}} + hasEndpoints := false + for _, serviceName := range server.Services { + service := plan.root.API.GRPC.Service(serviceName) + if service == nil { + continue + } + hasEndpoints = hasEndpoints || len(service.GRPCEndpoints) > 0 + if grpcServiceStreamsResult(service) { + clientImports.generated = append(clientImports.generated, plan.packages[service].service) + } + servicePlan := grpcServicePlanByName(plan.servicesPlan, serviceName) + if servicePlan != nil && len(servicePlan.source.ServiceExpr.ClientInterceptors) > 0 { + clientImports.generated = append(clientImports.generated, codegen.NewImport("interceptors", rootPath+"/interceptors")) + } + } + if hasEndpoints { + clientImports.required = append(clientImports.required, codegen.SimpleImport("flag")) + } + if err := recordGRPCFileImports( + plan, + path.Join("cmd", server.Dir+"-cli", "grpc.go"), + clientPackage, + clientImports, + ); err != nil { + return err + } + } + return nil +} + +// grpcMethodHasViewedResult reports whether a method returns a result type +// projected through a Goa view. +func grpcMethodHasViewedResult(method *expr.MethodExpr) bool { + _, ok := method.Result.Type.(*expr.ResultTypeExpr) + return ok +} + +// grpcMethodUsesCallerSelectedView reports whether the caller supplies the +// view name for a result type with more than one available view. +func grpcMethodUsesCallerSelectedView(method *expr.MethodExpr) bool { + result, ok := method.Result.Type.(*expr.ResultTypeExpr) + if !ok || !result.HasMultipleViews() { + return false + } + _, fixed := method.Result.Meta.Last(expr.ViewMetaKey) + return !fixed +} + +// grpcValidationRuntimeImportSpecs returns the standard packages used by the +// validation functions written on one side of a gRPC service. +func grpcValidationRuntimeImportSpecs(protobuf *protobufServicePlan, side validateKind) []*codegen.ImportSpec { + var imports []*codegen.ImportSpec + for _, validator := range protobuf.catalog.validators { + if validator.side != side { + continue + } + for _, runtimeImport := range codegen.ValidationRuntimeImports( + validator.attribute, + codegen.GoLayoutPolicy{Pointer: true}, + ) { + imports = appendGRPCImportSpec(imports, codegen.NewImport(runtimeImport.Name, runtimeImport.Path)) + } + } + return imports +} + +// grpcMetadataValidationImportSpecs returns the Go packages used while one +// codec decodes metadata. Clients decode response metadata; servers decode +// request metadata. +func grpcMetadataValidationImportSpecs(service *grpcServicePlan, client bool) []*codegen.ImportSpec { + var imports []*codegen.ImportSpec + for _, endpoint := range service.endpoints { + groups := []*expr.MappedAttributeExpr{endpoint.expression.Metadata} + if client { + groups = []*expr.MappedAttributeExpr{ + endpoint.expression.Response.Headers, + endpoint.expression.Response.Trailers, + } + } else if endpoint.legacyMetadata != nil { + groups = append(groups, endpoint.legacyMetadata) + } + for _, group := range groups { + for _, metadata := range endpoint.metadata[group] { + if metadata.validation == "" { + continue + } + for _, runtimeImport := range codegen.ValidationRuntimeImports( + metadata.wire, + codegen.GoLayoutPolicy{Pointer: metadata.pointer}, + ) { + imports = appendGRPCImportSpec(imports, codegen.NewImport(runtimeImport.Name, runtimeImport.Path)) + } + } + } + } + return imports +} + +// grpcPayloadBuilderImportPreferences returns the fixed packages used to turn +// command-line flag text into one service's gRPC payloads. +func grpcPayloadBuilderImportPreferences(servicePlan *grpcServicePlan, protobuf *protobufServicePlan) []*codegen.ImportSpec { + var imports []*codegen.ImportSpec + for index, endpoint := range servicePlan.endpoints { + request := protobuf.messages[index].request + if protobufCLIRequestNeedsMessage(request) { + imports = appendGRPCImportSpec(imports, codegen.SimpleImport("fmt")) + imports = appendGRPCImportSpec(imports, codegen.SimpleImport("google.golang.org/protobuf/encoding/protojson")) + } + for _, metadata := range endpoint.metadata[endpoint.expression.Metadata] { + preferences := cli.FlagImportPreferences(metadata.wire, metadata.validation != "") + for _, preference := range preferences { + imports = appendGRPCImportSpec(imports, preference) + switch preference.Path { + case "encoding/json", "strconv": + imports = appendGRPCImportSpec(imports, codegen.SimpleImport("fmt")) + } + } + if metadata.required && metadata.wire.DefaultValue == nil { + imports = appendGRPCImportSpec(imports, codegen.SimpleImport("fmt")) + } + } + } + if grpcServicePayloadUsesAny(servicePlan.expression) { + imports = appendGRPCImportSpec(imports, codegen.SimpleImport("fmt")) + imports = appendGRPCImportSpec(imports, codegen.SimpleImport("google.golang.org/protobuf/types/known/structpb")) + } + return imports +} + +// grpcServiceHasCLIMessage reports whether a payload builder parses a +// protobuf request before constructing the service payload. +func grpcServiceHasCLIMessage(protobuf *protobufServicePlan) bool { + for _, messages := range protobuf.messages { + if protobufCLIRequestNeedsMessage(messages.request) { + return true + } + } + return false +} + +// grpcServicePayloadUsesAny reports whether a payload builder converts an Any +// value into a protobuf request field. +func grpcServicePayloadUsesAny(service *expr.GRPCServiceExpr) bool { + for _, endpoint := range service.GRPCEndpoints { + if hasAnyType(endpoint.MethodExpr.Payload) { + return true + } + } + return false +} + +// grpcMetadataElementKind returns the kind formatted for one metadata value. +func grpcMetadataElementKind(attribute *expr.AttributeExpr) expr.Kind { + if array := expr.AsArray(attribute.Type); array != nil { + return array.ElemType.Type.Kind() + } + return attribute.Type.Kind() +} + +// grpcMetadataKindUsesStrconv reports whether metadata templates call the +// standard strconv package for one primitive kind. +func grpcMetadataKindUsesStrconv(kind expr.Kind) bool { + switch kind { + case expr.BooleanKind, + expr.IntKind, + expr.Int32Kind, + expr.Int64Kind, + expr.UIntKind, + expr.UInt32Kind, + expr.UInt64Kind, + expr.Float32Kind, + expr.Float64Kind: + return true + default: + return false + } +} + +// appendGRPCImportSpec adds one complete import path if it is not already in +// the list. +func appendGRPCImportSpec(imports []*codegen.ImportSpec, spec *codegen.ImportSpec) []*codegen.ImportSpec { + for _, existing := range imports { + if existing.Path == spec.Path { + return imports + } + } + return append(imports, spec) +} + +func grpcServiceStreamsResult(service *expr.GRPCServiceExpr) bool { + for _, endpoint := range service.GRPCEndpoints { + if endpoint.MethodExpr.IsResultStreaming() && !endpoint.MethodExpr.IsPayloadStreaming() { + return true + } + } + return false +} + +func grpcServicePlanByName(services []*grpcServicePlan, name string) *grpcServicePlan { + for _, service := range services { + if service.expression.Name() == name { + return service + } + } + return nil +} diff --git a/grpc/codegen/import_plan_test.go b/grpc/codegen/import_plan_test.go new file mode 100644 index 0000000000..dfa61f5bfa --- /dev/null +++ b/grpc/codegen/import_plan_test.go @@ -0,0 +1,548 @@ +// This file verifies that gRPC import planning records the runtime packages +// used by each generated file before package names become final. +package codegen + +import ( + "os" + "path" + "strings" + "testing" + + "github.com/stretchr/testify/require" + + "goa.design/goa/v3/codegen" + "goa.design/goa/v3/codegen/testutil" + "goa.design/goa/v3/dsl" + "goa.design/goa/v3/expr" +) + +// TestGRPCErrorAnyImportsArePlannedForTypeFiles checks that method errors +// containing Any reserve both packages used by their conversion code. A user +// package named fmt must then receive the same final name in imports and type +// references, while command builders stay free of structpb when payloads and +// results do not use Any. +func TestGRPCErrorAnyImportsArePlannedForTypeFiles(t *testing.T) { + root := expr.RunDSL(t, func() { + failure := dsl.Type("Failure", func() { + dsl.Field(1, "details", dsl.Any) + dsl.Field(2, "code", dsl.String, func() { + dsl.Meta("struct:field:type", "fmt.Value", "example.com/fmt", "fmt") + }) + }) + dsl.Service("Imports", func() { + dsl.Method("Read", func() { + dsl.Payload(func() { + dsl.Field(1, "value", dsl.String) + }) + dsl.Result(func() { + dsl.Field(1, "value", dsl.String) + }) + dsl.Error("failure", failure) + dsl.GRPC(func() { + dsl.Response("failure", dsl.CodeInvalidArgument) + }) + }) + }) + }) + + generation, services := grpcServicePlans(t, []*expr.RootExpr{root}) + plans, err := newPlans( + generation, + fixedProtobufToolResolver(), + PlanInput{Root: root, Service: services[0]}, + ) + require.NoError(t, err) + require.NoError(t, generation.Freeze()) + require.NoError(t, services[0].Link()) + require.NoError(t, plans[0].Link()) + + for _, files := range [][]*codegen.File{plans[0].ClientTypeFiles(), plans[0].ServerTypeFiles()} { + header := sectionCode(t, files[0].SectionTemplates[0]) + require.Contains(t, header, `"fmt"`) + require.Contains(t, header, `fmt2 "example.com/fmt"`) + require.Contains(t, header, `"google.golang.org/protobuf/types/known/structpb"`) + } + for _, file := range plans[0].ClientCLIFiles() { + header := sectionCode(t, file.SectionTemplates[0]) + require.NotContains(t, header, "structpb") + } +} + +// TestGRPCRelocatedCustomErrorServerCompiles checks that server handlers +// import the package of a custom error type that is generated outside the +// service package. +func TestGRPCRelocatedCustomErrorServerCompiles(t *testing.T) { + root := expr.RunDSL(t, func() { + detail := dsl.Type("Detail", func() { + dsl.Field(1, "message", dsl.String) + dsl.Meta("struct:pkg:path", "details") + }) + failure := dsl.Type("Failure", func() { + dsl.Field(1, "detail", detail) + dsl.Required("detail") + dsl.Meta("struct:pkg:path", "types") + }) + dsl.Service("Imports", func() { + dsl.Method("Read", func() { + dsl.Error("failure", failure) + dsl.GRPC(func() { + dsl.Response("failure", dsl.CodeFailedPrecondition) + }) + }) + }) + }) + + generation, servicePlans := grpcServicePlans(t, []*expr.RootExpr{root}) + plans, err := NewPlans(generation, PlanInput{Root: root, Service: servicePlans[0]}) + require.NoError(t, err) + require.NoError(t, generation.Freeze()) + require.NoError(t, servicePlans[0].Link()) + require.NoError(t, plans[0].Link()) + header := sectionCode(t, plans[0].ServerFiles()[0].SectionTemplates[0]) + require.Contains(t, header, `types "generated.local/gen/types"`) + require.NotContains(t, header, `"generated.local/gen/details"`) + compileProtobufMethodServer(t, plans[0], servicePlans) +} + +// TestGRPCRelocatedPrimitiveErrorServerCompiles checks that a primitive error +// does not add a type import because its handler uses Goa's generic error +// response. +func TestGRPCRelocatedPrimitiveErrorServerCompiles(t *testing.T) { + root := expr.RunDSL(t, func() { + failure := dsl.Type("FailureCode", dsl.String) + failure.Attribute().AddMeta("struct:pkg:path", "types") + dsl.Service("Imports", func() { + dsl.Method("Read", func() { + dsl.Error("failure", failure) + dsl.GRPC(func() { + dsl.Response("failure", dsl.CodeFailedPrecondition) + }) + }) + }) + }) + + generation, servicePlans := grpcServicePlans(t, []*expr.RootExpr{root}) + plans, err := NewPlans(generation, PlanInput{Root: root, Service: servicePlans[0]}) + require.NoError(t, err) + require.NoError(t, generation.Freeze()) + require.NoError(t, servicePlans[0].Link()) + require.NoError(t, plans[0].Link()) + header := sectionCode(t, plans[0].ServerFiles()[0].SectionTemplates[0]) + require.NotContains(t, header, `"generated.local/gen/types"`) + compileProtobufMethodServer(t, plans[0], servicePlans) +} + +// TestGRPCStandardImportsAreReservedOnlyWhenGeneratedFilesUseThem checks that +// a generated service package keeps its preferred fmt, strconv, or errors name +// until source in the same output package calls that standard package. +func TestGRPCStandardImportsAreReservedOnlyWhenGeneratedFilesUseThem(t *testing.T) { + unused := grpcUnusedStandardImportCollisionRoot(t) + for _, packageName := range []string{"fmt", "strconv", "errors"} { + t.Run("unused "+packageName, func(t *testing.T) { + importPath := "example.com/" + packageName + require.Equal(t, packageName, grpcServerExternalImportName(t, unused, importPath)) + }) + } + + tests := []struct { + name string + root func(*testing.T) *expr.RootExpr + }{ + {"fmt used by Any error conversion", grpcFmtCollisionRoot}, + {"strconv used by response metadata", grpcStrconvCollisionRoot}, + {"errors used by method error handling", grpcErrorsCollisionRoot}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + root := test.root(t) + packageName := test.name[:strings.IndexByte(test.name, ' ')] + importPath := "example.com/" + packageName + require.Equal(t, packageName+"2", grpcServerExternalImportName(t, root, importPath)) + }) + } +} + +// TestGRPCResponseMetadataAnyPlansFmt checks that server response metadata +// formatting reserves fmt before a user package with the same preferred name. +func TestGRPCResponseMetadataAnyPlansFmt(t *testing.T) { + root := expr.RunDSL(t, func() { + result := dsl.Type("Result", func() { + dsl.Field(1, "dynamic", dsl.Any) + dsl.Field(2, "label", dsl.String, func() { + dsl.Meta("struct:field:type", "fmt.Label", "example.com/fmt", "fmt") + }) + }) + dsl.Service("Metadata", func() { + dsl.Method("Read", func() { + dsl.Result(result) + dsl.GRPC(func() { + dsl.Response(func() { + dsl.Headers(func() { + dsl.Attribute("dynamic:x-dynamic") + }) + }) + }) + }) + }) + }) + plans := linkedGRPCPlans(t, root) + header := sectionCode(t, plans[0].ServerFiles()[1].SectionTemplates[0]) + require.Contains(t, header, `"fmt"`) + require.Contains(t, header, `fmt2 "example.com/fmt"`) +} + +// TestGRPCNumericMetadataAliasesPlanStrconv checks that metadata conversion +// follows the transport's primitive copy instead of the named service type. +func TestGRPCNumericMetadataAliasesPlanStrconv(t *testing.T) { + root := expr.RunDSL(t, func() { + count := dsl.Type("Count", dsl.Int) + counts := dsl.Type("Counts", dsl.ArrayOf(count)) + dsl.Service("Metadata", func() { + dsl.Method("Read", func() { + dsl.Payload(func() { + dsl.Field(1, "count", count) + dsl.Field(2, "counts", counts) + }) + dsl.Result(func() { + dsl.Field(1, "count", count) + dsl.Field(2, "counts", counts) + }) + dsl.GRPC(func() { + dsl.Metadata(func() { + dsl.Attribute("count:x-count") + dsl.Attribute("counts:x-counts") + }) + dsl.Response(func() { + dsl.Headers(func() { + dsl.Attribute("count:x-count") + dsl.Attribute("counts:x-counts") + }) + }) + }) + }) + }) + }) + + plans := linkedGRPCPlans(t, root) + clientHeader := sectionCode(t, plans[0].ClientFiles()[1].SectionTemplates[0]) + serverHeader := sectionCode(t, plans[0].ServerFiles()[1].SectionTemplates[0]) + require.Contains(t, clientHeader, `"strconv"`) + require.Contains(t, serverHeader, `"strconv"`) +} + +// TestGRPCMetadataStringLengthValidationImports checks that each decoder +// codec reserves the UTF-8 package only when its own metadata needs it. +func TestGRPCMetadataStringLengthValidationImports(t *testing.T) { + tests := []struct { + name string + request bool + response bool + wantClient bool + wantServer bool + }{ + {name: "request metadata", request: true, wantServer: true}, + {name: "response metadata", response: true, wantClient: true}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + plans := linkedGRPCPlans(t, grpcMetadataStringLengthRoot(t, test.request, test.response)) + clientHeader := sectionCode(t, plans[0].ClientFiles()[1].SectionTemplates[0]) + serverHeader := sectionCode(t, plans[0].ServerFiles()[1].SectionTemplates[0]) + require.Equal(t, test.wantClient, strings.Contains(clientHeader, `"unicode/utf8"`)) + require.Equal(t, test.wantServer, strings.Contains(serverHeader, `"unicode/utf8"`)) + }) + } +} + +// TestGRPCMetadataStringLengthValidationFilesCompile checks complete generated +// client and server packages with request and response metadata validation. +func TestGRPCMetadataStringLengthValidationFilesCompile(t *testing.T) { + root := grpcMetadataStringLengthRoot(t, true, true) + generation, servicePlans := grpcServicePlans(t, []*expr.RootExpr{root}) + plans, err := NewPlans(generation, PlanInput{Root: root, Service: servicePlans[0]}) + require.NoError(t, err) + require.NoError(t, generation.Freeze()) + require.NoError(t, servicePlans[0].Link()) + require.NoError(t, plans[0].Link()) + clientCodec := grpcCodecFile(t, plans[0].ClientFiles()) + serverCodec := grpcCodecFile(t, plans[0].ServerFiles()) + testutil.AssertGo(t, "testdata/golden/metadata_validation_client_codec.go.golden", renderedGRPCFile(t, clientCodec)) + testutil.AssertGo(t, "testdata/golden/metadata_validation_server_codec.go.golden", renderedGRPCFile(t, serverCodec)) + compileProtobufMethodServer(t, plans[0], servicePlans) +} + +// TestGRPCCLIImportsFollowFlagCode checks that the command parser and payload +// builder reserve only the fixed packages their generated code names. +func TestGRPCCLIImportsFollowFlagCode(t *testing.T) { + root := expr.RunDSL(t, func() { + dsl.Service("Commands", func() { + dsl.Method("Read", func() { + dsl.Payload(func() { + dsl.Field(1, "name", dsl.String, func() { + dsl.MinLength(2) + }) + dsl.Field(2, "count", dsl.Int) + dsl.Required("name") + }) + dsl.GRPC(func() { + dsl.Metadata(func() { + dsl.Attribute("name:x-name") + dsl.Attribute("count:x-count") + }) + }) + }) + }) + }) + + plans := linkedGRPCPlans(t, root) + files := plans[0].ClientCLIFiles() + require.Len(t, files, 2) + parserHeader := sectionCode(t, files[0].SectionTemplates[0]) + require.Contains(t, parserHeader, `"flag"`) + require.Contains(t, parserHeader, `"fmt"`) + require.Contains(t, parserHeader, `"os"`) + require.Contains(t, parserHeader, `goa "goa.design/goa/v3/pkg"`) + require.Contains(t, parserHeader, `grpc "google.golang.org/grpc"`) + for _, absent := range []string{"context", "goagrpc", "strconv", "utf8", "protojson", "structpb"} { + require.NotContains(t, parserHeader, absent) + } + + builderHeader := sectionCode(t, files[1].SectionTemplates[0]) + require.Contains(t, builderHeader, `"fmt"`) + require.Contains(t, builderHeader, `"strconv"`) + require.Contains(t, builderHeader, `"unicode/utf8"`) + require.Contains(t, builderHeader, `goa "goa.design/goa/v3/pkg"`) + for _, absent := range []string{"encoding/json", "protojson", "structpb"} { + require.NotContains(t, builderHeader, absent) + } +} + +// TestGRPCPayloadAnyPlansStructPBOnlyForBuilder checks that an Any payload +// adds protobuf conversion imports to the payload builder, not the parser. +func TestGRPCPayloadAnyPlansStructPBOnlyForBuilder(t *testing.T) { + root := expr.RunDSL(t, func() { + dsl.Service("Commands", func() { + dsl.Method("Create", func() { + dsl.Payload(func() { + dsl.Field(1, "value", dsl.Any) + }) + dsl.GRPC(func() {}) + }) + }) + }) + + files := linkedGRPCPlans(t, root)[0].ClientCLIFiles() + require.Len(t, files, 2) + parserHeader := sectionCode(t, files[0].SectionTemplates[0]) + require.NotContains(t, parserHeader, "protojson") + require.NotContains(t, parserHeader, "structpb") + builderHeader := sectionCode(t, files[1].SectionTemplates[0]) + require.Contains(t, builderHeader, `"fmt"`) + require.Contains(t, builderHeader, `"google.golang.org/protobuf/encoding/protojson"`) + require.Contains(t, builderHeader, `"google.golang.org/protobuf/types/known/structpb"`) +} + +// TestGRPCEmptyResultPlansServerProtobufImport checks that the generated +// empty response constructor imports its protobuf result type. +func TestGRPCEmptyResultPlansServerProtobufImport(t *testing.T) { + root := expr.RunDSL(t, func() { + dsl.Service("Values", func() { + dsl.Method("Read", func() { + dsl.GRPC(func() {}) + }) + }) + }) + + plans := linkedGRPCPlans(t, root) + file := plans[0].ServerTypeFiles()[0] + header := sectionCode(t, file.SectionTemplates[0]) + require.Contains(t, header, `"generated.local/gen/grpc/values/pb"`) +} + +// grpcServerExternalImportName returns the final name used for importPath by +// the generated gRPC server package. +func grpcServerExternalImportName(t *testing.T, root *expr.RootExpr, importPath string) string { + t.Helper() + generation, services := grpcServicePlans(t, []*expr.RootExpr{root}) + plans, err := newPlans( + generation, + fixedProtobufToolResolver(), + PlanInput{Root: root, Service: services[0]}, + ) + require.NoError(t, err) + require.NoError(t, generation.Freeze()) + require.NoError(t, services[0].Link()) + require.NoError(t, plans[0].Link()) + servicePlan := plans[0].servicesPlan[0] + serverPath := path.Join(generation.GenPkg(), "grpc", servicePlan.packages.pathName, "server") + return generation.Package(serverPath).ImportName(importPath) +} + +// grpcCodecFile returns the encoder and decoder file from one generated side. +func grpcCodecFile(t *testing.T, files []*codegen.File) *codegen.File { + t.Helper() + for _, file := range files { + if strings.HasSuffix(file.Path, "encode_decode.go") { + return file + } + } + require.Fail(t, "generated gRPC codec file was not planned") + return nil +} + +// renderedGRPCFile writes one planned file and returns its complete source. +func renderedGRPCFile(t *testing.T, file *codegen.File) string { + t.Helper() + filePath, err := file.Render(t.TempDir()) + require.NoError(t, err) + source, err := os.ReadFile(filePath) + require.NoError(t, err) + return string(source) +} + +// grpcMetadataStringLengthRoot returns a service whose selected metadata +// fields validate their Unicode character count while decoding. +func grpcMetadataStringLengthRoot(t *testing.T, request, response bool) *expr.RootExpr { + t.Helper() + return expr.RunDSL(t, func() { + dsl.Service("Metadata Validation", func() { + dsl.Method("Check", func() { + dsl.Payload(func() { + dsl.Field(1, "request", dsl.String, func() { + dsl.MinLength(2) + }) + dsl.Required("request") + }) + dsl.Result(func() { + dsl.Field(1, "response", dsl.String, func() { + dsl.MinLength(3) + }) + dsl.Required("response") + }) + dsl.GRPC(func() { + if request { + dsl.Metadata(func() { + dsl.Attribute("request:x-request") + }) + } + if response { + dsl.Response(func() { + dsl.Headers(func() { + dsl.Attribute("response:x-response") + }) + }) + } + }) + }) + }) + }) +} + +// linkedGRPCPlans creates and links the gRPC plans for root. +func linkedGRPCPlans(t *testing.T, root *expr.RootExpr) []*Plan { + t.Helper() + generation, services := grpcServicePlans(t, []*expr.RootExpr{root}) + plans, err := newPlans( + generation, + fixedProtobufToolResolver(), + PlanInput{Root: root, Service: services[0]}, + ) + require.NoError(t, err) + require.NoError(t, generation.Freeze()) + require.NoError(t, services[0].Link()) + require.NoError(t, plans[0].Link()) + return plans +} + +// grpcFmtCollisionRoot returns a service whose error conversion calls fmt. +func grpcFmtCollisionRoot(t *testing.T) *expr.RootExpr { + t.Helper() + return expr.RunDSL(t, func() { + failure := dsl.Type("Failure", func() { + dsl.Field(1, "details", dsl.Any) + dsl.Field(2, "label", dsl.String, func() { + dsl.Meta("struct:field:type", "fmt.Label", "example.com/fmt", "fmt") + }) + }) + dsl.Service("Collision", func() { + dsl.Method("Read", func() { + dsl.Result(dsl.String) + dsl.Error("failure", failure) + dsl.GRPC(func() { + dsl.Response("failure", dsl.CodeInvalidArgument) + }) + }) + }) + }) +} + +// grpcStrconvCollisionRoot returns a service whose response metadata formats +// an integer with strconv. +func grpcStrconvCollisionRoot(t *testing.T) *expr.RootExpr { + t.Helper() + return expr.RunDSL(t, func() { + dsl.Service("Collision", func() { + dsl.Method("Read", func() { + dsl.Result(func() { + dsl.Field(1, "count", dsl.Int, func() { + dsl.Meta("struct:field:type", "strconv.Count", "example.com/strconv", "strconv") + }) + }) + dsl.GRPC(func() { + dsl.Response(func() { + dsl.Headers(func() { + dsl.Attribute("count:x-count") + }) + }) + }) + }) + }) + }) +} + +// grpcErrorsCollisionRoot returns a service whose server handler inspects a +// declared method error with the standard errors package. +func grpcErrorsCollisionRoot(t *testing.T) *expr.RootExpr { + t.Helper() + return expr.RunDSL(t, func() { + failure := dsl.Type("Failure", func() { + dsl.Field(1, "cause", dsl.String, func() { + dsl.Meta("struct:field:type", "errors.Cause", "example.com/errors", "errors") + }) + }) + dsl.Service("Collision", func() { + dsl.Method("Read", func() { + dsl.Result(dsl.String) + dsl.Error("failure", failure) + dsl.GRPC(func() { + dsl.Response("failure", dsl.CodeInvalidArgument) + }) + }) + }) + }) +} + +// grpcUnusedStandardImportCollisionRoot returns one service that references +// user packages named fmt, strconv, and errors without calling the standard +// packages of those names. +func grpcUnusedStandardImportCollisionRoot(t *testing.T) *expr.RootExpr { + t.Helper() + return expr.RunDSL(t, func() { + dsl.Service("Collision", func() { + dsl.Method("Read", func() { + dsl.Result(func() { + dsl.Field(1, "label", dsl.String, func() { + dsl.Meta("struct:field:type", "fmt.Label", "example.com/fmt", "fmt") + }) + dsl.Field(2, "count", dsl.Int, func() { + dsl.Meta("struct:field:type", "strconv.Count", "example.com/strconv", "strconv") + }) + dsl.Field(3, "cause", dsl.String, func() { + dsl.Meta("struct:field:type", "errors.Cause", "example.com/errors", "errors") + }) + }) + dsl.GRPC(func() {}) + }) + }) + }) +} diff --git a/grpc/codegen/metadata_specialization_test.go b/grpc/codegen/metadata_specialization_test.go new file mode 100644 index 0000000000..d019a82b01 --- /dev/null +++ b/grpc/codegen/metadata_specialization_test.go @@ -0,0 +1,107 @@ +// This file verifies that gRPC metadata uses the exact string conversion for +// each primitive type selected by the design. +package codegen + +import ( + "testing" + + "github.com/stretchr/testify/require" + + "goa.design/goa/v3/codegen" + "goa.design/goa/v3/dsl" + "goa.design/goa/v3/expr" + "goa.design/goa/v3/grpc/codegen/testdata" +) + +func TestMetadataEncodingSpecializesPrimitiveFormatting(t *testing.T) { + root := expr.RunDSL(t, func() { + alias := dsl.Type("Count", dsl.Int) + fields := dsl.Type("Fields", func() { + dsl.Field(1, "boolean", dsl.Boolean) + dsl.Field(2, "integer", dsl.Int) + dsl.Field(3, "small", dsl.Int32) + dsl.Field(4, "large", dsl.Int64) + dsl.Field(5, "unsigned", dsl.UInt) + dsl.Field(6, "unsigned_small", dsl.UInt32) + dsl.Field(7, "unsigned_large", dsl.UInt64) + dsl.Field(8, "ratio", dsl.Float32) + dsl.Field(9, "score", dsl.Float64) + dsl.Field(10, "text", dsl.String) + dsl.Field(11, "bytes", dsl.Bytes) + dsl.Field(12, "count", alias) + dsl.Field(13, "booleans", dsl.ArrayOf(dsl.Boolean)) + dsl.Field(14, "dynamic", dsl.Any) + dsl.Field(15, "dynamic_values", dsl.ArrayOf(dsl.Any)) + }) + dsl.Service("Values", func() { + dsl.Method("Read", func() { + dsl.Payload(fields) + dsl.Result(fields) + dsl.GRPC(func() { + dsl.Metadata(func() { + metadataFields() + }) + dsl.Response(func() { + dsl.Headers(func() { + metadataFields() + }) + }) + }) + }) + }) + }) + + services := CreateGRPCServices(root) + generatedClientFiles := clientFiles(services) + generatedServerFiles := serverFiles(services) + request := codegen.SectionsCode(t, generatedClientFiles[1].Section("request-encoder")) + response := codegen.SectionsCode(t, generatedServerFiles[1].Section("response-encoder")) + for _, generated := range []string{request, response} { + require.Contains(t, generated, "strconv.FormatBool(booleanWire)") + require.Contains(t, generated, "strconv.Itoa(integerWire)") + require.Contains(t, generated, "strconv.FormatInt(int64(smallWire), 10)") + require.Contains(t, generated, "strconv.FormatInt(largeWire, 10)") + require.Contains(t, generated, "strconv.FormatUint(uint64(unsignedWire), 10)") + require.Contains(t, generated, "strconv.FormatUint(uint64(unsignedSmallWire), 10)") + require.Contains(t, generated, "strconv.FormatUint(unsignedLargeWire, 10)") + require.Contains(t, generated, "strconv.FormatFloat(float64(ratioWire), 'f', -1, 32)") + require.Contains(t, generated, "strconv.FormatFloat(scoreWire, 'f', -1, 64)") + require.Contains(t, generated, `Append("text", textWire)`) + require.Contains(t, generated, `Append("bytes", string(bytesWire))`) + require.Contains(t, generated, "strconv.Itoa(countWire)") + require.Contains(t, generated, "strconv.FormatBool(value)") + require.Contains(t, generated, `fmt.Sprintf("%v", dynamicWire)`) + require.Contains(t, generated, `fmt.Sprintf("%v", value)`) + require.NotContains(t, generated, `fmt.Sprintf("%v", integerWire)`) + require.NotContains(t, generated, `fmt.Sprintf("%v", booleanWire)`) + } + require.Contains(t, sectionCode(t, generatedClientFiles[1].SectionTemplates[0]), `"fmt"`) + require.Contains(t, sectionCode(t, generatedServerFiles[1].SectionTemplates[0]), `"fmt"`) + + withoutAny := CreateGRPCServices(RunGRPCDSL(t, testdata.MessageWithMetadataDSL)) + require.NotContains(t, sectionCode(t, clientFiles(withoutAny)[1].SectionTemplates[0]), `"fmt"`) + require.NotContains(t, sectionCode(t, serverFiles(withoutAny)[1].SectionTemplates[0]), `"fmt"`) +} + +// metadataFields maps every test field to a metadata key with the same name. +func metadataFields() { + for _, name := range []string{ + "boolean", + "integer", + "small", + "large", + "unsigned", + "unsigned_small", + "unsigned_large", + "ratio", + "score", + "text", + "bytes", + "count", + "booleans", + "dynamic", + "dynamic_values", + } { + dsl.Attribute(name) + } +} diff --git a/grpc/codegen/oneof_anonymous_user_union_test.go b/grpc/codegen/oneof_anonymous_user_union_test.go index 04b7e7bbb2..50ae13f425 100644 --- a/grpc/codegen/oneof_anonymous_user_union_test.go +++ b/grpc/codegen/oneof_anonymous_user_union_test.go @@ -1,3 +1,5 @@ +// This file verifies protobuf generation for unions containing anonymous user +// type branches. package codegen import ( @@ -44,14 +46,19 @@ func TestAnonymousUserUnionArrayNoWrappersFromProto(t *testing.T) { }) sd := &ServiceData{Name: "Svc", Scope: codegen.NewNameScope()} - svcCtx := serviceTypeContext("proto", sd.Scope) - pbCtx := protoBufTypeContext("proto", sd.Scope, true) + svcCtx := codegen.NewAttributeContext(false, false, true, "proto", sd.Scope) // Transform protobuf -> Go for Container target := &expr.AttributeExpr{Type: root.UserType("Container")} - source := makeProtoBufMessage(expr.DupAtt(target), target.Type.Name(), sd) + source := makeProtoBufMessage( + expr.DupAtt(target), + target.Type.Name(), + testGRPCMessageExampleIdentity("anonymous-user-union"), + ) + freezeProtoBufTransformMessages(t, sd, source) + pbCtx := protoBufTypeContext("proto", sd) - code, _, err := protoBufTransform(source, target, "source", "target", pbCtx, svcCtx, false, true) + code, _, err := protoBufTransform(source, target, pbCtx, svcCtx, false, true) require.NoError(t, err) out := codegen.FormatTestCode(t, "package foo\nfunc transform(){\n"+code+"}") diff --git a/grpc/codegen/parse_endpoint_test.go b/grpc/codegen/parse_endpoint_test.go index ca592dcaa9..5d2d85094b 100644 --- a/grpc/codegen/parse_endpoint_test.go +++ b/grpc/codegen/parse_endpoint_test.go @@ -1,3 +1,5 @@ +// This file verifies that gRPC client endpoint parsing renders from a legal +// generated package root while preserving configured interceptor wiring. package codegen import ( @@ -25,8 +27,8 @@ func TestParseEndpointWithInterceptors(t *testing.T) { for _, c := range cases { t.Run(c.Name, func(t *testing.T) { root := RunGRPCDSL(t, c.DSL) - services := CreateGRPCServices(root) - fs := ClientCLIFiles("", services) + services := createServiceServicesForPackage(root, "generated.local/gen") + fs := clientCLIFiles(services) require.Greater(t, len(fs), 1, "expected at least 2 files") require.NotEmpty(t, fs[0].SectionTemplates) var buf bytes.Buffer diff --git a/grpc/codegen/plan.go b/grpc/codegen/plan.go new file mode 100644 index 0000000000..5f439e50f7 --- /dev/null +++ b/grpc/codegen/plan.go @@ -0,0 +1,396 @@ +// This file stores one gRPC design, its chosen Go names, and every file built +// from it. +package codegen + +import ( + "fmt" + "path" + + "goa.design/goa/v3/codegen" + "goa.design/goa/v3/codegen/cli" + "goa.design/goa/v3/codegen/example" + "goa.design/goa/v3/codegen/service" + "goa.design/goa/v3/expr" +) + +type ( + // PlanInput pairs one design with the generated service names chosen for it. + PlanInput struct { + // Root is the design that contains the gRPC services. + Root *expr.RootExpr + // Service provides the method names selected for Root. + Service *service.Plan + } + + // Plan stores one design and every gRPC file built from it. + Plan struct { + generation *codegen.Generation + root *expr.RootExpr + service *service.Plan + cli *grpcCLIPlan + protobuf map[*expr.GRPCServiceExpr]*protobufServicePlan + packages map[*expr.GRPCServiceExpr]*grpcServicePackage + tools map[*expr.GRPCServiceExpr]*protobufToolPlan + symbols map[*expr.GRPCServiceExpr]*grpcSymbols + fileImports map[string]*codegen.GeneratedImportPlan + expressions []*expr.GRPCServiceExpr + servicesPlan []*grpcServicePlan + services *ServicesData + proto []*codegen.File + server []*codegen.File + client []*codegen.File + serverType []*codegen.File + clientType []*codegen.File + clientCLI []*codegen.File + } + + // ExamplePlan builds runnable gRPC programs from server data and generated + // services that came from the same design. + ExamplePlan struct { + root *example.Root + transport *Plan + } + + // grpcCLIPlan contains the command parser and payload function names for one + // design. + grpcCLIPlan struct { + parsers map[*expr.ServerExpr]*cli.ParserPlan + builders map[*expr.GRPCEndpointExpr]*codegen.NameDeclaration + servers []*grpcCLIServerPlan + } + + // grpcCLIServerPlan stores one server name and the command parser declared + // for that server before generated files are built. + grpcCLIServerPlan struct { + expression *expr.ServerExpr + name string + parser *cli.ParserPlan + } + + // grpcServicePackage stores the generated service import and the directory + // used by every gRPC package for that service. + grpcServicePackage struct { + service *codegen.ImportSpec + views *codegen.ImportSpec + pathName string + } +) + +// NewPlans reads every service design and stores one plan for each input. It +// chooses all shared package names before files are built. +func NewPlans(generation *codegen.Generation, inputs ...PlanInput) ([]*Plan, error) { + return newPlans(generation, systemProtobufTools(), inputs...) +} + +// NewExamplePlan returns an example renderer only when examples contains the +// server data copied from transport's service design. +func NewExamplePlan(transport *Plan, examples *example.Plan) (*ExamplePlan, error) { + root, ok := examples.Root(transport.service) + if !ok { + return nil, fmt.Errorf("gRPC examples require server data created from the same service design") + } + if err := planGRPCExampleImports(transport.generation, transport, root); err != nil { + return nil, err + } + return &ExamplePlan{root: root, transport: transport}, nil +} + +// newPlans lets tests provide fixed protobuf executable paths and versions. +func newPlans(generation *codegen.Generation, resolver protobufToolResolver, inputs ...PlanInput) ([]*Plan, error) { + owned := make(map[*expr.RootExpr]struct{}) + for _, candidate := range generation.Roots() { + if root, ok := candidate.(*expr.RootExpr); ok && len(root.API.GRPC.Services) > 0 { + owned[root] = struct{}{} + } + } + if len(inputs) != len(owned) { + return nil, fmt.Errorf("gRPC planning requires all %d gRPC roots, got %d", len(owned), len(inputs)) + } + seen := make(map[*expr.RootExpr]struct{}, len(inputs)) + for _, input := range inputs { + if input.Service == nil || input.Service.Root() != input.Root { + return nil, fmt.Errorf("gRPC plan input does not pair a design with its service plan") + } + if _, ok := owned[input.Root]; !ok { + return nil, fmt.Errorf("gRPC root %p is not part of this generation", input.Root) + } + if _, ok := seen[input.Root]; ok { + return nil, fmt.Errorf("gRPC root %p is planned more than once", input.Root) + } + seen[input.Root] = struct{}{} + } + toolPlans, err := planProtobufTools(inputs, resolver) + if err != nil { + return nil, err + } + plans := make([]*Plan, len(inputs)) + for index, input := range inputs { + packages, err := planGRPCServicePackages(input) + if err != nil { + return nil, err + } + cliPlan, err := planGRPCCLI(generation, input, packages) + if err != nil { + return nil, err + } + tools := make(map[*expr.GRPCServiceExpr]*protobufToolPlan, len(input.Root.API.GRPC.Services)) + for _, grpcService := range input.Root.API.GRPC.Services { + tools[grpcService] = toolPlans[grpcService] + } + plans[index] = &Plan{ + generation: generation, + root: input.Root, + service: input.Service, + cli: cliPlan, + protobuf: make(map[*expr.GRPCServiceExpr]*protobufServicePlan), + packages: packages, + tools: tools, + symbols: make(map[*expr.GRPCServiceExpr]*grpcSymbols), + fileImports: make(map[string]*codegen.GeneratedImportPlan), + expressions: append([]*expr.GRPCServiceExpr(nil), input.Root.API.GRPC.Services...), + } + } + if err := planProtobufServices(generation, plans); err != nil { + return nil, err + } + conversions := make(map[grpcConversionKey]*grpcConversion) + registries := make(map[*codegen.GeneratedPackage]*grpcTransformRegistry) + for _, plan := range plans { + input := PlanInput{Root: plan.root, Service: plan.service} + for _, grpcService := range plan.expressions { + pathName := plan.packages[grpcService].pathName + symbols, err := collectGRPCSymbols(generation, input, grpcService, pathName) + if err != nil { + return nil, err + } + if err := planGRPCValidations(generation, input, grpcService, plan.protobuf[grpcService], pathName); err != nil { + return nil, err + } + if err := planGRPCTransforms(generation, input, grpcService, plan.protobuf[grpcService], symbols, conversions, registries, pathName); err != nil { + return nil, err + } + plan.symbols[grpcService] = symbols + } + } + if err := declareGRPCTransforms(conversions, registries); err != nil { + return nil, err + } + for _, plan := range plans { + servicesPlan, err := collectGRPCServicePlans(plan) + if err != nil { + return nil, err + } + plan.servicesPlan = servicesPlan + if err := planGRPCImports(generation, plan); err != nil { + return nil, err + } + } + return plans, nil +} + +// Generation returns the generation that owns this plan's package names. +func (p *Plan) Generation() *codegen.Generation { + return p.generation +} + +// Root returns the exact design supplied to NewPlans. +func (p *Plan) Root() *expr.RootExpr { + return p.root +} + +// Service returns the exact service plan supplied to NewPlans. +func (p *Plan) Service() *service.Plan { + return p.service +} + +// ServiceData returns the finalized gRPC data for the exact service used to +// build this plan. Callers must call Link before reading the service data. +func (p *Plan) ServiceData(service *expr.GRPCServiceExpr) (*ServiceData, bool) { + p.requireLinked() + data, ok := p.services.serviceByExpr[service] + return data, ok +} + +// Link builds the gRPC files after all generated Go names are fixed. The +// service plan must already have built its files. +func (p *Plan) Link() error { + if !p.generation.Frozen() { + return fmt.Errorf("gRPC plan cannot link before generation freeze") + } + if p.services != nil { + return fmt.Errorf("gRPC plan is already linked") + } + for filePath, imports := range p.fileImports { + if err := imports.Link(); err != nil { + return fmt.Errorf("link gRPC imports for %q: %w", filePath, err) + } + } + services := newServicesData(p.service.Services(), p) + p.services = services + p.proto = protoFiles(services) + p.server = serverFiles(services) + p.client = clientFiles(services) + p.serverType = serverTypeFiles(services) + p.clientType = clientTypeFiles(services) + p.clientCLI = clientCLIFiles(services) + return nil +} + +// ProtoFiles returns the protobuf schemas built by Link. +func (p *Plan) ProtoFiles() []*codegen.File { + p.requireLinked() + return p.proto +} + +// ServerFiles returns the gRPC server files built by Link. +func (p *Plan) ServerFiles() []*codegen.File { + p.requireLinked() + return p.server +} + +// ClientFiles returns the gRPC client files built by Link. +func (p *Plan) ClientFiles() []*codegen.File { + p.requireLinked() + return p.client +} + +// ServerTypeFiles returns the server transport type files built by Link. +func (p *Plan) ServerTypeFiles() []*codegen.File { + p.requireLinked() + return p.serverType +} + +// ClientTypeFiles returns the client transport type files built by Link. +func (p *Plan) ClientTypeFiles() []*codegen.File { + p.requireLinked() + return p.clientType +} + +// ClientCLIFiles returns the command-line client files built by Link. +func (p *Plan) ClientCLIFiles() []*codegen.File { + p.requireLinked() + return p.clientCLI +} + +// ServerFiles builds runnable gRPC servers from the copied server data. +func (p *ExamplePlan) ServerFiles() []*codegen.File { + p.transport.requireLinked() + return exampleServerFiles(p.root, p.transport.services) +} + +// CLIFiles builds runnable gRPC clients from the copied server data. +func (p *ExamplePlan) CLIFiles() []*codegen.File { + p.transport.requireLinked() + return exampleCLIFiles(p.root, p.transport.services) +} + +// planGRPCCLI chooses parser and payload builder names for one design. +func planGRPCCLI(generation *codegen.Generation, input PlanInput, packages map[*expr.GRPCServiceExpr]*grpcServicePackage) (*grpcCLIPlan, error) { + design := input.Root + plan := &grpcCLIPlan{ + parsers: make(map[*expr.ServerExpr]*cli.ParserPlan), + builders: make(map[*expr.GRPCEndpointExpr]*codegen.NameDeclaration), + } + for _, grpcService := range design.API.GRPC.Services { + pathName := packages[grpcService].pathName + clientPackage, err := generation.ClaimPackage(path.Join(generation.GenPkg(), "grpc", pathName, "client")) + if err != nil { + return nil, err + } + for _, endpoint := range grpcService.GRPCEndpoints { + if endpoint.MethodExpr.Payload.Type == expr.Empty { + continue + } + names, err := input.Service.HTTPMethodNames(endpoint.MethodExpr) + if err != nil { + return nil, err + } + declaration, err := cli.DeclarePayloadBuilder(clientPackage, "grpc", design.API.Name, grpcService.Name(), endpoint.Name(), "Build"+names.Method+"Payload") + if err != nil { + return nil, err + } + plan.builders[endpoint] = declaration + } + } + if len(design.API.GRPC.Services) == 0 { + return plan, nil + } + for _, server := range design.API.Servers { + var commands []cli.CommandDeclarationInput + for _, serviceName := range server.Services { + grpcService := design.API.GRPC.Service(serviceName) + if grpcService == nil { + continue + } + if len(grpcService.GRPCEndpoints) == 0 { + continue + } + command := cli.CommandDeclarationInput{Service: grpcService.Name()} + for _, endpoint := range grpcService.GRPCEndpoints { + command.Methods = append(command.Methods, endpoint.Name()) + command.NeedsFlagPresence = command.NeedsFlagPresence || grpcEndpointNeedsCLIFlagPresence(endpoint) + } + commands = append(commands, command) + } + if len(commands) == 0 { + continue + } + serverName := codegen.SnakeCase(codegen.Goify(server.Name, true)) + serverPackage, err := generation.ClaimPackage(path.Join(generation.GenPkg(), "grpc", "cli", serverName)) + if err != nil { + return nil, err + } + parser, err := cli.DeclareParser(serverPackage, "grpc", design.API.Name, server.Name, commands) + if err != nil { + return nil, err + } + plan.parsers[server] = parser + plan.servers = append(plan.servers, &grpcCLIServerPlan{ + expression: server, + name: server.Name, + parser: parser, + }) + } + return plan, nil +} + +// grpcEndpointNeedsCLIFlagPresence reports whether the generated command must +// distinguish a missing flag from a flag whose value is empty. Protobuf request +// messages have no CLI default. Metadata fields use the default from the design +// when one is present. +func grpcEndpointNeedsCLIFlagPresence(endpoint *expr.GRPCEndpointExpr) bool { + if endpoint.Request.Type != expr.Empty { + return true + } + for _, field := range *expr.AsObject(endpoint.Metadata.Type) { + if field.Attribute.DefaultValue == nil { + return true + } + } + return false +} + +// planGRPCServicePackages records the exact service imports before generated +// package names become final. Every later gRPC planning step uses these paths. +func planGRPCServicePackages(input PlanInput) (map[*expr.GRPCServiceExpr]*grpcServicePackage, error) { + packages := make(map[*expr.GRPCServiceExpr]*grpcServicePackage, len(input.Root.API.GRPC.Services)) + for _, transportService := range input.Root.API.GRPC.Services { + serviceImport, viewsImport, err := input.Service.ServicePackageImports(transportService.ServiceExpr) + if err != nil { + return nil, err + } + packages[transportService] = &grpcServicePackage{ + service: serviceImport, + views: viewsImport, + pathName: path.Base(serviceImport.Path), + } + } + return packages, nil +} + +// requireLinked stops file reads before Link stores the files. +func (p *Plan) requireLinked() { + if p.services == nil { + panic("gRPC files requested before plan linking") + } +} diff --git a/grpc/codegen/plan_retention_test.go b/grpc/codegen/plan_retention_test.go new file mode 100644 index 0000000000..82d31b9880 --- /dev/null +++ b/grpc/codegen/plan_retention_test.go @@ -0,0 +1,210 @@ +// This file proves gRPC files use the service and endpoint values saved by +// NewPlans even when a caller changes the evaluated design before Link. +package codegen + +import ( + "sort" + "testing" + + "github.com/stretchr/testify/require" + + "goa.design/goa/v3/dsl" + "goa.design/goa/v3/expr" + "goa.design/goa/v3/grpc/codegen/testdata" +) + +type grpcPlanRetentionFixture struct { + root *expr.RootExpr + service *expr.GRPCServiceExpr + endpoint *expr.GRPCEndpointExpr + method *expr.MethodExpr +} + +// TestGRPCPlanIgnoresDesignChangesAfterPlanning checks endpoint membership, +// messages, metadata, validation, and streaming decisions separately. +func TestGRPCPlanIgnoresDesignChangesAfterPlanning(t *testing.T) { + baselineFixture := grpcPlanRetentionDSL(t) + baseline := renderGRPCPlanRetentionFixture(t, baselineFixture, nil) + + tests := []struct { + name string + mutate func(*grpcPlanRetentionFixture) + }{ + {"service membership", func(f *grpcPlanRetentionFixture) { + f.root.API.GRPC.Services = nil + }}, + {"server membership", func(f *grpcPlanRetentionFixture) { + f.root.API.Servers = nil + }}, + {"endpoint membership", func(f *grpcPlanRetentionFixture) { + f.service.GRPCEndpoints = append(f.service.GRPCEndpoints, &expr.GRPCEndpointExpr{}) + }}, + {"request messages", func(f *grpcPlanRetentionFixture) { + f.endpoint.Request.Type = expr.Empty + f.endpoint.StreamingRequest.Type = expr.Empty + }}, + {"response message and metadata", func(f *grpcPlanRetentionFixture) { + f.endpoint.Response.Message.Type = expr.Empty + f.endpoint.Response.Headers = expr.NewEmptyMappedAttributeExpr() + f.endpoint.Response.Trailers = expr.NewEmptyMappedAttributeExpr() + f.endpoint.Response.StatusCode = 13 + }}, + {"request metadata", func(f *grpcPlanRetentionFixture) { + f.endpoint.Metadata = expr.NewEmptyMappedAttributeExpr() + }}, + {"validation", func(f *grpcPlanRetentionFixture) { + f.endpoint.Request.Validation.Required = nil + field := expr.AsObject(f.method.Payload.Type).Attribute("value") + field.Validation.MinLength = nil + }}, + {"imports", func(f *grpcPlanRetentionFixture) { + field := expr.AsObject(f.method.Payload.Type).Attribute("value") + field.Meta["struct:field:type"] = []string{"time.Time", "time"} + }}, + {"streaming method", func(f *grpcPlanRetentionFixture) { + f.method.Stream = expr.NoStreamKind + f.method.StreamingPayload.Type = expr.Empty + f.method.StreamingResult.Type = expr.Empty + }}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + fixture := grpcPlanRetentionDSL(t) + actual := renderGRPCPlanRetentionFixture(t, fixture, test.mutate) + require.Equal(t, baseline, actual) + }) + } +} + +// TestGRPCPlanCopiesMissingStreamingResult checks that a unary method keeps +// its allowed nil streaming result when NewPlans copies the method. +func TestGRPCPlanCopiesMissingStreamingResult(t *testing.T) { + root := grpcPlanRoots(t, "Unary")[0] + require.Nil(t, root.API.GRPC.Services[0].GRPCEndpoints[0].MethodExpr.StreamingResult) + generation, services := grpcServicePlans(t, []*expr.RootExpr{root}) + plans, err := newPlans( + generation, + fixedProtobufToolResolver(), + PlanInput{Root: root, Service: services[0]}, + ) + require.NoError(t, err) + require.Nil(t, plans[0].servicesPlan[0].expression.GRPCEndpoints[0].MethodExpr.StreamingResult) +} + +// TestGRPCPlanCopiesMethodErrors checks that each copied gRPC error uses the +// matching copied method error, including Goa's built-in error value. +func TestGRPCPlanCopiesMethodErrors(t *testing.T) { + root := RunGRPCDSL(t, testdata.UnaryRPCWithErrorsDSL) + service, err := copyGRPCService(root.API.GRPC.Services[0]) + require.NoError(t, err) + endpoint := service.expression.GRPCEndpoints[0] + for _, grpcError := range endpoint.GRPCErrors { + require.Same(t, endpoint.MethodExpr.Error(grpcError.Name), grpcError.ErrorExpr) + } + require.Same(t, expr.ErrorResult, endpoint.MethodExpr.Error("timeout").Type) +} + +// TestGRPCPlanKeepsEmptyPayloadResponseConversion checks that a method without +// a payload still saves and renders its result conversion. +func TestGRPCPlanKeepsEmptyPayloadResponseConversion(t *testing.T) { + baseline := renderGRPCPlanRetentionFixture(t, grpcEmptyPayloadFixture(t), nil) + actual := renderGRPCPlanRetentionFixture(t, grpcEmptyPayloadFixture(t), func(f *grpcPlanRetentionFixture) { + f.method.Result.Type = expr.Empty + f.endpoint.Response.Message.Type = expr.Empty + }) + require.Equal(t, baseline, actual) +} + +// grpcPlanRetentionDSL creates one streaming endpoint with request and +// response metadata and message validation. +func grpcPlanRetentionDSL(t *testing.T) *grpcPlanRetentionFixture { + t.Helper() + fixture := new(grpcPlanRetentionFixture) + fixture.root = expr.RunDSL(t, func() { + payload := dsl.Type("SavedPayload", func() { + dsl.Field(1, "value", dsl.String, func() { dsl.MinLength(2) }) + dsl.Field(2, "token", dsl.String) + dsl.Required("value", "token") + }) + result := dsl.Type("SavedResult", func() { + dsl.Field(1, "value", dsl.String) + dsl.Field(2, "count", dsl.Int) + dsl.Required("value", "count") + }) + stream := dsl.Type("SavedStream", func() { + dsl.Field(1, "value", dsl.String) + dsl.Required("value") + }) + dsl.Service("SavedTransport", func() { + fixture.method = dsl.Method("Watch", func() { + dsl.Payload(payload) + dsl.StreamingPayload(stream) + dsl.StreamingResult(result) + dsl.GRPC(func() { + dsl.Metadata(func() { dsl.Attribute("token:authorization") }) + dsl.Response(dsl.CodeOK, func() { + dsl.Headers(func() { dsl.Attribute("count:x-count") }) + dsl.Trailers(func() { dsl.Attribute("value:x-value") }) + }) + }) + }) + }) + }) + fixture.service = fixture.root.API.GRPC.Services[0] + fixture.endpoint = fixture.service.GRPCEndpoints[0] + return fixture +} + +// grpcEmptyPayloadFixture creates one unary method with no payload and a +// custom result that needs a protobuf conversion. +func grpcEmptyPayloadFixture(t *testing.T) *grpcPlanRetentionFixture { + t.Helper() + fixture := new(grpcPlanRetentionFixture) + fixture.root = expr.RunDSL(t, func() { + result := dsl.Type("SavedEmptyPayloadResult", func() { + dsl.Field(1, "value", dsl.String) + dsl.Required("value") + }) + dsl.Service("SavedEmptyPayload", func() { + fixture.method = dsl.Method("Read", func() { + dsl.Result(result) + dsl.GRPC(func() {}) + }) + }) + }) + fixture.service = fixture.root.API.GRPC.Services[0] + fixture.endpoint = fixture.service.GRPCEndpoints[0] + return fixture +} + +// renderGRPCPlanRetentionFixture saves the design, applies one later change, +// and renders every non-example gRPC file. +func renderGRPCPlanRetentionFixture(t *testing.T, fixture *grpcPlanRetentionFixture, mutate func(*grpcPlanRetentionFixture)) []string { + t.Helper() + generation, services := grpcServicePlans(t, []*expr.RootExpr{fixture.root}) + plans, err := newPlans( + generation, + fixedProtobufToolResolver(), + PlanInput{Root: fixture.root, Service: services[0]}, + ) + require.NoError(t, err) + if mutate != nil { + mutate(fixture) + } + require.NoError(t, generation.Freeze()) + require.NoError(t, services[0].Link()) + require.NoError(t, plans[0].Link()) + + files := plans[0].ProtoFiles() + files = append(files, plans[0].ServerFiles()...) + files = append(files, plans[0].ClientFiles()...) + files = append(files, plans[0].ServerTypeFiles()...) + files = append(files, plans[0].ClientTypeFiles()...) + files = append(files, plans[0].ClientCLIFiles()...) + result := make([]string, len(files)) + for index, file := range files { + result[index] = file.Path + "\n" + sectionCode(t, file.SectionTemplates...) + } + sort.Strings(result) + return result +} diff --git a/grpc/codegen/plan_service_data_test.go b/grpc/codegen/plan_service_data_test.go new file mode 100644 index 0000000000..51c2b84b39 --- /dev/null +++ b/grpc/codegen/plan_service_data_test.go @@ -0,0 +1,70 @@ +// This file checks that retained gRPC plans link the exact service expressions +// and do not invent protobuf messages for payloads built only from metadata. +package codegen + +import ( + "testing" + + "github.com/stretchr/testify/require" + + "goa.design/goa/v3/dsl" +) + +func TestPlanServiceDataUsesExactExpressionAfterLink(t *testing.T) { + roots := grpcPlanRoots(t, "Calc") + generation, services := grpcServicePlans(t, roots) + plans, err := newPlans( + generation, + fixedProtobufToolResolver(), + PlanInput{Root: roots[0], Service: services[0]}, + ) + require.NoError(t, err) + require.PanicsWithValue(t, "gRPC files requested before plan linking", func() { + plans[0].ServiceData(roots[0].API.GRPC.Services[0]) + }) + + require.NoError(t, generation.Freeze()) + require.NoError(t, services[0].Link()) + require.NoError(t, plans[0].Link()) + + data, ok := plans[0].ServiceData(roots[0].API.GRPC.Services[0]) + require.True(t, ok) + require.Equal(t, "Calc", data.Name) + require.NotEmpty(t, data.ClientStruct) + require.NotEmpty(t, data.ServerStruct) + + foreign := grpcPlanRoots(t, "Calc") + data, ok = plans[0].ServiceData(foreign[0].API.GRPC.Services[0]) + require.False(t, ok) + require.Nil(t, data) +} + +// TestPlanLinksMetadataOnlyStreamingPayload verifies that a payload built only +// from metadata does not require a protobuf request message. +func TestPlanLinksMetadataOnlyStreamingPayload(t *testing.T) { + root := RunGRPCDSL(t, func() { + dsl.Service("Chatter", func() { + dsl.Method("Echo", func() { + dsl.Payload(func() { + dsl.Field(1, "token", dsl.String) + dsl.Required("token") + }) + dsl.StreamingPayload(dsl.String) + dsl.StreamingResult(dsl.String) + dsl.GRPC(func() { + dsl.Metadata(func() { + dsl.Attribute("token") + }) + }) + }) + }) + }) + + services := CreateGRPCServices(root) + request := services.Get("Chatter").Endpoints[0].Request + require.Nil(t, request.PayloadMessage) + require.NotNil(t, request.ServerConvert) + require.Empty(t, request.ServerConvert.SrcName) + require.Empty(t, request.ServerConvert.SrcRef) + require.Nil(t, request.ServerConvert.Validation) +} diff --git a/grpc/codegen/plan_test.go b/grpc/codegen/plan_test.go new file mode 100644 index 0000000000..4af4cab1cb --- /dev/null +++ b/grpc/codegen/plan_test.go @@ -0,0 +1,308 @@ +// This file verifies one gRPC plan keeps the exact design, service plan, and +// generated files selected for a generation run. +package codegen + +import ( + "fmt" + "path" + "sort" + "testing" + + "github.com/stretchr/testify/require" + + "goa.design/goa/v3/codegen" + "goa.design/goa/v3/codegen/example" + "goa.design/goa/v3/codegen/service" + "goa.design/goa/v3/dsl" + "goa.design/goa/v3/eval" + "goa.design/goa/v3/expr" +) + +// TestNewPlansKeepsExactInputs checks that each result keeps its input pair. +func TestNewPlansKeepsExactInputs(t *testing.T) { + roots := grpcPlanRoots(t, "First", "Second") + generation, services := grpcServicePlans(t, roots) + plans, err := NewPlans(generation, + PlanInput{Root: roots[1], Service: services[1]}, + PlanInput{Root: roots[0], Service: services[0]}, + ) + require.NoError(t, err) + require.Same(t, generation, plans[0].Generation()) + require.Same(t, roots[1], plans[0].Root()) + require.Same(t, services[1], plans[0].Service()) + require.Same(t, roots[0], plans[1].Root()) + require.Same(t, services[0], plans[1].Service()) +} + +// TestNewExamplePlanRejectsAnotherServicePlan checks that server names and +// URLs cannot come from a different design with the same authored names. +func TestNewExamplePlanRejectsAnotherServicePlan(t *testing.T) { + roots := grpcPlanRoots(t, "Service") + generation, services := grpcServicePlans(t, roots) + plans, err := newPlans(generation, fixedProtobufToolResolver(), PlanInput{Root: roots[0], Service: services[0]}) + require.NoError(t, err) + + otherRoots := grpcPlanRoots(t, "Service") + otherGeneration, otherServices := grpcServicePlans(t, otherRoots) + examples, err := example.NewPlan(otherGeneration, otherServices[0]) + require.NoError(t, err) + + _, err = NewExamplePlan(plans[0], examples) + require.EqualError(t, err, "gRPC examples require server data created from the same service design") +} + +// TestNewPlansRequiresEveryRoot checks that a batch cannot omit a design. +func TestNewPlansRequiresEveryRoot(t *testing.T) { + roots := grpcPlanRoots(t, "First", "Second") + generation, services := grpcServicePlans(t, roots) + _, err := NewPlans(generation, PlanInput{Root: roots[0], Service: services[0]}) + require.EqualError(t, err, "gRPC planning requires all 2 gRPC roots, got 1") +} + +// TestNewPlansRejectsDuplicateRoot checks that a batch cannot repeat a design. +func TestNewPlansRejectsDuplicateRoot(t *testing.T) { + roots := grpcPlanRoots(t, "First", "Second") + generation, services := grpcServicePlans(t, roots) + _, err := NewPlans(generation, + PlanInput{Root: roots[0], Service: services[0]}, + PlanInput{Root: roots[0], Service: services[0]}, + ) + require.EqualError(t, err, fmt.Sprintf("gRPC root %p is planned more than once", roots[0])) +} + +// TestNewPlansRejectsMismatchedServicePlan checks that input pairs must match. +func TestNewPlansRejectsMismatchedServicePlan(t *testing.T) { + roots := grpcPlanRoots(t, "First", "Second") + generation, services := grpcServicePlans(t, roots) + _, err := NewPlans(generation, + PlanInput{Root: roots[0], Service: services[1]}, + PlanInput{Root: roots[1], Service: services[0]}, + ) + require.EqualError(t, err, "gRPC plan input does not pair a design with its service plan") +} + +// TestPlanLinksOnceAfterFreeze checks the required link order. +func TestPlanLinksOnceAfterFreeze(t *testing.T) { + roots := grpcPlanRoots(t, "Calc") + generation, services := grpcServicePlans(t, roots) + plans, err := NewPlans(generation, PlanInput{Root: roots[0], Service: services[0]}) + require.NoError(t, err) + require.EqualError(t, plans[0].Link(), "gRPC plan cannot link before generation freeze") + require.NoError(t, generation.Freeze()) + require.NoError(t, services[0].Link()) + require.NoError(t, plans[0].Link()) + require.EqualError(t, plans[0].Link(), "gRPC plan is already linked") +} + +// TestPlanLinksStoredServicesAfterRootListRemoved checks that Link builds the +// services selected by NewPlans without reading the root service list again. +func TestPlanLinksStoredServicesAfterRootListRemoved(t *testing.T) { + roots := grpcPlanRoots(t, "Calc") + generation, services := grpcServicePlans(t, roots) + plans, err := NewPlans(generation, PlanInput{Root: roots[0], Service: services[0]}) + require.NoError(t, err) + require.NoError(t, generation.Freeze()) + require.NoError(t, services[0].Link()) + + roots[0].API.GRPC.Services = nil + require.NoError(t, plans[0].Link()) + require.NotEmpty(t, plans[0].ProtoFiles()) + require.NotEmpty(t, plans[0].ServerFiles()) + require.NotEmpty(t, plans[0].ClientFiles()) + require.NotEmpty(t, plans[0].ServerTypeFiles()) + require.NotEmpty(t, plans[0].ClientTypeFiles()) +} + +// TestPlanReturnsStoredFiles checks that later reads reuse the linked files. +func TestPlanReturnsStoredFiles(t *testing.T) { + roots := grpcPlanRoots(t, "Calc") + generation, services := grpcServicePlans(t, roots) + plans, err := NewPlans(generation, PlanInput{Root: roots[0], Service: services[0]}) + require.NoError(t, err) + require.NoError(t, generation.Freeze()) + require.NoError(t, services[0].Link()) + require.NoError(t, plans[0].Link()) + want := grpcPlanFileSignatures(t, plans[0]) + roots[0].API.GRPC.Services = append(roots[0].API.GRPC.Services, &expr.GRPCServiceExpr{}) + require.Equal(t, want, grpcPlanFileSignatures(t, plans[0])) + require.Equal(t, want, grpcPlanFileSignatures(t, plans[0])) +} + +// TestNewPlansIsIndependentOfInputOrder checks that input order does not +// change files written to the same generated packages. +func TestNewPlansIsIndependentOfInputOrder(t *testing.T) { + forwardNames, forwardErr := collidingGRPCPlanResult(t, false) + reverseNames, reverseErr := collidingGRPCPlanResult(t, true) + require.Empty(t, forwardErr) + require.Empty(t, reverseErr) + require.Equal(t, forwardNames, reverseNames) +} + +// TestGRPCCLIImportAliasesBelongToOutputPackage checks that a name used by an +// unrelated HTTP package cannot change the qualifier written by the gRPC +// command parser package. +func TestGRPCCLIImportAliasesBelongToOutputPackage(t *testing.T) { + roots := grpcPlanRoots(t, "Echo") + generation, services := grpcServicePlans(t, roots) + httpPackage, err := generation.ClaimPackage("generated.local/gen/http/unrelated/client") + require.NoError(t, err) + require.NoError(t, httpPackage.ReserveGeneratedImport(codegen.NewImport("echoc", "example.com/unrelated/client"))) + + plans, err := NewPlans(generation, PlanInput{Root: roots[0], Service: services[0]}) + require.NoError(t, err) + require.NoError(t, generation.Freeze()) + require.NoError(t, services[0].Link()) + require.NoError(t, plans[0].Link()) + + serverName := codegen.SnakeCase(codegen.Goify(roots[0].API.Servers[0].Name, true)) + cliPackage := generation.Package(path.Join(generation.GenPkg(), "grpc", "cli", serverName)) + clientPath := path.Join(generation.GenPkg(), "grpc", "echo", "client") + require.Equal(t, "echoc", cliPackage.ImportName(clientPath)) +} + +// TestGRPCReferencesKeepNamesWhenStandardPackageIsUnused checks that a service +// package named strings keeps that name when generated files do not call the +// standard strings package. +func TestGRPCReferencesKeepNamesWhenStandardPackageIsUnused(t *testing.T) { + roots := grpcPlanRoots(t, "Strings") + generation, services := grpcServicePlans(t, roots) + plans, err := NewPlans(generation, PlanInput{Root: roots[0], Service: services[0]}) + require.NoError(t, err) + require.NoError(t, generation.Freeze()) + require.NoError(t, services[0].Link()) + require.NoError(t, plans[0].Link()) + + clientCode := sectionCode(t, plans[0].ClientFiles()[1].SectionTemplates...) + serverCode := sectionCode(t, plans[0].ServerFiles()[0].SectionTemplates...) + require.Contains(t, clientCode, `strings "generated.local/gen/strings"`) + require.Contains(t, clientCode, `*strings.ReadPayload`) + require.Contains(t, serverCode, `strings "generated.local/gen/strings"`) + require.Contains(t, serverCode, `*strings.Endpoints`) +} + +// TestGRPCServerUsesItsOwnProtobufAlias checks that the runtime protobuf +// import used by a client cannot carry its suffixed alias into server source. +func TestGRPCServerUsesItsOwnProtobufAlias(t *testing.T) { + root := expr.RunDSL(t, func() { + for _, serviceName := range []string{"Goa", "Goapb"} { + dsl.Service(serviceName, func() { + dsl.Method("Read", func() { + dsl.Payload(func() { dsl.Field(1, "value", dsl.String) }) + dsl.GRPC(func() {}) + }) + }) + } + }) + generation, services := grpcServicePlans(t, []*expr.RootExpr{root}) + plans, err := NewPlans(generation, PlanInput{Root: root, Service: services[0]}) + require.NoError(t, err) + require.NoError(t, generation.Freeze()) + require.NoError(t, services[0].Link()) + require.NoError(t, plans[0].Link()) + + serverCode := sectionCode(t, plans[0].ServerFiles()[0].SectionTemplates...) + require.Contains(t, serverCode, `goapb "generated.local/gen/grpc/goa/pb"`) + require.NotContains(t, serverCode, "goapb2.") +} + +// grpcPlanRoots creates independent designs with one unary gRPC method. +func grpcPlanRoots(t *testing.T, serviceNames ...string) []*expr.RootExpr { + t.Helper() + roots := make([]*expr.RootExpr, len(serviceNames)) + for index, serviceName := range serviceNames { + roots[index] = expr.RunDSL(t, func() { + dsl.Service(serviceName, func() { + dsl.Method("Read", func() { + dsl.Payload(func() { dsl.Field(1, "value", dsl.String) }) + dsl.Result(func() { dsl.Field(1, "value", dsl.String) }) + dsl.GRPC(func() {}) + }) + }) + }) + } + return roots +} + +// grpcServicePlans creates one generation and the service plan for each root. +func grpcServicePlans(t *testing.T, roots []*expr.RootExpr) (*codegen.Generation, []*service.Plan) { + t.Helper() + evaluated := make([]eval.Root, len(roots)) + inputs := make([]service.PlanInput, len(roots)) + for index, root := range roots { + evaluated[index] = root + inputs[index] = service.PlanInput{ + Root: root, + Examples: expr.NewExampleGenerator(root.API.RandomizerFactory), + } + } + generation, err := codegen.NewGeneration("generated.local/gen", evaluated) + require.NoError(t, err) + plans, err := service.NewPlans(generation, inputs...) + require.NoError(t, err) + return generation, plans +} + +// grpcPlanFileSignatures renders every stored file for a stable comparison. +func grpcPlanFileSignatures(t *testing.T, plan *Plan) []string { + t.Helper() + files := plan.ProtoFiles() + files = append(files, plan.ServerFiles()...) + files = append(files, plan.ClientFiles()...) + files = append(files, plan.ServerTypeFiles()...) + files = append(files, plan.ClientTypeFiles()...) + files = append(files, plan.ClientCLIFiles()...) + signatures := make([]string, len(files)) + for index, file := range files { + signatures[index] = file.Path + "\n" + sectionCode(t, file.SectionTemplates...) + } + sort.Strings(signatures) + return signatures +} + +// collidingGRPCPlanResult returns stored files or the exact planning error for +// two designs that write the same gRPC and protobuf packages. +func collidingGRPCPlanResult(t *testing.T, reverse bool) ([]string, string) { + t.Helper() + makeRoot := func(serviceName, typeName string) *expr.RootExpr { + return expr.RunDSL(t, func() { + choice := dsl.Type(typeName, func() { + dsl.Meta("struct:name:proto", "API2_Choice") + dsl.OneOf("state", func() { + dsl.Field(1, "api2URL", dsl.String) + dsl.Field(2, "reset", dsl.String) + }) + }) + dsl.Service(serviceName, func() { + dsl.Method("Sync2URL", func() { + dsl.Payload(choice) + dsl.StreamingPayload(choice) + dsl.StreamingResult(choice) + dsl.GRPC(func() {}) + }) + }) + }) + } + roots := []*expr.RootExpr{makeRoot("Foo Bar", "FirstChoice"), makeRoot("Foo-Bar", "SecondChoice")} + generation, services := grpcServicePlans(t, roots) + inputs := []PlanInput{{Root: roots[0], Service: services[0]}, {Root: roots[1], Service: services[1]}} + if reverse { + inputs[0], inputs[1] = inputs[1], inputs[0] + } + plans, err := NewPlans(generation, inputs...) + if err != nil { + return nil, err.Error() + } + require.NoError(t, generation.Freeze()) + for _, servicePlan := range services { + require.NoError(t, servicePlan.Link()) + } + var names []string + for _, plan := range plans { + if err := plan.Link(); err != nil { + return nil, err.Error() + } + names = append(names, grpcPlanFileSignatures(t, plan)...) + } + sort.Strings(names) + return names, "" +} diff --git a/grpc/codegen/planned_name_collision_test.go b/grpc/codegen/planned_name_collision_test.go new file mode 100644 index 0000000000..15edeaad38 --- /dev/null +++ b/grpc/codegen/planned_name_collision_test.go @@ -0,0 +1,133 @@ +// This file checks that gRPC definitions and their callers use the package +// names selected after preferred names are already taken. +package codegen + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/require" + + "goa.design/goa/v3/codegen" + "goa.design/goa/v3/codegen/testutil" + "goa.design/goa/v3/expr" +) + +// TestGRPCPlannedNamesSurvivePackageCollisions covers endpoint functions, +// stream types, conversion constructors, and validators in both definitions +// and calls. +func TestGRPCPlannedNamesSurvivePackageCollisions(t *testing.T) { + fixture := grpcPlanRetentionDSL(t) + generation, services := grpcServicePlans(t, []*expr.RootExpr{fixture.root}) + clientPackage, err := generation.ClaimPackage("generated.local/gen/grpc/saved_transport/client") + require.NoError(t, err) + serverPackage, err := generation.ClaimPackage("generated.local/gen/grpc/saved_transport/server") + require.NoError(t, err) + for _, declaration := range []*codegen.NameDeclaration{ + codegen.NewExactName(codegen.NameFunction, "BuildWatchFunc"), + codegen.NewExactName(codegen.NameFunction, "EncodeWatchRequest"), + codegen.NewExactName(codegen.NameFunction, "DecodeWatchResponse"), + codegen.NewExactName(codegen.NameFunction, "NewProtoWatchRequest"), + codegen.NewExactName(codegen.NameType, "WatchClientStream"), + } { + require.NoError(t, clientPackage.DeclareName(declaration)) + } + for _, declaration := range []*codegen.NameDeclaration{ + codegen.NewExactName(codegen.NameFunction, "NewWatchHandler"), + codegen.NewExactName(codegen.NameFunction, "DecodeWatchRequest"), + codegen.NewExactName(codegen.NameFunction, "EncodeWatchResponse"), + codegen.NewExactName(codegen.NameFunction, "ValidateWatchRequest"), + codegen.NewExactName(codegen.NameType, "WatchServerStream"), + } { + require.NoError(t, serverPackage.DeclareName(declaration)) + } + plans, err := newPlans( + generation, + fixedProtobufToolResolver(), + PlanInput{Root: fixture.root, Service: services[0]}, + ) + require.NoError(t, err) + require.NoError(t, generation.Freeze()) + require.NoError(t, services[0].Link()) + require.NoError(t, plans[0].Link()) + + data, ok := plans[0].ServiceData(fixture.service) + require.True(t, ok) + endpoint := data.Endpoints[0] + require.NotEqual(t, "BuildWatchFunc", endpoint.ClientBuildDeclaration.Name()) + require.NotEqual(t, "EncodeWatchRequest", endpoint.ClientEncodeDeclaration.Name()) + require.NotEqual(t, "DecodeWatchResponse", endpoint.ClientDecodeDeclaration.Name()) + require.NotEqual(t, "NewProtoWatchRequest", endpoint.Request.ClientConvert.Init.Declaration.Name()) + require.NotEqual(t, "NewWatchHandler", endpoint.ServerHandlerDeclaration.Name()) + require.NotEqual(t, "DecodeWatchRequest", endpoint.ServerDecodeDeclaration.Name()) + require.NotEqual(t, "EncodeWatchResponse", endpoint.ServerEncodeDeclaration.Name()) + require.NotEqual(t, "ValidateWatchRequest", endpoint.Request.ServerConvert.Validation.Declaration.Name()) + require.NotEqual(t, "WatchClientStream", endpoint.ClientStream.Declaration.Name()) + require.NotEqual(t, "WatchServerStream", endpoint.ServerStream.Declaration.Name()) + + var source strings.Builder + for _, selection := range []struct { + files []*codegen.File + section string + }{ + {plans[0].ClientFiles(), "remote-method-builder"}, + {plans[0].ClientFiles(), "request-encoder"}, + {plans[0].ClientFiles(), "response-decoder"}, + {plans[0].ClientFiles(), "client-endpoint-init"}, + {plans[0].ClientFiles(), "client-stream-struct-type"}, + {plans[0].ServerFiles(), "request-decoder"}, + {plans[0].ServerFiles(), "response-encoder"}, + {plans[0].ServerFiles(), "grpc-handler-init"}, + {plans[0].ServerFiles(), "server-grpc-interface"}, + {plans[0].ServerFiles(), "server-stream-struct-type"}, + } { + sections := matchingGRPCSections(selection.files, selection.section, endpoint) + require.NotEmpty(t, sections, "missing %s section", selection.section) + source.WriteString(codegen.SectionsCode(t, sections)) + source.WriteString("\n") + } + for _, selection := range []struct { + files []*codegen.File + section string + }{ + {plans[0].ClientTypeFiles(), "client-type-init"}, + {plans[0].ServerTypeFiles(), "server-type-init"}, + {plans[0].ServerTypeFiles(), "server-validate"}, + } { + sections := namedGRPCSections(selection.files, selection.section) + require.NotEmpty(t, sections, "missing %s section", selection.section) + source.WriteString(codegen.SectionsCode(t, sections)) + source.WriteString("\n") + } + testutil.AssertGo(t, "testdata/golden/planned_name_collisions.go.golden", strings.TrimSpace(source.String())+"\n") +} + +// namedGRPCSections returns every section with name in file order. +func namedGRPCSections(files []*codegen.File, name string) []*codegen.SectionTemplate { + result := make([]*codegen.SectionTemplate, 0, len(files)) + for _, file := range files { + result = append(result, file.Section(name)...) + } + return result +} + +// matchingGRPCSections returns sections for endpoint without depending on file +// order or on the number of other sections in the file. +func matchingGRPCSections(files []*codegen.File, name string, endpoint *EndpointData) []*codegen.SectionTemplate { + var result []*codegen.SectionTemplate + for _, file := range files { + for _, section := range file.Section(name) { + switch data := section.Data.(type) { + case *EndpointData: + if data == endpoint { + result = append(result, section) + } + case *StreamData: + if data.Endpoint == endpoint { + result = append(result, section) + } + } + } + } + return result +} diff --git a/grpc/codegen/proto.go b/grpc/codegen/proto.go index 88e1db6f3a..99763062c6 100644 --- a/grpc/codegen/proto.go +++ b/grpc/codegen/proto.go @@ -1,3 +1,5 @@ +// This file builds protobuf schema files and compiles each one with the tools +// selected before rendering starts. package codegen import ( @@ -5,6 +7,7 @@ import ( "os" "os/exec" "path/filepath" + "strconv" "strings" "goa.design/goa/v3/codegen" @@ -19,19 +22,27 @@ const ( ProtoPrefix = "goagen" ) -// ProtoFiles returns the protobuf file for every gRPC service. -func ProtoFiles(genpkg string, services *ServicesData) []*codegen.File { - fw := make([]*codegen.File, len(services.Root.API.GRPC.Services)) - for i, svc := range services.Root.API.GRPC.Services { - fw[i] = protoFile(genpkg, svc, services) +// defaultProtocCmd selects protoc when the design does not choose a compiler. +var defaultProtocCmd = []string{expr.DefaultProtoc} + +// protoFiles returns the planned protobuf file for every gRPC service. +func protoFiles(services *ServicesData) []*codegen.File { + fw := make([]*codegen.File, len(services.servicePlans)) + for i, servicePlan := range services.servicePlans { + fw[i] = protoFile(servicePlan.expression, services) } return fw } // protoFile returns the protobuf file defining the specified service. -func protoFile(genpkg string, svc *expr.GRPCServiceExpr, services *ServicesData) *codegen.File { +func protoFile(svc *expr.GRPCServiceExpr, services *ServicesData) *codegen.File { + genpkg := services.GenPkg() data := services.Get(svc.Name()) svcName := data.Service.PathName + fileServiceName := svcName + if planned := services.protobuf[svc]; planned != nil && planned.fileIndex > 1 { + fileServiceName += strconv.Itoa(planned.fileIndex) + } parts := strings.Split(genpkg, "/") var repoName string if len(parts) > 1 { @@ -39,8 +50,9 @@ func protoFile(genpkg string, svc *expr.GRPCServiceExpr, services *ServicesData) } else { repoName = parts[0] } - // the filename is used by protoc to set the namespace so try to make it unique - fname := fmt.Sprintf("%s_%s_%s.proto", ProtoPrefix, repoName, svcName) + // Include the repository and service so two services do not write the same + // file. + fname := fmt.Sprintf("%s_%s_%s.proto", ProtoPrefix, repoName, fileServiceName) path := filepath.Join(codegen.Gendir, "grpc", svcName, pbPkgName, fname) sections := make([]*codegen.SectionTemplate, 0, 3+len(data.Messages)) @@ -80,31 +92,22 @@ func protoFile(genpkg string, svc *expr.GRPCServiceExpr, services *ServicesData) }) } - runProtoc := func(path string) error { - includes := svc.ServiceExpr.Meta["protoc:include"] - includes = append(includes, services.Root.API.Meta["protoc:include"]...) - - cmd := defaultProtocCmd - if c, ok := services.Root.API.Meta["protoc:cmd"]; ok { - cmd = c - } - if c, ok := svc.ServiceExpr.Meta["protoc:cmd"]; ok { - cmd = c - } - if len(cmd) == 0 { - return fmt.Errorf(`Meta("protoc:cmd"): must be given arguments`) - } - - return protoc(cmd, path, includes) + tools := services.tools[svc] + if tools == nil { + panic(fmt.Sprintf("protobuf tools for service %q were not planned", svc.Name())) } return &codegen.File{ Path: path, SectionTemplates: sections, - FinalizeFunc: runProtoc, + FinalizeFunc: func(path string) error { + return runProtoc(tools, path) + }, } } +// pkgName returns the protobuf package chosen by the design or the service +// name used when the design does not choose one. func pkgName(svc *expr.GRPCServiceExpr, svcName string) string { if svc.ProtoPkg != "" { return svc.ProtoPkg @@ -112,9 +115,8 @@ func pkgName(svc *expr.GRPCServiceExpr, svcName string) string { return codegen.SnakeCase(svcName) } -var defaultProtocCmd = []string{expr.DefaultProtoc} - -func protoc(protocCmd []string, path string, includes []string) error { +// runProtoc compiles one schema with the command fixed during planning. +func runProtoc(tools *protobufToolPlan, path string) error { dir := filepath.Dir(path) if err := os.MkdirAll(dir, 0750); err != nil { return err @@ -127,11 +129,14 @@ func protoc(protocCmd []string, path string, includes []string) error { "--go-grpc_out", dir, "--go_opt=paths=source_relative", "--go-grpc_opt=paths=source_relative", + "--plugin=protoc-gen-go=" + tools.goPlugin, + "--plugin=protoc-gen-go-grpc=" + tools.goGRPCPlugin, } - for _, include := range includes { + for _, include := range tools.includes { args = append(args, "-I", include) } - cmd := exec.Command(protocCmd[0], append(protocCmd[1:len(protocCmd):len(protocCmd)], args...)...) + command := tools.command + cmd := exec.Command(command[0], append(command[1:len(command):len(command)], args...)...) cmd.Dir = filepath.Dir(path) if output, err := cmd.CombinedOutput(); err != nil { diff --git a/grpc/codegen/proto_hooks.go b/grpc/codegen/proto_hooks.go index 82438a96e6..b731c83a4d 100644 --- a/grpc/codegen/proto_hooks.go +++ b/grpc/codegen/proto_hooks.go @@ -1,8 +1,12 @@ +// This file tells the shared Go conversion code how protobuf wrappers, +// collections, unions, and nil values differ from service values. package codegen import ( "bytes" "fmt" + "reflect" + "slices" "strings" "text/template" @@ -10,23 +14,61 @@ import ( "goa.design/goa/v3/expr" ) +type ( + // protobufOneofAttributor returns the Go wrapper type generated for one + // protobuf union branch. + protobufOneofAttributor interface { + OneofWrapper(*expr.AttributeExpr) string + } + + // grpcHelperAttributePair stops comparison after two recursive type fields + // have already been compared. + grpcHelperAttributePair struct { + first *expr.AttributeExpr + next *expr.AttributeExpr + } +) + var ( - // renderGoArrayT is the template rendering protocol buffer array - // transformations driven by the shared transform engine. + // transformToProtoProgram owns the immutable service-to-protobuf rules used + // by every generated gRPC package. + transformToProtoProgram *codegen.TransformProgram + // transformFromProtoProgram owns the immutable protobuf-to-service rules used + // by every generated gRPC package. + transformFromProtoProgram *codegen.TransformProgram + // renderGoArrayT writes a conversion between service and protobuf arrays. renderGoArrayT *template.Template - // renderGoMapT is the template rendering protocol buffer map - // transformations driven by the shared transform engine. + // renderGoMapT writes a conversion between service and protobuf maps. renderGoMapT *template.Template - // renderGoUnionToProtoT is the template rendering Go union to protobuf - // oneof transformations. + // renderGoUnionToProtoT writes a service union into a protobuf oneof. renderGoUnionToProtoT *template.Template - // renderGoUnionFromProtoT is the template rendering protobuf oneof to - // Go union transformations. + // renderGoUnionFromProtoT writes a protobuf oneof into a service union. renderGoUnionFromProtoT *template.Template ) -// NOTE: can't initialize inline because https://github.com/golang/go/issues/1817 +// grpcTransformProgram returns the one conversion program for a direction. +// Plans made from the returned value may share helpers after their complete +// generated type layouts and child calls have been compared. +func grpcTransformProgram(proto bool) *codegen.TransformProgram { + if proto { + return transformToProtoProgram + } + return transformFromProtoProgram +} + +// The templates are initialized here because Go cannot initialize this cycle +// of template functions in the variable declarations. func init() { + var err error + transformToProtoProgram, err = codegen.NewTransformProgram(protoHooks(true)) + if err != nil { + panic(fmt.Sprintf("create service-to-protobuf transform program: %s", err)) + } + transformFromProtoProgram, err = codegen.NewTransformProgram(protoHooks(false)) + if err != nil { + panic(fmt.Sprintf("create protobuf-to-service transform program: %s", err)) + } + fm := template.FuncMap{"transformAttribute": codegen.TransformAttribute} renderGoArrayT = template.Must(template.New("renderGoArray").Funcs(fm).Parse(grpcTemplates.Read(grpcTransformGoArrayT))) renderGoMapT = template.Must(template.New("renderGoMap").Funcs(fm).Parse(grpcTemplates.Read(grpcTransformGoMapT))) @@ -34,24 +76,23 @@ func init() { renderGoUnionFromProtoT = template.Must(template.New("renderGoUnionFromProto").Parse(grpcTemplates.Read(grpcTransformGoUnionFromProtoT))) } -// protoHooks returns the transform hooks that specialize the shared Go -// transform engine for protocol buffer transformations. proto is true when -// the transformation initializes a protocol buffer type from a service type -// and false when it initializes a service type from a protocol buffer type. -// targetCtx is the target attribute context of the transformation; it is used -// to name the synthetic wrapper messages initialized by the generated code. -func protoHooks(proto bool, targetCtx *codegen.AttributeContext) *codegen.TransformHooks { +// protoHooks returns the functions that convert between service and protobuf +// values. proto is true when the target is the protobuf value. +func protoHooks(proto bool) *codegen.TransformHooks { return &codegen.TransformHooks{ UnwrapPair: func(src, tgt *expr.AttributeExpr) (*expr.AttributeExpr, *expr.AttributeExpr, *codegen.WrapDirective) { if proto { if isWrappedAttr(tgt) { - name := targetCtx.Scope.Name(tgt, targetCtx.Pkg(tgt), targetCtx.Pointer, targetCtx.UseDefault) - return src, unwrapAttr(expr.DupAtt(tgt)), &codegen.WrapDirective{WrapTarget: true, InitTypeName: name, FieldName: "Field"} + return src, unwrapAttr(tgt), &codegen.WrapDirective{ + WrapTarget: true, + Target: tgt, + FieldName: "Field", + } } return src, tgt, nil } if isWrappedAttr(src) { - return unwrapAttr(expr.DupAtt(src)), tgt, &codegen.WrapDirective{FieldName: "Field"} + return unwrapAttr(src), tgt, &codegen.WrapDirective{FieldName: "Field"} } return src, tgt, nil }, @@ -61,8 +102,7 @@ func protoHooks(proto bool, targetCtx *codegen.AttributeContext) *codegen.Transf ConvertPrimitive: func(src, tgt *expr.AttributeExpr, srcVar string, srcPtr, tgtPtr bool, ta *codegen.TransformAttrs) (string, bool) { exp := convertType(src, tgt, srcPtr, tgtPtr, srcVar, proto, ta) if _, isSrcUT := src.Type.(expr.UserType); isSrcUT && !proto { - // If the source is an alias type and the code is initializing a - // service type then we must cast to the alias type. + // A service alias must keep its named Go type. deref := "" if srcPtr { deref = "*" @@ -77,37 +117,26 @@ func protoHooks(proto bool, targetCtx *codegen.AttributeContext) *codegen.Transf TransformMap: func(source, target *expr.Map, sourceVar, targetVar string, newVar bool, ta *codegen.TransformAttrs) (string, error) { return renderMapTransform(source, target, sourceVar, targetVar, newVar, proto, ta) }, - TransformUnion: func(source, target *expr.AttributeExpr, sourceVar, targetVar string, _ bool, srcParent, tgtParent *expr.AttributeExpr, ta *codegen.TransformAttrs) (string, error) { + TransformUnion: func(source, target *expr.AttributeExpr, sourceVar, targetVar string, _ bool, _, _ *expr.AttributeExpr, ta *codegen.TransformAttrs) (string, error) { if proto { - // Go service union fields are interfaces (nil when absent); do - // not dereference. - return renderUnionToProtoTransform(source, target, sourceVar, targetVar, false, oneofMessageName(tgtParent, proto, ta), ta) + // The service union accessor returns the selected branch value. + return renderUnionToProtoTransform(source, target, sourceVar, targetVar, false, ta) } - // Service unions in Goa are represented as interface types, not - // *interface. Always assign concrete values to the interface (no - // pointer-to-interface). - return renderUnionFromProtoTransform(source, target, sourceVar, targetVar, oneofMessageName(srcParent, proto, ta), ta) + // Select the matching branch in the service union. + return renderUnionFromProtoTransform(source, target, sourceVar, targetVar, ta) }, - HelperNameAttrs: func(src, tgt *expr.AttributeExpr) (*expr.AttributeExpr, *expr.AttributeExpr) { - // Do not consider package overrides for protogen generated types. - if proto { - tgt = expr.DupAtt(tgt) - codegen.Walk(tgt, func(att *expr.AttributeExpr) error { // nolint: errcheck - delete(att.Meta, "struct:pkg:path") - return nil - }) - } else { - src = expr.DupAtt(src) - codegen.Walk(src, func(att *expr.AttributeExpr) error { // nolint: errcheck - delete(att.Meta, "struct:pkg:path") - return nil - }) + PlanUnionHelpers: func(source, target *expr.AttributeExpr, record func(*expr.AttributeExpr, *expr.AttributeExpr)) { + sourceUnion, targetUnion := expr.AsUnion(source.Type), expr.AsUnion(target.Type) + for index, sourceBranch := range sourceUnion.Values { + targetBranch := targetUnion.Values[index] + if protoUnionBranchUsesHelper(sourceBranch.Attribute, targetBranch.Attribute) { + record(sourceBranch.Attribute, targetBranch.Attribute) + } } - return src, tgt }, + SameHelperDefinition: sameGRPCHelperDefinition, GuardCondition: func(src *expr.AttributeExpr, srcVar string, _, srcPtr bool) (string, bool) { - // Non-primitives are always guarded (proto3 message fields are - // always nilable). + // Protobuf message fields can be nil, so check them before use. if expr.IsPrimitive(src.Type) && !srcPtr { return "", true } @@ -126,7 +155,7 @@ func protoHooks(proto bool, targetCtx *codegen.AttributeContext) *codegen.Transf return "", false }, ObjectDeref: func(tgt *expr.AttributeExpr) (string, bool) { - // if the target is a raw struct no need to return a pointer + // An unnamed struct value does not need a pointer. if _, ok := tgt.Type.(*expr.Object); ok { return "", true } @@ -136,28 +165,117 @@ func protoHooks(proto bool, targetCtx *codegen.AttributeContext) *codegen.Transf } } -// oneofMessageName returns the reference to the protoc generated Go type of -// the message containing the oneof being transformed: protoc builds the union -// wrapper struct type names from the parent message type name. parent is the -// attribute of the object owning the union field, nil when the union is -// transformed directly in which case there is no message context. -func oneofMessageName(parent *expr.AttributeExpr, proto bool, ta *codegen.TransformAttrs) string { - if parent == nil { - return "" - } - if _, ok := parent.Type.(expr.UserType); !ok { - return "" +// sameGRPCHelperDefinition compares every retained attribute fact except the +// protobuf field number. A field number chooses its serialized position but +// cannot change the Go code that copies its value. +func sameGRPCHelperDefinition(firstSource, firstTarget, nextSource, nextTarget *expr.AttributeExpr) bool { + return sameGRPCHelperAttribute(firstSource, nextSource, make(map[grpcHelperAttributePair]struct{})) && + sameGRPCHelperAttribute(firstTarget, nextTarget, make(map[grpcHelperAttributePair]struct{})) +} + +// sameGRPCHelperAttribute compares the facts that can change gRPC conversion +// code. Field descriptions, documentation, examples, and validation limits do +// not affect that code. Required fields, defaults, generated field types, and +// protobuf wrappers do. +func sameGRPCHelperAttribute(first, next *expr.AttributeExpr, seen map[grpcHelperAttributePair]struct{}) bool { + if first == nil || next == nil { + return first == next } - if proto { - return ta.TargetCtx.Scope.Name(parent, ta.TargetCtx.Pkg(parent), false, false) + pair := grpcHelperAttributePair{first: first, next: next} + if _, ok := seen[pair]; ok { + return true + } + seen[pair] = struct{}{} + if !sameGRPCHelperMeta(first.Meta, next.Meta) || + !reflect.DeepEqual(first.DefaultValue, next.DefaultValue) { + return false } - return ta.SourceCtx.Scope.Ref(parent, ta.SourceCtx.Pkg(parent)) + firstObject := expr.AsObject(first.Type) + nextObject := expr.AsObject(next.Type) + if (firstObject == nil) != (nextObject == nil) { + return false + } + if firstObject != nil { + firstMapped := expr.NewMappedAttributeExpr(first) + nextMapped := expr.NewMappedAttributeExpr(next) + for _, field := range *firstObject { + name := strings.SplitN(field.Name, ":", 2)[0] + if firstMapped.IsRequired(name) != nextMapped.IsRequired(name) { + return false + } + } + } + return sameGRPCHelperDataType(first.Type, next.Type, seen) } -// renderArrayTransform renders the code transforming the source array held by -// sourceVar into the target array held by targetVar. proto is true when the -// target is the protocol buffer type. Wrapped element types are passed -// through to the engine which unwraps them when recursing. +// sameGRPCHelperMeta compares only metadata read while gRPC conversion code +// is planned or written. Other metadata can change generated documentation or +// validation, but it cannot change a conversion function. +func sameGRPCHelperMeta(first, next expr.MetaExpr) bool { + for _, name := range []string{ + "struct:field:proto", + "struct:field:type", + wrappedAttrMeta, + } { + if !slices.Equal(first[name], next[name]) { + return false + } + } + return true +} + +// sameGRPCHelperDataType compares the nested fields and collection rules that +// gRPC conversion hooks may inspect while writing one helper body. +func sameGRPCHelperDataType(first, next expr.DataType, seen map[grpcHelperAttributePair]struct{}) bool { + if first == nil || next == nil || reflect.TypeOf(first) != reflect.TypeOf(next) { + return first == next + } + switch first := first.(type) { + case expr.Primitive: + return first == next.(expr.Primitive) + case *expr.Array: + next := next.(*expr.Array) + return first.NonNullableElems == next.NonNullableElems && + sameGRPCHelperAttribute(first.ElemType, next.ElemType, seen) + case *expr.Map: + next := next.(*expr.Map) + return sameGRPCHelperAttribute(first.KeyType, next.KeyType, seen) && + sameGRPCHelperAttribute(first.ElemType, next.ElemType, seen) + case *expr.Object: + next := next.(*expr.Object) + if len(*first) != len(*next) { + return false + } + for index, field := range *first { + other := (*next)[index] + if field.Name != other.Name || !sameGRPCHelperAttribute(field.Attribute, other.Attribute, seen) { + return false + } + } + return true + case *expr.Union: + next := next.(*expr.Union) + if first.TypeName != next.TypeName || first.TypeKey != next.TypeKey || + first.ValueKey != next.ValueKey || len(first.Values) != len(next.Values) { + return false + } + for index, branch := range first.Values { + other := next.Values[index] + if branch.Name != other.Name || !sameGRPCHelperAttribute(branch.Attribute, other.Attribute, seen) { + return false + } + } + return true + case expr.UserType: + return sameGRPCHelperAttribute(first.Attribute(), next.(expr.UserType).Attribute(), seen) + default: + panic(fmt.Sprintf("cannot compare gRPC helper type %T", first)) + } +} + +// renderArrayTransform writes code that copies sourceVar into the target array. +// proto is true when the target is a protobuf array. The shared conversion code +// opens any protobuf wrapper around an array element. func renderArrayTransform(source, target *expr.Array, sourceVar, targetVar string, newVar, proto bool, ta *codegen.TransformAttrs) (string, error) { elem := target.ElemType if proto { @@ -170,15 +288,20 @@ func renderArrayTransform(source, target *expr.Array, sourceVar, targetVar strin valVar = "" } + loopVar, childAttrs := ta.EnterCollection() + childAttrs, err := childAttrs.EnterLocalBlock(sourceVar, targetVar, loopVar, "val") + if err != nil { + return "", err + } data := map[string]any{ "ElemTypeRef": targetRef, "SourceElem": source.ElemType, - "TargetElem": elem, + "TargetElem": target.ElemType, "SourceVar": sourceVar, "TargetVar": targetVar, "NewVar": newVar, - "TransformAttrs": ta, - "LoopVar": string(rune(105 + strings.Count(targetVar, "["))), + "TransformAttrs": childAttrs, + "LoopVar": loopVar, "ValVar": valVar, } var buf bytes.Buffer @@ -188,11 +311,9 @@ func renderArrayTransform(source, target *expr.Array, sourceVar, targetVar strin return ensureTrailingNewline(buf.String()), nil } -// renderMapTransform renders the code transforming the source map held by -// sourceVar into the target map held by targetVar. proto is true when the -// target is the protocol buffer type. Wrapped element types are passed -// through to the engine which unwraps them when recursing; map keys cannot be -// nested in protocol buffers so only elements may be wrapped. +// renderMapTransform writes code that copies sourceVar into the target map. +// proto is true when the target is a protobuf map. Protobuf map values may use +// wrapper messages; protobuf map keys may not. func renderMapTransform(source, target *expr.Map, sourceVar, targetVar string, newVar, proto bool, ta *codegen.TransformAttrs) (string, error) { if err := codegen.IsCompatible(source.KeyType.Type, target.KeyType.Type, sourceVar+"[key]", targetVar+"[key]"); err != nil { return "", err @@ -203,21 +324,37 @@ func renderMapTransform(source, target *expr.Map, sourceVar, targetVar string, n kt = unAlias(kt) et = unAlias(et) } + loopVar := "" + if depth := codegen.MapDepth(target); depth > 0 { + loopVar = string(rune(97 + depth)) + } + elemTarget := "tv" + loopVar + blockAttrs, err := ta.EnterLocalBlock(sourceVar, targetVar, "key", "val", "tk", elemTarget) + if err != nil { + return "", err + } + elemNewVar := true + if !proto && isWrappedAttr(source.ElemType) { + elemNewVar = false + } + elemTransform, err := codegen.TransformAttribute(source.ElemType, target.ElemType, "val", elemTarget, elemNewVar, blockAttrs) + if err != nil { + return "", err + } + if !elemNewVar { + elemTransform = fmt.Sprintf("var %s %s\nif val != nil {\n%s}\n", elemTarget, ta.TargetCtx.Scope.Ref(et, ta.TargetCtx.Pkg(et)), elemTransform) + } data := map[string]any{ "KeyTypeRef": ta.TargetCtx.Scope.Ref(kt, ta.TargetCtx.Pkg(kt)), "ElemTypeRef": ta.TargetCtx.Scope.Ref(et, ta.TargetCtx.Pkg(et)), "SourceKey": source.KeyType, "TargetKey": target.KeyType, - "SourceElem": source.ElemType, - "TargetElem": target.ElemType, "SourceVar": sourceVar, "TargetVar": targetVar, "NewVar": newVar, - "TransformAttrs": ta, - "LoopVar": "", - } - if depth := codegen.MapDepth(target); depth > 0 { - data["LoopVar"] = string(rune(97 + depth)) + "TransformAttrs": blockAttrs, + "LoopVar": loopVar, + "ElemTransform": elemTransform, } var buf bytes.Buffer if err := renderGoMapT.Execute(&buf, data); err != nil { @@ -226,11 +363,9 @@ func renderMapTransform(source, target *expr.Map, sourceVar, targetVar string, n return ensureTrailingNewline(buf.String()), nil } -// renderUnionToProtoTransform renders the code transforming the source Goa -// union held by sourceVar into the protoc generated oneof field held by -// targetVar. message is the reference to the protoc generated Go type of the -// message containing the oneof. -func renderUnionToProtoTransform(source, target *expr.AttributeExpr, sourceVar, targetVar string, sourcePtr bool, message string, ta *codegen.TransformAttrs) (string, error) { +// renderUnionToProtoTransform writes a service union from sourceVar into the +// protobuf oneof in targetVar. +func renderUnionToProtoTransform(source, target *expr.AttributeExpr, sourceVar, targetVar string, sourcePtr bool, ta *codegen.TransformAttrs) (string, error) { if err := codegen.IsCompatible(source.Type, target.Type, sourceVar, targetVar); err != nil { return "", err } @@ -239,10 +374,12 @@ func renderUnionToProtoTransform(source, target *expr.AttributeExpr, sourceVar, for i, sv := range src.Values { tv := tgt.Values[i] fieldName := ta.TargetCtx.Scope.Field(tv.Attribute, tv.Name, true) + scope := ta.TargetCtx.Scope.(protobufOneofAttributor) + wrapperType := scope.OneofWrapper(tv.Attribute) cases = append(cases, map[string]any{ "TypeTag": sv.Name, "SourceFieldName": codegen.Goify(sv.Name, true), - "TargetWrapperType": protocOneofWrapperRef(message, fieldName), + "TargetWrapperType": wrapperType, "TargetFieldName": fieldName, "ConvertedValue": convertType(sv.Attribute, tv.Attribute, false, false, "actual", true, ta), }) @@ -261,11 +398,9 @@ func renderUnionToProtoTransform(source, target *expr.AttributeExpr, sourceVar, return ensureTrailingNewline(buf.String()), nil } -// renderUnionFromProtoTransform renders the code transforming the protoc -// generated oneof field held by sourceVar into the target Goa union held by -// targetVar. message is the reference to the protoc generated Go type of the -// message containing the oneof. -func renderUnionFromProtoTransform(source, target *expr.AttributeExpr, sourceVar, targetVar, message string, ta *codegen.TransformAttrs) (string, error) { +// renderUnionFromProtoTransform writes the protobuf oneof in sourceVar into the +// service union in targetVar. +func renderUnionFromProtoTransform(source, target *expr.AttributeExpr, sourceVar, targetVar string, ta *codegen.TransformAttrs) (string, error) { if err := codegen.IsCompatible(source.Type, target.Type, sourceVar, targetVar); err != nil { return "", err } @@ -274,8 +409,10 @@ func renderUnionFromProtoTransform(source, target *expr.AttributeExpr, sourceVar for i, sv := range src.Values { tv := tgt.Values[i] sourceFieldName := ta.SourceCtx.Scope.Field(sv.Attribute, sv.Name, true) + scope := ta.SourceCtx.Scope.(protobufOneofAttributor) + wrapperType := scope.OneofWrapper(sv.Attribute) cases = append(cases, map[string]any{ - "SourceValueTypeRef": protocOneofWrapperRef(message, sourceFieldName), + "SourceValueTypeRef": "*" + wrapperType, "TargetFieldName": codegen.Goify(tv.Name, true), "ConvertedValue": convertType(sv.Attribute, tv.Attribute, false, false, "val."+sourceFieldName, false, ta), }) @@ -292,9 +429,8 @@ func renderUnionFromProtoTransform(source, target *expr.AttributeExpr, sourceVar return ensureTrailingNewline(buf.String()), nil } -// ensureTrailingNewline appends a newline to code when missing so that the -// rendered transformations compose with the code the engine emits around -// them. +// ensureTrailingNewline appends a newline when surrounding generated code must +// continue on the next line. func ensureTrailingNewline(code string) string { if code != "" && !strings.HasSuffix(code, "\n") { code += "\n" diff --git a/grpc/codegen/proto_hooks_specialization_test.go b/grpc/codegen/proto_hooks_specialization_test.go new file mode 100644 index 0000000000..cd827c7a58 --- /dev/null +++ b/grpc/codegen/proto_hooks_specialization_test.go @@ -0,0 +1,147 @@ +// This file verifies that gRPC collection conversions use the recorded nesting +// level to choose loop variables instead of examining generated Go expression +// text. +package codegen + +import ( + "testing" + + "github.com/stretchr/testify/require" + + "goa.design/goa/v3/codegen" + "goa.design/goa/v3/expr" +) + +type ( + // protobufOneofSnapshotAttributor records the exact union branch used to + // resolve a protobuf wrapper name. + protobufOneofSnapshotAttributor struct { + codegen.Attributor + branches *[]*expr.AttributeExpr + } +) + +func TestRenderArrayTransformUsesTraversalDepthForLoopVariable(t *testing.T) { + source := &expr.Array{ElemType: &expr.AttributeExpr{Type: expr.Int}} + target := &expr.Array{ElemType: &expr.AttributeExpr{Type: expr.Int}} + sourceContext := codegen.NewAttributeContext(false, false, true, "", codegen.NewNameScope()) + targetContext := codegen.NewAttributeContext(false, false, true, "", codegen.NewNameScope()) + attributes := &codegen.TransformAttrs{ + SourceCtx: sourceContext, + TargetCtx: targetContext, + Hooks: protoHooks(true), + } + + generated, err := renderArrayTransform(source, target, "source", "target[key]", false, true, attributes) + require.NoError(t, err) + require.Contains(t, generated, "for i, val := range source") + require.Contains(t, generated, "target[key][i] =") +} + +func TestRenderArrayTransformPreservesPrimitiveAliasForElementConversion(t *testing.T) { + alias := &expr.UserTypeExpr{ + TypeName: "Alias", + AttributeExpr: &expr.AttributeExpr{Type: expr.String}, + } + source := &expr.Array{ElemType: &expr.AttributeExpr{Type: alias}} + target := &expr.Array{ElemType: &expr.AttributeExpr{Type: alias}} + sourceContext := codegen.NewAttributeContext(false, false, true, "", codegen.NewNameScope()) + targetContext := codegen.NewAttributeContext(false, false, true, "", codegen.NewNameScope()) + var convertedTarget *expr.AttributeExpr + attributes := &codegen.TransformAttrs{ + SourceCtx: sourceContext, + TargetCtx: targetContext, + Hooks: &codegen.TransformHooks{ + ConvertPrimitive: func(_ *expr.AttributeExpr, target *expr.AttributeExpr, _ string, _, _ bool, _ *codegen.TransformAttrs) (string, bool) { + convertedTarget = target + return "string(val)", true + }, + }, + } + + _, err := renderArrayTransform(source, target, "source", "target", true, true, attributes) + require.NoError(t, err) + require.Same(t, target.ElemType, convertedTarget) +} + +func TestTransformPlanOneofLookupUsesOriginalBranch(t *testing.T) { + sourceBranch := &expr.AttributeExpr{Type: expr.String} + targetBranch := &expr.AttributeExpr{Type: expr.String} + source := &expr.AttributeExpr{Type: &expr.Union{ + TypeName: "SourceChoice", + Values: []*expr.NamedAttributeExpr{ + {Name: "text", Attribute: sourceBranch}, + }, + }} + target := &expr.AttributeExpr{Type: &expr.Union{ + TypeName: "TargetChoice", + Values: []*expr.NamedAttributeExpr{ + {Name: "text", Attribute: targetBranch}, + }, + }} + plan, err := codegen.NewTransformPlan(source, target, "", protoHooks(true)) + require.NoError(t, err) + require.Empty(t, plan.Helpers()) + + sourceContext := codegen.NewAttributeContext(false, false, true, "", codegen.NewNameScope()) + targetContext := codegen.NewAttributeContext(false, false, true, "", codegen.NewNameScope()) + var branches []*expr.AttributeExpr + targetContext.Scope = &protobufOneofSnapshotAttributor{ + Attributor: targetContext.Scope, + branches: &branches, + } + require.NoError(t, plan.BindContexts(sourceContext, targetContext)) + generated, definitions, err := plan.Render("source", "target", true) + require.NoError(t, err) + require.Empty(t, definitions) + require.Contains(t, generated, "TargetChoice_Text") + require.Len(t, branches, 1) + require.Same(t, targetBranch, branches[0]) +} + +// TestGRPCHelperDefinitionUsesConversionFacts checks that prose and examples +// do not split copy functions while a custom generated field type does. +func TestGRPCHelperDefinitionUsesConversionFacts(t *testing.T) { + typeExpr := &expr.UserTypeExpr{ + TypeName: "Child", + AttributeExpr: &expr.AttributeExpr{Type: &expr.Object{ + {Name: "value", Attribute: &expr.AttributeExpr{Type: expr.String}}, + }}, + } + first := &expr.AttributeExpr{ + Type: typeExpr, + Description: "First use of the child.", + Meta: expr.MetaExpr{"rpc:tag": {"1"}}, + UserExamples: []*expr.ExampleExpr{{Value: map[string]any{"value": "first"}}}, + } + second := &expr.AttributeExpr{ + Type: typeExpr, + Description: "Second use of the child.", + Meta: expr.MetaExpr{"rpc:tag": {"2"}}, + UserExamples: []*expr.ExampleExpr{{Value: map[string]any{"value": "second"}}}, + } + hook := protoHooks(true).SameHelperDefinition + require.True(t, hook(first, first, second, second)) + + second.Meta["struct:field:type"] = []string{"CustomChild", "example.com/custom"} + require.False(t, hook(first, first, second, second)) +} + +// Enter preserves protobuf wrapper lookup while entering a copied union. +func (a *protobufOneofSnapshotAttributor) Enter(attribute *expr.AttributeExpr) codegen.Attributor { + return &protobufOneofSnapshotAttributor{ + Attributor: a.Attributor.Enter(attribute), + branches: a.branches, + } +} + +// IsSumType reports that this test resolver uses protobuf oneof wrappers. +func (*protobufOneofSnapshotAttributor) IsSumType() bool { + return false +} + +// OneofWrapper records the branch and returns its planned protobuf type. +func (a *protobufOneofSnapshotAttributor) OneofWrapper(attribute *expr.AttributeExpr) string { + *a.branches = append(*a.branches, attribute) + return "TargetChoice_Text" +} diff --git a/grpc/codegen/proto_test.go b/grpc/codegen/proto_test.go index 61cd9b9ca9..b08c4823d5 100644 --- a/grpc/codegen/proto_test.go +++ b/grpc/codegen/proto_test.go @@ -1,3 +1,5 @@ +// This file verifies generated protobuf services and message declarations, +// including package-owned naming collisions, by compiling each schema. package codegen import ( @@ -34,13 +36,15 @@ func TestProtoFiles(t *testing.T) { {"protofiles-custom-package-name", testdata.ServiceWithPackageDSL}, {"protofiles-struct-meta-type", testdata.StructMetaTypeDSL}, {"protofiles-default-fields", testdata.DefaultFieldsDSL}, + {"protofiles-required-primitive-presence", testdata.RequiredPrimitivePresenceDSL}, {"protofiles-custom-message-name", testdata.CustomMessageNameDSL}, + {"protofiles-distinct-custom-message-names", testdata.DistinctCustomMessageNamesDSL}, } for _, c := range cases { t.Run(c.Name, func(t *testing.T) { root := RunGRPCDSL(t, c.DSL) services := CreateGRPCServices(root) - fs := ProtoFiles("", services) + fs := protoFiles(services) if len(fs) != 1 { t.Fatalf("got %d files, expected one", len(fs)) } @@ -50,7 +54,7 @@ func TestProtoFiles(t *testing.T) { // testutil.AssertString handles line ending normalization internally testutil.AssertString(t, "testdata/golden/proto_"+c.Name+".proto.golden", code) fpath := codegen.CreateTempFile(t, code) - assert.NoError(t, protoc(defaultProtocCmd, fpath, nil), "error occurred when compiling proto file %q", fpath) + assert.NoError(t, protoc(defaultProtocCmd, fpath), "error occurred when compiling proto file %q", fpath) }) } } @@ -75,7 +79,7 @@ func TestMessageDefSection(t *testing.T) { t.Run(c.Name, func(t *testing.T) { root := RunGRPCDSL(t, c.DSL) services := CreateGRPCServices(root) - fs := ProtoFiles("", services) + fs := protoFiles(services) require.Len(t, fs, 1) sections := fs[0].SectionTemplates require.GreaterOrEqual(t, len(sections), 3) @@ -84,7 +88,7 @@ func TestMessageDefSection(t *testing.T) { // testutil.AssertString handles line ending normalization internally testutil.AssertString(t, "testdata/golden/proto_"+c.Name+".proto.golden", code+msgCode) fpath := codegen.CreateTempFile(t, code+msgCode) - assert.NoError(t, protoc(defaultProtocCmd, fpath, nil), "error occurred when compiling proto file %q", fpath) + assert.NoError(t, protoc(defaultProtocCmd, fpath), "error occurred when compiling proto file %q", fpath) }) } } @@ -118,7 +122,7 @@ func TestProtoc(t *testing.T) { t.Cleanup(func() { assert.NoError(t, os.RemoveAll(dir)) }) fpath := filepath.Join(dir, "schema") require.NoError(t, os.WriteFile(fpath, []byte(code), 0o600), "error occurred writing proto schema") - require.NoError(t, protoc(c.Cmd, fpath, nil), "error occurred when compiling proto file with the standard protoc %q", fpath) + require.NoError(t, protoc(c.Cmd, fpath), "error occurred when compiling proto file with the standard protoc %q", fpath) fcontents, err := os.ReadFile(fpath + ".pb.go") require.NoError(t, err) diff --git a/grpc/codegen/protobuf.go b/grpc/codegen/protobuf.go index 94f44bbc32..35d6d775ce 100644 --- a/grpc/codegen/protobuf.go +++ b/grpc/codegen/protobuf.go @@ -1,8 +1,9 @@ +// This file defines protobuf-specific attribute naming used by gRPC type, +// validation, and transformation generation. package codegen import ( "fmt" - "regexp" "slices" "strconv" "strings" @@ -13,14 +14,12 @@ import ( ) type ( - // protoBufScope is the scope for protocol buffer attribute types. + // protoBufScope supplies the Go names used for protobuf fields and types in + // one generated service package. protoBufScope struct { - scope *codegen.NameScope + service *ServiceData + pkg string } - - // protoBufGoFieldNames tracks names occupied by generated message methods, - // fields, and getters while visiting one protobuf message. - protoBufGoFieldNames map[string]bool ) const ( @@ -34,10 +33,6 @@ const ( // deterministically. wrappedAttrMeta = "grpc:wrapped" - // protoBufGoFieldNameMeta records the Go field name selected by - // protoc-gen-go after resolving message method and getter conflicts. - protoBufGoFieldNameMeta = "grpc:protobuf:go:field:name" - // protoBufReservedNumberMeta and protoBufReservedNameMeta let an owning Goa // type preserve removed protobuf tags and field names across regeneration. protoBufReservedNumberMeta = "rpc:reserved:number" @@ -46,36 +41,73 @@ const ( // Name returns the protocol buffer type name. func (p *protoBufScope) Name(att *expr.AttributeExpr, pkg string, _, _ bool) string { - return protoBufGoFullTypeName(att, pkg, p.scope) + return protoBufGoFullTypeName(att, pkg, p.service) } // Ref returns the protocol buffer type reference. func (p *protoBufScope) Ref(att *expr.AttributeExpr, pkg string) string { - return protoBufGoFullTypeRef(att, pkg, p.scope) + return protoBufGoFullTypeRef(att, pkg, p.service) } -// Field returns the field name as generated by protocol buffer compiler. -// NOTE: protoc does not care about common initialisms like api -> API so we -// first transform the name into snake case to end up with Api. -func (*protoBufScope) Field(att *expr.AttributeExpr, name string, firstUpper bool) string { - if expr.IsUnion(att.Type) { - if fieldName, ok := att.Meta[protoBufGoFieldNameMeta]; ok && len(fieldName) > 0 { - return fieldName[0] - } - name = protoBufUnionName(name, expr.AsUnion(att.Type)) +// Package returns the protocol buffer package qualifier for att. +func (p *protoBufScope) Package(*expr.AttributeExpr) string { + return p.pkg +} + +// Enter keeps nested protobuf messages in the same generated package. +func (p *protoBufScope) Enter(*expr.AttributeExpr) codegen.Attributor { + return p +} + +// IsSumType reports that protobuf unions use generated oneof messages rather +// than Goa service values that hold one selected branch. +func (*protoBufScope) IsSumType() bool { + return false +} + +// ValidatorCall returns a call using the first choice for a protobuf validation +// function name. The service may add a suffix when another function uses it. +func (p *protoBufScope) ValidatorCall(att *expr.AttributeExpr, view, target, _ string) string { + name := "Validate" + p.Name(att, "", false, true) + codegen.Goify(view, true) + return fmt.Sprintf("%s(%s)", name, target) +} + +// Field returns the exact Go field name produced by the supported protobuf +// tools. +func (p *protoBufScope) Field(att *expr.AttributeExpr, name string, firstUpper bool) string { + planned, ok := p.service.protobuf.plan.fieldName(att) + if !ok { + panic(fmt.Sprintf("protobuf field %q was not planned", name)) } - return protoBufifyAtt(att, codegen.SnakeCase(name), firstUpper) + return planned } -// Scope returns the name scope. +// OneofWrapper returns the generated wrapper type for one branch in one +// parent message. +func (p *protoBufScope) OneofWrapper(attribute *expr.AttributeExpr) string { + name, ok := p.service.protobuf.plan.wrapperName(attribute) + if !ok { + panic("protobuf oneof branch was not planned") + } + if p.pkg == "" { + return name + } + return p.pkg + "." + name +} + +// Scope returns the object that assigns unique Go names in this package. func (p *protoBufScope) Scope() *codegen.NameScope { - return p.scope + return p.service.Scope } -// protoBufTypeContext returns a contextual attribute for the protocol buffer type. -func protoBufTypeContext(pkg string, scope *codegen.NameScope, useDefault bool) *codegen.AttributeContext { - ctx := codegen.NewAttributeContext(false, true, useDefault, pkg, scope) - ctx.Scope = &protoBufScope{scope: scope} +// protoBufTypeContext describes Go fields generated from protobuf messages. +// Singular booleans, numbers, strings, enums, and their aliases use pointers +// so validation can tell an omitted field from an explicit zero value. Bytes +// remain slices. Message fields, including Any, already use pointers. +func protoBufTypeContext(pkg string, service *ServiceData) *codegen.AttributeContext { + ctx := codegen.NewAttributeContext(true, false, false, pkg, service.Scope) + ctx.IgnoreRequiredCollections = true + ctx.Scope = &protoBufScope{service: service, pkg: pkg} return ctx } @@ -85,62 +117,54 @@ func protoBufTypeContext(pkg string, scope *codegen.NameScope, useDefault bool) // map, it wraps the given attribute with an object with a single "field" // attribute. For nested arrays/maps, the inner array/map is wrapped into a // user type. -func makeProtoBufMessage(att *expr.AttributeExpr, tname string, sd *ServiceData) *expr.AttributeExpr { +func makeProtoBufMessage(att *expr.AttributeExpr, tname string, owner expr.ExampleIdentity) *expr.AttributeExpr { att = expr.DupAtt(att) expr.RemovePkgPath(att) ut, isut := att.Type.(expr.UserType) switch { case att.Type == expr.Empty: - att.Type = &expr.UserTypeExpr{ - TypeName: tname, - AttributeExpr: &expr.AttributeExpr{Type: &expr.Object{}}, - UID: sd.Name + "#" + tname, - } + att.Type = expr.NewGeneratedUserType(tname, &expr.AttributeExpr{Type: &expr.Object{}}, owner) return att case expr.IsPrimitive(att.Type): - wrapAttr(att, tname, true, sd) + wrapAttr(att, tname, true, owner) return att case isut: - if expr.IsArray(ut) { - wrapAttr(att, tname, false, sd) + if expr.IsArray(ut) || expr.IsMap(ut) { + wrapAttr(att, tname, false, owner) } case expr.IsArray(att.Type) || expr.IsMap(att.Type): - wrapAttr(att, tname, false, sd) + wrapAttr(att, tname, false, owner) case expr.IsObject(att.Type) || expr.IsUnion(att.Type): - att.Type = &expr.UserTypeExpr{ - TypeName: tname, - AttributeExpr: expr.DupAtt(att), - UID: sd.Name + "#" + tname, - } + att.Type = expr.NewGeneratedUserType(tname, expr.DupAtt(att), owner) } n := "" - makeProtoBufMessageR(att, &n, sd, make(map[string]struct{})) - planProtoBufGoFieldNames(att, make(map[string]struct{})) + makeProtoBufMessageR(att, &n, owner, make(map[expr.UserType]struct{})) return att } // makeProtoBufMessageR is the recursive implementation of makeProtoBufMessage. -func makeProtoBufMessageR(att *expr.AttributeExpr, tname *string, sd *ServiceData, seen map[string]struct{}) { +func makeProtoBufMessageR(att *expr.AttributeExpr, tname *string, owner expr.ExampleIdentity, seen map[expr.UserType]struct{}) { ut, isut := att.Type.(expr.UserType) // handle infinite recursions if isut { - if _, ok := seen[ut.ID()]; ok { + origin := ut.Origin() + if _, ok := seen[origin]; ok { return } - seen[ut.ID()] = struct{}{} + seen[origin] = struct{}{} } wrap := func(att *expr.AttributeExpr, tname string) { switch { case expr.IsArray(att.Type): wrapAttr(att, "ArrayOf"+tname+ - protoBufify(protoBufMessageDef(expr.AsArray(att.Type).ElemType, sd), true, true), true, sd) + codegen.ProtobufName(protoBufShapeTypeName(expr.AsArray(att.Type).ElemType)), true, owner) case expr.IsMap(att.Type): m := expr.AsMap(att.Type) wrapAttr(att, tname+"MapOf"+ - protoBufify(protoBufMessageDef(m.KeyType, sd), true, true)+ - protoBufify(protoBufMessageDef(m.ElemType, sd), true, true), true, sd) + codegen.ProtobufName(protoBufShapeTypeName(m.KeyType))+ + codegen.ProtobufName(protoBufShapeTypeName(m.ElemType)), true, owner) } } @@ -148,129 +172,37 @@ func makeProtoBufMessageR(att *expr.AttributeExpr, tname *string, sd *ServiceDat case expr.IsPrimitive(att.Type): return case isut: - if expr.IsArray(ut) { - wrapAttr(ut.Attribute(), ut.Name(), false, sd) + switch { + case expr.IsArray(ut): + wrapAttr(ut.Attribute(), ut.Name(), false, expr.GRPCArrayWrapperExampleIdentity(ut)) + case expr.IsMap(ut): + wrapAttr(ut.Attribute(), ut.Name(), false, expr.GRPCMapWrapperExampleIdentity(ut)) } - makeProtoBufMessageR(ut.Attribute(), tname, sd, seen) + makeProtoBufMessageR(ut.Attribute(), tname, owner, seen) case expr.IsArray(att.Type): ar := expr.AsArray(att.Type) - makeProtoBufMessageR(ar.ElemType, tname, sd, seen) + elementOwner := owner.ArrayElement(0) + makeProtoBufMessageR(ar.ElemType, tname, elementOwner, seen) wrap(ar.ElemType, *tname) case expr.IsMap(att.Type): m := expr.AsMap(att.Type) - makeProtoBufMessageR(m.ElemType, tname, sd, seen) + valueOwner := owner.MapValue(0) + makeProtoBufMessageR(m.ElemType, tname, valueOwner, seen) wrap(m.ElemType, *tname) case expr.IsUnion(att.Type): for _, nat := range expr.AsUnion(att.Type).Values { - makeProtoBufMessageR(nat.Attribute, tname, sd, seen) + makeProtoBufMessageR(nat.Attribute, tname, owner.UnionMember(nat.Name), seen) } case expr.IsObject(att.Type): for _, nat := range *(expr.AsObject(att.Type)) { - makeProtoBufMessageR(nat.Attribute, tname, sd, seen) + makeProtoBufMessageR(nat.Attribute, tname, owner.Member(nat.Name), seen) } } } -// planProtoBufGoFieldNames walks every generated protobuf message and records -// the field names that protoc-gen-go will emit. Transforms consume these names -// instead of independently guessing from one attribute at a time. -func planProtoBufGoFieldNames(att *expr.AttributeExpr, seen map[string]struct{}) { - if ut, ok := att.Type.(expr.UserType); ok { - if _, exists := seen[ut.ID()]; exists { - return - } - seen[ut.ID()] = struct{}{} - planProtoBufGoFieldNames(ut.Attribute(), seen) - return - } - switch { - case expr.IsArray(att.Type): - planProtoBufGoFieldNames(expr.AsArray(att.Type).ElemType, seen) - case expr.IsMap(att.Type): - planProtoBufGoFieldNames(expr.AsMap(att.Type).ElemType, seen) - case expr.IsUnion(att.Type): - planProtoBufUnionGoFieldNames(att.Type.Name(), att, expr.AsUnion(att.Type), seen) - case expr.IsObject(att.Type): - usedNames := newProtoBufGoFieldNames() - for _, nat := range *expr.AsObject(att.Type) { - if union := expr.AsUnion(nat.Attribute.Type); union != nil { - planProtoBufUnionMemberGoFieldNames(nat.Name, nat.Attribute, union, usedNames, seen) - continue - } - usedNames.makeUnique(protoBufRawGoFieldName(nat.Attribute, nat.Name), true) - planProtoBufGoFieldNames(nat.Attribute, seen) - } - } -} - -// planProtoBufUnionGoFieldNames handles a message whose complete body is one -// union, such as a standalone result type. -func planProtoBufUnionGoFieldNames(name string, att *expr.AttributeExpr, union *expr.Union, seen map[string]struct{}) { - planProtoBufUnionMemberGoFieldNames(name, att, union, newProtoBufGoFieldNames(), seen) -} - -// planProtoBufUnionMemberGoFieldNames flattens oneof members into their -// containing message in declaration order, then reserves the oneof interface -// field after its first member exactly as protoc-gen-go does. -func planProtoBufUnionMemberGoFieldNames(name string, att *expr.AttributeExpr, union *expr.Union, usedNames *protoBufGoFieldNames, seen map[string]struct{}) { - oneofName, memberNames := protoBufUnionNames(name, union) - for i, nat := range union.Values { - usedNames.makeUnique(protoBufify(memberNames[i], true, false), true) - if i == 0 { - setProtoBufGoFieldName( - att, - usedNames.makeUnique(protoBufify(oneofName, true, false), false), - ) - } - planProtoBufGoFieldNames(nat.Attribute, seen) - } -} - -// protoBufRawGoFieldName computes the initial Go name before message-level -// collision resolution. -func protoBufRawGoFieldName(att *expr.AttributeExpr, name string) string { - return protoBufifyAtt(att, codegen.SnakeCase(name), true) -} - -// setProtoBufGoFieldName stores the resolved name on the protobuf-side -// attribute used by generated transforms. -func setProtoBufGoFieldName(att *expr.AttributeExpr, name string) { - if att.Meta == nil { - att.Meta = make(expr.MetaExpr) - } - att.Meta[protoBufGoFieldNameMeta] = []string{name} -} - -// newProtoBufGoFieldNames starts with the method names reserved by -// protoc-gen-go's open-struct API. -func newProtoBufGoFieldNames() *protoBufGoFieldNames { - names := protoBufGoFieldNames{ - "Reset": true, - "String": true, - "ProtoMessage": true, - "Marshal": true, - "Unmarshal": true, - "ExtensionRangeArray": true, - "ExtensionMap": true, - "Descriptor": true, - } - return &names -} - -// makeUnique appends underscores until the field and its generated getter do -// not conflict with a name already assigned in the message. -func (n *protoBufGoFieldNames) makeUnique(name string, hasGetter bool) string { - for (*n)[name] || hasGetter && (*n)["Get"+name] { - name += "_" - } - (*n)[name] = true - (*n)["Get"+name] = hasGetter - return name -} - // wrapAttr makes the attribute type a user type by wrapping the given // attribute into an attribute named "field". -func wrapAttr(att *expr.AttributeExpr, tname string, req bool, sd *ServiceData) { +func wrapAttr(att *expr.AttributeExpr, tname string, req bool, owner expr.ExampleIdentity) { wrap := func(attr *expr.AttributeExpr) *expr.AttributeExpr { res := &expr.AttributeExpr{ Type: &expr.Object{ @@ -285,7 +217,7 @@ func wrapAttr(att *expr.AttributeExpr, tname string, req bool, sd *ServiceData) }, Meta: expr.MetaExpr{wrappedAttrMeta: []string{wrappedField}}, } - if req { + if req && !expr.IsArray(attr.Type) && !expr.IsMap(attr.Type) { res.Validation = &expr.ValidationExpr{ Required: []string{wrappedField}, } @@ -295,15 +227,9 @@ func wrapAttr(att *expr.AttributeExpr, tname string, req bool, sd *ServiceData) switch dt := att.Type.(type) { case expr.UserType: // Don't change the original user type. Create a copy and wrap that. - ut := expr.Dup(dt).(expr.UserType) - ut.SetAttribute(wrap(ut.Attribute())) - att.Type = ut + att.Type = expr.NewGeneratedUserType(dt.Name(), wrap(expr.DupAtt(dt.Attribute())), owner) default: - att.Type = &expr.UserTypeExpr{ - TypeName: tname, - AttributeExpr: wrap(att), - UID: sd.Name + "#" + tname, - } + att.Type = expr.NewGeneratedUserType(tname, wrap(att), owner) } // Validation is moved to wrapped attribute. att.Validation = nil @@ -351,40 +277,55 @@ func unwrapAttr(att *expr.AttributeExpr) *expr.AttributeExpr { // protoBufMessageName returns the protocol buffer message name of the given // attribute type. -func protoBufMessageName(att *expr.AttributeExpr, s *codegen.NameScope) string { - return protoBufFullMessageName(att, "", s) +func protoBufMessageName(att *expr.AttributeExpr, service *ServiceData) string { + return protoBufFullMessageName(att, "", service) +} + +// protoBufSourceMessageName returns the message name written to the .proto +// file. +func protoBufSourceMessageName(att *expr.AttributeExpr, service *ServiceData) string { + userType, ok := att.Type.(expr.UserType) + if !ok { + if composite, ok := att.Type.(expr.CompositeExpr); ok { + return protoBufSourceMessageName(composite.Attribute(), service) + } + panic(fmt.Sprintf("data type is not a protobuf message: received type %T", att.Type)) // bug + } + record := service.protobuf.message(att) + if record == nil { + panic(fmt.Sprintf("protobuf message %q has no planned name", userType.Name())) + } + return record.protoName } // protoBufFullMessageName returns the protocol buffer message name of the // given user type qualified with the given package name if applicable. -func protoBufFullMessageName(att *expr.AttributeExpr, pkg string, s *codegen.NameScope) string { +func protoBufFullMessageName(att *expr.AttributeExpr, pkg string, service *ServiceData) string { switch actual := att.Type.(type) { - case expr.UserType, *expr.Union: - n := s.HashedUnique(actual, protoBufify(actual.Name(), true, true), "") - if name := att.Meta["struct:name:proto"]; len(name) > 0 { - n = name[0] + case expr.UserType: + if service.protobuf == nil { + panic(fmt.Sprintf("protobuf message %q has no package catalog", actual.Name())) + } + record := service.protobuf.message(att) + if record == nil { + panic(fmt.Sprintf("protobuf message %q has no frozen declaration", actual.Name())) } + n := record.name if pkg == "" { return n } return pkg + "." + n case expr.CompositeExpr: - return protoBufFullMessageName(actual.Attribute(), pkg, s) + return protoBufFullMessageName(actual.Attribute(), pkg, service) default: - panic(fmt.Sprintf("data type is not a user type or union: received type %T", actual)) // bug + panic(fmt.Sprintf("data type is not a protobuf message: received type %T", actual)) // bug } } -// protoBufGoTypeName returns the protocol buffer type name for the given -// attribute generated after compiling the proto file (in *.pb.go). -func protoBufGoTypeName(att *expr.AttributeExpr, s *codegen.NameScope) string { - return protoBufGoFullTypeName(att, "", s) -} - // protoBufGoFullTypeName returns the protocol buffer type name qualified with // the given package name for the given attribute generated after compiling // the proto file (in *.pb.go). -func protoBufGoFullTypeName(att *expr.AttributeExpr, pkg string, s *codegen.NameScope) string { +func protoBufGoFullTypeName(att *expr.AttributeExpr, pkg string, service *ServiceData) string { if proto := att.Meta["struct:field:proto"]; len(proto) > 2 { typ := proto[2] if len(att.Meta["struct:field:proto"]) > 3 { @@ -393,24 +334,68 @@ func protoBufGoFullTypeName(att *expr.AttributeExpr, pkg string, s *codegen.Name } return typ } + if primitive := getPrimitive(att); primitive != nil { + return protoBufGoFullTypeName(primitive, pkg, service) + } switch actual := att.Type.(type) { - case expr.UserType, expr.CompositeExpr, *expr.Union: - return protoBufFullMessageName(att, pkg, s) + case *expr.Union: + if service.protobuf == nil { + panic(fmt.Sprintf("protobuf oneof %q has no package catalog", actual.Name())) + } + name := service.protobuf.unionName(att) + if pkg == "" { + return name + } + return pkg + "." + name + case expr.UserType, expr.CompositeExpr: + return protoBufFullMessageName(att, pkg, service) case expr.Primitive: return protoBufNativeGoTypeName(att.Type) case *expr.Array: - return "[]" + protoBufGoFullTypeRef(actual.ElemType, pkg, s) + return "[]" + protoBufGoFullTypeRef(actual.ElemType, pkg, service) case *expr.Map: return fmt.Sprintf("map[%s]%s", - protoBufGoFullTypeRef(actual.KeyType, pkg, s), - protoBufGoFullTypeRef(actual.ElemType, pkg, s)) + protoBufGoFullTypeRef(actual.KeyType, pkg, service), + protoBufGoFullTypeRef(actual.ElemType, pkg, service)) case *expr.Object: - return s.GoTypeDef(att, false, false) + return service.Scope.GoTypeDef(att, false, false) default: panic(fmt.Sprintf("unknown data type %T", actual)) // bug } } +// protoBufShapeTypeName returns the type name used when Goa wraps an array or +// map inside a protobuf message. It reads the design but does not reserve a Go +// name for the generated message. +func protoBufShapeTypeName(att *expr.AttributeExpr) string { + if protos := att.Meta["struct:field:proto"]; len(protos) > 0 { + return protos[0] + } + switch actual := att.Type.(type) { + case expr.Primitive: + return protoNativeType(actual) + case expr.UserType: + if names := att.Meta["struct:name:proto"]; len(names) > 0 { + return names[0] + } + if names := actual.Attribute().Meta["struct:name:proto"]; len(names) > 0 { + return names[0] + } + return codegen.ProtobufName(actual.Name()) + case expr.CompositeExpr: + return protoBufShapeTypeName(actual.Attribute()) + case *expr.Object: + return "Object" + case *expr.Union: + if actual.TypeName != "" { + return codegen.ProtobufName(actual.TypeName) + } + return "Union" + default: + panic(fmt.Sprintf("unknown protobuf shaping type %T", actual)) // bug + } +} + // protoType returns the protocol buffer type name for the given attribute. func protoType(att *expr.AttributeExpr, sd *ServiceData) string { if protos := att.Meta["struct:field:proto"]; len(protos) > 0 { @@ -431,7 +416,7 @@ func protoBufMessageDef(att *expr.AttributeExpr, sd *ServiceData) string { case *expr.Map: return fmt.Sprintf("map<%s, %s>", protoType(actual.KeyType, sd), protoType(actual.ElemType, sd)) case *expr.Union: - return protoBufUnionDef(actual.Name(), actual, sd) + return protoBufUnionDef(actual.Name(), att, actual, sd) case expr.UserType: if actual == expr.Empty { return " {}" @@ -439,14 +424,14 @@ func protoBufMessageDef(att *expr.AttributeExpr, sd *ServiceData) string { if prim := getPrimitive(att); prim != nil { return protoBufMessageDef(prim, sd) } - return protoBufMessageName(att, sd.Scope) + return protoBufSourceMessageName(att, sd) case *expr.Object: var ss []string ss = append(ss, " {") ss = append(ss, protoBufReservations(att)...) for _, nat := range *actual { if expr.IsUnion(nat.Attribute.Type) { - ss = append(ss, protoBufUnionDef(nat.Name, expr.AsUnion(nat.Attribute.Type), sd)) + ss = append(ss, protoBufUnionDef(nat.Name, nat.Attribute, expr.AsUnion(nat.Attribute.Type), sd)) continue } var ( @@ -457,14 +442,17 @@ func protoBufMessageDef(att *expr.AttributeExpr, sd *ServiceData) string { desc string ) { - fn = codegen.SnakeCase(protoBufify(nat.Name, false, false)) + fn = protobufSourceFieldName(nat.Name) + if sd.protobuf != nil { + fn = sd.protobuf.plan.sourceFieldName(nat.Attribute) + } fnum = rpcTag(nat.Attribute) if prim := getPrimitive(nat.Attribute); prim != nil { typ = protoType(prim, sd) } else { typ = protoType(nat.Attribute, sd) } - if !att.IsRequired(nat.Name) && expr.IsPrimitive(nat.Attribute.Type) { + if expr.IsPrimitive(nat.Attribute.Type) && unAlias(nat.Attribute).Type.Kind() != expr.AnyKind { opt = "optional " } if nat.Attribute.Description != "" { @@ -514,12 +502,25 @@ func protoBufReservations(att *expr.AttributeExpr) []string { return reservations } -// protoBufUnionDef renders a protobuf oneof using the field name selected by -// its caller. Nested unions pass their containing attribute name so a custom -// service type name cannot rename the protobuf field used by transforms. -// Standalone unions pass their service type name because no field contains them. -func protoBufUnionDef(name string, union *expr.Union, sd *ServiceData) string { - oneofName, fieldNames := protoBufUnionNames(name, union) +// protoBufUnionDef renders a protobuf oneof using the source names stored in +// the service plan. Tests that render an isolated message use the same fallback +// spelling rules without reserving package names. +func protoBufUnionDef(name string, attribute *expr.AttributeExpr, union *expr.Union, sd *ServiceData) string { + oneofName := codegen.ProtobufFieldName(name) + fieldNames := make([]string, len(union.Values)) + for index, branch := range union.Values { + fieldNames[index] = protobufSourceFieldName(branch.Name) + } + if sd.protobuf == nil { + for slices.Contains(fieldNames, oneofName) { + oneofName += "_oneof" + } + } else { + oneofName = sd.protobuf.plan.sourceOneofName(attribute) + for index, branch := range union.Values { + fieldNames[index] = sd.protobuf.plan.sourceFieldName(branch.Attribute) + } + } def := "\toneof " + oneofName + " {" for i, nat := range union.Values { fn := fieldNames[i] @@ -540,28 +541,6 @@ func protoBufUnionDef(name string, union *expr.Union, sd *ServiceData) string { return def + "\n\t}" } -// protoBufUnionName returns the oneof name emitted by protoc. When a member -// has the same normalized name, it adds a suffix so protoc can generate both -// the oneof interface field and the member wrapper. -func protoBufUnionName(name string, union *expr.Union) string { - oneofName, _ := protoBufUnionNames(name, union) - return oneofName -} - -// protoBufUnionNames returns the oneof name and member names emitted by protoc -// so schema generation and transform field access use the same normalization. -func protoBufUnionNames(name string, union *expr.Union) (string, []string) { - oneofName := codegen.SnakeCase(protoBufify(name, false, false)) - fieldNames := make([]string, len(union.Values)) - for i, nat := range union.Values { - fieldNames[i] = codegen.SnakeCase(protoBufify(nat.Name, false, false)) - } - for slices.Contains(fieldNames, oneofName) { - oneofName += "_oneof" - } - return oneofName, fieldNames -} - func protoJSONOption(att *expr.AttributeExpr) string { if att == nil || att.Meta == nil { return "" @@ -575,71 +554,14 @@ func protoJSONOption(att *expr.AttributeExpr) string { // protoBufGoFullTypeRef returns the Go code qualified with package name that // refers to the Go type generated by compiling the protocol buffer // (in *.pb.go) for the given attribute. -func protoBufGoFullTypeRef(att *expr.AttributeExpr, pkg string, s *codegen.NameScope) string { - name := protoBufGoFullTypeName(att, pkg, s) +func protoBufGoFullTypeRef(att *expr.AttributeExpr, pkg string, service *ServiceData) string { + name := protoBufGoFullTypeName(att, pkg, service) if expr.IsObject(att.Type) || expr.IsUnion(att.Type) { return "*" + name } return name } -var digits = regexp.MustCompile("[0-9]+") - -// protoBufify makes a valid protocol buffer identifier out of any string. -// It does that by removing any non letter and non digit character and by -// making sure the first character is a letter or "_". protoBufify produces a -// "CamelCase" version of the string. -// -// If firstUpper is true the first character of the identifier is uppercase -// otherwise it's lowercase. -func protoBufify(str string, firstUpper, acronym bool) string { - // Optimize trivial case - if str == "" { - return "" - } - - // Remove optional suffix that defines corresponding transport specific - // name. - idx := strings.Index(str, ":") - if idx > 0 { - str = str[:idx] - } - - // The CamelCase implementation of protoc-gen-go considers digits as words - // but our CamelCase implementation considers them as lower case characters, - // compensate by adding an underscore after any series of digits. - // See https://github.com/golang/protobuf/blob/d04d7b157bb510b1e0c10132224b616ac0e26b17/protoc-gen-go/generator/generator.go#L2648-L2685 - str = string(digits.ReplaceAllFunc([]byte(str), func(match []byte) []byte { - res := make([]byte, len(match)+1) // need to allocate new slice - copy(res, match) - res[len(res)-1] = '_' - return res - })) - - str = codegen.CamelCase(str, firstUpper, acronym) - if str == "" { - // All characters are invalid. Produce a default value. - if firstUpper { - return "Val" - } - return "val" - } - - return fixReservedProtoBuf(str) -} - -// protoBufifyAtt honors any struct:field:name meta set on the attribute and -// and calls protoBufify with the tag value if present or the given name -// otherwise. -func protoBufifyAtt(att *expr.AttributeExpr, name string, upper bool) string { - if tname, ok := att.Meta["struct:field:name"]; ok { - if len(tname) > 0 { - name = tname[0] - } - } - return protoBufify(name, upper, false) -} - // protoNativeType returns the protocol buffer built-in type // corresponding to the given primitive type. It panics if t is not a // primitive type. @@ -723,50 +645,3 @@ func rpcTag(a *expr.AttributeExpr) uint64 { } return tag } - -// fixReservedProtoBuf appends an underscore on to protocol buffer reserved -// keywords. -func fixReservedProtoBuf(w string) string { - if _, ok := reservedProtoBuf[codegen.CamelCase(w, false, false)]; ok { - w += "_" - } - return w -} - -var ( - // reserved protocol buffer keywords and package names - reservedProtoBuf = map[string]struct{}{ - // types - "bool": {}, - "bytes": {}, - "double": {}, - "fixed32": {}, - "fixed64": {}, - "float": {}, - "int32": {}, - "int64": {}, - "sfixed32": {}, - "sfixed64": {}, - "sint32": {}, - "sint64": {}, - "string": {}, - "uint32": {}, - "uint64": {}, - - // reserved - "enum": {}, - "import": {}, - "map": {}, - "message": {}, - "oneof": {}, - "option": {}, - "package": {}, - "public": {}, - "repeated": {}, - "reserved": {}, - "returns": {}, - "rpc": {}, - "service": {}, - "syntax": {}, - } -) diff --git a/grpc/codegen/protobuf_catalog.go b/grpc/codegen/protobuf_catalog.go new file mode 100644 index 0000000000..37e45207f7 --- /dev/null +++ b/grpc/codegen/protobuf_catalog.go @@ -0,0 +1,1016 @@ +// This file records the protobuf messages and validation functions written for +// one gRPC service. It chooses every package-level name before conversion code +// refers to that name. +package codegen + +import ( + "cmp" + "fmt" + "reflect" + "regexp" + "slices" + + "goa.design/goa/v3/codegen" + "goa.design/goa/v3/codegen/service" + "goa.design/goa/v3/expr" +) + +type ( + // protobufPackageCatalog stores every message and validation function that + // Goa writes for one service. + protobufPackageCatalog struct { + packageName string + plan *protobufServicePlan + messages []*protobufMessageRecord + messageUses map[*expr.AttributeExpr]*protobufMessageRecord + unions []*protobufUnionRecord + unionUses map[*expr.AttributeExpr]*protobufUnionRecord + rootSources map[expr.UserType]protobufMessageSource + validators []*protobufValidationRecord + validationUses map[protobufValidationUse]*protobufValidationRecord + frozen bool + messagesRendered bool + validationsFrozen bool + } + + // protobufEndpointMessages contains the copied request, response, and error + // values used to write one endpoint's protobuf code. + protobufEndpointMessages struct { + request *expr.AttributeExpr + streamingRequest *expr.AttributeExpr + requestEnvelope *expr.AttributeExpr + response *expr.AttributeExpr + responseValidations map[string]*expr.AttributeExpr + errors map[string]*expr.AttributeExpr + } + + // protobufMessageRecord stores one protobuf message and every place that + // uses it. + protobufMessageRecord struct { + identity protobufMessageIdentity + uses []*expr.AttributeExpr + protoName string + plannedName string + declaration *codegen.NameDeclaration + name string + goRef string + data *service.UserTypeData + } + + // This record stores the source declaration, requested name, explicit-name + // flag, and type fields used to decide whether two values can share one + // protobuf message. + protobufMessageIdentity struct { + source protobufMessageSource + preferredName string + explicitName bool + userType expr.UserType + attribute *expr.AttributeExpr + } + + // protobufUnionRecord stores one oneof inside its protobuf message. + protobufUnionRecord struct { + owner *protobufMessageRecord + attribute *expr.AttributeExpr + fieldName string + uses []*expr.AttributeExpr + name string + } + + // protobufMessageSource points to either an authored declaration or an + // endpoint message created by the generator. + protobufMessageSource struct { + origin expr.UserType + synthetic protobufSyntheticMessage + } + + // protobufSyntheticMessage stores a message that Goa creates for an endpoint. + protobufSyntheticMessage struct { + endpoint *expr.GRPCEndpointExpr + error *expr.GRPCErrorExpr + role protobufSyntheticRole + } + + // protobufSyntheticRole says which endpoint value a Goa-created message holds. + protobufSyntheticRole uint8 + + // protobufValidationRecord stores one validation function written to a + // client or server package. + protobufValidationRecord struct { + message *protobufMessageRecord + declaration *codegen.NameDeclaration + attribute *expr.AttributeExpr + source protobufValidationSource + side validateKind + targetName string + contextName string + uses []*expr.AttributeExpr + data *ValidationData + } + + // protobufValidationSource records the endpoint value and nested field that + // first needs one validation function. + protobufValidationSource struct { + api string + service string + method string + error string + view string + path string + pathSteps []protobufValidationPathStep + role protobufValidationRole + } + + // protobufValidationPathStep records one field, collection part, or union + // branch on the way from an endpoint value to a nested message. + protobufValidationPathStep struct { + kind protobufValidationPathKind + name string + } + + // protobufValidationPathKind identifies how one nested value is reached. + protobufValidationPathKind uint8 + + // protobufValidationRole says which endpoint value is checked. + protobufValidationRole uint8 + + // protobufValidationUse records which function checks one copied value in a + // generated client or server package. + protobufValidationUse struct { + attribute *expr.AttributeExpr + side validateKind + } + + // protobufAttributePair stops a comparison when recursive values lead back + // to the same pair. + protobufAttributePair struct { + left *expr.AttributeExpr + right *expr.AttributeExpr + } + + // This value supplies the protobuf message names and validation function + // names used while Goa writes validation code. + protobufValidationScope struct { + *protoBufScope + catalog *protobufPackageCatalog + side validateKind + message *protobufMessageRecord + parent expr.UserType + } +) + +const ( + protobufRequestMessage protobufSyntheticRole = iota + 1 + protobufStreamingRequestMessage + protobufStreamEnvelopeMessage + protobufResponseMessage + protobufErrorMessage + protobufWrapperMessage +) + +var protobufExactNamePattern = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*$`) + +const ( + protobufRequestValidation protobufValidationRole = iota + 1 + protobufResponseValidation + protobufErrorValidation + protobufStreamingRequestValidation +) + +const ( + protobufValidationField protobufValidationPathKind = iota + 1 + protobufValidationArrayElement + protobufValidationMapKey + protobufValidationMapValue + protobufValidationUnionBranch +) + +// newProtobufPackageCatalog creates the protobuf messages for one service and +// the functions that validate those messages in its client and server. +func newProtobufPackageCatalog(packageName string) *protobufPackageCatalog { + return &protobufPackageCatalog{ + packageName: packageName, + messageUses: make(map[*expr.AttributeExpr]*protobufMessageRecord), + unionUses: make(map[*expr.AttributeExpr]*protobufUnionRecord), + rootSources: make(map[expr.UserType]protobufMessageSource), + validationUses: make(map[protobufValidationUse]*protobufValidationRecord), + } +} + +// bindRootSource records which service or endpoint value a generated top-level +// message carries. Copies of that message use the same information. +func (c *protobufPackageCatalog) bindRootSource(attribute *expr.AttributeExpr, source protobufMessageSource) { + if attribute.Type == expr.Empty { + return + } + userType, ok := attribute.Type.(expr.UserType) + if !ok || (source.origin == nil && source.synthetic.role == 0) { + return + } + c.rootSources[userType.Origin()] = source +} + +// collectMessage records every protobuf message reachable from an endpoint's +// request, response, or error attribute. source identifies that top-level +// attribute. Nested user types keep their own declarations. +func (c *protobufPackageCatalog) collectMessage(attribute *expr.AttributeExpr, source protobufMessageSource) error { + if c.frozen { + panic("cannot collect a protobuf message after the package catalog is frozen") + } + return c.collectMessageRecursive(attribute, source, true, nil, "") +} + +// freezeMessages chooses the final protobuf and Go names for every message. It +// then connects each copied value to its message and prepares template data. +func (c *protobufPackageCatalog) freezeMessages(sd *ServiceData) []*service.UserTypeData { + c.freezeMessageNames() + if c.messagesRendered { + return c.messageData() + } + c.messagesRendered = true + for _, record := range c.messages { + identity := record.identity + userType := identity.userType + definition := protoBufMessageDef(userTypeAttribute(userType), sd) + for _, use := range record.uses[1:] { + other := protoBufMessageDef(userTypeAttribute(use.Type.(expr.UserType)), sd) + if other != definition { + panic(fmt.Sprintf("protobuf declaration %q has one typed identity but different wire definitions", record.name)) + } + } + record.data = &service.UserTypeData{ + Name: record.name, + VarName: record.name, + Description: userType.Attribute().Description, + Def: definition, + Ref: record.goRef, + Type: userType, + } + } + return c.messageData() +} + +// freezeMessageNames chooses the final package-level name for every message +// without creating its .proto definition. Conversion code uses this when it +// needs the Go type name but another service writes the message. +func (c *protobufPackageCatalog) freezeMessageNames() { + if c.frozen { + return + } + c.frozen = true + if c.plan == nil { + panic("protobuf message names were not planned") + } + for _, record := range c.messages { + record.name = record.declaration.Name() + record.goRef = "*" + c.packageName + "." + record.name + } + for _, record := range c.unions { + record.name = c.plan.oneofInterfaceName(record) + } +} + +// collectValidation records the validation function needed for attribute and +// every nested protobuf message it can call. +func (c *protobufPackageCatalog) collectValidation(attribute *expr.AttributeExpr, side validateKind, source protobufValidationSource, targetName, contextName string) { + if c.validationsFrozen { + panic("cannot collect a protobuf validator after validators freeze") + } + c.collectValidationRecursive(attribute, side, source, targetName, contextName, make(map[*protobufValidationRecord]struct{})) +} + +// freezeValidations builds each validation function with the name already +// chosen for its generated client or server package. +func (c *protobufPackageCatalog) freezeValidations(sd *ServiceData) []*ValidationData { + if c.validationsFrozen { + return c.validationData() + } + c.validationsFrozen = true + for _, record := range c.validators { + if record.declaration == nil { + panic(fmt.Sprintf("protobuf validator for %q has no generated declaration", record.message.plannedName)) + } + validationAttribute := expr.NewAttributeGraphCopier().Copy(record.attribute) + c.plan.bindAttributeCopy(record.attribute, validationAttribute) + c.bindCopiedValidationUses(record.attribute, validationAttribute, record.side) + collectionField := c.collectionWrapperField(validationAttribute) + removeMeta(validationAttribute) + userType := validationAttribute.Type.(expr.UserType) + context := protoBufTypeContext(c.packageName, sd) + context.Scope = &protobufValidationScope{ + protoBufScope: context.Scope.(*protoBufScope), + catalog: c, + side: record.side, + message: record.message, + parent: userType, + } + var definition string + if collectionField != nil { + fieldTarget := record.targetName + codegen.Goify(collectionField.Name, true) + fieldRef := context.Scope.Ref(collectionField.Attribute, context.Pkg(collectionField.Attribute)) + fieldName := context.Scope.Field(collectionField.Attribute, collectionField.Name, true) + definition = fmt.Sprintf("var %s %s\nif %s != nil {\n\t%s = %s.%s\n}\n%s", + fieldTarget, + fieldRef, + record.targetName, + fieldTarget, + record.targetName, + fieldName, + codegen.AttributeValidationCode( + collectionField.Attribute, + nil, + context, + false, + false, + fieldTarget, + record.contextName+"."+collectionField.Name, + ), + ) + } else { + definition = codegen.AttributeValidationCode( + userTypeAttribute(userType), + userType, + context, + true, + false, + record.targetName, + record.contextName, + ) + } + if definition == "" { + continue + } + record.data = &ValidationData{ + Declaration: record.declaration, + Name: record.declaration.Name(), + Def: definition, + ArgName: record.targetName, + SrcName: record.message.name, + SrcRef: record.message.goRef, + Kind: record.side, + } + } + return c.validationData() +} + +// collectionWrapperField returns the repeated or map field held by a +// protobuf wrapper message. Authored one-field messages are not wrappers. +func (c *protobufPackageCatalog) collectionWrapperField(attribute *expr.AttributeExpr) *expr.NamedAttributeExpr { + record := c.messageRecord(attribute) + if record == nil || record.identity.source.synthetic.role != protobufWrapperMessage { + return nil + } + object := expr.AsObject(attribute.Type) + if object == nil || len(*object) != 1 { + return nil + } + field := (*object)[0] + if !expr.IsArray(field.Attribute.Type) && !expr.IsMap(field.Attribute.Type) { + return nil + } + return field +} + +// message returns the completed message used for attribute. +func (c *protobufPackageCatalog) message(attribute *expr.AttributeExpr) *protobufMessageRecord { + if !c.frozen { + panic("cannot resolve a protobuf message before the package catalog freezes") + } + return c.messageRecord(attribute) +} + +// messageRecord returns the protobuf message collected for attribute. It may +// be called before package names are fixed. +func (c *protobufPackageCatalog) messageRecord(attribute *expr.AttributeExpr) *protobufMessageRecord { + return c.messageUses[attribute] +} + +// unionName returns the chosen Go interface name for one oneof declaration. +func (c *protobufPackageCatalog) unionName(attribute *expr.AttributeExpr) string { + if !c.frozen { + panic("cannot resolve a protobuf oneof before the package catalog freezes") + } + record := c.unionUses[attribute] + if record == nil { + panic(fmt.Sprintf("protobuf oneof %q has no frozen declaration", attribute.Type.Name())) + } + return record.name +} + +// validation returns the completed validation function used for attribute in +// the client or server package. +func (c *protobufPackageCatalog) validation(attribute *expr.AttributeExpr, side validateKind) *ValidationData { + if !c.validationsFrozen { + panic("cannot resolve a protobuf validator before validators freeze") + } + record := c.validationUses[protobufValidationUse{attribute: attribute, side: side}] + if record == nil { + return nil + } + return record.data +} + +// Ref returns the current message name when union validation asks for its +// parent type. Other values use the protobuf package records. +func (s *protobufValidationScope) Ref(attribute *expr.AttributeExpr, pkg string) string { + if attribute.Type == s.parent { + name := s.message.name + if pkg != "" { + name = pkg + "." + name + } + return "*" + name + } + return s.protoBufScope.Ref(attribute, pkg) +} + +// ValidatorCall returns a call to the validation function written to the +// current client or server package. +func (s *protobufValidationScope) ValidatorCall(attribute *expr.AttributeExpr, _, target, _ string) string { + validator := s.catalog.validationRecord(attribute, s.side) + if validator == nil { + panic("protobuf validator was not retained") + } + return fmt.Sprintf("%s(%s)", validator.declaration.Name(), target) +} + +// ValidationAcceptsNil reports whether a generated collection wrapper +// validator treats a nil wrapper as an empty collection. +func (s *protobufValidationScope) ValidationAcceptsNil(attribute *expr.AttributeExpr) bool { + return s.catalog.collectionWrapperField(attribute) != nil +} + +// collectMessageRecursive records messages and oneofs. Existing message +// records stop recursive user types. +func (c *protobufPackageCatalog) collectMessageRecursive(attribute *expr.AttributeExpr, source protobufMessageSource, root bool, owner *protobufMessageRecord, fieldName string) error { + if attribute == nil { + return nil + } + if expr.IsPrimitive(attribute.Type) { + return nil + } + switch actual := attribute.Type.(type) { + case expr.UserType: + origin := actual.Origin() + identitySource := protobufMessageSource{origin: origin} + if rootSource, ok := c.rootSources[origin]; ok { + identitySource = rootSource + } + if !root && len(actual.Attribute().Meta[wrappedAttrMeta]) > 0 { + identitySource = protobufMessageSource{synthetic: protobufSyntheticMessage{ + role: protobufWrapperMessage, + }} + } + if root && (source.origin != nil || source.synthetic.role != 0) { + identitySource = source + c.rootSources[origin] = source + } + identity, err := protobufMessageIdentityFor(attribute, identitySource) + if err != nil { + return err + } + record := c.findMessage(identity) + if record != nil { + record.uses = append(record.uses, attribute) + c.messageUses[attribute] = record + c.bindCopiedMessageUses(record.uses[0], attribute) + return nil + } + record = &protobufMessageRecord{identity: identity, uses: []*expr.AttributeExpr{attribute}} + c.messages = append(c.messages, record) + c.messageUses[attribute] = record + return c.collectMessageRecursive(userTypeAttribute(actual), protobufMessageSource{}, false, record, "") + case *expr.Object: + for _, named := range *actual { + if err := c.collectMessageRecursive(named.Attribute, protobufMessageSource{}, false, owner, named.Name); err != nil { + return err + } + } + case *expr.Array: + return c.collectMessageRecursive(actual.ElemType, protobufMessageSource{}, false, owner, fieldName+"Elem") + case *expr.Map: + if err := c.collectMessageRecursive(actual.KeyType, protobufMessageSource{}, false, owner, fieldName+"Key"); err != nil { + return err + } + return c.collectMessageRecursive(actual.ElemType, protobufMessageSource{}, false, owner, fieldName+"Elem") + case *expr.Union: + if owner == nil { + panic(fmt.Sprintf("protobuf oneof %q has no owning message", actual.Name())) + } + if fieldName == "" { + fieldName = actual.Name() + } + record := c.findUnion(owner, fieldName, attribute) + if record == nil { + record = &protobufUnionRecord{ + owner: owner, + attribute: attribute, + fieldName: fieldName, + } + c.unions = append(c.unions, record) + } + record.uses = append(record.uses, attribute) + c.unionUses[attribute] = record + for _, named := range actual.Values { + if err := c.collectMessageRecursive(named.Attribute, protobufMessageSource{}, false, owner, fieldName+named.Name); err != nil { + return err + } + } + } + return nil +} + +// bindCopiedMessageUses records the message, choice, field, and wrapper names +// for every part of a copied protobuf value. +func (c *protobufPackageCatalog) bindCopiedMessageUses(original, copy *expr.AttributeExpr) { + walkProtobufCopy(original, copy, func(original, copy *expr.AttributeExpr) { + if record := c.messageUses[original]; record != nil { + if existing := c.messageUses[copy]; existing != nil && existing != record { + panic("protobuf copy is connected to two message declarations") + } + if c.messageUses[copy] == nil { + c.messageUses[copy] = record + record.uses = append(record.uses, copy) + } + } + if record := c.unionUses[original]; record != nil { + if existing := c.unionUses[copy]; existing != nil && existing != record { + panic("protobuf copy is connected to two oneof declarations") + } + if c.unionUses[copy] == nil { + c.unionUses[copy] = record + record.uses = append(record.uses, copy) + } + } + }) +} + +// bindCopiedValidationUses records the validation function for each matching +// part of a copied protobuf value. +func (c *protobufPackageCatalog) bindCopiedValidationUses(original, copy *expr.AttributeExpr, side validateKind) { + walkProtobufCopy(original, copy, func(original, copy *expr.AttributeExpr) { + record := c.validationUses[protobufValidationUse{attribute: original, side: side}] + if record != nil { + c.bindValidationUse(copy, side, record) + } + }) +} + +// findUnion returns the oneof with the same parent message, field name, and +// branch types. +func (c *protobufPackageCatalog) findUnion(owner *protobufMessageRecord, fieldName string, attribute *expr.AttributeExpr) *protobufUnionRecord { + for _, record := range c.unions { + if record.owner == owner && record.fieldName == fieldName && + sameProtobufWireAttribute(record.attribute, attribute, make(map[protobufAttributePair]struct{})) { + return record + } + } + return nil +} + +// collectValidationRecursive records one function per message, set of rules, +// and client or server package, then visits the nested messages it may call. +func (c *protobufPackageCatalog) collectValidationRecursive(attribute *expr.AttributeExpr, side validateKind, source protobufValidationSource, targetName, contextName string, seen map[*protobufValidationRecord]struct{}) { + switch actual := attribute.Type.(type) { + case expr.UserType: + if expr.IsPrimitive(actual) { + return + } + policy := codegen.GoLayoutPolicy{Pointer: true} + if !codegen.NeedsValidation(userTypeAttribute(actual), policy) { + return + } + message := c.messageRecord(attribute) + if message == nil { + panic(fmt.Sprintf("no protobuf declaration collected for validation type %q", actual.Name())) + } + record := c.findValidation(message, attribute, side, source, targetName, contextName) + if record == nil { + record = &protobufValidationRecord{ + message: message, + attribute: attribute, + source: source, + side: side, + targetName: targetName, + contextName: contextName, + } + c.validators = append(c.validators, record) + } + c.bindValidationUse(attribute, side, record) + if _, ok := seen[record]; ok { + return + } + seen[record] = struct{}{} + c.collectValidationRecursive(userTypeAttribute(actual), side, source, targetName, contextName, seen) + delete(seen, record) + case *expr.Object: + for _, named := range *actual { + c.collectValidationRecursive(named.Attribute, side, source.field(named.Name), codegen.Goify(named.Name, false), named.Name, seen) + } + case *expr.Array: + c.collectValidationRecursive(actual.ElemType, side, source.arrayElement(), "elem", "elem", seen) + case *expr.Map: + c.collectValidationRecursive(actual.KeyType, side, source.mapKey(), "key", "key", seen) + c.collectValidationRecursive(actual.ElemType, side, source.mapValue(), "val", "val", seen) + case *expr.Union: + for _, named := range actual.Values { + c.collectValidationRecursive(named.Attribute, side, source.unionBranch(named.Name), codegen.Goify(named.Name, false), named.Name, seen) + } + } +} + +// findMessage returns the message written for the same source type, requested +// name, and protobuf fields. +func (c *protobufPackageCatalog) findMessage(identity protobufMessageIdentity) *protobufMessageRecord { + for _, record := range c.messages { + if sameProtobufMessageIdentity(record.identity, identity) { + return record + } + } + return nil +} + +// findValidation returns the existing function that checks the same message +// rules in the same client or server package. +func (c *protobufPackageCatalog) findValidation(declaration *protobufMessageRecord, attribute *expr.AttributeExpr, side validateKind, source protobufValidationSource, targetName, contextName string) *protobufValidationRecord { + for _, record := range c.validators { + if record.message == declaration && record.side == side && sameProtobufValidationSource(record.source, source) && record.targetName == targetName && record.contextName == contextName && + sameProtobufValidationAttribute(record.attribute, attribute, make(map[protobufAttributePair]struct{})) { + if compareProtobufValidationSources(source, record.source) < 0 { + record.source = source + } + return record + } + } + return nil +} + +// sameProtobufValidationSource reports whether two uses may call one function. +// Top-level functions with different selected views keep separate released +// names. Nested values may share one function when their complete checks and +// error paths are identical. +func sameProtobufValidationSource(left, right protobufValidationSource) bool { + leftNested := left.path != "" + rightNested := right.path != "" + if leftNested != rightNested { + return false + } + if leftNested { + return true + } + return normalizedProtobufValidationView(left.view) == normalizedProtobufValidationView(right.view) +} + +// compareProtobufValidationSources orders every authored part of a validator +// source. A shared validation body therefore keeps the same source when service +// or field traversal order changes. +func compareProtobufValidationSources(left, right protobufValidationSource) int { + for _, values := range [][2]string{ + {left.api, right.api}, + {left.service, right.service}, + {left.method, right.method}, + {left.error, right.error}, + {normalizedProtobufValidationView(left.view), normalizedProtobufValidationView(right.view)}, + } { + if order := cmp.Compare(values[0], values[1]); order != 0 { + return order + } + } + if order := cmp.Compare(left.role, right.role); order != 0 { + return order + } + return slices.CompareFunc(left.pathSteps, right.pathSteps, func(left, right protobufValidationPathStep) int { + if order := cmp.Compare(left.kind, right.kind); order != 0 { + return order + } + return cmp.Compare(left.name, right.name) + }) +} + +// normalizedProtobufValidationView treats the default view the same as an +// omitted view because both produce the released root validator name. +func normalizedProtobufValidationView(view string) string { + if view == expr.DefaultView { + return "" + } + return view +} + +// validationRecord returns the validation function called for one copied +// message value. +func (c *protobufPackageCatalog) validationRecord(attribute *expr.AttributeExpr, side validateKind) *protobufValidationRecord { + return c.validationUses[protobufValidationUse{attribute: attribute, side: side}] +} + +// bindValidationUse records the function that checks one protobuf value in a +// generated client or server package. +func (c *protobufPackageCatalog) bindValidationUse(attribute *expr.AttributeExpr, side validateKind, record *protobufValidationRecord) { + key := protobufValidationUse{attribute: attribute, side: side} + if existing := c.validationUses[key]; existing != nil && existing != record { + panic("protobuf value is connected to two validation functions") + } + if c.validationUses[key] == nil { + c.validationUses[key] = record + record.uses = append(record.uses, attribute) + } +} + +// walkProtobufCopy visits matching parts of an original protobuf value and its +// copy. Recursive user types are visited once. +func walkProtobufCopy(original, copy *expr.AttributeExpr, visit func(*expr.AttributeExpr, *expr.AttributeExpr)) { + seen := make(map[protobufAttributePair]struct{}) + var walk func(*expr.AttributeExpr, *expr.AttributeExpr) + walk = func(original, copy *expr.AttributeExpr) { + pair := protobufAttributePair{left: original, right: copy} + if _, ok := seen[pair]; ok { + return + } + seen[pair] = struct{}{} + visit(original, copy) + switch originalType := original.Type.(type) { + case expr.UserType: + copyType := copy.Type.(expr.UserType) + walk(userTypeAttribute(originalType), userTypeAttribute(copyType)) + case *expr.Object: + copyType := copy.Type.(*expr.Object) + for index, field := range *originalType { + walk(field.Attribute, (*copyType)[index].Attribute) + } + case *expr.Array: + walk(originalType.ElemType, copy.Type.(*expr.Array).ElemType) + case *expr.Map: + copyType := copy.Type.(*expr.Map) + walk(originalType.KeyType, copyType.KeyType) + walk(originalType.ElemType, copyType.ElemType) + case *expr.Union: + copyType := copy.Type.(*expr.Union) + for index, branch := range originalType.Values { + walk(branch.Attribute, copyType.Values[index].Attribute) + } + } + } + walk(original, copy) +} + +// messageData returns only messages whose names and template data are complete. +func (c *protobufPackageCatalog) messageData() []*service.UserTypeData { + data := make([]*service.UserTypeData, 0, len(c.messages)) + for _, record := range c.messages { + if record.data != nil { + data = append(data, record.data) + } + } + return data +} + +// protoMessageData returns message records with the names written to the +// protobuf source file. +func (c *protobufPackageCatalog) protoMessageData() []*service.UserTypeData { + data := make([]*service.UserTypeData, 0, len(c.messages)) + for _, record := range c.messages { + if record.data == nil { + continue + } + message := *record.data + message.Name = record.protoName + message.VarName = record.protoName + data = append(data, &message) + } + return data +} + +// validationData returns only validation functions that contain checks. +func (c *protobufPackageCatalog) validationData() []*ValidationData { + data := make([]*ValidationData, 0, len(c.validators)) + for _, record := range c.validators { + if record.data != nil { + data = append(data, record.data) + } + } + return data +} + +// child returns the same endpoint value at one nested field, collection part, +// or union branch. +func (s protobufValidationSource) child(kind protobufValidationPathKind, name string) protobufValidationSource { + if s.path == "" { + s.path = name + } else { + s.path += "." + name + } + s.pathSteps = append(append([]protobufValidationPathStep(nil), s.pathSteps...), protobufValidationPathStep{ + kind: kind, + name: name, + }) + return s +} + +// field returns the source for one named object field. +func (s protobufValidationSource) field(name string) protobufValidationSource { + return s.child(protobufValidationField, name) +} + +// arrayElement returns the source for an array element. +func (s protobufValidationSource) arrayElement() protobufValidationSource { + return s.child(protobufValidationArrayElement, "element") +} + +// mapKey returns the source for a map key. +func (s protobufValidationSource) mapKey() protobufValidationSource { + return s.child(protobufValidationMapKey, "key") +} + +// mapValue returns the source for a map value. +func (s protobufValidationSource) mapValue() protobufValidationSource { + return s.child(protobufValidationMapValue, "value") +} + +// unionBranch returns the source for one named union branch. +func (s protobufValidationSource) unionBranch(name string) protobufValidationSource { + return s.child(protobufValidationUnionBranch, name) +} + +// protobufMessageIdentityFor records the source type, requested name, and +// protobuf fields that decide whether two uses share one message. +func protobufMessageIdentityFor(attribute *expr.AttributeExpr, source protobufMessageSource) (protobufMessageIdentity, error) { + userType := attribute.Type.(expr.UserType) + if source.origin == nil && source.synthetic.role == 0 { + source.origin = userType.Origin() + } + preferred := codegen.ProtobufName(userType.Name()) + explicit := false + names := attribute.Meta["struct:name:proto"] + if len(names) == 0 { + names = userType.Attribute().Meta["struct:name:proto"] + } + if len(names) > 0 { + if !protobufExactNamePattern.MatchString(names[0]) { + return protobufMessageIdentity{}, fmt.Errorf("protobuf message name %q from struct:name:proto is not a valid protobuf identifier", names[0]) + } + preferred = names[0] + explicit = true + } + return protobufMessageIdentity{ + source: source, + preferredName: preferred, + explicitName: explicit, + userType: userType, + attribute: userTypeAttribute(userType), + }, nil +} + +// sameProtobufMessageIdentity reports whether two requests have the same source +// type, requested name, and protobuf fields. It does not compare generated Go +// names. +func sameProtobufMessageIdentity(left, right protobufMessageIdentity) bool { + if left.source != right.source || left.preferredName != right.preferredName || left.explicitName != right.explicitName { + return false + } + return sameProtobufWireAttribute(left.attribute, right.attribute, make(map[protobufAttributePair]struct{})) +} + +// sameProtobufWireAttribute reports whether two values produce the same fields +// in a .proto message. +func sameProtobufWireAttribute(left, right *expr.AttributeExpr, seen map[protobufAttributePair]struct{}) bool { + if left == right { + return true + } + if left == nil || right == nil || left.Description != right.Description { + return false + } + pair := protobufAttributePair{left: left, right: right} + if _, ok := seen[pair]; ok { + return true + } + seen[pair] = struct{}{} + if !sameProtobufMeta(left.Meta, right.Meta) { + return false + } + return sameProtobufWireType(left.Type, right.Type, seen) +} + +// sameProtobufWireType reports whether two types produce the same protobuf type, +// including ordered fields, choices, arrays, maps, and nested messages. +func sameProtobufWireType(left, right expr.DataType, seen map[protobufAttributePair]struct{}) bool { + if left.Kind() != right.Kind() { + return false + } + switch left := left.(type) { + case expr.Primitive: + return left == right.(expr.Primitive) + case expr.UserType: + right := right.(expr.UserType) + sameSource := left.Origin() == right.Origin() + bothSyntheticWrappers := len(left.Attribute().Meta[wrappedAttrMeta]) > 0 && + len(right.Attribute().Meta[wrappedAttrMeta]) > 0 + return (sameSource || bothSyntheticWrappers) && sameProtobufWireAttribute(left.Attribute(), right.Attribute(), seen) + case *expr.Object: + right := right.(*expr.Object) + if len(*left) != len(*right) { + return false + } + for index, named := range *left { + other := (*right)[index] + if named.Name != other.Name || !sameProtobufWireAttribute(named.Attribute, other.Attribute, seen) { + return false + } + } + return true + case *expr.Array: + right := right.(*expr.Array) + return sameProtobufWireAttribute(left.ElemType, right.ElemType, seen) + case *expr.Map: + right := right.(*expr.Map) + return sameProtobufWireAttribute(left.KeyType, right.KeyType, seen) && sameProtobufWireAttribute(left.ElemType, right.ElemType, seen) + case *expr.Union: + right := right.(*expr.Union) + if left.TypeName != right.TypeName || left.TypeKey != right.TypeKey || left.ValueKey != right.ValueKey || len(left.Values) != len(right.Values) { + return false + } + for index, named := range left.Values { + other := right.Values[index] + if named.Name != other.Name || !sameProtobufWireAttribute(named.Attribute, other.Attribute, seen) { + return false + } + } + return true + default: + panic(fmt.Sprintf("unknown protobuf wire type %T", left)) + } +} + +// sameProtobufValidationAttribute compares the source types and validation +// rules without comparing the protobuf message name. +func sameProtobufValidationAttribute(left, right *expr.AttributeExpr, seen map[protobufAttributePair]struct{}) bool { + if left == right { + return true + } + if left == nil || right == nil || !reflect.DeepEqual(left.Validation, right.Validation) || + !reflect.DeepEqual(left.DefaultValue, right.DefaultValue) || + !reflect.DeepEqual(left.Meta["struct:field:type"], right.Meta["struct:field:type"]) { + return false + } + pair := protobufAttributePair{left: left, right: right} + if _, ok := seen[pair]; ok { + return true + } + seen[pair] = struct{}{} + if left.Type.Kind() != right.Type.Kind() { + return false + } + switch left := left.Type.(type) { + case expr.Primitive: + return left == right.Type.(expr.Primitive) + case expr.UserType: + right := right.Type.(expr.UserType) + return sameProtobufValidationAttribute(left.Attribute(), right.Attribute(), seen) + case *expr.Object: + right := right.Type.(*expr.Object) + if len(*left) != len(*right) { + return false + } + for index, named := range *left { + other := (*right)[index] + if named.Name != other.Name || !sameProtobufValidationAttribute(named.Attribute, other.Attribute, seen) { + return false + } + } + return true + case *expr.Array: + right := right.Type.(*expr.Array) + return left.NonNullableElems == right.NonNullableElems && sameProtobufValidationAttribute(left.ElemType, right.ElemType, seen) + case *expr.Map: + right := right.Type.(*expr.Map) + return sameProtobufValidationAttribute(left.KeyType, right.KeyType, seen) && sameProtobufValidationAttribute(left.ElemType, right.ElemType, seen) + case *expr.Union: + right := right.Type.(*expr.Union) + if len(left.Values) != len(right.Values) { + return false + } + for index, named := range left.Values { + other := right.Values[index] + if named.Name != other.Name || !sameProtobufValidationAttribute(named.Attribute, other.Attribute, seen) { + return false + } + } + return true + default: + panic(fmt.Sprintf("unknown protobuf validation type %T", left)) + } +} + +// sameProtobufMeta compares metadata that changes protobuf field numbers, +// external types, explicit names, wrapper layout, or JSON names. +func sameProtobufMeta(left, right expr.MetaExpr) bool { + for _, name := range []string{ + "rpc:tag", + "struct:field:proto", + "struct:name:proto", + "proto:tag:json", + wrappedAttrMeta, + } { + if !reflect.DeepEqual(left[name], right[name]) { + return false + } + } + return true +} diff --git a/grpc/codegen/protobuf_descriptor_plan_test.go b/grpc/codegen/protobuf_descriptor_plan_test.go new file mode 100644 index 0000000000..0df6cde149 --- /dev/null +++ b/grpc/codegen/protobuf_descriptor_plan_test.go @@ -0,0 +1,351 @@ +// This file checks that a linked gRPC plan uses the Go names produced by the +// supported protobuf tools in every generated client and server file. +package codegen + +import ( + "context" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/require" + "google.golang.org/protobuf/proto" + "google.golang.org/protobuf/types/descriptorpb" + + "goa.design/goa/v3/codegen/service" + "goa.design/goa/v3/dsl" + "goa.design/goa/v3/expr" +) + +// TestPlanUsesNamesFromSupportedProtobufTools checks both method orders because +// changing source order must not change the Go declarations chosen for a file. +func TestPlanUsesNamesFromSupportedProtobufTools(t *testing.T) { + for _, reverse := range []bool{false, true} { + name := "unary-first" + if reverse { + name = "stream-first" + } + t.Run(name, func(t *testing.T) { + moduleDir, protoPath, generatedGo := renderProtobufDescriptorPlan(t, reverse) + descriptor := describeGeneratedProto(t, protoPath) + rules, err := newProtocNameRules(protocNameVersionGo1_36GRPC1_6) + require.NoError(t, err) + names, err := rules.file(descriptor) + require.NoError(t, err) + + serviceDescriptor := descriptor.GetService()[0] + messageDescriptor := messageWithOneof(t, descriptor, "result2_kind") + oneofDescriptor := messageDescriptor.GetOneofDecl()[0] + resetDescriptor := fieldNamed(t, messageDescriptor, "reset") + apiURLDescriptor := fieldNamed(t, messageDescriptor, "api_url") + dns2Descriptor := fieldNamed(t, messageDescriptor, "dns2_server") + stringDescriptor := fieldNamed(t, messageDescriptor, "string_") + oneofStringDescriptor := fieldNamed(t, messageDescriptor, "string_2") + require.NotEqual(t, stringDescriptor.GetName(), oneofStringDescriptor.GetName()) + require.NotNil(t, oneofStringDescriptor.OneofIndex) + packageName := descriptor.GetPackage() + serviceName := packageName + "." + serviceDescriptor.GetName() + messageName := packageName + "." + messageDescriptor.GetName() + outerStringName, ok := names.lookup(messageName+"."+stringDescriptor.GetName(), protocFieldName) + require.True(t, ok) + require.Equal(t, "String_", outerStringName) + oneofStringName, ok := names.lookup(messageName+"."+oneofStringDescriptor.GetName(), protocFieldName) + require.True(t, ok) + require.Equal(t, "String_2", oneofStringName) + oneofStringWrapper, ok := names.lookup(messageName+"."+oneofStringDescriptor.GetName(), protocOneofWrapperName) + require.True(t, ok) + require.Equal(t, messageDescriptor.GetName()+"_String_2", oneofStringWrapper) + methodNames := make(map[string]string, len(serviceDescriptor.GetMethod())) + for _, method := range serviceDescriptor.GetMethod() { + methodNames[method.GetName()] = serviceName + "." + method.GetName() + } + + checks := []struct { + descriptor string + role protocNameRole + }{ + {messageName, protocMessageName}, + {messageName + "." + apiURLDescriptor.GetName(), protocFieldName}, + {messageName + "." + dns2Descriptor.GetName(), protocFieldName}, + {messageName + "." + stringDescriptor.GetName(), protocFieldName}, + {messageName + "." + oneofStringDescriptor.GetName(), protocFieldName}, + {messageName + "." + oneofStringDescriptor.GetName(), protocOneofWrapperName}, + {messageName + "." + oneofDescriptor.GetName(), protocOneofFieldName}, + {messageName + "." + resetDescriptor.GetName(), protocOneofWrapperName}, + {serviceName, protocServiceClientName}, + {serviceName, protocServiceServerName}, + {methodNames["GetUrl2"], protocMethodName}, + {methodNames["SyncX509"], protocMethodName}, + {methodNames["SyncX509"], protocMethodClientStreamName}, + {methodNames["SyncX509"], protocMethodServerStreamName}, + } + declarations := declaredGoNames(t, + strings.TrimSuffix(protoPath, ".proto")+".pb.go", + strings.TrimSuffix(protoPath, ".proto")+"_grpc.pb.go", + ) + for _, check := range checks { + name, ok := names.lookup(check.descriptor, check.role) + require.True(t, ok, "%s was not recorded for %s", check.role, check.descriptor) + require.Contains(t, declarations, name, "the protobuf tools did not declare %s", name) + require.True(t, strings.Contains(generatedGo, name), "Goa did not use %s", name) + } + + protoSource, err := os.ReadFile(protoPath) + require.NoError(t, err) + require.Contains(t, string(protoSource), "message lower_snake_message {") + require.Contains(t, string(protoSource), "lower_snake_message lower = 2;") + require.Contains(t, string(protoSource), "message "+messageDescriptor.GetName()+" {") + require.Contains(t, string(protoSource), "oneof result2_kind {") + require.Contains(t, string(protoSource), "service "+serviceDescriptor.GetName()+" {") + require.Contains(t, string(protoSource), "rpc GetUrl2 (") + require.Contains(t, string(protoSource), "rpc CafRead (") + require.Contains(t, string(protoSource), "rpc SyncX509 (stream ") + compileProtobufDescriptorModule(t, moduleDir) + }) + } +} + +// TestPlanWritesLegalFieldAndOneofNames checks names that would make protoc +// reject the complete generated file if Goa wrote them unchanged. +func TestPlanWritesLegalFieldAndOneofNames(t *testing.T) { + _, protoPath, _ := renderProtobufDescriptorPlan(t, false) + protoSource, err := os.ReadFile(protoPath) + require.NoError(t, err) + + require.Contains(t, string(protoSource), "message LeadingDigit {") + require.Contains(t, string(protoSource), "optional string _123_field = 1;") + require.Contains(t, string(protoSource), "message UnicodeName {") + require.Contains(t, string(protoSource), "optional string caf_field = 1;") + require.Contains(t, string(protoSource), "oneof foo_bar_oneof {") + require.Contains(t, string(protoSource), "optional string foo_bar = 3;") + require.Contains(t, string(protoSource), "message CollisionReverse {") + require.Equal(t, 2, strings.Count(string(protoSource), "oneof foo_bar_oneof {")) +} + +// TestPlanRejectsIllegalExactProtobufName checks that an exact metadata name +// fails before Goa writes a protobuf file that protoc cannot parse. +func TestPlanRejectsIllegalExactProtobufName(t *testing.T) { + root := RunGRPCDSL(t, func() { + message := dsl.Type("Message", func() { + dsl.Meta("struct:name:proto", "123_message") + dsl.Field(1, "value", dsl.String) + }) + dsl.Service("invalid", func() { + dsl.Method("read", func() { + dsl.Payload(message) + dsl.Result(message) + dsl.GRPC(func() {}) + }) + }) + }) + generation, servicePlans := grpcServicePlans(t, []*expr.RootExpr{root}) + + _, err := NewPlans(generation, PlanInput{Root: root, Service: servicePlans[0]}) + require.EqualError(t, err, `service "invalid" protobuf message name "123_message" from struct:name:proto is not a valid protobuf identifier`) +} + +// renderProtobufDescriptorPlan writes one linked plan and returns the temporary +// module, its protobuf source file, and the Goa client and server source. +func renderProtobufDescriptorPlan(t *testing.T, reverse bool) (string, string, string) { + t.Helper() + root := RunGRPCDSL(t, protobufDescriptorPlanDSL(reverse)) + generation, servicePlans := grpcServicePlans(t, []*expr.RootExpr{root}) + plans, err := NewPlans(generation, PlanInput{Root: root, Service: servicePlans[0]}) + require.NoError(t, err) + require.NoError(t, generation.Freeze()) + require.NoError(t, servicePlans[0].Link()) + require.NoError(t, plans[0].Link()) + + files, err := service.Files(servicePlans...) + require.NoError(t, err) + files = append(files, plans[0].ServerFiles()...) + files = append(files, plans[0].ClientFiles()...) + files = append(files, plans[0].ServerTypeFiles()...) + files = append(files, plans[0].ClientTypeFiles()...) + files = append(files, plans[0].ProtoFiles()...) + + moduleDir := t.TempDir() + writeProtobufDescriptorModule(t, moduleDir) + var protoPath string + var generated strings.Builder + for _, file := range files { + path, err := file.Render(moduleDir) + require.NoError(t, err) + if filepath.Ext(path) == ".proto" { + protoPath = path + continue + } + if strings.Contains(filepath.ToSlash(path), "/grpc/") { + source, err := os.ReadFile(path) + require.NoError(t, err) + generated.Write(source) + } + } + require.NotEmpty(t, protoPath) + return moduleDir, protoPath, generated.String() +} + +// protobufDescriptorPlanDSL returns a design that uses one object in unary and +// streaming messages and places its preferred protobuf name beside a service +// declaration with the same Go name. +func protobufDescriptorPlanDSL(reverse bool) func() { + return func() { + leadingDigit := dsl.Type("LeadingDigit", func() { + dsl.Field(1, "123_field", dsl.String, func() { + dsl.Meta("struct:field:name", "LeadingField") + }) + }) + unicodeName := dsl.Type("UnicodeName", func() { + dsl.Field(1, "caféField", dsl.String, func() { + dsl.Meta("struct:field:name", "CafeField") + }) + }) + collision := dsl.Type("Collision", func() { + dsl.OneOf("fooBar", func() { + dsl.TypeName("CollisionFooBar") + dsl.Field(2, "text", dsl.String) + }) + dsl.Field(3, "foo_bar", dsl.String, func() { + dsl.Meta("struct:field:name", "OtherFooBar") + }) + }) + collisionReverse := dsl.Type("CollisionReverse", func() { + dsl.Field(1, "foo_bar", dsl.String, func() { + dsl.Meta("struct:field:name", "OtherFooBar") + }) + dsl.OneOf("fooBar", func() { + dsl.TypeName("CollisionReverseFooBar") + dsl.Field(2, "text", dsl.String) + }) + }) + shared := dsl.Type("Api2HttpServiceClient", func() { + dsl.Field(1, "api_url", dsl.String) + dsl.Field(2, "dns_2_server", dsl.String) + dsl.Field(5, "string", dsl.String) + dsl.OneOf("result_2_kind", func() { + dsl.Field(3, "http_2xx", dsl.String) + dsl.Field(4, "reset", dsl.String) + dsl.Field(6, "string", dsl.String) + }) + }) + lowerSnake := dsl.Type("LowerSnake", func() { + dsl.Meta("struct:name:proto", "lower_snake_message") + dsl.Field(1, "value", dsl.String) + }) + envelope := dsl.Type("API2Envelope", func() { + dsl.Field(1, "client", shared) + dsl.Field(2, "lower", lowerSnake) + dsl.Field(3, "leading", leadingDigit) + dsl.Field(4, "collision", collision) + dsl.Field(5, "unicode", unicodeName) + dsl.Field(6, "collisionReverse", collisionReverse) + }) + unary := func() { + dsl.Method("get_url2", func() { + dsl.Payload(envelope) + dsl.Result(envelope) + dsl.GRPC(func() {}) + }) + } + stream := func() { + dsl.Method("sync_x509", func() { + dsl.StreamingPayload(envelope) + dsl.StreamingResult(envelope) + dsl.GRPC(func() {}) + }) + } + unicodeMethod := func() { + dsl.Method("café_read", func() { + dsl.Payload(envelope) + dsl.Result(envelope) + dsl.GRPC(func() {}) + }) + } + dsl.Service("api2_http_service", func() { + if reverse { + stream() + unary() + unicodeMethod() + return + } + unary() + stream() + unicodeMethod() + }) + } +} + +// describeGeneratedProto asks protoc for the names and fields written in one +// generated protobuf source file. +func describeGeneratedProto(t *testing.T, protoPath string) *descriptorpb.FileDescriptorProto { + t.Helper() + descriptorPath := filepath.Join(t.TempDir(), "descriptor.pb") + args := defaultProtocCmd[1:len(defaultProtocCmd):len(defaultProtocCmd)] + args = append(args, + "--proto_path", filepath.Dir(protoPath), + "--descriptor_set_out", descriptorPath, + protoPath, + ) + output, err := exec.Command(defaultProtocCmd[0], args...).CombinedOutput() + require.NoError(t, err, string(output)) + encoded, err := os.ReadFile(descriptorPath) + require.NoError(t, err) + set := &descriptorpb.FileDescriptorSet{} + require.NoError(t, proto.Unmarshal(encoded, set)) + require.Len(t, set.File, 1) + return set.File[0] +} + +// messageWithOneof returns the message that declares the named choice field. +func messageWithOneof(t *testing.T, file *descriptorpb.FileDescriptorProto, name string) *descriptorpb.DescriptorProto { + t.Helper() + for _, message := range file.GetMessageType() { + for _, oneof := range message.GetOneofDecl() { + if oneof.GetName() == name { + return message + } + } + } + t.Fatalf("protobuf source did not declare oneof %q", name) + return nil +} + +// fieldNamed returns the field with the requested protobuf source name. +func fieldNamed(t *testing.T, message *descriptorpb.DescriptorProto, name string) *descriptorpb.FieldDescriptorProto { + t.Helper() + for _, field := range message.GetField() { + if field.GetName() == name { + return field + } + } + t.Fatalf("protobuf message %q did not declare field %q", message.GetName(), name) + return nil +} + +// writeProtobufDescriptorModule writes a module that imports this Goa checkout. +func writeProtobufDescriptorModule(t *testing.T, directory string) { + t.Helper() + command := exec.Command("go", "list", "-m", "-f", "{{.Dir}}", "goa.design/goa/v3") + output, err := command.CombinedOutput() + require.NoError(t, err, string(output)) + goaDirectory := strings.TrimSpace(string(output)) + require.NotEmpty(t, goaDirectory) + module := "module generated.local\n\ngo 1.25\n\nrequire goa.design/goa/v3 v3.0.0\n\n" + + "replace goa.design/goa/v3 => " + filepath.ToSlash(goaDirectory) + "\n" + require.NoError(t, os.WriteFile(filepath.Join(directory, "go.mod"), []byte(module), 0o600)) +} + +// compileProtobufDescriptorModule compiles every package written by the plan. +func compileProtobufDescriptorModule(t *testing.T, directory string) { + t.Helper() + ctx, cancel := context.WithTimeout(context.Background(), time.Minute) + defer cancel() + command := exec.CommandContext(ctx, "go", "test", "-mod=mod", "./...") + command.Dir = directory + command.Env = append(os.Environ(), "GOWORK=off") + output, err := command.CombinedOutput() + require.NoError(t, err, string(output)) +} diff --git a/grpc/codegen/protobuf_plan.go b/grpc/codegen/protobuf_plan.go new file mode 100644 index 0000000000..c154d6d2c4 --- /dev/null +++ b/grpc/codegen/protobuf_plan.go @@ -0,0 +1,909 @@ +// This file asks the supported protobuf tools for every Go name that Goa must +// reference, then stores those names before any generated file is built. +package codegen + +import ( + "cmp" + "fmt" + "sort" + "strconv" + + "google.golang.org/protobuf/proto" + "google.golang.org/protobuf/types/descriptorpb" + + "goa.design/goa/v3/codegen" + "goa.design/goa/v3/expr" +) + +type ( + // protobufServicePlan stores one service's copied messages and the Go names + // that protoc and its Go plugins produce for them. + protobufServicePlan struct { + expression *expr.GRPCServiceExpr + catalog *protobufPackageCatalog + messages []*protobufEndpointMessages + protoPackage string + serviceName string + fileIndex int + order protobufServiceOrder + methods map[*expr.GRPCEndpointExpr]string + names map[protocNameKey]*codegen.NameDeclaration + localNames map[protocNameKey]string + fields map[*expr.AttributeExpr]protocNameKey + sourceFields map[*expr.AttributeExpr]string + sourceOneofs map[*expr.AttributeExpr]string + wrappers map[*expr.AttributeExpr]protocNameKey + oneofs map[*expr.AttributeExpr]protocNameKey + } + + // protobufNameGroup holds one name written to a .proto file and every Go name + // generated from it. If a Go name is already used, Goa adds the same number + // to the .proto name and asks the tools for the complete set again. + protobufNameGroup struct { + preferred string + name string + suffix int + message *protobufMessageRecord + method *expr.GRPCEndpointExpr + service bool + } + + // protobufServiceOrder holds the two names used to place services in a + // stable order. + protobufServiceOrder struct { + service string + api string + } +) + +// planProtobufServices records every protobuf declaration in the generated Go +// package that will contain it. +func planProtobufServices(generation *codegen.Generation, roots []*Plan) error { + groups := make(map[string][]*protobufServicePlan) + for _, rootPlan := range roots { + for _, service := range rootPlan.expressions { + pathName := rootPlan.packages[service].pathName + packagePath := generation.GenPkg() + "/grpc/" + pathName + "/" + pbPkgName + catalog := newProtobufPackageCatalog("") + messages, err := collectProtobufPackage(service, catalog) + if err != nil { + return fmt.Errorf("service %q %w", service.Name(), err) + } + plan := &protobufServicePlan{ + expression: service, + catalog: catalog, + messages: messages, + protoPackage: pkgName(service, pathName), + order: protobufServiceOrder{ + service: service.Name(), + api: rootPlan.root.API.Name, + }, + methods: make(map[*expr.GRPCEndpointExpr]string, len(service.GRPCEndpoints)), + names: make(map[protocNameKey]*codegen.NameDeclaration), + localNames: make(map[protocNameKey]string), + fields: make(map[*expr.AttributeExpr]protocNameKey), + sourceFields: make(map[*expr.AttributeExpr]string), + sourceOneofs: make(map[*expr.AttributeExpr]string), + wrappers: make(map[*expr.AttributeExpr]protocNameKey), + oneofs: make(map[*expr.AttributeExpr]protocNameKey), + } + catalog.plan = plan + rootPlan.protobuf[service] = plan + groups[packagePath] = append(groups[packagePath], plan) + } + } + for packagePath, group := range groups { + pkg, err := generation.ClaimPackage(packagePath) + if err != nil { + return err + } + sort.Slice(group, func(i, j int) bool { + if group[i].order.service != group[j].order.service { + return group[i].order.service < group[j].order.service + } + return group[i].order.api < group[j].order.api + }) + for index := 1; index < len(group); index++ { + if group[index-1].order == group[index].order { + return fmt.Errorf( + "generated protobuf package %q has two services named %q in API %q", + packagePath, + group[index].order.service, + group[index].order.api, + ) + } + } + for _, plan := range group[1:] { + if plan.protoPackage != group[0].protoPackage { + return fmt.Errorf("generated package %q cannot contain protobuf packages %q and %q", packagePath, group[0].protoPackage, plan.protoPackage) + } + } + used := make(map[string]struct{}) + for index, plan := range group { + plan.fileIndex = index + 1 + if err := plan.chooseNames(pkg, used); err != nil { + return fmt.Errorf("plan protobuf names for service %q: %w", plan.expression.Name(), err) + } + } + } + return nil +} + +// name returns one Go name produced by the supported protobuf tools. +func (p *protobufServicePlan) name(descriptor string, role protocNameRole) string { + key := protocNameKey{descriptor: descriptor, role: role} + if declaration := p.names[key]; declaration != nil { + return declaration.Name() + } + name, ok := p.localNames[key] + if !ok { + panic(fmt.Sprintf("protobuf Go name %s for %q was not planned", role, descriptor)) + } + return name +} + +// fieldName returns the Go field name produced for one copied protobuf field. +func (p *protobufServicePlan) fieldName(attribute *expr.AttributeExpr) (string, bool) { + key, ok := p.fields[attribute] + if !ok { + return "", false + } + return p.name(key.descriptor, key.role), true +} + +// sourceFieldName returns the field name written to the protobuf file. +func (p *protobufServicePlan) sourceFieldName(attribute *expr.AttributeExpr) string { + name, ok := p.sourceFields[attribute] + if !ok { + panic("protobuf source field was not planned") + } + return name +} + +// sourceOneofName returns the oneof name written to the protobuf file. +func (p *protobufServicePlan) sourceOneofName(attribute *expr.AttributeExpr) string { + name, ok := p.sourceOneofs[attribute] + if !ok { + panic("protobuf source oneof was not planned") + } + return name +} + +// wrapperName returns the Go wrapper type for one branch in one parent +// message. +func (p *protobufServicePlan) wrapperName(attribute *expr.AttributeExpr) (string, bool) { + key, ok := p.wrappers[attribute] + if !ok { + return "", false + } + return p.name(key.descriptor, key.role), true +} + +// oneofInterfaceName returns the Go interface produced for one oneof. +func (p *protobufServicePlan) oneofInterfaceName(record *protobufUnionRecord) string { + key, ok := p.oneofs[record.attribute] + if !ok { + panic("protobuf oneof interface name was not planned") + } + return p.name(key.descriptor, key.role) +} + +// bindAttributeCopy records every message, choice, field, and wrapper name for +// the matching parts of a copied protobuf value. +func (p *protobufServicePlan) bindAttributeCopy(original, copy *expr.AttributeExpr) { + p.catalog.bindCopiedMessageUses(original, copy) + walkProtobufCopy(original, copy, func(original, copy *expr.AttributeExpr) { + if key, ok := p.fields[original]; ok { + p.fields[copy] = key + } + if name, ok := p.sourceFields[original]; ok { + p.sourceFields[copy] = name + } + if name, ok := p.sourceOneofs[original]; ok { + p.sourceOneofs[copy] = name + } + if key, ok := p.wrappers[original]; ok { + p.wrappers[copy] = key + } + if key, ok := p.oneofs[original]; ok { + p.oneofs[copy] = key + } + }) +} + +// chooseNames tries numbered protobuf names until every generated Go name is +// unique in the package. +func (p *protobufServicePlan) chooseNames(pkg *codegen.GeneratedPackage, used map[string]struct{}) error { + groups, err := p.nameGroups() + if err != nil { + return err + } + p.assignInitialNames(groups) + rules, err := newProtocNameRules(protocNameVersionGo1_36GRPC1_6) + if err != nil { + return err + } + for attempts := 0; attempts < 1000; attempts++ { + descriptor, owners, err := p.namingDescriptor(groups) + if err != nil { + return err + } + generated, err := rules.file(descriptor) + if err != nil { + return err + } + colliding := collidingProtobufGroup(generated, owners, groups, used) + if colliding == nil { + if err := p.declareNames(pkg, generated); err != nil { + return err + } + for key, name := range generated.values { + if _, packageName := protocPackageNameKind(key.role); packageName { + used[name] = struct{}{} + } + } + return nil + } + colliding.name = nextAvailableProtobufName(colliding, groups) + } + return fmt.Errorf("could not choose unique protobuf Go names") +} + +// nameGroups puts the service and methods before messages. This keeps the +// requested service and method names when a message would generate the same Go +// name. +func (p *protobufServicePlan) nameGroups() ([]*protobufNameGroup, error) { + service := &protobufNameGroup{ + preferred: codegen.ProtobufName(p.expression.Name()), + service: true, + } + methods := make([]*protobufNameGroup, 0, len(p.expression.GRPCEndpoints)) + for _, endpoint := range p.expression.GRPCEndpoints { + methods = append(methods, &protobufNameGroup{ + preferred: codegen.ProtobufName(endpoint.Name()), + method: endpoint, + }) + } + sort.Slice(methods, func(i, j int) bool { + return compareProtobufEndpointSource(methods[i].method, methods[j].method) < 0 + }) + messages := make([]*protobufNameGroup, 0, len(p.catalog.messages)) + for _, message := range p.catalog.messages { + messages = append(messages, &protobufNameGroup{ + preferred: message.identity.preferredName, + message: message, + }) + } + if err := sortProtobufMessageGroups(messages); err != nil { + return nil, err + } + groups := []*protobufNameGroup{service} + groups = append(groups, methods...) + return append(groups, messages...), nil +} + +// assignInitialNames makes message and service names unique in the file. Method +// names use a separate list because protobuf allows the same method name in a +// different service. +func (p *protobufServicePlan) assignInitialNames(groups []*protobufNameGroup) { + packageNames := make(map[string]struct{}) + methodNames := make(map[string]struct{}) + for _, group := range groups { + used := packageNames + if group.method != nil { + used = methodNames + } + assignProtobufGroupName(group, used) + } +} + +// namingDescriptor builds a small protobuf file containing only declarations +// that can change generated Go names. A field's type cannot change its Go name, +// so these temporary fields all use string. +func (p *protobufServicePlan) namingDescriptor(groups []*protobufNameGroup) (*descriptorpb.FileDescriptorProto, map[protocNameKey]*protobufNameGroup, error) { + owners := make(map[protocNameKey]*protobufNameGroup) + file := &descriptorpb.FileDescriptorProto{ + Name: proto.String("goa_names.proto"), + Package: proto.String(p.protoPackage), + Syntax: proto.String(ProtoVersion), + Options: &descriptorpb.FileOptions{GoPackage: proto.String("/" + p.protoPackage + "pb")}, + } + var serviceGroup *protobufNameGroup + for _, group := range groups { + switch { + case group.service: + serviceGroup = group + p.serviceName = group.name + case group.method != nil: + p.methods[group.method] = group.name + case group.message != nil: + group.message.protoName = group.name + message, keys := p.messageDescriptor(group.message) + for _, use := range group.message.uses { + useType := use.Type.(expr.UserType) + p.bindAttributeCopy(group.message.identity.attribute, userTypeAttribute(useType)) + } + file.MessageType = append(file.MessageType, message) + for _, key := range keys { + owners[key] = group + } + } + } + service, keys, err := p.serviceDescriptor(serviceGroup, groups) + if err != nil { + return nil, nil, err + } + file.Service = []*descriptorpb.ServiceDescriptorProto{service} + for _, key := range keys { + owner := serviceGroup + for _, group := range groups { + if group.method != nil && key.descriptor == p.serviceFullName()+"."+group.name { + owner = group + break + } + } + owners[key] = owner + } + return file, owners, nil +} + +// messageDescriptor records one message's fields and oneofs for protogen. +func (p *protobufServicePlan) messageDescriptor(record *protobufMessageRecord) (*descriptorpb.DescriptorProto, []protocNameKey) { + message := &descriptorpb.DescriptorProto{Name: proto.String(record.protoName)} + fullName := p.protoPackage + "." + record.protoName + keys := []protocNameKey{{descriptor: fullName, role: protocMessageName}} + usedFieldNames := make(map[string]struct{}) + fieldNames := make(map[*expr.AttributeExpr]string) + fieldNumber := int32(1) + attribute := record.identity.attribute + if userType, ok := attribute.Type.(expr.UserType); ok { + attribute = userType.Attribute() + } + object := expr.AsObject(attribute.Type) + if object == nil { + if union, ok := attribute.Type.(*expr.Union); ok { + for _, branch := range union.Values { + fieldNames[branch.Attribute] = uniqueProtobufSourceName(protobufSourceFieldName(branch.Name), usedFieldNames) + } + oneofName := uniqueProtobufOneofSourceName(union.Name(), usedFieldNames) + p.addOneofDescriptor(message, fullName, oneofName, attribute, union, fieldNames, &fieldNumber, &keys) + } + return message, keys + } + for _, named := range *object { + if union, ok := named.Attribute.Type.(*expr.Union); ok { + for _, branch := range union.Values { + fieldNames[branch.Attribute] = uniqueProtobufSourceName(protobufSourceFieldName(branch.Name), usedFieldNames) + } + continue + } + fieldNames[named.Attribute] = uniqueProtobufSourceName(protobufSourceFieldName(named.Name), usedFieldNames) + } + oneofNames := make(map[*expr.AttributeExpr]string) + for _, named := range *object { + if _, ok := named.Attribute.Type.(*expr.Union); ok { + oneofNames[named.Attribute] = uniqueProtobufOneofSourceName(named.Name, usedFieldNames) + } + } + for _, named := range *object { + if union, ok := named.Attribute.Type.(*expr.Union); ok { + p.addOneofDescriptor(message, fullName, oneofNames[named.Attribute], named.Attribute, union, fieldNames, &fieldNumber, &keys) + continue + } + fieldName := fieldNames[named.Attribute] + message.Field = append(message.Field, namingField(fieldName, fieldNumber, nil)) + fieldNumber++ + key := protocNameKey{descriptor: fullName + "." + fieldName, role: protocFieldName} + p.fields[named.Attribute] = key + p.sourceFields[named.Attribute] = fieldName + keys = append(keys, key) + } + return message, keys +} + +// addOneofDescriptor records one oneof and each branch field after every source +// name in the message has been selected. +func (p *protobufServicePlan) addOneofDescriptor(message *descriptorpb.DescriptorProto, messageName, oneofName string, attribute *expr.AttributeExpr, union *expr.Union, fieldNames map[*expr.AttributeExpr]string, fieldNumber *int32, keys *[]protocNameKey) { + index := int32(len(message.OneofDecl)) + message.OneofDecl = append(message.OneofDecl, &descriptorpb.OneofDescriptorProto{Name: proto.String(oneofName)}) + fieldKey := protocNameKey{descriptor: messageName + "." + oneofName, role: protocOneofFieldName} + interfaceKey := protocNameKey{descriptor: messageName + "." + oneofName, role: protocOneofInterfaceName} + p.fields[attribute] = fieldKey + p.sourceOneofs[attribute] = oneofName + p.oneofs[attribute] = interfaceKey + *keys = append(*keys, fieldKey, interfaceKey) + for _, branch := range union.Values { + name := fieldNames[branch.Attribute] + message.Field = append(message.Field, namingField(name, *fieldNumber, &index)) + *fieldNumber++ + fieldKey := protocNameKey{descriptor: messageName + "." + name, role: protocFieldName} + wrapperKey := protocNameKey{descriptor: messageName + "." + name, role: protocOneofWrapperName} + p.fields[branch.Attribute] = fieldKey + p.sourceFields[branch.Attribute] = name + p.wrappers[branch.Attribute] = wrapperKey + *keys = append(*keys, fieldKey, wrapperKey) + } +} + +// serviceDescriptor records the service methods and their stream directions. +func (p *protobufServicePlan) serviceDescriptor(serviceGroup *protobufNameGroup, groups []*protobufNameGroup) (*descriptorpb.ServiceDescriptorProto, []protocNameKey, error) { + service := &descriptorpb.ServiceDescriptorProto{Name: proto.String(serviceGroup.name)} + serviceName := p.protoPackage + "." + serviceGroup.name + keys := serviceNameKeys(serviceName) + for _, group := range groups { + if group.method == nil { + continue + } + endpoint := group.method + index := slicesIndexEndpoint(p.expression.GRPCEndpoints, endpoint) + if index < 0 { + return nil, nil, fmt.Errorf("method %q is not part of service %q", endpoint.Name(), p.expression.Name()) + } + messages := p.messages[index] + request := p.catalog.messageUses[messages.request] + if messages.requestEnvelope != nil { + request = p.catalog.messageUses[messages.requestEnvelope] + } else if endpoint.MethodExpr.StreamingPayload.Type != expr.Empty { + request = p.catalog.messageUses[messages.streamingRequest] + } + response := p.catalog.messageUses[messages.response] + if request == nil || response == nil { + return nil, nil, fmt.Errorf("method %q has no protobuf request or response message", endpoint.Name()) + } + method := &descriptorpb.MethodDescriptorProto{ + Name: proto.String(group.name), + InputType: proto.String("." + p.protoPackage + "." + request.protoName), + OutputType: proto.String("." + p.protoPackage + "." + response.protoName), + ClientStreaming: proto.Bool(endpoint.MethodExpr.IsPayloadStreaming()), + ServerStreaming: proto.Bool(endpoint.MethodExpr.IsResultStreaming()), + } + service.Method = append(service.Method, method) + keys = append(keys, methodNameKeys(serviceName+"."+group.name, endpoint.MethodExpr.IsStreaming())...) + } + return service, keys, nil +} + +// declareNames stores package-level Go names with the package that writes them. +// It stores field and method names directly because they cannot collide with +// names outside their message or service. +func (p *protobufServicePlan) declareNames(pkg *codegen.GeneratedPackage, generated *protocNames) error { + for key, name := range generated.values { + kind, packageName := protocPackageNameKind(key.role) + if !packageName { + p.localNames[key] = name + continue + } + declaration := codegen.NewExactName(kind, name) + if err := pkg.DeclareName(declaration); err != nil { + return err + } + p.names[key] = declaration + } + for _, record := range p.catalog.messages { + key := protocNameKey{ + descriptor: p.protoPackage + "." + record.protoName, + role: protocMessageName, + } + record.plannedName = generated.values[key] + record.declaration = p.names[key] + if record.declaration == nil { + return fmt.Errorf("protobuf message %q has no generated Go declaration", record.protoName) + } + } + return nil +} + +// collidingProtobufGroup returns the first group that would generate a Go name +// already used in the package. +func collidingProtobufGroup(names *protocNames, owners map[protocNameKey]*protobufNameGroup, groups []*protobufNameGroup, occupied map[string]struct{}) *protobufNameGroup { + byGroup := make(map[*protobufNameGroup][]string) + for key, name := range names.values { + _, packageName := protocPackageNameKind(key.role) + if packageName { + byGroup[owners[key]] = append(byGroup[owners[key]], name) + } + } + used := make(map[string]struct{}, len(occupied)) + for name := range occupied { + used[name] = struct{}{} + } + for _, group := range groups { + for _, name := range byGroup[group] { + if _, ok := used[name]; ok { + return group + } + } + for _, name := range byGroup[group] { + used[name] = struct{}{} + } + } + return nil +} + +// protocPackageNameKind identifies names declared at package level. +func protocPackageNameKind(role protocNameRole) (codegen.PackageNameKind, bool) { + switch role { + case protocMessageName, protocEnumName, protocOneofInterfaceName, protocOneofWrapperName, + protocServiceClientName, protocServiceClientStructName, protocServiceServerName, + protocServiceUnimplementedServerName, protocServiceUnsafeServerName, + protocMethodClientStreamName, protocMethodServerStreamName: + return codegen.NameType, true + case protocServiceClientConstructorName, protocServiceRegisterName, protocMethodHandlerName: + return codegen.NameFunction, true + case protocMethodFullName: + return codegen.NameConstant, true + case protocServiceDescriptorName: + return codegen.NameVariable, true + default: + return 0, false + } +} + +// sortProtobufMessageGroups puts messages in the same order for every input +// order. It reports separate declarations when their source, requested name, +// and protobuf fields cannot choose which one comes first. +func sortProtobufMessageGroups(groups []*protobufNameGroup) error { + sort.Slice(groups, func(i, j int) bool { + return compareProtobufMessageIdentity(groups[i].message.identity, groups[j].message.identity) < 0 + }) + for index := 1; index < len(groups); index++ { + left := groups[index-1].message.identity + right := groups[index].message.identity + if compareProtobufMessageIdentity(left, right) == 0 && !sameProtobufMessageIdentity(left, right) { + return fmt.Errorf("protobuf messages named %q have the same source, name, and fields but come from separate declarations", left.preferredName) + } + } + return nil +} + +// compareProtobufMessageIdentity compares the source, requested name, and +// protobuf fields that decide whether two values use one message. +func compareProtobufMessageIdentity(left, right protobufMessageIdentity) int { + if order := compareProtobufMessageSource(left.source, right.source); order != 0 { + return order + } + if order := cmp.Compare(left.preferredName, right.preferredName); order != 0 { + return order + } + if order := compareBool(left.explicitName, right.explicitName); order != 0 { + return order + } + return compareProtobufWireAttribute(left.attribute, right.attribute, make(map[protobufAttributePair]struct{})) +} + +// compareProtobufMessageSource compares the design declaration or method value +// that produced a protobuf message. +func compareProtobufMessageSource(left, right protobufMessageSource) int { + leftAuthored := left.origin != nil + rightAuthored := right.origin != nil + if order := compareBool(leftAuthored, rightAuthored); order != 0 { + return order + } + if leftAuthored { + if left.origin == right.origin { + return 0 + } + if order := cmp.Compare(left.origin.ID(), right.origin.ID()); order != 0 { + return order + } + return cmp.Compare(left.origin.Name(), right.origin.Name()) + } + if order := cmp.Compare(left.synthetic.role, right.synthetic.role); order != 0 { + return order + } + if order := compareProtobufEndpointSource(left.synthetic.endpoint, right.synthetic.endpoint); order != 0 { + return order + } + return compareProtobufErrorSource(left.synthetic.error, right.synthetic.error) +} + +// compareProtobufEndpointSource compares the service and method names that +// produced a generated request or response message. +func compareProtobufEndpointSource(left, right *expr.GRPCEndpointExpr) int { + if order := compareBool(left != nil, right != nil); order != 0 { + return order + } + if left == nil || left == right { + return 0 + } + if order := cmp.Compare(left.Service.Name(), right.Service.Name()); order != 0 { + return order + } + return cmp.Compare(left.Name(), right.Name()) +} + +// compareProtobufErrorSource compares the error names that produced generated +// error messages. +func compareProtobufErrorSource(left, right *expr.GRPCErrorExpr) int { + if order := compareBool(left != nil, right != nil); order != 0 { + return order + } + if left == nil || left == right { + return 0 + } + return cmp.Compare(left.Name, right.Name) +} + +// compareProtobufWireAttribute orders values by the description, protobuf +// settings, and type written to the .proto file. +func compareProtobufWireAttribute(left, right *expr.AttributeExpr, seen map[protobufAttributePair]struct{}) int { + if left == right { + return 0 + } + if order := compareBool(left != nil, right != nil); order != 0 { + return order + } + if order := cmp.Compare(left.Description, right.Description); order != 0 { + return order + } + pair := protobufAttributePair{left: left, right: right} + if _, ok := seen[pair]; ok { + return 0 + } + seen[pair] = struct{}{} + if order := compareProtobufMeta(left.Meta, right.Meta); order != 0 { + return order + } + if order := compareProtobufWireType(left.Type, right.Type, seen); order != 0 { + return order + } + return 0 +} + +// compareProtobufWireType orders values by the protobuf type and each nested +// field in the order Goa writes them. +func compareProtobufWireType(left, right expr.DataType, seen map[protobufAttributePair]struct{}) int { + if order := cmp.Compare(left.Kind(), right.Kind()); order != 0 { + return order + } + switch left := left.(type) { + case expr.Primitive: + return cmp.Compare(left, right.(expr.Primitive)) + case expr.UserType: + right := right.(expr.UserType) + leftWrapped := len(left.Attribute().Meta[wrappedAttrMeta]) > 0 + rightWrapped := len(right.Attribute().Meta[wrappedAttrMeta]) > 0 + if order := compareBool(leftWrapped, rightWrapped); order != 0 { + return order + } + if !leftWrapped && left.Origin() != right.Origin() { + if order := cmp.Compare(left.Origin().ID(), right.Origin().ID()); order != 0 { + return order + } + if order := cmp.Compare(left.Origin().Name(), right.Origin().Name()); order != 0 { + return order + } + } + return compareProtobufWireAttribute(left.Attribute(), right.Attribute(), seen) + case *expr.Object: + right := right.(*expr.Object) + if order := cmp.Compare(len(*left), len(*right)); order != 0 { + return order + } + for index, field := range *left { + other := (*right)[index] + if order := cmp.Compare(field.Name, other.Name); order != 0 { + return order + } + if order := compareProtobufWireAttribute(field.Attribute, other.Attribute, seen); order != 0 { + return order + } + } + return 0 + case *expr.Array: + return compareProtobufWireAttribute(left.ElemType, right.(*expr.Array).ElemType, seen) + case *expr.Map: + right := right.(*expr.Map) + if order := compareProtobufWireAttribute(left.KeyType, right.KeyType, seen); order != 0 { + return order + } + return compareProtobufWireAttribute(left.ElemType, right.ElemType, seen) + case *expr.Union: + right := right.(*expr.Union) + for _, order := range []int{ + cmp.Compare(left.TypeName, right.TypeName), + cmp.Compare(left.TypeKey, right.TypeKey), + cmp.Compare(left.ValueKey, right.ValueKey), + cmp.Compare(len(left.Values), len(right.Values)), + } { + if order != 0 { + return order + } + } + for index, branch := range left.Values { + other := right.Values[index] + if order := cmp.Compare(branch.Name, other.Name); order != 0 { + return order + } + if order := compareProtobufWireAttribute(branch.Attribute, other.Attribute, seen); order != 0 { + return order + } + } + return 0 + default: + panic(fmt.Sprintf("unknown protobuf wire type %T", left)) + } +} + +// compareProtobufMeta compares settings that change protobuf field numbers, +// names, external types, or generated wrappers. +func compareProtobufMeta(left, right expr.MetaExpr) int { + for _, name := range []string{ + "rpc:tag", + "struct:field:proto", + "struct:name:proto", + "proto:tag:json", + wrappedAttrMeta, + } { + if order := compareStringList(left[name], right[name]); order != 0 { + return order + } + } + return 0 +} + +// compareStringList compares both presence and contents because a missing list +// and an empty list are separate metadata values. +func compareStringList(left, right []string) int { + if order := compareBool(left != nil, right != nil); order != 0 { + return order + } + if order := cmp.Compare(len(left), len(right)); order != 0 { + return order + } + for index, value := range left { + if order := cmp.Compare(value, right[index]); order != 0 { + return order + } + } + return 0 +} + +// compareBool orders false before true. +func compareBool(left, right bool) int { + switch { + case left == right: + return 0 + case left: + return 1 + default: + return -1 + } +} + +// uniqueProtobufSourceName adds a number until the name is unused in the set. +func uniqueProtobufSourceName(preferred string, used map[string]struct{}) string { + for index := 1; ; index++ { + name := preferred + if index > 1 { + name += strconv.Itoa(index) + } + if _, ok := used[name]; ok { + continue + } + used[name] = struct{}{} + return name + } +} + +// assignProtobufGroupName stores both the selected name and its numeric suffix +// so later collision retries do not need to parse the name. +func assignProtobufGroupName(group *protobufNameGroup, used map[string]struct{}) { + for suffix := 1; ; suffix++ { + name := group.preferred + if suffix > 1 { + name += strconv.Itoa(suffix) + } + if _, ok := used[name]; ok { + continue + } + used[name] = struct{}{} + group.name = name + group.suffix = suffix + return + } +} + +// nextAvailableProtobufName returns the next numbered name that is not used by +// another message, service, or method in the same protobuf file. +func nextAvailableProtobufName(changed *protobufNameGroup, groups []*protobufNameGroup) string { + preferred := changed.preferred + index := max(changed.suffix, 1) + for { + index++ + candidate := preferred + strconv.Itoa(index) + available := true + for _, group := range groups { + if group == changed || group.name != candidate { + continue + } + if (group.method == nil) == (changed.method == nil) { + available = false + break + } + } + if available { + changed.suffix = index + return candidate + } + } +} + +// namingField creates one field whose type is sufficient for Go name planning. +func namingField(name string, number int32, oneof *int32) *descriptorpb.FieldDescriptorProto { + field := &descriptorpb.FieldDescriptorProto{ + Name: proto.String(name), + Number: &number, + Label: descriptorpb.FieldDescriptorProto_LABEL_OPTIONAL.Enum(), + Type: descriptorpb.FieldDescriptorProto_TYPE_STRING.Enum(), + } + if oneof != nil { + field.OneofIndex = oneof + } + return field +} + +// protobufSourceFieldName returns the field spelling written to .proto. +func protobufSourceFieldName(name string) string { + return codegen.ProtobufFieldName(name) +} + +// uniqueProtobufOneofSourceName adds a suffix until the oneof name differs +// from every field, branch, and earlier oneof in the message. +func uniqueProtobufOneofSourceName(fieldName string, used map[string]struct{}) string { + name := codegen.ProtobufFieldName(fieldName) + for { + if _, ok := used[name]; !ok { + used[name] = struct{}{} + return name + } + name += "_oneof" + } +} + +// serviceNameKeys returns every package name written for one service. +func serviceNameKeys(descriptor string) []protocNameKey { + roles := []protocNameRole{ + protocServiceClientName, + protocServiceClientStructName, + protocServiceClientConstructorName, + protocServiceServerName, + protocServiceUnimplementedServerName, + protocServiceUnsafeServerName, + protocServiceRegisterName, + protocServiceDescriptorName, + } + keys := make([]protocNameKey, len(roles)) + for index, role := range roles { + keys[index] = protocNameKey{descriptor: descriptor, role: role} + } + return keys +} + +// methodNameKeys returns every name written for one method. +func methodNameKeys(descriptor string, streaming bool) []protocNameKey { + roles := []protocNameRole{protocMethodName, protocMethodFullName, protocMethodHandlerName} + if streaming { + roles = append(roles, protocMethodClientStreamName, protocMethodServerStreamName) + } + keys := make([]protocNameKey, len(roles)) + for index, role := range roles { + keys[index] = protocNameKey{descriptor: descriptor, role: role} + } + return keys +} + +// serviceFullName returns the current service descriptor name. +func (p *protobufServicePlan) serviceFullName() string { + return p.protoPackage + "." + p.serviceName +} + +// slicesIndexEndpoint returns endpoint's position in endpoints. +func slicesIndexEndpoint(endpoints []*expr.GRPCEndpointExpr, endpoint *expr.GRPCEndpointExpr) int { + for index, candidate := range endpoints { + if candidate == endpoint { + return index + } + } + return -1 +} diff --git a/grpc/codegen/protobuf_plan_order_test.go b/grpc/codegen/protobuf_plan_order_test.go new file mode 100644 index 0000000000..f67bd6bcbe --- /dev/null +++ b/grpc/codegen/protobuf_plan_order_test.go @@ -0,0 +1,206 @@ +// This file checks that protobuf service ordering has one clear result for +// every generation input. +package codegen + +import ( + "testing" + + "github.com/stretchr/testify/require" + + "goa.design/goa/v3/codegen" + "goa.design/goa/v3/eval" + "goa.design/goa/v3/expr" +) + +// TestSortProtobufMessageGroupsUsesWireDetails checks that field numbers and +// explicit protobuf names give messages the same order when the input is reversed. +func TestSortProtobufMessageGroupsUsesWireDetails(t *testing.T) { + tests := []struct { + name string + groups func() []*protobufNameGroup + value func(*protobufNameGroup) string + want []string + }{ + { + name: "field numbers", + groups: func() []*protobufNameGroup { + source := protobufOrderUserType() + return []*protobufNameGroup{ + protobufOrderGroup(source, "Message", false, "2"), + protobufOrderGroup(source, "Message", false, "1"), + } + }, + value: func(group *protobufNameGroup) string { + field := expr.AsObject(group.message.identity.attribute.Type).Attribute("value") + return field.Meta["rpc:tag"][0] + }, + want: []string{"1", "2"}, + }, + { + name: "explicit names", + groups: func() []*protobufNameGroup { + source := protobufOrderUserType() + return []*protobufNameGroup{ + protobufOrderGroup(source, "Zulu", true, "1"), + protobufOrderGroup(source, "Alpha", true, "1"), + } + }, + value: func(group *protobufNameGroup) string { + return group.message.identity.preferredName + }, + want: []string{"Alpha", "Zulu"}, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + forward := test.groups() + reverse := test.groups() + reverse[0], reverse[1] = reverse[1], reverse[0] + + require.NoError(t, sortProtobufMessageGroups(forward)) + require.NoError(t, sortProtobufMessageGroups(reverse)) + require.Equal(t, test.want, protobufOrderValues(forward, test.value)) + require.Equal(t, test.want, protobufOrderValues(reverse, test.value)) + }) + } +} + +// TestSortProtobufMessageGroupsRejectsEqualOrder checks that two separate +// declarations cannot receive names according to their input order. +func TestSortProtobufMessageGroupsRejectsEqualOrder(t *testing.T) { + left := protobufOrderUserType() + right := protobufOrderUserType() + groups := []*protobufNameGroup{ + protobufOrderGroup(left, "Message", false, "1"), + protobufOrderGroup(right, "Message", false, "1"), + } + + err := sortProtobufMessageGroups(groups) + require.EqualError(t, err, `protobuf messages named "Message" have the same source, name, and fields but come from separate declarations`) +} + +// TestProtobufMessageIdentityIgnoresRequiredFields checks that validation rules +// do not split messages with the same protobuf fields. +func TestProtobufMessageIdentityIgnoresRequiredFields(t *testing.T) { + source := protobufOrderUserType() + left := protobufRequiredOrderGroup(source, "a") + right := protobufRequiredOrderGroup(source, "b") + + forward := compareProtobufMessageIdentity(left.message.identity, right.message.identity) + backward := compareProtobufMessageIdentity(right.message.identity, left.message.identity) + require.Zero(t, forward) + require.Zero(t, backward) + require.True(t, sameProtobufMessageIdentity(left.message.identity, right.message.identity)) + require.False(t, sameProtobufValidationAttribute( + left.message.identity.attribute, + right.message.identity.attribute, + make(map[protobufAttributePair]struct{}), + )) +} + +// TestNextAvailableProtobufNameUsesRetainedSuffix checks that collision retries +// do not parse the generated name Goa stored for the group. +func TestNextAvailableProtobufNameUsesRetainedSuffix(t *testing.T) { + group := &protobufNameGroup{preferred: "Message", name: "unrelated"} + occupied := &protobufNameGroup{preferred: "Message", name: "Message2"} + + next := nextAvailableProtobufName(group, []*protobufNameGroup{group, occupied}) + require.Equal(t, "Message3", next) +} + +// TestPlanProtobufServicesRejectsEqualOrder checks that two separate services +// cannot receive names according to their input order. +func TestPlanProtobufServicesRejectsEqualOrder(t *testing.T) { + roots := grpcPlanRoots(t, "Shared", "Shared") + roots[0].API.Name = "Shared API" + roots[1].API.Name = "Shared API" + generation, err := codegen.NewGeneration("generated.local/gen", []eval.Root{roots[0], roots[1]}) + require.NoError(t, err) + plans := []*Plan{ + { + root: roots[0], + expressions: roots[0].API.GRPC.Services, + protobuf: make(map[*expr.GRPCServiceExpr]*protobufServicePlan), + packages: map[*expr.GRPCServiceExpr]*grpcServicePackage{ + roots[0].API.GRPC.Services[0]: {pathName: "shared"}, + }, + }, + { + root: roots[1], + expressions: roots[1].API.GRPC.Services, + protobuf: make(map[*expr.GRPCServiceExpr]*protobufServicePlan), + packages: map[*expr.GRPCServiceExpr]*grpcServicePackage{ + roots[1].API.GRPC.Services[0]: {pathName: "shared"}, + }, + }, + } + + err = planProtobufServices(generation, plans) + require.EqualError(t, err, `generated protobuf package "generated.local/gen/grpc/shared/pb" has two services named "Shared" in API "Shared API"`) +} + +// protobufOrderUserType creates one separate source declaration for order +// tests. +func protobufOrderUserType() *expr.UserTypeExpr { + return &expr.UserTypeExpr{ + TypeName: "Message", + UID: "message", + AttributeExpr: &expr.AttributeExpr{Type: &expr.Object{ + { + Name: "value", + Attribute: &expr.AttributeExpr{ + Type: expr.String, + Meta: expr.MetaExpr{"rpc:tag": {"1"}}, + }, + }, + }}, + } +} + +// protobufOrderGroup creates one message with the requested name and field +// number. +func protobufOrderGroup(source expr.UserType, name string, explicit bool, tag string) *protobufNameGroup { + attribute := expr.DupAtt(source.Attribute()) + expr.AsObject(attribute.Type).Attribute("value").Meta["rpc:tag"] = []string{tag} + return &protobufNameGroup{ + preferred: name, + message: &protobufMessageRecord{identity: protobufMessageIdentity{ + source: protobufMessageSource{origin: source}, + preferredName: name, + explicitName: explicit, + userType: source, + attribute: attribute, + }}, + } +} + +// protobufRequiredOrderGroup creates one message with a selected required field. +func protobufRequiredOrderGroup(source expr.UserType, required string) *protobufNameGroup { + attribute := &expr.AttributeExpr{ + Type: &expr.Object{ + {Name: "a", Attribute: &expr.AttributeExpr{Type: expr.String}}, + {Name: "b", Attribute: &expr.AttributeExpr{Type: expr.String}}, + }, + Validation: &expr.ValidationExpr{Required: []string{required}}, + } + return &protobufNameGroup{ + preferred: "Message", + message: &protobufMessageRecord{identity: protobufMessageIdentity{ + source: protobufMessageSource{origin: source}, + preferredName: "Message", + userType: source, + attribute: attribute, + }}, + } +} + +// protobufOrderValues reads the fact checked by one order test from every +// message. +func protobufOrderValues(groups []*protobufNameGroup, value func(*protobufNameGroup) string) []string { + values := make([]string, len(groups)) + for index, group := range groups { + values[index] = value(group) + } + return values +} diff --git a/grpc/codegen/protobuf_test.go b/grpc/codegen/protobuf_test.go index e177bf50ca..51598a2ef6 100644 --- a/grpc/codegen/protobuf_test.go +++ b/grpc/codegen/protobuf_test.go @@ -1,8 +1,8 @@ +// This file verifies protobuf wire shaping, naming, JSON options, wrappers, +// and recursion follow generated-package declaration ownership. package codegen import ( - "os" - "regexp" "strings" "testing" @@ -10,56 +10,10 @@ import ( "github.com/stretchr/testify/require" "goa.design/goa/v3/codegen" - . "goa.design/goa/v3/dsl" + "goa.design/goa/v3/dsl" "goa.design/goa/v3/expr" ) -func TestProtobufify(t *testing.T) { - cases := []struct { - Name string - String string - FirstUpper bool - Acronym bool - Expected string - }{{ - "AllLower", "lower", false, false, "lower", - }, { - "AllLowerFirstUpper", "lower", true, false, "Lower", - }, { - "AllUpper", "UPPER", false, false, "uPPER", - }, { - "AllUpperFirstUpper", "UPPER", true, false, "UPPER", - }, { - "StartUpperThenLower", "Upper", false, false, "upper", - }, { - "StartUpperThenLowerFirstUpper", "Upper", true, false, "Upper", - }, { - "StartsWithUnderscore", "_foo", false, false, "foo", - }, { - "EndsWithUnderscore", "foo_", false, false, "foo", - }, { - "ContainsUnderscore", "foo_bar", false, false, "fooBar", - }, { - "StartsWithDigits", "123foo", false, false, "123Foo", - }, { - "EndsWithDigits", "foo123", false, false, "foo123", - }, { - "ContainsDigits", "foo123bar", false, false, "foo123Bar", - }, { - "ContainsIgnoredAcronym", "foo_jwt", false, false, "fooJwt", - }, { - "ContainsAcronym", "foo_jwt", false, true, "fooJWT", - }} - for _, c := range cases { - t.Run(c.Name, func(t *testing.T) { - got := protoBufify(c.String, c.FirstUpper, c.Acronym) - if got != c.Expected { - t.Errorf("got %q, expected %q", got, c.Expected) - } - }) - } -} - func TestProtoNativeType(t *testing.T) { cases := []struct { Name string @@ -140,6 +94,18 @@ func TestProtoBufNativeGoTypeName(t *testing.T) { } } +// TestProtoBufTypeContextPreservesPrimitivePresence checks that protobuf Go +// values always retain scalar presence and never apply service defaults. +func TestProtoBufTypeContextPreservesPrimitivePresence(t *testing.T) { + sd := &ServiceData{Scope: codegen.NewNameScope()} + ctx := protoBufTypeContext("proto", sd) + require.True(t, ctx.Pointer) + require.False(t, ctx.IgnoreRequired) + require.True(t, ctx.IgnoreRequiredCollections) + require.True(t, ctx.Dup().LayoutPolicy().IgnoreRequiredCollections) + require.False(t, ctx.UseDefault) +} + func TestHasAnyType(t *testing.T) { cases := []struct { Name string @@ -187,6 +153,27 @@ func TestHasAnyType(t *testing.T) { } } +// TestHasAnyTypeStopsAtRecursiveTypes checks that a cycle does not hide an Any +// field elsewhere in the same type. +func TestHasAnyTypeStopsAtRecursiveTypes(t *testing.T) { + recursive := &expr.UserTypeExpr{TypeName: "Recursive", UID: "recursive"} + recursive.AttributeExpr = &expr.AttributeExpr{Type: &expr.Object{ + &expr.NamedAttributeExpr{ + Name: "next", + Attribute: &expr.AttributeExpr{Type: recursive}, + }, + &expr.NamedAttributeExpr{ + Name: "data", + Attribute: &expr.AttributeExpr{Type: expr.Any}, + }, + }} + require.True(t, hasAnyType(recursive.Attribute())) + + object := expr.AsObject(recursive.Attribute().Type) + *object = (*object)[:1] + require.False(t, hasAnyType(recursive.Attribute())) +} + func TestProtoBufMessageDefJSONNameOption(t *testing.T) { attr := &expr.AttributeExpr{ Type: &expr.Object{ @@ -234,120 +221,12 @@ func TestProtoBufMessageDefJSONNameOptionOneOf(t *testing.T) { } } -func TestProtoBufMessageDefOneOfUsesAttributeName(t *testing.T) { - tests := []struct { - name string - unionName string - members []string - wantOneof string - wantGoField string - unwantedOneof string - }{ - { - name: "custom service name", - unionName: "subject", - members: []string{"equipment", "facility"}, - wantOneof: "subject", - wantGoField: "Subject", - unwantedOneof: "alarm_info_subject", - }, - { - name: "member name collision", - unionName: "subject", - members: []string{"subject", "facility"}, - wantOneof: "subject_oneof", - wantGoField: "SubjectOneof", - }, - { - name: "repeated member name collision", - unionName: "subject", - members: []string{"subject", "subject_oneof", "facility"}, - wantOneof: "subject_oneof_oneof", - wantGoField: "SubjectOneofOneof", - }, - { - name: "generated method collision", - unionName: "reset", - members: []string{"value"}, - wantOneof: "reset", - wantGoField: "Reset_", - }, - } - for _, test := range tests { - t.Run(test.name, func(t *testing.T) { - root := codegen.RunDSL(t, func() { - Type("Alarm", func() { - OneOf(test.unionName, func() { - TypeName("AlarmInfoSubject") - for i, member := range test.members { - Field(i+1, member, String) - } - }) - }) - }) - alarm := root.UserType("Alarm") - subject := alarm.Attribute().Find(test.unionName) - require.Equal(t, "AlarmInfoSubject", subject.Type.Name()) - - sd := &ServiceData{Scope: codegen.NewNameScope()} - def := protoBufMessageDef(alarm.Attribute(), sd) - - require.Contains(t, def, "oneof "+test.wantOneof+" {") - if test.unwantedOneof != "" { - require.NotContains(t, def, "oneof "+test.unwantedOneof+" {") - } - - proto := "syntax = \"proto3\";\npackage test;\noption go_package = \"example.com/test;test\";\nmessage Alarm" + def - fpath := codegen.CreateTempFile(t, proto) - require.NoError(t, protoc(defaultProtocCmd, fpath, nil)) - generated, err := os.ReadFile(fpath + ".pb.go") - require.NoError(t, err) - require.Regexp( - t, - regexp.MustCompile(`\n\t`+regexp.QuoteMeta(test.wantGoField)+`\s+isAlarm_`), - string(generated), - ) - - source := makeProtoBufMessage(expr.DupAtt(alarm.Attribute()), alarm.Name(), sd) - transform, _, err := protoBufTransform( - source, - alarm.Attribute(), - "source", - "target", - protoBufTypeContext("test", sd.Scope, true), - serviceTypeContext("test", sd.Scope), - false, - true, - ) - require.NoError(t, err) - code := codegen.FormatTestCode(t, "package test\nfunc transform() {\n"+transform+"}") - require.Contains(t, code, "source."+test.wantGoField) - require.NotContains(t, code, "source.AlarmInfoSubject") - - transform, _, err = protoBufTransform( - alarm.Attribute(), - source, - "source", - "target", - serviceTypeContext("test", sd.Scope), - protoBufTypeContext("test", sd.Scope, true), - true, - true, - ) - require.NoError(t, err) - code = codegen.FormatTestCode(t, "package test\nfunc transform() {\n"+transform+"}") - require.Contains(t, code, "target."+test.wantGoField) - require.NotContains(t, code, "target.AlarmInfoSubject") - }) - } -} - func TestProtoBufMessageDefReservations(t *testing.T) { root := codegen.RunDSL(t, func() { - Type("Activation", func() { - Meta("rpc:reserved:number", "20", "3", "15") - Meta("rpc:reserved:name", "linked_control_point_id", "deployment_id") - Field(1, "id", String) + dsl.Type("Activation", func() { + dsl.Meta("rpc:reserved:number", "20", "3", "15") + dsl.Meta("rpc:reserved:name", "linked_control_point_id", "deployment_id") + dsl.Field(1, "id", dsl.String) }) }) activation := root.UserType("Activation") @@ -358,7 +237,7 @@ func TestProtoBufMessageDefReservations(t *testing.T) { proto := "syntax = \"proto3\";\npackage test;\noption go_package = \"example.com/test;test\";\nmessage Activation" + def fpath := codegen.CreateTempFile(t, proto) - require.NoError(t, protoc(defaultProtocCmd, fpath, nil)) + require.NoError(t, protoc(defaultProtocCmd, fpath)) } func TestMakeProtoBufMessageMarksWrappers(t *testing.T) { @@ -402,8 +281,11 @@ func TestMakeProtoBufMessageMarksWrappers(t *testing.T) { }} for _, c := range cases { t.Run(c.Name, func(t *testing.T) { - sd := &ServiceData{Name: "Service", Scope: codegen.NewNameScope()} - att := makeProtoBufMessage(&expr.AttributeExpr{Type: c.Type()}, "Message", sd) + att := makeProtoBufMessage( + &expr.AttributeExpr{Type: c.Type()}, + "Message", + testGRPCMessageExampleIdentity(c.Name), + ) require.True(t, isWrappedAttr(att), "expected message to be marked as a wrapper") field := unwrapAttr(att) assert.Equal(t, c.FieldKind, field.Type.Kind(), "unexpected wrapped field kind") @@ -417,6 +299,147 @@ func TestMakeProtoBufMessageMarksWrappers(t *testing.T) { } } +func TestMakeProtoBufMessageDistinguishesEqualUIDOrigins(t *testing.T) { + first := protobufArrayTraversalType("First", "shared") + second := protobufArrayTraversalType("Second", "shared") + body := &expr.AttributeExpr{Type: &expr.Object{ + {Name: "first", Attribute: &expr.AttributeExpr{Type: first}}, + {Name: "second", Attribute: &expr.AttributeExpr{Type: second}}, + }} + + message := makeProtoBufMessage(body, "Request", testGRPCMessageExampleIdentity("equal-UID-origins")) + object := expr.AsObject(message.Type.(expr.UserType).Attribute().Type) + wireFirst := object.Attribute("first").Type.(expr.UserType) + wireSecond := object.Attribute("second").Type.(expr.UserType) + require.True(t, isWrappedAttr(&expr.AttributeExpr{Type: wireFirst})) + require.True(t, isWrappedAttr(&expr.AttributeExpr{Type: wireSecond})) +} + +func TestMakeProtoBufMessageDistinguishesNormalizedMethodNames(t *testing.T) { + service := &expr.ServiceExpr{Name: "Values"} + dashedMethod := &expr.MethodExpr{Name: "foo-bar", Service: service} + underscoreMethod := &expr.MethodExpr{Name: "foo_bar", Service: service} + dashedOwner := expr.GRPCRequestMessageExampleIdentity(dashedMethod) + underscoreOwner := expr.GRPCRequestMessageExampleIdentity(underscoreMethod) + dashed := makeProtoBufMessage( + &expr.AttributeExpr{Type: &expr.Object{ + {Name: "dashed", Attribute: &expr.AttributeExpr{Type: expr.String}}, + }}, + "FooBarRequest", + dashedOwner, + ) + underscore := makeProtoBufMessage( + &expr.AttributeExpr{Type: &expr.Object{ + {Name: "underscore", Attribute: &expr.AttributeExpr{Type: expr.String}}, + }}, + "FooBarRequest", + underscoreOwner, + ) + require.NotEqual(t, dashed.Type.(expr.UserType).ID(), underscore.Type.(expr.UserType).ID()) + + cases := []struct { + name string + first *expr.AttributeExpr + firstOwner expr.ExampleIdentity + firstField string + second *expr.AttributeExpr + secondOwner expr.ExampleIdentity + secondField string + }{ + { + name: "dashed then underscore", + first: dashed, + firstOwner: dashedOwner, + firstField: "dashed", + second: underscore, + secondOwner: underscoreOwner, + secondField: "underscore", + }, + { + name: "underscore then dashed", + first: underscore, + firstOwner: underscoreOwner, + firstField: "underscore", + second: dashed, + secondOwner: dashedOwner, + secondField: "dashed", + }, + } + for _, test := range cases { + t.Run(test.name, func(t *testing.T) { + generator := expr.NewExampleGenerator(expr.NewFakerRandomizerFactory("test")) + first := test.first.Example(generator.At(test.firstOwner)).(map[string]any) + second := test.second.Example(generator.At(test.secondOwner)).(map[string]any) + + require.Contains(t, first, test.firstField) + require.NotContains(t, first, test.secondField) + require.Contains(t, second, test.secondField) + require.NotContains(t, second, test.firstField) + }) + } +} + +func TestMakeProtoBufMessageSharesAuthoredCollectionWrapperIdentity(t *testing.T) { + arrayAlias := &expr.UserTypeExpr{ + TypeName: "Strings", + UID: "strings", + AttributeExpr: &expr.AttributeExpr{Type: &expr.Array{ + ElemType: &expr.AttributeExpr{Type: expr.String}, + }}, + } + mapAlias := &expr.UserTypeExpr{ + TypeName: "Labels", + UID: "labels", + AttributeExpr: &expr.AttributeExpr{Type: &expr.Map{ + KeyType: &expr.AttributeExpr{Type: expr.String}, + ElemType: &expr.AttributeExpr{Type: expr.Int}, + }}, + } + owner := testGRPCMessageExampleIdentity("shared-collection-aliases") + build := func(fields []string) *expr.AttributeExpr { + attributes := make(expr.Object, len(fields)) + for index, name := range fields { + typ := expr.UserType(arrayAlias) + if name == "map_a" || name == "map_b" { + typ = mapAlias + } + attributes[index] = &expr.NamedAttributeExpr{ + Name: name, + Attribute: &expr.AttributeExpr{Type: typ}, + } + } + return makeProtoBufMessage( + &expr.AttributeExpr{Type: &attributes}, + "SharedCollectionsRequest", + owner, + ) + } + forward := build([]string{"array_a", "map_a", "array_b", "map_b"}) + reverse := build([]string{"map_b", "array_b", "map_a", "array_a"}) + example := func(message *expr.AttributeExpr) map[string]any { + generator := expr.NewExampleGenerator(expr.NewFakerRandomizerFactory("test")) + return message.Example(generator.At(owner)).(map[string]any) + } + + forwardExample := example(forward) + reverseExample := example(reverse) + require.Equal(t, forwardExample, reverseExample) + require.Equal(t, forwardExample["array_a"], forwardExample["array_b"]) + require.Equal(t, forwardExample["map_a"], forwardExample["map_b"]) +} + +// protobufArrayTraversalType builds an authored array declaration that protobuf +// conversion must wrap in a message. +func protobufArrayTraversalType(name, uid string) *expr.UserTypeExpr { + return &expr.UserTypeExpr{ + AttributeExpr: &expr.AttributeExpr{Type: &expr.Array{ + ElemType: &expr.AttributeExpr{Type: expr.String}, + }}, + TypeName: name, + UID: uid, + } +} + func TestUnwrapAttrPanicsOnNonWrapper(t *testing.T) { cases := []struct { Name string diff --git a/grpc/codegen/protobuf_tools.go b/grpc/codegen/protobuf_tools.go new file mode 100644 index 0000000000..50174e0c9f --- /dev/null +++ b/grpc/codegen/protobuf_tools.go @@ -0,0 +1,176 @@ +// This file chooses the protobuf compiler and Go plugins before any generated +// file is built. +package codegen + +import ( + "fmt" + "os/exec" + "path/filepath" + "strings" + + "goa.design/goa/v3/expr" +) + +type ( + // protobufToolPlan stores the compiler, plugins, and include paths for one + // service. + protobufToolPlan struct { + command []string + includes []string + goPlugin string + goGRPCPlugin string + } + + // protobufToolResolver lets tests provide fixed program paths and versions + // without changing PATH or running real programs. + protobufToolResolver struct { + resolve func(string) (string, error) + version func(string) (string, error) + } +) + +const ( + protocGenGoName = "protoc-gen-go" + protocGenGoGRPCName = "protoc-gen-go-grpc" + protocGenGoVersion = "protoc-gen-go v1.36.12" + protocGenGoGRPCVersion = "protoc-gen-go-grpc 1.6.2" +) + +// planProtobufTools copies the compiler settings for every gRPC service and +// uses one checked pair of Go plugins for one call to NewPlans. +func planProtobufTools(inputs []PlanInput, resolver protobufToolResolver) (map[*expr.GRPCServiceExpr]*protobufToolPlan, error) { + goPlugin, err := resolveProtobufPlugin(resolver, protocGenGoName, protocGenGoVersion) + if err != nil { + return nil, err + } + goGRPCPlugin, err := resolveProtobufPlugin(resolver, protocGenGoGRPCName, protocGenGoGRPCVersion) + if err != nil { + return nil, err + } + + plans := make(map[*expr.GRPCServiceExpr]*protobufToolPlan) + compilers := make(map[string]string) + for _, input := range inputs { + for _, service := range input.Root.API.GRPC.Services { + command := protobufCompilerCommand(input.Root, service) + if len(command) == 0 { + return nil, fmt.Errorf(`Meta("protoc:cmd"): must be given arguments`) + } + if plugin := replacedGoPlugin(command[1:]); plugin != "" { + return nil, fmt.Errorf(`Meta("protoc:cmd") cannot replace required plugin %q`, plugin) + } + compiler := compilers[command[0]] + if compiler == "" { + compiler, err = resolver.resolve(command[0]) + if err != nil { + return nil, fmt.Errorf("resolve protobuf compiler %q: %w", command[0], err) + } + compilers[command[0]] = compiler + } + command[0] = compiler + includes := append([]string{}, service.ServiceExpr.Meta["protoc:include"]...) + includes = append(includes, input.Root.API.Meta["protoc:include"]...) + plans[service] = &protobufToolPlan{ + command: command, + includes: includes, + goPlugin: goPlugin, + goGRPCPlugin: goGRPCPlugin, + } + } + } + return plans, nil +} + +// systemProtobufTools uses the programs available to Goa. +func systemProtobufTools() protobufToolResolver { + return protobufToolResolver{ + resolve: resolveProtobufExecutable, + version: protobufExecutableVersion, + } +} + +// protobufCompilerCommand copies the command selected by the service or API. +func protobufCompilerCommand(root *expr.RootExpr, service *expr.GRPCServiceExpr) []string { + command := defaultProtocCmd + if configured, ok := root.API.Meta["protoc:cmd"]; ok { + command = configured + } + if configured, ok := service.ServiceExpr.Meta["protoc:cmd"]; ok { + command = configured + } + return append([]string{}, command...) +} + +// resolveProtobufPlugin finds one required plugin and checks its version. +func resolveProtobufPlugin(resolver protobufToolResolver, name, wantVersion string) (string, error) { + path, err := resolver.resolve(name) + if err != nil { + return "", fmt.Errorf("resolve protobuf plugin %s: %w", name, err) + } + version, err := resolver.version(path) + if err != nil { + return "", fmt.Errorf("read protobuf plugin %s version: %w", name, err) + } + if !protobufPluginVersionMatches(name, version, wantVersion) { + return "", fmt.Errorf("protobuf plugin %s reports version %s, want %s", name, version, wantVersion) + } + return path, nil +} + +// protobufPluginVersionMatches accepts the program name printed on Unix and +// the same name with the executable suffix printed on Windows. +func protobufPluginVersionMatches(name, version, wantVersion string) bool { + windowsVersion := name + ".exe" + strings.TrimPrefix(wantVersion, name) + return version == wantVersion || version == windowsVersion +} + +// resolveProtobufExecutable returns an absolute path for one executable. +func resolveProtobufExecutable(name string) (string, error) { + path, err := exec.LookPath(name) + if err != nil { + return "", err + } + path, err = filepath.Abs(path) + if err != nil { + return "", fmt.Errorf("make executable path absolute: %w", err) + } + return path, nil +} + +// protobufExecutableVersion returns the single version line printed by an +// executable. +func protobufExecutableVersion(path string) (string, error) { + output, err := exec.Command(path, "--version").CombinedOutput() + if err != nil { + return "", fmt.Errorf("run %s --version: %w: %s", path, err, output) + } + return strings.TrimSpace(string(output)), nil +} + +// replacedGoPlugin returns a required plugin name when the flags try to +// replace it. +func replacedGoPlugin(arguments []string) string { + for index := 0; index < len(arguments); index++ { + argument := arguments[index] + var plugin string + switch { + case argument == "--plugin" && index+1 < len(arguments): + index++ + plugin = arguments[index] + case strings.HasPrefix(argument, "--plugin="): + plugin = strings.TrimPrefix(argument, "--plugin=") + default: + continue + } + var name string + if configuredName, _, ok := strings.Cut(plugin, "="); ok { + name = configuredName + } else { + name = filepath.Base(plugin) + } + if name == protocGenGoName || name == protocGenGoGRPCName { + return name + } + } + return "" +} diff --git a/grpc/codegen/protobuf_tools_test.go b/grpc/codegen/protobuf_tools_test.go new file mode 100644 index 0000000000..f7d538b638 --- /dev/null +++ b/grpc/codegen/protobuf_tools_test.go @@ -0,0 +1,231 @@ +// This file checks that gRPC planning fixes the protobuf commands before any +// generated file is rendered or compiled. +package codegen + +import ( + "fmt" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/require" + + "goa.design/goa/v3/expr" +) + +// TestNewPlansRetainsProtobufCommands checks that later design and PATH changes +// cannot replace the compiler, plugins, or include paths chosen by NewPlans. +func TestNewPlansRetainsProtobufCommands(t *testing.T) { + root := grpcPlanRoots(t, "Calc")[0] + generation, services := grpcServicePlans(t, []*expr.RootExpr{root}) + recordPath := filepath.Join(t.TempDir(), "compiler-arguments") + compilerPath, err := filepath.Abs(os.Args[0]) + require.NoError(t, err) + root.API.Meta = make(expr.MetaExpr) + root.API.GRPC.Services[0].ServiceExpr.Meta = make(expr.MetaExpr) + root.API.Meta["protoc:cmd"] = []string{ + compilerPath, + "-test.run=TestProtobufCompilerProcess", + "--", + recordPath, + } + root.API.Meta["protoc:include"] = []string{"api-before"} + root.API.GRPC.Services[0].ServiceExpr.Meta["protoc:include"] = []string{"service-before"} + + resolver := protobufToolResolver{ + resolve: func(name string) (string, error) { + switch name { + case compilerPath: + return compilerPath, nil + case protocGenGoName: + return "/planned/protoc-gen-go", nil + case protocGenGoGRPCName: + return "/planned/protoc-gen-go-grpc", nil + default: + t.Fatalf("unexpected executable lookup %q", name) + return "", nil + } + }, + version: func(path string) (string, error) { + switch path { + case "/planned/protoc-gen-go": + return protocGenGoVersion, nil + case "/planned/protoc-gen-go-grpc": + return protocGenGoGRPCVersion, nil + default: + t.Fatalf("unexpected version check %q", path) + return "", nil + } + }, + } + plans, err := newPlans(generation, resolver, PlanInput{Root: root, Service: services[0]}) + require.NoError(t, err) + + root.API.Meta["protoc:cmd"] = []string{"compiler-after"} + root.API.Meta["protoc:include"] = []string{"api-after"} + root.API.GRPC.Services[0].ServiceExpr.Meta["protoc:include"] = []string{"service-after"} + t.Setenv("PATH", t.TempDir()) + require.NoError(t, generation.Freeze()) + require.NoError(t, services[0].Link()) + + grpcService := root.API.GRPC.Services[0] + renderData := newServicesData(services[0].Services(), plans[0]) + renderData.GRPCServices[grpcService.Name()] = &ServiceData{ + Service: services[0].Services().Get(grpcService.Name()), + } + files := protoFiles(renderData) + require.Len(t, files, 1) + t.Setenv("GO_WANT_PROTOBUF_COMPILER_PROCESS", "1") + require.NoError(t, files[0].FinalizeFunc(filepath.Join(t.TempDir(), "service.proto"))) + encoded, err := os.ReadFile(recordPath) + require.NoError(t, err) + arguments := strings.Split(string(encoded), "\n") + require.Contains(t, arguments, "--plugin=protoc-gen-go=/planned/protoc-gen-go") + require.Contains(t, arguments, "--plugin=protoc-gen-go-grpc=/planned/protoc-gen-go-grpc") + require.Contains(t, arguments, "service-before") + require.Contains(t, arguments, "api-before") + require.NotContains(t, arguments, "service-after") + require.NotContains(t, arguments, "api-after") +} + +// TestNewPlansChecksProtobufPluginVersions checks both required plugin +// versions before planning succeeds. +func TestNewPlansChecksProtobufPluginVersions(t *testing.T) { + tests := []struct { + name string + plugin string + gotVersion string + wantVersion string + }{ + {"Go plugin", protocGenGoName, "protoc-gen-go v1.36.11", protocGenGoVersion}, + {"gRPC plugin", protocGenGoGRPCName, "protoc-gen-go-grpc 1.6.1", protocGenGoGRPCVersion}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + root := grpcPlanRoots(t, "Calc")[0] + generation, services := grpcServicePlans(t, []*expr.RootExpr{root}) + resolver := fixedProtobufToolResolver() + resolver.version = func(path string) (string, error) { + if filepath.Base(path) == test.plugin { + return test.gotVersion, nil + } + if filepath.Base(path) == protocGenGoName { + return protocGenGoVersion, nil + } + return protocGenGoGRPCVersion, nil + } + _, err := newPlans(generation, resolver, PlanInput{Root: root, Service: services[0]}) + require.EqualError(t, err, "protobuf plugin "+test.plugin+" reports version "+ + test.gotVersion+", want "+test.wantVersion) + }) + } +} + +// TestResolveProtobufPluginAcceptsWindowsExecutableName checks the version text +// printed when Windows adds its executable suffix to the plugin name. +func TestResolveProtobufPluginAcceptsWindowsExecutableName(t *testing.T) { + resolver := fixedProtobufToolResolver() + resolver.version = func(string) (string, error) { + return "protoc-gen-go.exe v1.36.12", nil + } + + plugin, err := resolveProtobufPlugin(resolver, protocGenGoName, protocGenGoVersion) + + require.NoError(t, err) + require.Equal(t, "/tools/protoc-gen-go", plugin) +} + +// TestNewPlansRejectsGoPluginOverrides checks every protoc flag form that +// could replace either required Go plugin. +func TestNewPlansRejectsGoPluginOverrides(t *testing.T) { + tests := []struct { + name string + args []string + }{ + {"Go plugin equals", []string{"--plugin=protoc-gen-go=/other/go"}}, + {"gRPC plugin equals", []string{"--plugin=protoc-gen-go-grpc=/other/grpc"}}, + {"Go plugin separate", []string{"--plugin", "protoc-gen-go=/other/go"}}, + {"gRPC plugin separate", []string{"--plugin", "protoc-gen-go-grpc=/other/grpc"}}, + {"Go plugin path", []string{"--plugin=/other/protoc-gen-go"}}, + {"gRPC plugin path", []string{"--plugin=/other/protoc-gen-go-grpc"}}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + root := grpcPlanRoots(t, "Calc")[0] + generation, services := grpcServicePlans(t, []*expr.RootExpr{root}) + root.API.Meta = make(expr.MetaExpr) + root.API.Meta["protoc:cmd"] = append([]string{"protoc"}, test.args...) + _, err := newPlans( + generation, + fixedProtobufToolResolver(), + PlanInput{Root: root, Service: services[0]}, + ) + require.ErrorContains(t, err, `Meta("protoc:cmd") cannot replace`) + }) + } +} + +// TestProtobufCompilerProcess records the compiler arguments for its parent +// test and exits without compiling the schema. +func TestProtobufCompilerProcess(t *testing.T) { + if os.Getenv("GO_WANT_PROTOBUF_COMPILER_PROCESS") != "1" { + return + } + separator := -1 + for index, argument := range os.Args { + if argument == "--" { + separator = index + break + } + } + if separator < 0 || len(os.Args) <= separator+1 { + os.Exit(2) + } + recordPath := os.Args[separator+1] + arguments := strings.Join(os.Args[separator+2:], "\n") + if err := os.WriteFile(recordPath, []byte(arguments), 0o600); err != nil { + os.Exit(3) + } + os.Exit(0) +} + +// fixedProtobufToolResolver returns stable paths and required versions. +func fixedProtobufToolResolver() protobufToolResolver { + return protobufToolResolver{ + resolve: func(name string) (string, error) { + return "/tools/" + filepath.Base(name), nil + }, + version: func(path string) (string, error) { + if filepath.Base(path) == protocGenGoName { + return protocGenGoVersion, nil + } + return protocGenGoGRPCVersion, nil + }, + } +} + +// protoc compiles a schema directly for tests that inspect protobuf output. +func protoc(command []string, path string) error { + if len(command) == 0 { + return fmt.Errorf("protobuf compiler command is empty") + } + resolver := systemProtobufTools() + goPlugin, err := resolveProtobufPlugin(resolver, protocGenGoName, protocGenGoVersion) + if err != nil { + return err + } + goGRPCPlugin, err := resolveProtobufPlugin(resolver, protocGenGoGRPCName, protocGenGoGRPCVersion) + if err != nil { + return err + } + compiler, err := resolver.resolve(command[0]) + if err != nil { + return fmt.Errorf("resolve protobuf compiler %q: %w", command[0], err) + } + return runProtoc(&protobufToolPlan{ + command: append([]string{compiler}, command[1:]...), + goPlugin: goPlugin, + goGRPCPlugin: goGRPCPlugin, + }, path) +} diff --git a/grpc/codegen/protobuf_transform.go b/grpc/codegen/protobuf_transform.go index addd57b591..8962fcd166 100644 --- a/grpc/codegen/protobuf_transform.go +++ b/grpc/codegen/protobuf_transform.go @@ -1,3 +1,4 @@ +// This file writes Go conversions between service values and protobuf values. package codegen import ( @@ -7,80 +8,69 @@ import ( "goa.design/goa/v3/expr" ) -// protoBufTransform produces Go code to initialize a data structure defined -// by target from an instance of data structure defined by source. The source -// or target is a protocol buffer type. The transformation is generated by the -// shared transform engine specialized via the protocol buffer transform hooks -// (see protoHooks). -// -// source, target are the source and target attributes used in transformation -// -// sourceVar, targetVar are the source and target variables -// -// sourceCtx, targetCtx are the source and target attribute contexts -// -// `proto` param if true indicates that the target is a protocol buffer type -// -// newVar if true initializes a target variable with the generated Go code -// using `:=` operator. If false, it assigns Go code to the target variable -// using `=`. -func protoBufTransform(source, target *expr.AttributeExpr, sourceVar, targetVar string, sourceCtx, targetCtx *codegen.AttributeContext, proto, newVar bool) (string, []*codegen.TransformFunctionData, error) { +// protoBufTransform writes code that copies source into target. One side is a +// service value and the other is a protobuf value. proto is true when the +// target is the protobuf value. newVar chooses between := and =. +func protoBufTransform(source, target *expr.AttributeExpr, sourceCtx, targetCtx *codegen.AttributeContext, proto, newVar bool) (string, []*codegen.TransformFunctionData, error) { prefix := "protobuf" if proto { + original := target target = expr.DupAtt(target) + targetCtx.Scope.(*protoBufScope).service.protobuf.plan.bindAttributeCopy(original, target) removeMeta(target) prefix = "svc" } else { + original := source source = expr.DupAtt(source) + sourceCtx.Scope.(*protoBufScope).service.protobuf.plan.bindAttributeCopy(original, source) removeMeta(source) } ta := &codegen.TransformAttrs{ SourceCtx: sourceCtx, TargetCtx: targetCtx, Prefix: prefix, - Hooks: protoHooks(proto, targetCtx), + Hooks: protoHooks(proto), } - return codegen.GoTransformWithAttrs(source, target, sourceVar, targetVar, ta, newVar) + return codegen.GoTransformWithAttrs(source, target, "source", "target", ta, newVar) } -// removeMeta removes meta attributes from the given attribute that cannot be -// honored. This is needed to make sure that any field name overridding is -// removed when generating protobuf types (as protogen itself won't honor these -// overrides). +// removeMeta removes service field and package settings from a protobuf copy. +// The protobuf compiler does not use these settings when it writes Go types. func removeMeta(att *expr.AttributeExpr) { - _ = codegen.Walk(att, func(a *expr.AttributeExpr) error { + err := codegen.Walk(att, func(a *expr.AttributeExpr) error { delete(a.Meta, "struct:field:name") delete(a.Meta, "struct:field:external") delete(a.Meta, "struct.field.external") // Deprecated syntax. Only present for backward compatibility. + delete(a.Meta, "struct:pkg:path") return nil }) + if err != nil { + panic(fmt.Sprintf("remove protobuf metadata: %s", err)) + } } -// convertType produces code to initialize a target type from a source type -// held by srcVar. proto is true when the transformation initializes a -// protocol buffer type. -// NOTE: For Int and UInt kinds, protocol buffer Go compiler generates -// int32 and uint32 respectively whereas Goa generates int and uint. +// convertType writes the expression that converts srcVar to the target type. +// proto is true when the target is a protobuf value. Protobuf uses int32 and +// uint32 where Goa uses int and uint. func convertType(src, tgt *expr.AttributeExpr, srcPtr, tgtPtr bool, srcVar string, proto bool, ta *codegen.TransformAttrs) string { + if protoUnionBranchUsesHelper(src, tgt) { + return fmt.Sprintf("%s(%s)", codegen.TransformHelperName(src, tgt, ta), srcVar) + } if expr.IsAlias(src.Type) || expr.IsAlias(tgt.Type) { srcp, tgtp := unAlias(src), unAlias(tgt) - if srcp.Type == tgtp.Type { - if proto { - return convertPrimitiveToProto(src, tgtp, srcPtr, tgtPtr, srcVar) - } - return convertPrimitiveFromProto(srcp, tgt, srcPtr, tgtPtr, srcVar, ta) + if proto { + return convertPrimitiveToProto(src, tgtp, srcPtr, tgtPtr, srcVar) } - return fmt.Sprintf("%s(%s)", codegen.TransformHelperName(src, tgt, ta), srcVar) - } - - if _, ok := src.Type.(expr.UserType); ok { - return fmt.Sprintf("%s(%s)", codegen.TransformHelperName(src, tgt, ta), srcVar) + return convertPrimitiveFromProto(srcp, tgt, srcPtr, tgtPtr, srcVar, ta) } srcType, _ := codegen.GetMetaType(src) tgtType, _ := codegen.GetMetaType(tgt) if srcType == "" && tgtType == "" && (src.Type != expr.Int) && (src.Type != expr.UInt) && (src.Type != expr.Any) { - // Nothing to do, except for Any type which needs special conversion + // Any values need a protobuf conversion. Other matching values do not. + if !proto && srcPtr && !tgtPtr { + return "*" + srcVar + } return srcVar } @@ -90,6 +80,17 @@ func convertType(src, tgt *expr.AttributeExpr, srcPtr, tgtPtr bool, srcVar strin return convertPrimitiveFromProto(src, tgt, srcPtr, tgtPtr, srcVar, ta) } +// protoUnionBranchUsesHelper reports whether protobuf union rendering emits a +// TransformHelperName call for the branch. Planning and rendering use this +// same rule so their helper order cannot differ. +func protoUnionBranchUsesHelper(source, target *expr.AttributeExpr) bool { + if expr.IsAlias(source.Type) || expr.IsAlias(target.Type) { + return unAlias(source).Type != unAlias(target).Type + } + _, named := source.Type.(expr.UserType) + return named +} + const convertGoAnyToProtobufValueFunc = `func() *structpb.Value { // Convert Go any to protobuf Value directly if %s == nil { @@ -113,7 +114,7 @@ const convertProtobufValueToGoAnyFunc = `func() any { // convertPrimitiveToProto returns the code to convert a primitive type to its // protocol buffer representation. func convertPrimitiveToProto(_, tgt *expr.AttributeExpr, srcPtr, _ bool, srcVar string) string { - // Special handling for Any type conversion to google.protobuf.Value + // Any values use google.protobuf.Value in protobuf messages. if tgt.Type.Kind() == expr.AnyKind { if srcPtr { srcVar = "*" + srcVar @@ -132,7 +133,7 @@ func convertPrimitiveToProto(_, tgt *expr.AttributeExpr, srcPtr, _ bool, srcVar // convertPrimitiveFromProto returns the code to convert the protocol buffer // representation of a primitive type back to the service type. func convertPrimitiveFromProto(_, tgt *expr.AttributeExpr, srcPtr, _ bool, srcVar string, ta *codegen.TransformAttrs) string { - // Special handling for Any type conversion from google.protobuf.Value + // Any values arrive from protobuf as google.protobuf.Value. if tgt.Type.Kind() == expr.AnyKind { if srcPtr { srcVar = "*" + srcVar @@ -141,20 +142,9 @@ func convertPrimitiveFromProto(_, tgt *expr.AttributeExpr, srcPtr, _ bool, srcVa return fmt.Sprintf(convertProtobufValueToGoAnyFunc, srcVar, srcVar) } - tgtType, _ := codegen.GetMetaType(tgt) - if tgtType == "" { - tgtType = ta.TargetCtx.Scope.Ref(tgt, ta.TargetCtx.Pkg(tgt)) - } + tgtType := ta.TargetCtx.Scope.Ref(tgt, ta.TargetCtx.Pkg(tgt)) if srcPtr { srcVar = "*" + srcVar } return fmt.Sprintf("%s(%s)", tgtType, srcVar) } - -// protocOneofWrapperRef returns the reference to the Go wrapper type that -// protoc generates for a oneof field: it mirrors protoc generated Go oneof -// wrapper type naming which joins the parent message type name and the oneof -// field name with an underscore (Message_Field). -func protocOneofWrapperRef(message, fieldName string) string { - return message + "_" + fieldName -} diff --git a/grpc/codegen/protobuf_transform_test.go b/grpc/codegen/protobuf_transform_test.go index 413aab48a4..73fcb06906 100644 --- a/grpc/codegen/protobuf_transform_test.go +++ b/grpc/codegen/protobuf_transform_test.go @@ -1,3 +1,5 @@ +// This file verifies generated transformations between service values and +// protobuf messages. package codegen import ( @@ -9,6 +11,7 @@ import ( "goa.design/goa/v3/codegen" ctestdata "goa.design/goa/v3/codegen/testdata" + d "goa.design/goa/v3/dsl" "goa.design/goa/v3/expr" ) @@ -52,9 +55,8 @@ func TestProtoBufTransform(t *testing.T) { pkgOverride = root.UserType("CompositePkgOverride") // attribute contexts used in test cases - svcCtx = serviceTypeContext("proto", sd.Scope) + svcCtx = codegen.NewAttributeContext(false, false, true, "proto", sd.Scope) ptrCtx = pointerContext("proto", sd.Scope) - pbCtx = protoBufTypeContext("proto", sd.Scope, true) ) // gRPC does not support any @@ -67,7 +69,6 @@ func TestProtoBufTransform(t *testing.T) { nat.Attribute.Type = expr.String } } - tc := map[string][]struct { Name string Source expr.DataType @@ -169,13 +170,23 @@ func TestProtoBufTransform(t *testing.T) { srcCtx := c.Ctx tgtCtx := c.Ctx if c.ToProto { - target = makeProtoBufMessage(expr.DupAtt(target), target.Type.Name(), sd) - tgtCtx = pbCtx + target = makeProtoBufMessage( + expr.DupAtt(target), + target.Type.Name(), + testGRPCMessageExampleIdentity(name+"/"+c.Name+"/target"), + ) + freezeProtoBufTransformMessages(t, sd, target) + tgtCtx = protoBufTypeContext("proto", sd) } else { - source = makeProtoBufMessage(expr.DupAtt(source), source.Type.Name(), sd) - srcCtx = pbCtx + source = makeProtoBufMessage( + expr.DupAtt(source), + source.Type.Name(), + testGRPCMessageExampleIdentity(name+"/"+c.Name+"/source"), + ) + freezeProtoBufTransformMessages(t, sd, source) + srcCtx = protoBufTypeContext("proto", sd) } - code, _, err := protoBufTransform(source, target, "source", "target", srcCtx, tgtCtx, c.ToProto, true) + code, _, err := protoBufTransform(source, target, srcCtx, tgtCtx, c.ToProto, true) require.NoError(t, err) code = codegen.FormatTestCode(t, "package foo\nfunc transform(){\n"+code+"}") testutil.AssertGo(t, "testdata/golden/protobuf_type_encode_"+name+"_"+c.Name+".go.golden", code) @@ -189,8 +200,15 @@ func TestProtoBufTransformAnyType(t *testing.T) { var ( sd = &ServiceData{Name: "Service", Scope: codegen.NewNameScope()} svcCtx = codegen.NewAttributeContext(false, false, true, "", sd.Scope) - pbCtx = protoBufTypeContext("", sd.Scope, false) ) + sd.protobuf = newProtobufPackageCatalog("") + sd.protobuf.plan = &protobufServicePlan{ + catalog: sd.protobuf, + fields: make(map[*expr.AttributeExpr]protocNameKey), + wrappers: make(map[*expr.AttributeExpr]protocNameKey), + oneofs: make(map[*expr.AttributeExpr]protocNameKey), + } + pbCtx := protoBufTypeContext("", sd) cases := []struct { Name string @@ -215,7 +233,7 @@ func TestProtoBufTransformAnyType(t *testing.T) { } else { srcCtx = pbCtx } - code, _, err := protoBufTransform(source, target, "source", "target", srcCtx, tgtCtx, c.ToProto, c.NewVar) + code, _, err := protoBufTransform(source, target, srcCtx, tgtCtx, c.ToProto, c.NewVar) require.NoError(t, err) t.Logf("Generated code: %s", code) @@ -237,6 +255,84 @@ func TestProtoBufTransformAnyType(t *testing.T) { } } +// TestConvertPrimitiveFromProtoUsesFinalCustomTypeImportAlias verifies that a +// protobuf conversion casts to the package name written by the import header. +func TestConvertPrimitiveFromProtoUsesFinalCustomTypeImportAlias(t *testing.T) { + const ( + packagePath = "generated.local/gen/grpc/values" + customPath = "example.com/custom/wire" + ) + generation, err := codegen.NewGeneration("generated.local/gen", nil) + require.NoError(t, err) + pkg, err := generation.ClaimPackage(packagePath) + require.NoError(t, err) + require.NoError(t, pkg.DeclareImport(codegen.NewImport("wire", customPath))) + require.NoError(t, pkg.RequireImport(codegen.NewImport("wire", "example.com/fixed/wire"))) + require.NoError(t, generation.Freeze()) + require.Equal(t, "wire2", pkg.ImportName(customPath)) + + target := &expr.AttributeExpr{ + Type: expr.Int, + Meta: expr.MetaExpr{ + "struct:field:type": {"wire.Value", customPath, "wire"}, + }, + } + targetContext := codegen.NewAttributeContext(false, false, true, "", pkg.Scope()) + attrs := &codegen.TransformAttrs{TargetCtx: targetContext} + + converted := convertPrimitiveFromProto( + &expr.AttributeExpr{Type: expr.Int}, + target, + false, + false, + "source", + attrs, + ) + require.Equal(t, "wire2.Value(source)", converted) +} + +// TestProtoBufTransformNilableDefaults checks that absent protobuf bytes and +// Any fields use their service defaults without treating present empty or null +// values as absent. +func TestProtoBufTransformNilableDefaults(t *testing.T) { + root := codegen.RunDSL(t, func() { + d.API("nilable-defaults", func() {}) + d.Type("NilableDefaults", func() { + d.Field(1, "bytes", d.Bytes, func() { + d.Default([]byte("fallback")) + }) + d.Field(2, "value", d.Any, func() { + d.Default("fallback") + }) + }) + }) + defaults := root.UserType("NilableDefaults") + source := makeProtoBufMessage( + &expr.AttributeExpr{Type: defaults}, + "NilableDefaults", + testGRPCMessageExampleIdentity("nilable-defaults/source"), + ) + target := &expr.AttributeExpr{Type: defaults} + sd := &ServiceData{Name: "Service", Scope: codegen.NewNameScope()} + freezeProtoBufTransformMessages(t, sd, source) + sourceCtx := protoBufTypeContext("proto", sd) + targetCtx := codegen.NewAttributeContext(false, false, true, "service", sd.Scope) + + generated, _, err := protoBufTransform(source, target, sourceCtx, targetCtx, false, true) + require.NoError(t, err) + generated = codegen.FormatTestCode(t, "package foo\nfunc transform(){\n"+generated+"}") + testutil.AssertGo(t, "testdata/golden/protobuf_type_encode_to-service-type_nilable-defaults.go.golden", generated) +} + +// freezeProtoBufTransformMessages prepares the message names consumed by one +// standalone transformation test outside full service analysis. +func freezeProtoBufTransformMessages(t *testing.T, sd *ServiceData, attribute *expr.AttributeExpr) { + sd.protobuf = newProtobufPackageCatalog("proto") + require.NoError(t, sd.protobuf.collectMessage(attribute, protobufMessageSource{})) + planTestProtobufCatalog(t, sd) + sd.protobuf.freezeMessageNames() +} + func pointerContext(pkg string, scope *codegen.NameScope) *codegen.AttributeContext { return codegen.NewAttributeContext(true, false, true, pkg, scope) } diff --git a/grpc/codegen/protoc_names.go b/grpc/codegen/protoc_names.go new file mode 100644 index 0000000000..0ae9d64bce --- /dev/null +++ b/grpc/codegen/protoc_names.go @@ -0,0 +1,275 @@ +// This file reads the Go names that the supported protobuf tools assign to a +// compiled protobuf file. The gRPC planner uses these names when it records +// declarations that later files define or call. +package codegen + +import ( + "fmt" + "strings" + + "google.golang.org/protobuf/compiler/protogen" + "google.golang.org/protobuf/types/descriptorpb" + "google.golang.org/protobuf/types/pluginpb" +) + +type ( + // protocNameRules reads names using one supported pair of protobuf tools. + protocNameRules struct{} + + // protocNames stores each Go name under the protobuf item and the way that + // generated code uses it. + protocNames struct { + values map[protocNameKey]string + } + + // protocNameKey identifies one Go name produced for a protobuf item. + protocNameKey struct { + descriptor string + role protocNameRole + } + + // protocNameRole identifies one Go declaration or field produced by the + // supported protobuf tools. + protocNameRole uint8 +) + +const ( + protocMessageName protocNameRole = iota + 1 + protocFieldName + protocEnumName + protocEnumValueName + protocOneofFieldName + protocOneofInterfaceName + protocOneofWrapperName + protocServiceClientName + protocServiceClientStructName + protocServiceClientConstructorName + protocServiceServerName + protocServiceUnimplementedServerName + protocServiceUnsafeServerName + protocServiceRegisterName + protocServiceDescriptorName + protocMethodName + protocMethodFullName + protocMethodHandlerName + protocMethodClientStreamName + protocMethodServerStreamName +) + +const protocNameVersionGo1_36GRPC1_6 = "protoc-gen-go-v1.36.12/protoc-gen-go-grpc-v1.6.2" + +// newProtocNameRules returns the name reader for a supported protobuf tool +// pair. An unknown value is rejected because its Go names may differ. +func newProtocNameRules(version string) (*protocNameRules, error) { + if version != protocNameVersionGo1_36GRPC1_6 { + return nil, fmt.Errorf("unsupported protobuf Go naming version %q", version) + } + return &protocNameRules{}, nil +} + +// file returns every Go name used for messages, fields, enumerations, oneofs, +// services, and methods in descriptor. +func (r *protocNameRules) file(descriptor *descriptorpb.FileDescriptorProto) (*protocNames, error) { + request := &pluginpb.CodeGeneratorRequest{ + FileToGenerate: []string{descriptor.GetName()}, + ProtoFile: []*descriptorpb.FileDescriptorProto{descriptor}, + } + plugin, err := (protogen.Options{}).New(request) + if err != nil { + return nil, fmt.Errorf("read protobuf Go names: %w", err) + } + if len(plugin.Files) != 1 || plugin.Files[0].Desc.Path() != descriptor.GetName() { + return nil, fmt.Errorf("read protobuf Go names: file %q was not returned", descriptor.GetName()) + } + + names := &protocNames{values: make(map[protocNameKey]string)} + file := plugin.Files[0] + for _, enum := range file.Enums { + if err := names.addEnum(enum); err != nil { + return nil, err + } + } + for _, message := range file.Messages { + if err := names.addMessage(message); err != nil { + return nil, err + } + } + for _, service := range file.Services { + if err := names.addService(service); err != nil { + return nil, err + } + } + return names, nil +} + +// lookup returns the Go name stored for descriptor and role. +func (n *protocNames) lookup(descriptor string, role protocNameRole) (string, bool) { + name, ok := n.values[protocNameKey{descriptor: descriptor, role: role}] + return name, ok +} + +// String returns the short role name used in test and error labels. +func (r protocNameRole) String() string { + switch r { + case protocMessageName: + return "message" + case protocFieldName: + return "field" + case protocEnumName: + return "enum" + case protocEnumValueName: + return "enum value" + case protocOneofFieldName: + return "oneof field" + case protocOneofInterfaceName: + return "oneof interface" + case protocOneofWrapperName: + return "oneof wrapper" + case protocServiceClientName: + return "service client" + case protocServiceClientStructName: + return "service client struct" + case protocServiceClientConstructorName: + return "service client constructor" + case protocServiceServerName: + return "service server" + case protocServiceUnimplementedServerName: + return "unimplemented service server" + case protocServiceUnsafeServerName: + return "unsafe service server" + case protocServiceRegisterName: + return "service register function" + case protocServiceDescriptorName: + return "service description" + case protocMethodName: + return "method" + case protocMethodFullName: + return "full method name" + case protocMethodHandlerName: + return "method handler" + case protocMethodClientStreamName: + return "client stream" + case protocMethodServerStreamName: + return "server stream" + default: + panic(fmt.Sprintf("unknown protobuf Go name role %d", r)) + } +} + +// addMessage stores one message, its nested declarations, fields, and oneofs. +func (n *protocNames) addMessage(message *protogen.Message) error { + if err := n.add(string(message.Desc.FullName()), protocMessageName, message.GoIdent.GoName); err != nil { + return err + } + for _, enum := range message.Enums { + if err := n.addEnum(enum); err != nil { + return err + } + } + for _, nested := range message.Messages { + if err := n.addMessage(nested); err != nil { + return err + } + } + for _, field := range message.Fields { + descriptor := string(field.Desc.FullName()) + if err := n.add(descriptor, protocFieldName, field.GoName); err != nil { + return err + } + if field.Oneof != nil && !field.Oneof.Desc.IsSynthetic() { + if err := n.add(descriptor, protocOneofWrapperName, field.GoIdent.GoName); err != nil { + return err + } + } + } + for _, oneof := range message.Oneofs { + if oneof.Desc.IsSynthetic() { + continue + } + descriptor := string(oneof.Desc.FullName()) + if err := n.add(descriptor, protocOneofFieldName, oneof.GoName); err != nil { + return err + } + if err := n.add(descriptor, protocOneofInterfaceName, "is"+oneof.GoIdent.GoName); err != nil { + return err + } + } + return nil +} + +// addEnum stores one enumeration and all of its values. +func (n *protocNames) addEnum(enum *protogen.Enum) error { + if err := n.add(string(enum.Desc.FullName()), protocEnumName, enum.GoIdent.GoName); err != nil { + return err + } + for _, value := range enum.Values { + if err := n.add(string(value.Desc.FullName()), protocEnumValueName, value.GoIdent.GoName); err != nil { + return err + } + } + return nil +} + +// addService stores the declarations written by protoc-gen-go-grpc v1.6 for +// one service and all of its methods. +func (n *protocNames) addService(service *protogen.Service) error { + descriptor := string(service.Desc.FullName()) + serviceName := service.GoName + declarations := []struct { + role protocNameRole + name string + }{ + {protocServiceClientName, serviceName + "Client"}, + {protocServiceClientStructName, protocGRPCUnexport(serviceName) + "Client"}, + {protocServiceClientConstructorName, "New" + serviceName + "Client"}, + {protocServiceServerName, serviceName + "Server"}, + {protocServiceUnimplementedServerName, "Unimplemented" + serviceName + "Server"}, + {protocServiceUnsafeServerName, "Unsafe" + serviceName + "Server"}, + {protocServiceRegisterName, "Register" + serviceName + "Server"}, + {protocServiceDescriptorName, serviceName + "_ServiceDesc"}, + } + for _, declaration := range declarations { + if err := n.add(descriptor, declaration.role, declaration.name); err != nil { + return err + } + } + for _, method := range service.Methods { + methodDescriptor := string(method.Desc.FullName()) + methodName := method.GoName + if err := n.add(methodDescriptor, protocMethodName, methodName); err != nil { + return err + } + if err := n.add(methodDescriptor, protocMethodFullName, serviceName+"_"+methodName+"_FullMethodName"); err != nil { + return err + } + if err := n.add(methodDescriptor, protocMethodHandlerName, "_"+serviceName+"_"+methodName+"_Handler"); err != nil { + return err + } + if method.Desc.IsStreamingClient() || method.Desc.IsStreamingServer() { + if err := n.add(methodDescriptor, protocMethodClientStreamName, serviceName+"_"+methodName+"Client"); err != nil { + return err + } + if err := n.add(methodDescriptor, protocMethodServerStreamName, serviceName+"_"+methodName+"Server"); err != nil { + return err + } + } + } + return nil +} + +// add stores one name and rejects two values for the same protobuf item and +// role. +func (n *protocNames) add(descriptor string, role protocNameRole, name string) error { + key := protocNameKey{descriptor: descriptor, role: role} + if previous, ok := n.values[key]; ok { + return fmt.Errorf("protobuf item %q has two %s names, %q and %q", descriptor, role, previous, name) + } + n.values[key] = name + return nil +} + +// protocGRPCUnexport changes the first letter exactly as protoc-gen-go-grpc +// v1.6 does when it writes the private client type. +func protocGRPCUnexport(name string) string { + return strings.ToLower(name[:1]) + name[1:] +} diff --git a/grpc/codegen/protoc_names_test.go b/grpc/codegen/protoc_names_test.go new file mode 100644 index 0000000000..7f66c33f0d --- /dev/null +++ b/grpc/codegen/protoc_names_test.go @@ -0,0 +1,170 @@ +// This file compares Goa's protobuf Go names with code generated by the +// supported protobuf tools. +package codegen + +import ( + "go/ast" + "go/parser" + "go/token" + "os" + "os/exec" + "path/filepath" + "testing" + + "github.com/stretchr/testify/require" + "google.golang.org/protobuf/proto" + "google.golang.org/protobuf/types/descriptorpb" +) + +// TestProtocNameVersion rejects a name version that Goa does not support. +func TestProtocNameVersion(t *testing.T) { + _, err := newProtocNameRules("unknown") + require.EqualError(t, err, `unsupported protobuf Go naming version "unknown"`) +} + +// TestProtocDeclarationFamilies checks every generated name family that Goa +// uses in its gRPC client and server code. +func TestProtocDeclarationFamilies(t *testing.T) { + descriptor, declarations := generateProtocNameFixture(t) + rules, err := newProtocNameRules(protocNameVersionGo1_36GRPC1_6) + require.NoError(t, err) + names, err := rules.file(descriptor) + require.NoError(t, err) + + tests := []struct { + descriptor string + role protocNameRole + want string + }{ + {"goa.names.v1.api2_http_request", protocMessageName, "Api2HttpRequest"}, + {"goa.names.v1.api2_http_request.nested_api2", protocMessageName, "Api2HttpRequestNestedApi2"}, + {"goa.names.v1.wrapper_conflict._BranchValue", protocMessageName, "WrapperConflict_XBranchValue"}, + {"goa.names.v1.Explicit_HTTP2_Name", protocMessageName, "Explicit_HTTP2_Name"}, + {"goa.names.v1.api2_http_request.api_url", protocFieldName, "ApiUrl"}, + {"goa.names.v1.api2_http_request.api2_url", protocFieldName, "Api2Url"}, + {"goa.names.v1.api2_http_request.dns_2_server", protocFieldName, "Dns_2Server"}, + {"goa.names.v1.api2_http_request.x509_cert", protocFieldName, "X509Cert"}, + {"goa.names.v1.api2_http_request.reset", protocFieldName, "Reset_"}, + {"goa.names.v1.api2_http_request.string", protocFieldName, "String_"}, + {"goa.names.v1.api2_http_request.proto_message", protocFieldName, "ProtoMessage_"}, + {"goa.names.v1.api2_http_request.descriptor", protocFieldName, "Descriptor_"}, + {"goa.names.v1.Explicit_HTTP2_Name.Explicit_Field2_Name", protocFieldName, "Explicit_Field2_Name"}, + {"goa.names.v1.http_2_status", protocEnumName, "Http_2Status"}, + {"goa.names.v1.HTTP_2_STATUS_UNSPECIFIED", protocEnumValueName, "Http_2Status_HTTP_2_STATUS_UNSPECIFIED"}, + {"goa.names.v1.HTTP2_OK", protocEnumValueName, "Http_2Status_HTTP2_OK"}, + {"goa.names.v1.api2_http_request.nested_api2.state_2", protocEnumName, "Api2HttpRequestNestedApi2State_2"}, + {"goa.names.v1.api2_http_request.nested_api2.DNS2_READY", protocEnumValueName, "Api2HttpRequestNestedApi2_DNS2_READY"}, + {"goa.names.v1.api2_http_request.result_2_kind", protocOneofFieldName, "Result_2Kind"}, + {"goa.names.v1.api2_http_request.result_2_kind", protocOneofInterfaceName, "isApi2HttpRequest_Result_2Kind"}, + {"goa.names.v1.api2_http_request.http_2xx", protocOneofWrapperName, "Api2HttpRequest_Http_2Xx"}, + {"goa.names.v1.api2_http_request.api_url_value", protocOneofWrapperName, "Api2HttpRequest_ApiUrlValue"}, + {"goa.names.v1.wrapper_conflict.choiceValue", protocOneofFieldName, "ChoiceValue_"}, + {"goa.names.v1.wrapper_conflict.choiceValue", protocOneofInterfaceName, "isWrapperConflict_ChoiceValue_"}, + {"goa.names.v1.wrapper_conflict.branchValue", protocOneofWrapperName, "WrapperConflict_BranchValue"}, + {"goa.names.v1.wrapper_conflict.reset", protocOneofWrapperName, "WrapperConflict_Reset_"}, + {"goa.names.v1.api2_http_service", protocServiceClientName, "Api2HttpServiceClient"}, + {"goa.names.v1.api2_http_service", protocServiceClientStructName, "api2HttpServiceClient"}, + {"goa.names.v1.api2_http_service", protocServiceClientConstructorName, "NewApi2HttpServiceClient"}, + {"goa.names.v1.api2_http_service", protocServiceServerName, "Api2HttpServiceServer"}, + {"goa.names.v1.api2_http_service", protocServiceUnimplementedServerName, "UnimplementedApi2HttpServiceServer"}, + {"goa.names.v1.api2_http_service", protocServiceUnsafeServerName, "UnsafeApi2HttpServiceServer"}, + {"goa.names.v1.api2_http_service", protocServiceRegisterName, "RegisterApi2HttpServiceServer"}, + {"goa.names.v1.api2_http_service", protocServiceDescriptorName, "Api2HttpService_ServiceDesc"}, + {"goa.names.v1.api2_http_service.get_url2", protocMethodName, "GetUrl2"}, + {"goa.names.v1.api2_http_service.get_url2", protocMethodFullName, "Api2HttpService_GetUrl2_FullMethodName"}, + {"goa.names.v1.api2_http_service.get_url2", protocMethodHandlerName, "_Api2HttpService_GetUrl2_Handler"}, + {"goa.names.v1.api2_http_service.watch_dns2", protocMethodClientStreamName, "Api2HttpService_WatchDns2Client"}, + {"goa.names.v1.api2_http_service.watch_dns2", protocMethodServerStreamName, "Api2HttpService_WatchDns2Server"}, + {"goa.names.v1.api2_http_service.upload_api2", protocMethodClientStreamName, "Api2HttpService_UploadApi2Client"}, + {"goa.names.v1.api2_http_service.upload_api2", protocMethodServerStreamName, "Api2HttpService_UploadApi2Server"}, + {"goa.names.v1.api2_http_service.sync_x509", protocMethodClientStreamName, "Api2HttpService_SyncX509Client"}, + {"goa.names.v1.api2_http_service.sync_x509", protocMethodServerStreamName, "Api2HttpService_SyncX509Server"}, + } + + for _, test := range tests { + t.Run(test.descriptor+"/"+test.role.String(), func(t *testing.T) { + got, ok := names.lookup(test.descriptor, test.role) + require.True(t, ok, "name was not recorded") + require.Equal(t, test.want, got) + require.Contains(t, declarations, got, "the supported tools did not declare the predicted Go name") + }) + } +} + +// generateProtocNameFixture runs the supported tools and returns their input +// description and every Go name declared in their output. +func generateProtocNameFixture(t *testing.T) (*descriptorpb.FileDescriptorProto, map[string]struct{}) { + t.Helper() + directory := t.TempDir() + source, err := os.ReadFile(filepath.Join("testdata", "protoc_names.proto")) + require.NoError(t, err) + protoPath := filepath.Join(directory, "protoc_names.proto") + require.NoError(t, os.WriteFile(protoPath, source, 0o600)) + require.NoError(t, protoc(defaultProtocCmd, protoPath)) + + descriptorPath := filepath.Join(directory, "descriptor.pb") + args := defaultProtocCmd[1:len(defaultProtocCmd):len(defaultProtocCmd)] + args = append(args, + "--proto_path", directory, + "--descriptor_set_out", descriptorPath, + protoPath, + ) + output, err := exec.Command(defaultProtocCmd[0], args...).CombinedOutput() + require.NoError(t, err, string(output)) + + encoded, err := os.ReadFile(descriptorPath) + require.NoError(t, err) + return readProtocDescriptor(t, encoded), declaredGoNames( + t, + filepath.Join(directory, "protoc_names.pb.go"), + filepath.Join(directory, "protoc_names_grpc.pb.go"), + ) +} + +// readProtocDescriptorRequest converts a descriptor set into the request read +// by protobuf's public Go name code. +func readProtocDescriptor(t *testing.T, encoded []byte) *descriptorpb.FileDescriptorProto { + t.Helper() + set := &descriptorpb.FileDescriptorSet{} + require.NoError(t, proto.Unmarshal(encoded, set)) + require.Len(t, set.File, 1) + return set.File[0] +} + +// declaredGoNames returns package names, fields, and receiver methods declared +// by the generated files. +func declaredGoNames(t *testing.T, paths ...string) map[string]struct{} { + t.Helper() + result := make(map[string]struct{}) + for _, path := range paths { + file, err := parser.ParseFile(token.NewFileSet(), path, nil, 0) + require.NoError(t, err) + for _, declaration := range file.Decls { + switch declaration := declaration.(type) { + case *ast.GenDecl: + for _, specification := range declaration.Specs { + switch specification := specification.(type) { + case *ast.TypeSpec: + result[specification.Name.Name] = struct{}{} + ast.Inspect(specification.Type, func(node ast.Node) bool { + field, ok := node.(*ast.Field) + if ok { + for _, name := range field.Names { + result[name.Name] = struct{}{} + } + } + return true + }) + case *ast.ValueSpec: + for _, name := range specification.Names { + result[name.Name] = struct{}{} + } + } + } + case *ast.FuncDecl: + result[declaration.Name.Name] = struct{}{} + } + } + } + return result +} diff --git a/grpc/codegen/released_streaming_name_test.go b/grpc/codegen/released_streaming_name_test.go new file mode 100644 index 0000000000..04ea0c9f33 --- /dev/null +++ b/grpc/codegen/released_streaming_name_test.go @@ -0,0 +1,39 @@ +// This file checks that one gRPC response conversion keeps its released public +// name when both the response encoder and a stream send method use it. +package codegen + +import ( + "testing" + + "goa.design/goa/v3/codegen" + "goa.design/goa/v3/codegen/testutil" + "goa.design/goa/v3/grpc/codegen/testdata" +) + +// TestReleasedStreamingResponseConstructorNames catches replacing an honest +// method response name with an internal type-based name when conversions merge. +func TestReleasedStreamingResponseConstructorNames(t *testing.T) { + t.Run("caller selected view", func(t *testing.T) { + root := RunGRPCDSL(t, testdata.ServerStreamingResultWithViewsDSL) + services := CreateGRPCServices(root) + types := serverTypeFiles(services) + servers := serverFiles(services) + + sections := append(types[0].Section("server-type-init"), servers[1].Section("response-encoder")...) + sections = append(sections, servers[0].Section("server-stream-send")...) + code := codegen.SectionsCode(t, sections) + testutil.AssertGo(t, "testdata/golden/released_streaming_response_constructors.go.golden", code) + }) + + t.Run("fixed collection view", func(t *testing.T) { + root := RunGRPCDSL(t, testdata.ClientStreamingResultCollectionWithExplicitViewDSL) + services := CreateGRPCServices(root) + types := serverTypeFiles(services) + servers := serverFiles(services) + + sections := append(types[0].Section("server-type-init"), servers[1].Section("response-encoder")...) + sections = append(sections, servers[0].Section("server-stream-send")...) + code := codegen.SectionsCode(t, sections) + testutil.AssertGo(t, "testdata/golden/released_fixed_view_collection_constructor.go.golden", code) + }) +} diff --git a/grpc/codegen/required_collections_runtime_test.go b/grpc/codegen/required_collections_runtime_test.go new file mode 100644 index 0000000000..7a509e992d --- /dev/null +++ b/grpc/codegen/required_collections_runtime_test.go @@ -0,0 +1,181 @@ +// These tests run generated gRPC and HTTP validators against the same required +// collections. Protobuf has no collection presence; JSON still requires fields. +package codegen + +import ( + "go/format" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/require" + + "goa.design/goa/v3/codegen/service" + d "goa.design/goa/v3/dsl" + "goa.design/goa/v3/expr" + httpcodegen "goa.design/goa/v3/http/codegen" +) + +func TestRequiredCollectionsPreserveTransportContracts(t *testing.T) { + root := expr.RunDSL(t, requiredCollectionsDSL) + generation, servicePlans := grpcServicePlans(t, []*expr.RootExpr{root}) + grpcPlans, err := NewPlans(generation, PlanInput{Root: root, Service: servicePlans[0]}) + require.NoError(t, err) + httpPlans, err := httpcodegen.NewPlans(generation, httpcodegen.PlanInput{Root: root, Service: servicePlans[0]}) + require.NoError(t, err) + require.NoError(t, generation.Freeze()) + require.NoError(t, servicePlans[0].Link()) + require.NoError(t, grpcPlans[0].Link()) + require.NoError(t, httpPlans[0].Link()) + files, err := service.Files(servicePlans...) + require.NoError(t, err) + files = append(files, grpcPlans[0].ServerFiles()...) + files = append(files, grpcPlans[0].ClientFiles()...) + files = append(files, grpcPlans[0].ServerTypeFiles()...) + files = append(files, grpcPlans[0].ClientTypeFiles()...) + files = append(files, grpcPlans[0].ProtoFiles()...) + files = append(files, httpPlans[0].ServerTypeFiles()...) + files = append(files, httpPlans[0].ServerFiles()...) + files = append(files, httpPlans[0].PathFiles()...) + directory := t.TempDir() + writeProtobufDescriptorModule(t, directory) + for _, file := range files { + _, err := file.Render(directory) + require.NoError(t, err) + } + source, err := format.Source([]byte(requiredCollectionsRuntimeTest)) + require.NoError(t, err) + require.NoError(t, os.WriteFile(filepath.Join(directory, "collections_test.go"), source, 0o600)) + compileProtobufDescriptorModule(t, directory) +} + +func requiredCollectionsDSL() { + item := d.Type("Item", func() { + d.Field(1, "label", d.String) + d.Required("label") + }) + fields := func() { + d.Field(1, "items", d.ArrayOfRequired(item)) + d.Field(2, "labels", d.MapOf(d.String, d.String)) + d.Field(3, "detail", item) + d.Field(4, "enabled", d.Boolean) + d.Field(5, "count", d.Int) + d.Field(6, "text", d.String) + d.Required("items", "labels", "detail", "enabled", "count", "text") + } + bounded := func() { + d.Field(1, "items", d.ArrayOf(d.String), func() { d.MinLength(1) }) + d.Field(2, "labels", d.MapOf(d.String, d.String), func() { d.MinLength(1) }) + d.Required("items", "labels") + } + d.Service("Collections", func() { + d.Method("Exchange", func() { + d.Payload(fields) + d.Result(fields) + d.GRPC(func() {}) + d.HTTP(func() { d.POST("/exchange") }) + }) + d.Method("Bounded", func() { + d.Payload(bounded) + d.Result(bounded) + d.GRPC(func() {}) + }) + }) +} + +const requiredCollectionsRuntimeTest = `package collections_test + +import ( + "encoding/json" + "testing" + + gengrpcclient "generated.local/gen/grpc/collections/client" + gengrpcserver "generated.local/gen/grpc/collections/server" + genpb "generated.local/gen/grpc/collections/pb" + genhttpserver "generated.local/gen/http/collections/server" + "google.golang.org/protobuf/proto" +) + +func TestEmptyAndPopulatedCollectionsRoundTrip(t *testing.T) { + for _, populated := range []bool{false, true} { + input := &genpb.ExchangeRequest{ + Items: []*genpb.Item{}, Labels: map[string]string{}, + Detail: &genpb.Item{Label: proto.String("good")}, + Enabled: proto.Bool(false), Count: proto.Int32(0), Text: proto.String(""), + } + if populated { + input.Items = []*genpb.Item{{Label: proto.String("item")}} + input.Labels["key"] = "value" + } + wire, err := proto.Marshal(input) + if err != nil { t.Fatal(err) } + request := new(genpb.ExchangeRequest) + response := new(genpb.ExchangeResponse) + if err := proto.Unmarshal(wire, request); err != nil { t.Fatal(err) } + if err := proto.Unmarshal(wire, response); err != nil { t.Fatal(err) } + if !populated && (request.Items != nil || request.Labels != nil) { + t.Fatal("test must exercise protobuf's loss of empty collection presence") + } + if err := gengrpcserver.ValidateExchangeRequest(request); err != nil { t.Fatal(err) } + if err := gengrpcclient.ValidateExchangeResponse(response); err != nil { t.Fatal(err) } + } +} + +func TestRequiredNonCollectionFieldsAndElementsRemainValidated(t *testing.T) { + for _, field := range []string{"detail", "enabled", "count", "text", "item"} { + t.Run(field, func(t *testing.T) { + request := &genpb.ExchangeRequest{ + Detail: &genpb.Item{Label: proto.String("good")}, + Enabled: proto.Bool(false), Count: proto.Int32(0), Text: proto.String(""), + } + switch field { + case "detail": request.Detail = nil + case "enabled": request.Enabled = nil + case "count": request.Count = nil + case "text": request.Text = nil + case "item": request.Items = []*genpb.Item{{}} + } + wire, err := proto.Marshal(request) + if err != nil { t.Fatal(err) } + response := new(genpb.ExchangeResponse) + if err := proto.Unmarshal(wire, response); err != nil { t.Fatal(err) } + if err := gengrpcserver.ValidateExchangeRequest(request); err == nil { t.Fatal("server accepted missing required " + field) } + if err := gengrpcclient.ValidateExchangeResponse(response); err == nil { t.Fatal("client accepted missing required " + field) } + }) + } + request := &genpb.ExchangeRequest{Items: []*genpb.Item{nil}, Detail: &genpb.Item{Label: proto.String("good")}, Enabled: proto.Bool(false), Count: proto.Int32(0), Text: proto.String("")} + if err := gengrpcserver.ValidateExchangeRequest(request); err == nil { t.Fatal("accepted nil required element") } +} + +func TestCollectionMinimumLengthsRemainValidated(t *testing.T) { + for _, field := range []string{"items", "labels"} { + request := &genpb.BoundedRequest{Items: []string{"one"}, Labels: map[string]string{"key":"value"}} + if field == "items" { request.Items = nil } else { request.Labels = nil } + wire, err := proto.Marshal(request) + if err != nil { t.Fatal(err) } + response := new(genpb.BoundedResponse) + if err := proto.Unmarshal(wire, response); err != nil { t.Fatal(err) } + if err := gengrpcserver.ValidateBoundedRequest(request); err == nil { t.Fatal("server ignored minimum length for " + field) } + if err := gengrpcclient.ValidateBoundedResponse(response); err == nil { t.Fatal("client ignored minimum length for " + field) } + } +} + +func TestJSONStillRequiresPresentNonNullCollections(t *testing.T) { + for _, test := range []struct{ name, fields string; valid bool }{ + {"empty", "\"items\":[],\"labels\":{}", true}, + {"populated", "\"items\":[{\"label\":\"item\"}],\"labels\":{\"key\":\"value\"}", true}, + {"missing items", "\"labels\":{}", false}, + {"missing labels", "\"items\":[]", false}, + {"null items", "\"items\":null,\"labels\":{}", false}, + {"null labels", "\"items\":[],\"labels\":null", false}, + } { + t.Run(test.name, func(t *testing.T) { + data := "{" + test.fields + ",\"detail\":{\"label\":\"good\"},\"enabled\":false,\"count\":0,\"text\":\"\"}" + var body genhttpserver.ExchangeRequestBody + if err := json.Unmarshal([]byte(data), &body); err != nil { t.Fatal(err) } + err := genhttpserver.ValidateExchangeRequestBody(&body) + if (err == nil) != test.valid { t.Fatalf("valid=%v, error=%v", test.valid, err) } + }) + } +} +` diff --git a/grpc/codegen/required_union_validation_test.go b/grpc/codegen/required_union_validation_test.go new file mode 100644 index 0000000000..3c97b58d4c --- /dev/null +++ b/grpc/codegen/required_union_validation_test.go @@ -0,0 +1,124 @@ +// This file checks the validation functions generated for gRPC server requests +// and client responses which contain a required OneOf. +package codegen + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/require" + + "goa.design/goa/v3/codegen" + "goa.design/goa/v3/codegen/testutil" + d "goa.design/goa/v3/dsl" +) + +func TestRequiredUnionValidationUsesCompleteProtobufBranches(t *testing.T) { + root := RunGRPCDSL(t, requiredUnionValidationDSL) + services := CreateGRPCServices(root) + service := services.Get("UnionValidation") + for _, expected := range []struct { + side validateKind + name string + }{ + {validateServer, "validatetest_20_api_UnionValidation_Detail_At_detail"}, + {validateClient, "validatetest_20_api_UnionValidation_Detail_At_detail"}, + } { + var nested *protobufValidationRecord + for _, validation := range service.protobuf.validators { + if validation.side == expected.side && + validation.source.path == "choice.detail" { + nested = validation + break + } + } + require.NotNil(t, nested) + require.Equal(t, expected.name, nested.declaration.Name()) + require.Contains(t, nested.data.Def, `MissingFieldError("label", "detail")`) + } + + for _, test := range []struct { + name string + files []*codegen.File + sectionName string + function string + golden string + }{ + { + name: "server", + files: serverTypeFiles(services), + sectionName: "server-validate", + function: "ValidateExchangeRequest", + golden: "testdata/golden/server_types_server-required-union-validation.go.golden", + }, + { + name: "client", + files: clientTypeFiles(services), + sectionName: "client-validate", + function: "ValidateExchangeResponse", + golden: "testdata/golden/client_types_client-required-union-validation.go.golden", + }, + } { + t.Run(test.name, func(t *testing.T) { + require.Len(t, test.files, 1) + sections := test.files[0].Section(test.sectionName) + require.NotEmpty(t, sections) + testutil.AssertGo(t, test.golden, validationSection(t, sections, test.function)) + }) + } +} + +// validationSection returns the generated validator named function. +func validationSection(t *testing.T, sections []*codegen.SectionTemplate, function string) string { + t.Helper() + for _, section := range sections { + code := codegen.SectionCode(t, section) + if strings.Contains(code, "func "+function+"(") { + return code + } + } + t.Errorf("missing generated validator %s", function) + return "" +} + +// requiredUnionValidationDSL creates branches whose values include scalars, +// messages, an empty message, a byte slice, a named string, and Any. +func requiredUnionValidationDSL() { + token := d.Type("Token", d.String) + detail := d.Type("Detail", func() { + d.Field(1, "label", d.String) + d.Required("label") + }) + inactive := d.Type("Inactive", func() {}) + request := d.Type("RequestChoice", func() { + d.OneOf("choice", func() { + d.TypeName("RequestChoiceValue") + d.Field(1, "number", d.Int, func() { d.Minimum(1) }) + d.Field(2, "detail", detail) + d.Field(3, "inactive", inactive) + d.Field(4, "blob", d.Bytes) + d.Field(5, "token", token) + d.Field(6, "metadata", d.Any) + }) + d.Required("choice") + }) + response := d.Type("ResponseChoice", func() { + d.OneOf("choice", func() { + d.TypeName("ResponseChoiceValue") + d.Field(1, "number", d.Int, func() { d.Minimum(1) }) + d.Field(2, "detail", detail) + d.Field(3, "inactive", inactive) + d.Field(4, "blob", d.Bytes) + d.Field(5, "token", token) + d.Field(6, "metadata", d.Any) + }) + d.Required("choice") + }) + d.Service("UnionValidation", func() { + d.Method("Exchange", func() { + d.Payload(request) + d.Result(response) + d.GRPC(func() {}) + }) + }) +} diff --git a/grpc/codegen/server.go b/grpc/codegen/server.go index de2f957e6f..cc20f28816 100644 --- a/grpc/codegen/server.go +++ b/grpc/codegen/server.go @@ -1,32 +1,30 @@ +// This file renders gRPC servers and codecs per service; each returned file +// receives imports from the complete endpoint set it renders. package codegen import ( "fmt" - "path" "path/filepath" "goa.design/goa/v3/codegen" "goa.design/goa/v3/expr" ) -// ServerFiles returns all the server files for every gRPC service. The files -// contain the server which implements the generated gRPC server interface and -// encoders and decoders to transform protocol buffer types and gRPC metadata -// into goa types and vice versa. -func ServerFiles(genpkg string, services *ServicesData) []*codegen.File { - svcLen := len(services.Root.API.GRPC.Services) +// serverFiles returns the planned server interfaces, encoders, and decoders. +func serverFiles(services *ServicesData) []*codegen.File { + svcLen := len(services.servicePlans) fw := make([]*codegen.File, 2*svcLen) - for i, svc := range services.Root.API.GRPC.Services { - fw[i] = serverFile(genpkg, svc, services) + for i, servicePlan := range services.servicePlans { + fw[i] = addEndpointImports(serverFile(servicePlan.expression, services), services) } - for i, svc := range services.Root.API.GRPC.Services { - fw[i+svcLen] = serverEncodeDecode(genpkg, svc, services) + for i, servicePlan := range services.servicePlans { + fw[i+svcLen] = addEndpointImports(serverEncodeDecode(servicePlan.expression, services), services) } return fw } // serverFile returns the files defining the gRPC server. -func serverFile(genpkg string, svc *expr.GRPCServiceExpr, services *ServicesData) *codegen.File { +func serverFile(svc *expr.GRPCServiceExpr, services *ServicesData) *codegen.File { var ( fpath string sections []*codegen.SectionTemplate @@ -36,24 +34,8 @@ func serverFile(genpkg string, svc *expr.GRPCServiceExpr, services *ServicesData { svcName := data.Service.PathName fpath = filepath.Join(codegen.Gendir, "grpc", svcName, "server", "server.go") - imports := []*codegen.ImportSpec{ - {Path: "context"}, - {Path: "errors"}, - codegen.GoaImport(""), - codegen.GoaNamedImport("grpc", "goagrpc"), - {Path: "google.golang.org/grpc/codes"}, - {Path: path.Join(genpkg, svcName), Name: data.Service.PkgName}, - {Path: path.Join(genpkg, svcName, "views"), Name: data.Service.ViewsPkg}, - {Path: path.Join(genpkg, "grpc", svcName, pbPkgName), Name: data.PkgName}, - } - for _, e := range data.Endpoints { - if e.Request.StreamEnvelope != nil { - imports = append(imports, &codegen.ImportSpec{Path: "io"}) - break - } - } sections = []*codegen.SectionTemplate{ - codegen.Header(svc.Name()+" gRPC server", "server", imports), + codegen.Header(svc.Name()+" gRPC server", "server", nil), { Name: "server-struct", Source: grpcTemplates.Read(grpcServerStructTypeT), @@ -123,7 +105,7 @@ func serverFile(genpkg string, svc *expr.GRPCServiceExpr, services *ServicesData // serverEncodeDecode returns the file defining the gRPC server encoding and // decoding logic. -func serverEncodeDecode(genpkg string, svc *expr.GRPCServiceExpr, services *ServicesData) *codegen.File { +func serverEncodeDecode(svc *expr.GRPCServiceExpr, services *ServicesData) *codegen.File { var ( fpath string sections []*codegen.SectionTemplate @@ -134,35 +116,22 @@ func serverEncodeDecode(genpkg string, svc *expr.GRPCServiceExpr, services *Serv svcName := data.Service.PathName fpath = filepath.Join(codegen.Gendir, "grpc", svcName, "server", "encode_decode.go") title := fmt.Sprintf("%s gRPC server encoders and decoders", svc.Name()) - imports := []*codegen.ImportSpec{ - {Path: "context"}, - {Path: "strings"}, - {Path: "strconv"}, - {Path: "unicode/utf8"}, - {Path: "google.golang.org/grpc"}, - {Path: "google.golang.org/grpc/metadata"}, - codegen.GoaImport(""), - codegen.GoaNamedImport("grpc", "goagrpc"), - {Path: path.Join(genpkg, svcName), Name: data.Service.PkgName}, - {Path: path.Join(genpkg, svcName, "views"), Name: data.Service.ViewsPkg}, - {Path: path.Join(genpkg, "grpc", svcName, pbPkgName), Name: data.PkgName}, - } - sections = []*codegen.SectionTemplate{codegen.Header(title, "server", imports)} + sections = []*codegen.SectionTemplate{codegen.Header(title, "server", nil)} for _, e := range data.Endpoints { if e.Response.ServerConvert != nil { sections = append(sections, &codegen.SectionTemplate{ Name: "response-encoder", - Source: grpcTemplates.Read(grpcResponseEncoderT, grpcConvertTypeToStringP, "string_conversion"), + Source: grpcTemplates.Read(grpcResponseEncoderT, grpcTypeToStringExpressionP), Data: e, FuncMap: map[string]any{ - "typeConversionData": typeConversionData, + "typeStringExpressionData": typeStringExpressionData, "metadataEncodeDecodeData": metadataEncodeDecodeData, }, }) } if e.PayloadRef != "" { - fm := transTmplFuncs(svc, services) + fm := transTmplFuncs(data) fm["isEmpty"] = isEmpty sections = append(sections, &codegen.SectionTemplate{ Name: "request-decoder", @@ -176,21 +145,22 @@ func serverEncodeDecode(genpkg string, svc *expr.GRPCServiceExpr, services *Serv return &codegen.File{Path: fpath, SectionTemplates: sections} } -func transTmplFuncs(s *expr.GRPCServiceExpr, services *ServicesData) map[string]any { +// transTmplFuncs returns the type formatter used by metadata templates for one +// saved service. +func transTmplFuncs(service *ServiceData) map[string]any { return map[string]any{ "goTypeRef": func(dt expr.DataType) string { - return services.ServicesData.Get(s.Name()).Scope.GoTypeRef(&expr.AttributeExpr{Type: dt}) + return service.Scope.GoTypeRef(&expr.AttributeExpr{Type: dt}) }, } } -// typeConversionData produces the template data suitable for executing the -// "type_conversion" template. -func typeConversionData(dt expr.DataType, varName, target string) map[string]any { +// typeStringExpressionData describes one primitive value that generated code +// converts to a metadata string. +func typeStringExpressionData(dt expr.DataType, target string) map[string]any { return map[string]any{ - "Type": dt, - "VarName": varName, - "Target": target, + "Type": dt, + "Target": target, } } diff --git a/grpc/codegen/server_protobuf_method_name_test.go b/grpc/codegen/server_protobuf_method_name_test.go new file mode 100644 index 0000000000..09d0587b65 --- /dev/null +++ b/grpc/codegen/server_protobuf_method_name_test.go @@ -0,0 +1,97 @@ +// This file checks that Goa's gRPC server methods use the names written by +// protoc when two design method names produce the same Go spelling. +package codegen + +import ( + "testing" + + "github.com/stretchr/testify/require" + + "goa.design/goa/v3/codegen" + "goa.design/goa/v3/codegen/service" + "goa.design/goa/v3/dsl" + "goa.design/goa/v3/expr" +) + +// TestServerUsesProtobufMethodNames checks both source orders. Each generated +// server method must match the protobuf method for the same endpoint. +func TestServerUsesProtobufMethodNames(t *testing.T) { + for _, reverse := range []bool{false, true} { + name := "underscored first" + if reverse { + name = "camel case first" + } + t.Run(name, func(t *testing.T) { + root := RunGRPCDSL(t, collidingProtobufMethodDSL(reverse)) + generation, servicePlans := grpcServicePlans(t, []*expr.RootExpr{root}) + plans, err := NewPlans(generation, PlanInput{Root: root, Service: servicePlans[0]}) + require.NoError(t, err) + require.NoError(t, generation.Freeze()) + require.NoError(t, servicePlans[0].Link()) + require.NoError(t, plans[0].Link()) + services := plans[0].services + service := services.Get("Values") + sections := serverFiles(services)[0].Section("server-grpc-interface") + require.Len(t, sections, len(service.Endpoints)) + + foundDifferentName := false + for index, endpoint := range service.Endpoints { + foundDifferentName = foundDifferentName || endpoint.GRPCMethodName != endpoint.Method.VarName + code := codegen.SectionCode(t, sections[index]) + require.Contains(t, code, "func (s *"+endpoint.ServerStruct+") "+endpoint.GRPCMethodName+"(") + } + require.True(t, foundDifferentName, "test methods did not exercise different service and protobuf names") + compileProtobufMethodServer(t, plans[0], servicePlans) + }) + } +} + +// collidingProtobufMethodDSL defines two methods that produce the same initial +// Go name but use different request and response messages. +func collidingProtobufMethodDSL(reverse bool) func() { + return func() { + underscored := func() { + dsl.Method("read_value", func() { + dsl.Payload(func() { dsl.Field(1, "text", dsl.String) }) + dsl.Result(func() { dsl.Field(1, "text", dsl.String) }) + dsl.GRPC(func() {}) + }) + } + camelCase := func() { + dsl.Method("readValue", func() { + dsl.Payload(func() { dsl.Field(1, "number", dsl.Int) }) + dsl.Result(func() { dsl.Field(1, "number", dsl.Int) }) + dsl.GRPC(func() {}) + }) + } + dsl.Service("Values", func() { + if reverse { + camelCase() + underscored() + return + } + underscored() + camelCase() + }) + } +} + +// compileProtobufMethodServer writes the service and transport files and asks +// Go to check that the server implements the generated protobuf interface. +func compileProtobufMethodServer(t *testing.T, plan *Plan, servicePlans []*service.Plan) { + t.Helper() + files, err := service.Files(servicePlans...) + require.NoError(t, err) + files = append(files, plan.ServerFiles()...) + files = append(files, plan.ClientFiles()...) + files = append(files, plan.ServerTypeFiles()...) + files = append(files, plan.ClientTypeFiles()...) + files = append(files, plan.ProtoFiles()...) + moduleDir := t.TempDir() + writeProtobufDescriptorModule(t, moduleDir) + for _, file := range files { + _, err := file.Render(moduleDir) + require.NoError(t, err) + } + compileProtobufDescriptorModule(t, moduleDir) +} diff --git a/grpc/codegen/server_test.go b/grpc/codegen/server_test.go index bd8f52c834..4931d1fbdd 100644 --- a/grpc/codegen/server_test.go +++ b/grpc/codegen/server_test.go @@ -33,7 +33,7 @@ func TestServerGRPCInterface(t *testing.T) { t.Run(c.Name, func(t *testing.T) { root := RunGRPCDSL(t, c.DSL) services := CreateGRPCServices(root) - fs := ServerFiles("", services) + fs := serverFiles(services) require.Len(t, fs, 2) sections := fs[0].Section("server-grpc-interface") require.NotEmpty(t, sections) @@ -61,7 +61,7 @@ func TestServerHandlerInit(t *testing.T) { t.Run(c.Name, func(t *testing.T) { root := RunGRPCDSL(t, c.DSL) services := CreateGRPCServices(root) - fs := ServerFiles("", services) + fs := serverFiles(services) require.Len(t, fs, 2) sections := fs[0].Section("grpc-handler-init") require.NotEmpty(t, sections) @@ -82,18 +82,20 @@ func TestRequestDecoder(t *testing.T) { {"request-decoder-payload-primitive", testdata.ServerStreamingRPCDSL}, {"request-decoder-payload-primitive-with-streaming-payload", testdata.ClientStreamingRPCWithPayloadDSL}, {"request-decoder-payload-user-type-with-streaming-payload", testdata.BidirectionalStreamingRPCWithPayloadDSL}, + {"request-decoder-metadata-only-payload-with-streaming-payload", testdata.ClientStreamingRPCWithMetadataOnlyPayloadDSL}, {"request-decoder-payload-primitive-with-streaming-payload-legacy-compat", testdata.ClientStreamingRPCWithPayloadLegacyCompatDSL}, {"request-decoder-payload-user-type-with-streaming-payload-legacy-compat", testdata.BidirectionalStreamingRPCWithPayloadLegacyCompatDSL}, {"request-decoder-payload-with-metadata-with-streaming-payload-legacy-compat", testdata.BidirectionalStreamingRPCWithMetadataLegacyCompatDSL}, {"request-decoder-payload-with-metadata", testdata.MessageWithMetadataDSL}, {"request-decoder-payload-with-validate", testdata.MessageWithValidateDSL}, {"request-decoder-payload-with-security-attributes", testdata.MessageWithSecurityAttrsDSL}, + {"request-decoder-named-security-metadata", testdata.NamedSecurityMetadataDSL}, } for _, c := range cases { t.Run(c.Name, func(t *testing.T) { root := RunGRPCDSL(t, c.DSL) services := CreateGRPCServices(root) - fs := ServerFiles("", services) + fs := serverFiles(services) require.Len(t, fs, 2) sections := fs[1].Section("request-decoder") require.NotEmpty(t, sections) @@ -121,7 +123,7 @@ func TestResponseEncoder(t *testing.T) { t.Run(c.Name, func(t *testing.T) { root := RunGRPCDSL(t, c.DSL) services := CreateGRPCServices(root) - fs := ServerFiles("", services) + fs := serverFiles(services) require.Len(t, fs, 2) sections := fs[1].Section("response-encoder") require.NotEmpty(t, sections) diff --git a/grpc/codegen/server_types_test.go b/grpc/codegen/server_types_test.go index 5aea024276..9361e1ace2 100644 --- a/grpc/codegen/server_types_test.go +++ b/grpc/codegen/server_types_test.go @@ -22,18 +22,22 @@ func TestServerTypeFiles(t *testing.T) { {"server-payload-with-mixed-attributes", testdata.PayloadWithMixedAttributesDSL}, {"server-payload-with-custom-type-package", testdata.PayloadWithCustomTypePackageDSL}, {"server-result-collection", testdata.ResultWithCollectionDSL}, + {"server-result-field-name-collision", testdata.ResultFieldNameCollisionDSL}, {"server-with-errors", testdata.UnaryRPCWithErrorsDSL}, {"server-elem-validation", testdata.ElemValidationDSL}, {"server-alias-validation", testdata.AliasValidationDSL}, {"server-struct-meta-type", testdata.StructMetaTypeDSL}, {"server-struct-field-name-meta-type", testdata.StructFieldNameMetaTypeDSL}, {"server-default-fields", testdata.DefaultFieldsDSL}, + {"server-result-with-views", testdata.MessageResultTypeWithViewsDSL}, + {"server-result-with-explicit-view", testdata.MessageResultTypeWithExplicitViewDSL}, + {"server-streaming-result-with-views", testdata.ServerStreamingResultWithViewsDSL}, } for _, c := range cases { t.Run(c.Name, func(t *testing.T) { root := RunGRPCDSL(t, c.DSL) services := CreateGRPCServices(root) - fs := ServerTypeFiles("", services) + fs := serverTypeFiles(services) require.Len(t, fs, 1) var buf bytes.Buffer for _, s := range fs[0].SectionTemplates[1:] { diff --git a/grpc/codegen/service_data.go b/grpc/codegen/service_data.go index 1ea46660ec..9613254970 100644 --- a/grpc/codegen/service_data.go +++ b/grpc/codegen/service_data.go @@ -1,10 +1,15 @@ +// This file analyzes gRPC endpoint designs into the immutable data consumed by +// protobuf message, client, server, conversion, and validation templates. package codegen import ( "fmt" + "path" + "reflect" "strings" "goa.design/goa/v3/codegen" + "goa.design/goa/v3/codegen/cli" "goa.design/goa/v3/codegen/service" "goa.design/goa/v3/expr" ) @@ -14,7 +19,18 @@ type ( // indexed by service name. ServicesData struct { *service.ServicesData - GRPCServices map[string]*ServiceData + GRPCServices map[string]*ServiceData + cliPlan *grpcCLIPlan + protobuf map[*expr.GRPCServiceExpr]*protobufServicePlan + tools map[*expr.GRPCServiceExpr]*protobufToolPlan + symbols map[*expr.GRPCServiceExpr]*grpcSymbols + expressions []*expr.GRPCServiceExpr + servicePlans []*grpcServicePlan + serviceByExpr map[*expr.GRPCServiceExpr]*ServiceData + endpointPlans map[*expr.GRPCEndpointExpr]*grpcEndpointPlan + metadataPlans map[*expr.MappedAttributeExpr][]*grpcMetadataPlan + fileImports map[string]*codegen.GeneratedImportPlan + generation *codegen.Generation } // ServiceData contains the data used to render the code related to a @@ -22,8 +38,20 @@ type ( ServiceData struct { // Service contains the related service data. Service *service.Data + // ClientPkgName is the final alias for the generated gRPC client package. + ClientPkgName string + // ServerPkgName is the final alias for the generated gRPC server package. + ServerPkgName string // PkgName is the name of the generated package in *.pb.go. PkgName string + // ClientProtobufPkgName is the protobuf import name in the generated client package. + ClientProtobufPkgName string + // ServerProtobufPkgName is the protobuf import name in the generated server package. + ServerProtobufPkgName string + // ClientServicePkgName is the service import name in the generated client package. + ClientServicePkgName string + // ServerServicePkgName is the service import name in the generated server package. + ServerServicePkgName string // ProtoImports is the list of proto package imports. ProtoImports []string // Name is the service name. @@ -34,14 +62,34 @@ type ( Endpoints []*EndpointData // Messages describes the message data for this service. Messages []*service.UserTypeData - // ServerStruct is the name of the gRPC server struct. + // ServerStruct is the server type name kept for existing plugins. + // Changing it does not rename generated code. + // + // Deprecated: Use ServerStructDeclaration.Name() after planning. ServerStruct string - // ClientStruct is the name of the gRPC client struct, + // ServerStructDeclaration supplies the generated server type name. + ServerStructDeclaration *codegen.NameDeclaration + // ClientStruct is the client type name kept for existing plugins. + // Changing it does not rename generated code. + // + // Deprecated: Use ClientStructDeclaration.Name() after planning. ClientStruct string - // ServerInit is the name of the constructor of the server struct. + // ClientStructDeclaration supplies the generated client type name. + ClientStructDeclaration *codegen.NameDeclaration + // ServerInit is the server constructor name kept for existing plugins. + // Changing it does not rename generated code. + // + // Deprecated: Use ServerInitDeclaration.Name() after planning. ServerInit string - // ClientInit is the name of the constructor of the client struct. + // ServerInitDeclaration supplies the generated server constructor name. + ServerInitDeclaration *codegen.NameDeclaration + // ClientInit is the client constructor name kept for existing plugins. + // Changing it does not rename generated code. + // + // Deprecated: Use ClientInitDeclaration.Name() after planning. ClientInit string + // ClientInitDeclaration supplies the generated client constructor name. + ClientInitDeclaration *codegen.NameDeclaration // ServerInterface is the name of the gRPC server interface implemented // by the service. ServerInterface string @@ -51,12 +99,25 @@ type ( // ClientInterfaceInit is the name of the client constructor function in // the generated pb.go package. ClientInterfaceInit string - // Scope is the name scope for protocol buffers + // UnimplementedServer is the generated server type embedded by Goa's + // server implementation. + UnimplementedServer string + // RegisterFunction is the generated function that registers the server. + RegisterFunction string + // Scope records and returns unique Go names for protobuf fields and types in + // this service package. Scope *codegen.NameScope - // transformHelpers is the list of transform functions required by the - // constructors. - transformHelpers []*codegen.TransformFunctionData + // protobuf contains the messages and validation functions written for this + // service. + protobuf *protobufPackageCatalog + + // clientTransformHelpers contains recursive conversion functions written + // in the generated client package. + clientTransformHelpers []*codegen.TransformFunctionData + // serverTransformHelpers contains recursive conversion functions written + // in the generated server package. + serverTransformHelpers []*codegen.TransformFunctionData // validations contain the data to generate the validation functions to // validate the initialized type. validations []*ValidationData @@ -71,14 +132,40 @@ type ( PkgName string // ServicePkgName is the name of the service package name. ServicePkgName string + // ClientProtobufPkgName is the protobuf import name in the generated client package. + ClientProtobufPkgName string + // ServerProtobufPkgName is the protobuf import name in the generated server package. + ServerProtobufPkgName string + // ClientServicePkgName is the service import name in the generated client package. + ClientServicePkgName string + // ServerServicePkgName is the service import name in the generated server package. + ServerServicePkgName string // Method is the data for the underlying method expression. Method *service.MethodData + // ProtoMethodName is the method name written to the protobuf service. + ProtoMethodName string + // ClientMethodName is the final protobuf client method name kept for + // existing plugins. + // + // Deprecated: Use ProtoMethodName. + ClientMethodName string + // FullMethodName is the protobuf service and method name logged when the + // generated server starts. + FullMethodName string // PayloadType is the type of the payload. PayloadType expr.DataType // PayloadRef is the fully qualified reference to the method payload. PayloadRef string + // ClientPayloadRef is the payload reference in the generated client package. + ClientPayloadRef string + // ServerPayloadRef is the payload reference in the generated server package. + ServerPayloadRef string // ResultRef is the fully qualified reference to the method result. ResultRef string + // ClientResultRef is the result reference in the generated client package. + ClientResultRef string + // ServerResultRef is the result reference in the generated server package. + ServerResultRef string // ViewedResultRef is the fully qualified reference to the viewed result. ViewedResultRef string // Request is the gRPC request data. @@ -96,8 +183,13 @@ type ( // server side - // ServerStruct is the name of the gRPC server struct. + // ServerStruct is the server type name kept for existing plugins. + // Changing it does not rename generated code. + // + // Deprecated: Use ServerStructDeclaration.Name() after planning. ServerStruct string + // ServerStructDeclaration supplies the generated server type name. + ServerStructDeclaration *codegen.NameDeclaration // ServerInterface is the name of the gRPC server interface implemented // by the service. ServerInterface string @@ -106,15 +198,63 @@ type ( // client side - // ClientMethodName is the name of the gRPC method generated by protoc-gen-go. - ClientMethodName string - // ClientStruct is the name of the gRPC client struct, + // GRPCMethodName is the Go method name written by protoc-gen-go-grpc for + // both its client and server interfaces. + GRPCMethodName string + // ClientBuild is the remote call builder name kept for existing plugins. + // Changing it does not rename generated code. + // + // Deprecated: Use ClientBuildDeclaration.Name() after planning. + ClientBuild string + // ClientBuildDeclaration supplies the generated remote call builder name. + ClientBuildDeclaration *codegen.NameDeclaration + // ClientEncode is the request encoder name kept for existing plugins. + // Changing it does not rename generated code. + // + // Deprecated: Use ClientEncodeDeclaration.Name() after planning. + ClientEncode string + // ClientEncodeDeclaration supplies the generated request encoder name. + ClientEncodeDeclaration *codegen.NameDeclaration + // ClientDecode is the response decoder name kept for existing plugins. + // Changing it does not rename generated code. + // + // Deprecated: Use ClientDecodeDeclaration.Name() after planning. + ClientDecode string + // ClientDecodeDeclaration supplies the generated response decoder name. + ClientDecodeDeclaration *codegen.NameDeclaration + // ClientStruct is the client type name kept for existing plugins. + // Changing it does not rename generated code. + // + // Deprecated: Use ClientStructDeclaration.Name() after planning. ClientStruct string + // ClientStructDeclaration supplies the generated client type name. + ClientStructDeclaration *codegen.NameDeclaration // ClientInterface is the name of the gRPC client interface implemented // by the service. ClientInterface string // ClientStream is the client stream data. ClientStream *StreamData + // ServerHandler is the handler constructor name kept for existing plugins. + // Changing it does not rename generated code. + // + // Deprecated: Use ServerHandlerDeclaration.Name() after planning. + ServerHandler string + // ServerHandlerDeclaration supplies the generated handler constructor name. + ServerHandlerDeclaration *codegen.NameDeclaration + // ServerDecode is the request decoder name kept for existing plugins. + // Changing it does not rename generated code. + // + // Deprecated: Use ServerDecodeDeclaration.Name() after planning. + ServerDecode string + // ServerDecodeDeclaration supplies the generated request decoder name. + ServerDecodeDeclaration *codegen.NameDeclaration + // ServerEncode is the response encoder name kept for existing plugins. + // Changing it does not rename generated code. + // + // Deprecated: Use ServerEncodeDeclaration.Name() after planning. + ServerEncode string + // ServerEncodeDeclaration supplies the generated response encoder name. + ServerEncodeDeclaration *codegen.NameDeclaration } // MetadataData describes a gRPC metadata field. @@ -130,9 +270,21 @@ type ( FieldName string // FieldType is the type of the struct field. FieldType expr.DataType + // FieldTypeRef is the field's final Go type name in the generated file. + FieldTypeRef string + // ServiceAttribute is the service field populated from this metadata. + ServiceAttribute *expr.AttributeExpr + // WireAttribute is an independent copy of the native gRPC metadata value. + WireAttribute *expr.AttributeExpr // VarName is the name of the Go variable used to read or // convert the metadata value. VarName string + // WireVarName is the local variable produced before metadata encoding. + WireVarName string + // EncodeCode converts the service field to WireVarName. + EncodeCode string + // DecodeCode converts VarName to the exact service constructor field. + DecodeCode string // TypeName is the name of the type. TypeName string // TypeRef is the reference to the type. @@ -145,16 +297,24 @@ type ( StringSlice bool // Slice is true if the metadata value type is an array. Slice bool - // MapStringSlice is true if the metadata value type is a map of string - // slice. + // MapStringSlice reports whether the metadata value is a map from strings + // to string arrays. Valid current designs always set it to false. + // + // Deprecated: gRPC metadata accepts only primitive values and arrays. MapStringSlice bool - // Map is true if the metadata value type is a map. + // Map reports whether the metadata value is a map. Valid current designs + // always set it to false. + // + // Deprecated: gRPC metadata accepts only primitive values and arrays. Map bool // Type describes the datatype of the variable value. Mainly // used for conversion. Type expr.DataType // Validate contains the validation code if any. Validate string + // CLIPlan describes how command-line text becomes this metadata value and + // how the generated payload builder validates it. + CLIPlan *cli.FlagPlan // DefaultValue contains the default value if any. DefaultValue any // Example is an example value. @@ -176,15 +336,23 @@ type ( // RequestData describes a gRPC request. RequestData struct { + // ProtoMessageName is the message name written in the .proto method. + ProtoMessageName string // Description is the request description. Description string // Message is the gRPC request message used by the transport. For // streaming payload methods with an initial payload frame, this is the // synthesized stream envelope. Message *service.UserTypeData + // ClientMessageRef is the request message reference in the generated client package. + ClientMessageRef string + // ServerMessageRef is the request message reference in the generated server package. + ServerMessageRef string // PayloadMessage is the gRPC message that carries the one-shot method // payload fields before any stream envelope wrapping. PayloadMessage *service.UserTypeData + // ServerPayloadMessageRef is the one-shot payload message reference in the server package. + ServerPayloadMessageRef string // StreamEnvelope describes the synthesized stream envelope when the // transport must carry both the one-shot payload and streaming payload // items through the same streamed protobuf message. @@ -208,6 +376,9 @@ type ( // CLIArgs is the list of arguments for the command-line client. // This is set only for the client side. CLIArgs []*InitArgData + // CLIInitCode builds the service payload from command-line values in the + // generated client package. + CLIInitCode string } // StreamEnvelopeData describes a synthesized streamed protobuf envelope. @@ -219,20 +390,33 @@ type ( // InitialWrapperRef is the fully qualified protobuf wrapper type for the // initial payload branch. InitialWrapperRef string + // ClientInitialWrapperRef is the initial payload wrapper in the client package. + ClientInitialWrapperRef string + // ServerInitialWrapperRef is the initial payload wrapper in the server package. + ServerInitialWrapperRef string // StreamItemFieldName is the name of the streaming payload item branch // field. StreamItemFieldName string // StreamItemWrapperRef is the fully qualified protobuf wrapper type for // the streaming payload item branch. StreamItemWrapperRef string + // ClientStreamItemWrapperRef is the stream item wrapper in the client package. + ClientStreamItemWrapperRef string + // ServerStreamItemWrapperRef is the stream item wrapper in the server package. + ServerStreamItemWrapperRef string } // LegacyDecodeData describes how generated servers decode the one-shot // method payload that legacy stream protocol clients send in gRPC // request metadata. LegacyDecodeData struct { - // FuncName is the name of the generated legacy request decoder. + // FuncName is the legacy decoder name kept for existing plugins. + // Changing it does not rename generated code. + // + // Deprecated: Use FuncDeclaration.Name() after planning. FuncName string + // FuncDeclaration supplies the generated legacy decoder name. + FuncDeclaration *codegen.NameDeclaration // Metadata lists the request metadata carrying the method payload // along with any explicitly mapped and security metadata. Metadata []*MetadataData @@ -245,12 +429,18 @@ type ( // ResponseData describes a gRPC success or error response. ResponseData struct { + // ProtoMessageName is the message name written in the .proto method. + ProtoMessageName string // StatusCode is the return code of the response. StatusCode string // Description is the response description. Description string // Message is the gRPC response message. Message *service.UserTypeData + // ClientMessageRef is the response message reference in the generated client package. + ClientMessageRef string + // ServerMessageRef is the response message reference in the generated server package. + ServerMessageRef string // Headers is the response header metadata. Headers []*MetadataData // Trailers is the response trailer metadata. @@ -259,10 +449,16 @@ type ( // initialize the generated response type in *.pb.go from the // method result type or the projected result type. ServerConvert *ConvertData + // ServerConverts lists the server conversion for each result view. It + // is empty for results without views. + ServerConverts []*ViewConvertData // ClientConvert is the type data with constructor function to // initialize the method result type or the projected result type // from the generated response type in *.pb.go. ClientConvert *ConvertData + // ClientConverts lists the client conversion for each result view. It + // is empty for results without views. + ClientConverts []*ViewConvertData } // ConvertData contains the data to convert source type to a target type. @@ -271,9 +467,11 @@ type ( // For response type, it contains data to transform gRPC response type to the // corresponding result type (client) and vice versa (server). ConvertData struct { - // SrcName is the fully qualified name of the source type. + // SrcName is the fully qualified name of the source type. It is empty + // when a streaming method builds its payload entirely from metadata. SrcName string - // SrcRef is the fully qualified reference to the source type. + // SrcRef is the fully qualified reference to the source type. It is empty + // when a streaming method builds its payload entirely from metadata. SrcRef string // TgtName is the fully qualified name of the target type. TgtName string @@ -289,10 +487,21 @@ type ( Validation *ValidationData } - // ValidationData contains the data necessary to render the validation - // function. + // ViewConvertData identifies the conversion generated for one result view. + ViewConvertData struct { + // View is the result view handled by Convert. + View string + // Convert builds the protobuf value using only fields in View. + Convert *ConvertData + } + + // ValidationData contains one generated validation function. ValidationData struct { - // Name is the validation function name. + // Declaration is the function name used by its definition and callers. + Declaration *codegen.NameDeclaration + // Name is the final validation function name kept for existing plugins. + // + // Deprecated: Use Declaration.Name() after planning. Name string // Def is the validation function definition. Def string @@ -310,6 +519,9 @@ type ( // InitData contains the data required to render a constructor. InitData struct { + // Declaration is the constructor declaration stored in the generated + // package and used by every call. + Declaration *codegen.NameDeclaration // Name is the constructor function name. Name string // Description is the function description. @@ -343,6 +555,10 @@ type ( // FieldType is the type of the data structure field that should be // initialized with the argument if any. FieldType expr.DataType + // FieldTypeRef is the field's final Go type name in the generated file. + FieldTypeRef string + // InitCode converts and assigns this argument to the constructor result. + InitCode string // TypeName is the argument type name. TypeName string // TypeRef is the argument type reference. @@ -358,6 +574,9 @@ type ( // Validate contains the validation code for the argument // value if any. Validate string + // CLIPlan describes how command-line text becomes this argument value and + // how the generated payload builder validates it. + CLIPlan *cli.FlagPlan // Example is a example value Example any } @@ -365,8 +584,13 @@ type ( // StreamData contains data to render the stream struct type that implements // the service stream interface. StreamData struct { - // VarName is the name of the struct type. + // VarName is the stream type name kept for existing plugins. Changing it + // does not rename generated code. + // + // Deprecated: Use Declaration.Name() after planning. VarName string + // Declaration supplies the generated stream type name. + Declaration *codegen.NameDeclaration // Type is the stream type (client or server). Type string // ServiceInterface is the service interface that the struct implements. @@ -390,10 +614,16 @@ type ( // constructor to convert the service send type to the type expected by // the gRPC send type (in *.pb.go) SendConvert *ConvertData + // SendConverts lists the server send conversion for each result view. + // It is empty for client streams and results without views. + SendConverts []*ViewConvertData // RecvConvert is the type received through the stream. It contains the // constructor to convert the gRPC type (in *.pb.go) to the service receive // type. RecvConvert *ConvertData + // RecvConverts lists the client receive conversion for each result view. + // It is empty for server streams and results without views. + RecvConverts []*ViewConvertData // RecvName is the name of the receive function. RecvName string // RecvDesc is the description for the recv function. @@ -415,45 +645,103 @@ type ( validateKind int ) -// NewServicesData creates a new ServicesData instance for the given service data. -func NewServicesData(services *service.ServicesData) *ServicesData { - return &ServicesData{ - ServicesData: services, - GRPCServices: make(map[string]*ServiceData), - } -} - const ( // pbPkgName is the directory name where the .proto file is generated and // compiled. pbPkgName = "pb" -) - -const ( // validateServer generates the validation code for request messages in the // server package. validateServer validateKind = iota + 1 - // validateClient generates the validation code for response messages in the - // client package. + // validateClient generates response and command-line request validation in + // the client package. validateClient - // validateBoth generates the validation code in both server and client - // packages. - validateBoth ) -// Get retrieves the transport data for the service with the given name -// computing it if needed. It returns nil if there is no service with the given -// name. +// newServicesData builds the values passed to gRPC client and server templates +// from the saved service data and gRPC plan. +func newServicesData(services *service.ServicesData, plan *Plan) *ServicesData { + if services.Root != plan.root { + panic(fmt.Sprintf("gRPC service data does not belong to design %q", plan.root.API.Name)) + } + data := &ServicesData{ + ServicesData: services, + GRPCServices: make(map[string]*ServiceData), + cliPlan: plan.cli, + protobuf: plan.protobuf, + tools: plan.tools, + symbols: plan.symbols, + servicePlans: append([]*grpcServicePlan(nil), plan.servicesPlan...), + serviceByExpr: make(map[*expr.GRPCServiceExpr]*ServiceData, len(plan.servicesPlan)), + endpointPlans: make(map[*expr.GRPCEndpointExpr]*grpcEndpointPlan), + metadataPlans: make(map[*expr.MappedAttributeExpr][]*grpcMetadataPlan), + fileImports: plan.fileImports, + generation: plan.generation, + } + data.expressions = make([]*expr.GRPCServiceExpr, len(data.servicePlans)) + for index, servicePlan := range data.servicePlans { + data.expressions[index] = servicePlan.expression + for _, endpointPlan := range servicePlan.endpoints { + data.endpointPlans[endpointPlan.expression] = endpointPlan + for mapped, metadata := range endpointPlan.metadata { + data.metadataPlans[mapped] = metadata + } + } + serviceData := data.analyze(servicePlan) + data.GRPCServices[servicePlan.expression.Name()] = serviceData + data.serviceByExpr[servicePlan.source] = serviceData + } + return data +} + +// Get retrieves the transport data saved for the service with the given name. +// It returns nil if there is no service with the given name. func (d *ServicesData) Get(name string) *ServiceData { - if data, ok := d.GRPCServices[name]; ok { - return data + return d.GRPCServices[name] +} + +// exampleServiceData copies the package qualifiers used by one executable. +// Server examples import the service, protobuf, and gRPC server packages; +// command-line examples import the service package only when they receive a +// result stream. +func (d *ServicesData) exampleServiceData(source *ServiceData, outputPackage string, server bool) *ServiceData { + data := *source + service := *source.Service + data.Service = &service + data.Endpoints = make([]*EndpointData, len(source.Endpoints)) + for index, endpoint := range source.Endpoints { + copy := *endpoint + data.Endpoints[index] = © + } + if server { + if len(source.Endpoints) > 0 { + service.PkgName = d.ServiceImport(outputPackage, service.Name).Name + } + protobufPath := path.Join(d.GenPkg(), "grpc", service.PathName, pbPkgName) + data.PkgName = d.PackageImport(outputPackage, protobufPath).Name + data.ServerPkgName = d.PackageImport(outputPackage, path.Join(d.GenPkg(), "grpc", service.PathName, "server")).Name + for _, endpoint := range data.Endpoints { + endpoint.PkgName = data.PkgName + endpoint.ServicePkgName = service.PkgName + } + return &data } - service := d.Root.API.GRPC.Service(name) - if service == nil { - return nil + if grpcServiceStreamsResult(d.servicePlan(service.Name).expression) { + service.PkgName = d.ServiceImport(outputPackage, service.Name).Name + for _, endpoint := range data.Endpoints { + endpoint.ServicePkgName = service.PkgName + } } - d.GRPCServices[name] = d.analyze(service) - return d.GRPCServices[name] + return &data +} + +// servicePlan returns the copied gRPC plan for service name. +func (d *ServicesData) servicePlan(name string) *grpcServicePlan { + for _, plan := range d.servicePlans { + if plan.expression.Name() == name { + return plan + } + } + panic(fmt.Sprintf("gRPC service plan %q is missing", name)) } // Endpoint returns the endpoint data for the endpoint with the given name, nil @@ -489,170 +777,195 @@ func (sd *ServiceData) HasStreamingEndpoint() bool { } // analyze creates the data necessary to render the code of the given service. -func (d *ServicesData) analyze(gs *expr.GRPCServiceExpr) *ServiceData { +func (d *ServicesData) analyze(servicePlan *grpcServicePlan) *ServiceData { + gs := servicePlan.expression svc := d.ServicesData.Get(gs.Name()) - scope := codegen.NewNameScope() - pkg := codegen.SnakeCase(codegen.Goify(svc.Name, false)) + pbPkgName - svcVarN := scope.HashedUnique(gs.ServiceExpr, codegen.Goify(svc.Name, true)) + transportService := *svc + transportService.ProtoImports = append([]*codegen.ImportSpec(nil), svc.ProtoImports...) + transportService.ProtoImports = append(transportService.ProtoImports, servicePlan.protoGoImports...) + clientPackage := path.Join(d.GenPkg(), "grpc", svc.PathName, "client") + serverPackage := path.Join(d.GenPkg(), "grpc", svc.PathName, "server") + clientServicePackage := "" + if grpcAttributesHaveValues(grpcEndpointAttributes(gs.GRPCEndpoints...)) || grpcServiceHasStreaming(gs) { + clientServicePackage = d.ServiceImport(clientPackage, svc.Name).Name + } + serverServicePackage := d.ServiceImport(serverPackage, svc.Name).Name + transportService.PkgName = clientServicePackage + svc = &transportService + protobufPath := path.Join(d.GenPkg(), "grpc", svc.PathName, pbPkgName) + clientProtobufPackage := d.PackageImport(clientPackage, protobufPath).Name + serverProtobufPackage := d.PackageImport(serverPackage, protobufPath).Name + planned := d.protobuf[gs] + if planned == nil { + panic(fmt.Sprintf("protobuf plan is missing for gRPC service %q", gs.Name())) + } + serviceDescriptor := planned.serviceFullName() + symbols := d.symbols[gs] + if symbols == nil { + panic(fmt.Sprintf("Go names are missing for gRPC service %q", gs.Name())) + } sd := &ServiceData{ - Service: svc, - Name: svcVarN, - Description: svc.Description, - PkgName: pkg, - ServerStruct: "Server", - ClientStruct: "Client", - ServerInit: "New", - ClientInit: "NewClient", - ServerInterface: svcVarN + "Server", - ClientInterface: svcVarN + "Client", - ClientInterfaceInit: fmt.Sprintf("%s.New%sClient", pkg, svcVarN), - Scope: scope, - } - seen, imported := make(map[string]struct{}), make(map[string]struct{}) - for _, e := range gs.GRPCEndpoints { - hasRequestMessage := !isEmpty(e.Request.Type) - useStreamEnvelope := usesStreamEnvelope(e) - - // Derive protocol buffer shaped copies of the request and response - // attributes. The design expressions are inputs to the analysis and - // must not be mutated: the shaped attributes are kept in locals and - // threaded explicitly to the data builders below. - requestMessage := makeProtoBufMessage(e.Request, protoBufify(e.Name()+"_request", true, true), sd) - streamingRequest := e.StreamingRequest - if e.MethodExpr.StreamingPayload.Type != expr.Empty { - streamMessageName := protoBufify(e.Name()+"_streaming_request", true, true) - if useStreamEnvelope { - streamMessageName = protoBufify(e.Name()+"_stream_item", true, true) - } - streamingRequest = makeProtoBufMessage(e.StreamingRequest, streamMessageName, sd) + Service: svc, + Name: planned.serviceName, + Description: svc.Description, + PkgName: clientProtobufPackage, + ClientProtobufPkgName: clientProtobufPackage, + ServerProtobufPkgName: serverProtobufPackage, + ClientServicePkgName: clientServicePackage, + ServerServicePkgName: serverServicePackage, + ProtoImports: append([]string(nil), servicePlan.protoImports...), + ServerStruct: symbols.serverStruct.Name(), + ServerStructDeclaration: symbols.serverStruct, + ClientStruct: symbols.clientStruct.Name(), + ClientStructDeclaration: symbols.clientStruct, + ServerInit: symbols.serverInit.Name(), + ServerInitDeclaration: symbols.serverInit, + ClientInit: symbols.clientInit.Name(), + ClientInitDeclaration: symbols.clientInit, + ServerInterface: planned.name(serviceDescriptor, protocServiceServerName), + ClientInterface: planned.name(serviceDescriptor, protocServiceClientName), + ClientInterfaceInit: clientProtobufPackage + "." + planned.name(serviceDescriptor, protocServiceClientConstructorName), + UnimplementedServer: planned.name(serviceDescriptor, protocServiceUnimplementedServerName), + RegisterFunction: planned.name(serviceDescriptor, protocServiceRegisterName), + Scope: servicePlan.scope, + protobuf: planned.catalog, + } + sd.protobuf.packageName = clientProtobufPackage + finishProtobufPackage(sd) + protobufMessages := planned.messages + for index, e := range gs.GRPCEndpoints { + endpointPlan := servicePlan.endpointByExpr[e] + if endpointPlan == nil { + panic(fmt.Sprintf("saved gRPC endpoint data is missing for %q", e.Name())) } - var requestEnvelope *expr.AttributeExpr - if useStreamEnvelope { - requestEnvelope = makeProtoBufStreamEnvelope( - requestMessage, - streamingRequest, - protoBufify(e.Name()+"_streaming_request", true, true), - sd, - ) + endpointSymbols := symbols.endpoints[e] + if endpointSymbols == nil { + panic(fmt.Sprintf("Go names are missing for gRPC endpoint %q", e.Name())) } - responseMessage := makeProtoBufMessage(e.Response.Message, protoBufify(e.Name()+"_response", true, true), sd) - errorMessages := make(map[string]*expr.AttributeExpr, len(e.GRPCErrors)) - for _, er := range e.GRPCErrors { - if er.Type == expr.ErrorResult || !expr.IsObject(er.Type) { - continue - } - errorMessages[er.Name] = makeProtoBufMessage(er.Response.Message, protoBufify(e.Name()+"_"+er.Name+"_error", true, true), sd) - } - - // collect all the nested messages and return the top-level message - // Also collect all proto imports specified via Meta. - collect := func(att *expr.AttributeExpr) *service.UserTypeData { - msgs, imports := collectMessages(att, sd, seen) - if len(imports) > 0 { - for _, imp := range imports { - if _, ok := imported[imp]; ok { - continue - } - imported[imp] = struct{}{} - sd.ProtoImports = append(sd.ProtoImports, imp) - } - } - if len(msgs) > 0 { - sd.Messages = append(sd.Messages, msgs...) - return msgs[0] - } - // lookup message in sd.Messages - if ut, ok := att.Type.(expr.UserType); ok { - name := ut.Name() - if n := att.Meta["struct:name:proto"]; n != nil { - name = n[0] - } - for _, t := range sd.Messages { - if t.Name == name { - return t - } - } + hasRequestMessage := !isEmpty(e.Request.Type) + messages := protobufMessages[index] + requestMessage := messages.request + streamingRequest := messages.streamingRequest + requestEnvelope := messages.requestEnvelope + responseMessage := messages.response + errorMessages := messages.errors + collect := func(attribute *expr.AttributeExpr) *protobufMessageRecord { + record := sd.protobuf.message(attribute) + if record == nil || record.data == nil { + panic(fmt.Sprintf("no protobuf message collected for attribute of type %q", attribute.Type.Name())) // bug } - panic(fmt.Sprintf("no protobuf message collected for attribute of type %q", att.Type.Name())) // bug + return record } var ( - payloadRef string - resultRef string - viewedResultRef string + clientPayloadRef string + serverPayloadRef string + clientResultRef string + serverResultRef string + viewedResultRef string ) md := svc.Method(e.Name()) if e.MethodExpr.Payload.Type != expr.Empty { - payloadRef = svc.Scope.GoFullTypeRef(e.MethodExpr.Payload, - md.PayloadLoc.PackageNameOrDefault(svc.PkgName)) + clientContext := d.serviceTypeContext(sd, "client").Enter(e.MethodExpr.Payload) + serverContext := d.serviceTypeContext(sd, "server").Enter(e.MethodExpr.Payload) + clientPayloadRef = clientContext.Scope.Ref(e.MethodExpr.Payload, clientContext.Pkg(e.MethodExpr.Payload)) + serverPayloadRef = serverContext.Scope.Ref(e.MethodExpr.Payload, serverContext.Pkg(e.MethodExpr.Payload)) } if e.MethodExpr.Result.Type != expr.Empty { - resultRef = svc.Scope.GoFullTypeRef(e.MethodExpr.Result, - md.ResultLoc.PackageNameOrDefault(svc.PkgName)) + clientContext := d.serviceTypeContext(sd, "client").Enter(e.MethodExpr.Result) + serverContext := d.serviceTypeContext(sd, "server").Enter(e.MethodExpr.Result) + clientResultRef = clientContext.Scope.Ref(e.MethodExpr.Result, clientContext.Pkg(e.MethodExpr.Result)) + serverResultRef = serverContext.Scope.Ref(e.MethodExpr.Result, serverContext.Pkg(e.MethodExpr.Result)) } if md.ViewedResult != nil { viewedResultRef = md.ViewedResult.FullRef } errors := d.buildErrorsData(e, errorMessages, sd) - for _, er := range e.GRPCErrors { - if er.Type == expr.ErrorResult || !expr.IsObject(er.Type) { - continue - } - collect(errorMessages[er.Name]) - } - // build request data - reqMD := extractMetadata(e.Metadata, e.MethodExpr.Payload, svc.Scope, *d) + payloadIdentity := expr.MethodPayloadExampleIdentity(e.MethodExpr) + resultIdentity := expr.MethodResultExampleIdentity(e.MethodExpr) + reqMD := d.extractMetadata(e.Metadata, e.MethodExpr.Payload, sd, "server", "v", payloadIdentity) request := &RequestData{ Description: requestMessage.Description, Metadata: reqMD, ServerConvert: d.buildRequestConvertData(requestMessage, e.MethodExpr.Payload, reqMD, e, sd, true), ClientConvert: d.buildRequestConvertData(requestMessage, e.MethodExpr.Payload, reqMD, e, sd, false), } + if e.MethodExpr.Payload.Type != expr.Empty { + request.CLIInitCode = d.buildCLIRequestTransform(e, requestMessage, sd) + } if hasRequestMessage { - request.PayloadMessage = collect(requestMessage) + request.PayloadMessage = collect(requestMessage).data + request.ServerPayloadMessageRef = protoBufGoFullTypeRef(requestMessage, sd.ServerProtobufPkgName, sd) } - if obj := expr.AsObject(requestMessage.Type); (obj != nil && len(*obj) > 0) || expr.IsUnion(requestMessage.Type) { + if protobufCLIRequestNeedsMessage(requestMessage) { // add the request message as the first argument to the CLI + typeName := protoBufGoFullTypeName(requestMessage, sd.PkgName, sd) request.CLIArgs = append(request.CLIArgs, &InitArgData{ Name: "message", Ref: "message", - TypeName: protoBufGoFullTypeName(requestMessage, sd.PkgName, sd.Scope), - TypeRef: protoBufGoFullTypeRef(requestMessage, sd.PkgName, sd.Scope), - Example: requestMessage.Example(d.Root.API.ExampleGenerator), + TypeName: typeName, + TypeRef: protoBufGoFullTypeRef(requestMessage, sd.PkgName, sd), + CLIPlan: cli.NewProtobufFlagPlan(requestMessage, typeName), + Example: protobufCLIExample(requestMessage, d.Example(requestMessage, payloadIdentity), sd.protobuf.plan), }) } // pass the metadata as arguments to client CLI args - request.CLIArgs = append(request.CLIArgs, initArgsFromMetadata(reqMD)...) + request.CLIArgs = append(request.CLIArgs, argsFromMetadata(reqMD)...) + transportRequest := requestMessage switch { case requestEnvelope != nil: - request.Message = collect(requestEnvelope) - request.StreamEnvelope = buildStreamEnvelopeData(requestEnvelope, request.Message, sd) - if e.LegacyStreamCompat() { + transportRequest = requestEnvelope + record := collect(requestEnvelope) + request.Message = record.data + request.ProtoMessageName = record.protoName + request.StreamEnvelope = buildStreamEnvelopeData(requestEnvelope, sd) + if endpointPlan.legacyStream { request.LegacyDecode = d.buildLegacyDecodeData(e, sd) } case streamingRequest.Type != expr.Empty: - request.Message = collect(streamingRequest) + transportRequest = streamingRequest + record := collect(streamingRequest) + request.Message = record.data + request.ProtoMessageName = record.protoName default: - request.Message = collect(requestMessage) + record := collect(requestMessage) + request.Message = record.data + request.ProtoMessageName = record.protoName } + request.ClientMessageRef = protoBufGoFullTypeRef(transportRequest, sd.ClientProtobufPkgName, sd) + request.ServerMessageRef = protoBufGoFullTypeRef(transportRequest, sd.ServerProtobufPkgName, sd) // build response data - result, svcCtx := resultContext(e, sd) - hdrs := extractMetadata(e.Response.Headers, result, svc.Scope, *d) - trlrs := extractMetadata(e.Response.Trailers, result, svc.Scope, *d) + serverResult, serverCtx := d.resultContext(e, sd, "server") + clientResult, clientCtx := d.resultContext(e, sd, "client") + hdrs := d.extractMetadata(e.Response.Headers, clientResult, sd, "client", "result", resultIdentity) + trlrs := d.extractMetadata(e.Response.Trailers, clientResult, sd, "client", "result", resultIdentity) + serverConverts := d.buildServerResponseConverts(responseMessage, serverResult, serverCtx, e, sd) + clientConverts := d.buildClientResponseConverts(responseMessage, messages.responseValidations, clientResult, clientCtx, hdrs, trlrs, e, sd) + var viewedServerConverts, viewedClientConverts []*ViewConvertData + if _, viewed := e.MethodExpr.Result.Type.(*expr.ResultTypeExpr); viewed { + viewedServerConverts = serverConverts + viewedClientConverts = clientConverts + } response := &ResponseData{ - StatusCode: statusCodeToGRPCConst(e.Response.StatusCode), - Description: e.Response.Description, - Headers: hdrs, - Trailers: trlrs, - ServerConvert: d.buildResponseConvertData(responseMessage, result, svcCtx, hdrs, trlrs, e, sd, true), - ClientConvert: d.buildResponseConvertData(responseMessage, result, svcCtx, hdrs, trlrs, e, sd, false), + StatusCode: statusCodeToGRPCConst(e.Response.StatusCode), + Description: e.Response.Description, + Headers: hdrs, + Trailers: trlrs, + ServerConvert: primaryViewConvert(serverConverts), + ServerConverts: viewedServerConverts, + ClientConvert: primaryViewConvert(clientConverts), + ClientConverts: viewedClientConverts, } // If the endpoint is a streaming endpoint, no message is returned // by gRPC. Hence, no need to set response message. if responseMessage.Type != expr.Empty || !e.MethodExpr.IsStreaming() { - response.Message = collect(responseMessage) + record := collect(responseMessage) + response.Message = record.data + response.ProtoMessageName = record.protoName + response.ClientMessageRef = protoBufGoFullTypeRef(responseMessage, sd.ClientProtobufPkgName, sd) + response.ServerMessageRef = protoBufGoFullTypeRef(responseMessage, sd.ServerProtobufPkgName, sd) } // gather security requirements @@ -673,251 +986,253 @@ func (d *ServicesData) analyze(gs *expr.GRPCServiceExpr) *ServiceData { } } ed := &EndpointData{ - ServiceName: svc.Name, - PkgName: sd.PkgName, - ServicePkgName: svc.PkgName, - Method: md, - PayloadType: e.MethodExpr.Payload.Type, - PayloadRef: payloadRef, - ResultRef: resultRef, - ViewedResultRef: viewedResultRef, - Request: request, - Response: response, - MessageSchemes: msgSch, - MetadataSchemes: metSch, - Errors: errors, - ServerStruct: sd.ServerStruct, - ServerInterface: sd.ServerInterface, - ClientMethodName: protoBufify(md.VarName, true, true), - ClientStruct: sd.ClientStruct, - ClientInterface: sd.ClientInterface, + ServiceName: svc.Name, + PkgName: sd.PkgName, + ServicePkgName: svc.PkgName, + ClientProtobufPkgName: sd.ClientProtobufPkgName, + ServerProtobufPkgName: sd.ServerProtobufPkgName, + ClientServicePkgName: sd.ClientServicePkgName, + ServerServicePkgName: sd.ServerServicePkgName, + Method: md, + ProtoMethodName: planned.methods[e], + ClientMethodName: planned.methods[e], + FullMethodName: planned.serviceFullName() + "/" + planned.methods[e], + PayloadType: e.MethodExpr.Payload.Type, + PayloadRef: clientPayloadRef, + ClientPayloadRef: clientPayloadRef, + ServerPayloadRef: serverPayloadRef, + ResultRef: clientResultRef, + ClientResultRef: clientResultRef, + ServerResultRef: serverResultRef, + ViewedResultRef: viewedResultRef, + Request: request, + Response: response, + MessageSchemes: msgSch, + MetadataSchemes: metSch, + Errors: errors, + ServerStruct: sd.ServerStruct, + ServerStructDeclaration: sd.ServerStructDeclaration, + ServerInterface: sd.ServerInterface, + GRPCMethodName: planned.name(serviceDescriptor+"."+planned.methods[e], protocMethodName), + ClientStruct: sd.ClientStruct, + ClientStructDeclaration: sd.ClientStructDeclaration, + ClientInterface: sd.ClientInterface, } + ed.ClientBuild = endpointSymbols.clientBuild.Name() + ed.ClientBuildDeclaration = endpointSymbols.clientBuild + if endpointSymbols.clientEncode != nil { + ed.ClientEncode = endpointSymbols.clientEncode.Name() + ed.ClientEncodeDeclaration = endpointSymbols.clientEncode + } + if endpointSymbols.clientDecode != nil { + ed.ClientDecode = endpointSymbols.clientDecode.Name() + ed.ClientDecodeDeclaration = endpointSymbols.clientDecode + } + ed.ServerHandler = endpointSymbols.serverHandler.Name() + ed.ServerHandlerDeclaration = endpointSymbols.serverHandler + if endpointSymbols.serverDecode != nil { + ed.ServerDecode = endpointSymbols.serverDecode.Name() + ed.ServerDecodeDeclaration = endpointSymbols.serverDecode + } + ed.ServerEncode = endpointSymbols.serverEncode.Name() + ed.ServerEncodeDeclaration = endpointSymbols.serverEncode sd.Endpoints = append(sd.Endpoints, ed) if e.MethodExpr.IsStreaming() { - ed.ServerStream = d.buildStreamData(e, streamingRequest, responseMessage, sd, true) - ed.ClientStream = d.buildStreamData(e, streamingRequest, responseMessage, sd, false) + ed.ServerStream = d.buildStreamData(e, streamingRequest, responseMessage, messages.responseValidations, sd, true) + ed.ServerStream.VarName = endpointSymbols.serverStream.Name() + ed.ServerStream.Declaration = endpointSymbols.serverStream + ed.ClientStream = d.buildStreamData(e, streamingRequest, responseMessage, messages.responseValidations, sd, false) + ed.ClientStream.VarName = endpointSymbols.clientStream.Name() + ed.ClientStream.Declaration = endpointSymbols.clientStream } } return sd } -// collectMessages recurses through the attribute to gather all the messages. -func collectMessages(at *expr.AttributeExpr, sd *ServiceData, seen map[string]struct{}) (data []*service.UserTypeData, imports []string) { - if at == nil { - return data, imports - } - if proto := at.Meta["struct:field:proto"]; len(proto) > 1 { - imp := proto[1] - found := false - for _, i := range sd.Service.ProtoImports { - if i.Path == imp { - found = true - break +// collectProtobufPackage copies each method message and records every message +// and oneof before generated Go names are fixed. +func collectProtobufPackage(serviceExpr *expr.GRPCServiceExpr, catalog *protobufPackageCatalog) ([]*protobufEndpointMessages, error) { + prepared := make([]*protobufEndpointMessages, len(serviceExpr.GRPCEndpoints)) + for index, endpoint := range serviceExpr.GRPCEndpoints { + useStreamEnvelope := usesStreamEnvelope(endpoint) + request := makeProtoBufMessage( + endpoint.Request, + codegen.ProtobufName(endpoint.Name()+"_request"), + expr.GRPCRequestMessageExampleIdentity(endpoint.MethodExpr), + ) + streamingRequest := endpoint.StreamingRequest + if endpoint.MethodExpr.StreamingPayload.Type != expr.Empty { + name := codegen.ProtobufName(endpoint.Name() + "_streaming_request") + if useStreamEnvelope { + name = codegen.ProtobufName(endpoint.Name() + "_stream_item") } + streamingRequest = makeProtoBufMessage( + endpoint.StreamingRequest, + name, + expr.GRPCStreamingRequestMessageExampleIdentity(endpoint.MethodExpr), + ) } - if !found { - imports = append(imports, imp) - if len(proto) > 3 { - elems := strings.Split(proto[3], "/") - sd.Service.ProtoImports = append(sd.Service.ProtoImports, &codegen.ImportSpec{Path: proto[3], Name: elems[len(elems)-1]}) + var requestEnvelope *expr.AttributeExpr + if useStreamEnvelope { + requestEnvelope = makeProtoBufStreamEnvelope( + request, + streamingRequest, + codegen.ProtobufName(endpoint.Name()+"_streaming_request"), + expr.GRPCStreamingRequestMessageExampleIdentity(endpoint.MethodExpr), + ) + } + responseOwner := expr.GRPCResponseMessageExampleIdentity(endpoint.MethodExpr) + if endpoint.MethodExpr.IsResultStreaming() { + responseOwner = expr.GRPCStreamingResponseMessageExampleIdentity(endpoint.MethodExpr) + } + response := makeProtoBufMessage( + endpoint.Response.Message, + codegen.ProtobufName(endpoint.Name()+"_response"), + responseOwner, + ) + errors := make(map[string]*expr.AttributeExpr, len(endpoint.GRPCErrors)) + for _, grpcError := range endpoint.GRPCErrors { + if expr.IsErrorResult(grpcError.Type) || !expr.IsObject(grpcError.Type) { + continue } + errors[grpcError.Name] = makeProtoBufMessage( + grpcError.Response.Message, + codegen.ProtobufName(endpoint.Name()+"_"+grpcError.Name+"_error"), + expr.GRPCErrorMessageExampleIdentity(endpoint.MethodExpr, grpcError.ErrorExpr), + ) + } + prepared[index] = &protobufEndpointMessages{ + request: request, + streamingRequest: streamingRequest, + requestEnvelope: requestEnvelope, + response: response, + errors: errors, } } - if expr.IsPrimitive(at.Type) { - // Add google.protobuf.Value import when Any type is used - if at.Type.Kind() == expr.AnyKind { - found := false - for _, imp := range imports { - if imp == "google/protobuf/struct.proto" { - found = true - break - } + + collect := catalog.collectMessage + for index, endpoint := range serviceExpr.GRPCEndpoints { + messages := prepared[index] + requestSource := protobufRootMessageSource(endpoint.Request, endpoint, nil, protobufRequestMessage) + streamingSource := protobufRootMessageSource(endpoint.StreamingRequest, endpoint, nil, protobufStreamingRequestMessage) + responseSource := protobufRootMessageSource(endpoint.Response.Message, endpoint, nil, protobufResponseMessage) + catalog.bindRootSource(messages.request, requestSource) + if messages.streamingRequest.Type != expr.Empty { + catalog.bindRootSource(messages.streamingRequest, streamingSource) + } + catalog.bindRootSource(messages.response, responseSource) + for _, grpcError := range endpoint.GRPCErrors { + message := messages.errors[grpcError.Name] + if message == nil { + continue } - if !found { - imports = append(imports, "google/protobuf/struct.proto") + errorSource := protobufRootMessageSource( + grpcError.Response.Message, + endpoint, + grpcError, + protobufErrorMessage, + ) + catalog.bindRootSource(message, errorSource) + if err := collect(message, errorSource); err != nil { + return nil, err + } + } + requestNeeded := !isEmpty(endpoint.Request.Type) || + (messages.requestEnvelope == nil && messages.streamingRequest.Type == expr.Empty) + if requestNeeded { + if err := collect(messages.request, requestSource); err != nil { + return nil, err + } + } + if messages.requestEnvelope != nil { + envelopeSource := protobufMessageSource{synthetic: protobufSyntheticMessage{ + endpoint: endpoint, + role: protobufStreamEnvelopeMessage, + }} + catalog.bindRootSource(messages.requestEnvelope, envelopeSource) + if err := collect(messages.requestEnvelope, envelopeSource); err != nil { + return nil, err + } + } + if messages.streamingRequest.Type != expr.Empty { + if err := collect(messages.streamingRequest, streamingSource); err != nil { + return nil, err + } + } + if messages.response.Type != expr.Empty || !endpoint.MethodExpr.IsStreaming() { + if err := collect(messages.response, responseSource); err != nil { + return nil, err + } + if _, viewed := endpoint.MethodExpr.Result.Type.(*expr.ResultTypeExpr); viewed { + messages.responseValidations = make(map[string]*expr.AttributeExpr) + for _, view := range grpcResultViews(endpoint.MethodExpr) { + selected, err := grpcResultForView(endpoint.MethodExpr.Result, view) + if err != nil { + return nil, err + } + validation := grpcProtobufValidationForView(messages.response, selected) + catalog.bindCopiedMessageUses(messages.response, validation) + messages.responseValidations[view] = validation + } } } - return data, imports - } - collect := func(at *expr.AttributeExpr) ([]*service.UserTypeData, []string) { - return collectMessages(at, sd, seen) } - switch dt := at.Type.(type) { - case expr.UserType: - name := dt.Name() - if n := at.Meta["struct:name:proto"]; n != nil { - name = n[0] - } - if _, ok := seen[name]; ok { - return data, imports - } - att := userTypeAttribute(dt) - data = append(data, &service.UserTypeData{ - Name: name, - VarName: protoBufMessageName(at, sd.Scope), - Description: dt.Attribute().Description, - Def: protoBufMessageDef(att, sd), - Ref: protoBufGoFullTypeRef(at, sd.PkgName, sd.Scope), - Type: dt, - }) - seen[name] = struct{}{} - d, i := collect(att) - data = append(data, d...) - imports = append(imports, i...) - case *expr.Object: - for _, nat := range *dt { - d, i := collect(nat.Attribute) - data = append(data, d...) - imports = append(imports, i...) + return prepared, nil +} + +// finishProtobufPackage reads the final Go names and builds the message and +// validation data used by generated clients and servers. +func finishProtobufPackage(sd *ServiceData) { + sd.protobuf.freezeMessages(sd) + sd.Messages = sd.protobuf.protoMessageData() + + sd.validations = sd.protobuf.freezeValidations(sd) +} + +// protobufRootMessageSource connects a root message to the authored service +// declaration whose value it carries. The endpoint value is used only when +// the service value is inline or created by Goa. An explicit Message DSL may +// provide the declaration instead. +func protobufRootMessageSource(attribute *expr.AttributeExpr, endpoint *expr.GRPCEndpointExpr, grpcError *expr.GRPCErrorExpr, role protobufSyntheticRole) protobufMessageSource { + var serviceAttribute *expr.AttributeExpr + switch role { + case protobufRequestMessage: + serviceAttribute = endpoint.MethodExpr.Payload + case protobufStreamingRequestMessage: + serviceAttribute = endpoint.MethodExpr.StreamingPayload + case protobufResponseMessage: + serviceAttribute = endpoint.MethodExpr.Result + case protobufErrorMessage: + if methodError := endpoint.MethodExpr.Error(grpcError.Name); methodError != nil { + serviceAttribute = methodError.AttributeExpr } - case *expr.Array: - d, i := collect(dt.ElemType) - data = append(data, d...) - imports = append(imports, i...) - case *expr.Map: - dk, ik := collect(dt.KeyType) - data = append(data, dk...) - imports = append(imports, ik...) - de, ie := collect(dt.ElemType) - data = append(data, de...) - imports = append(imports, ie...) - case *expr.Union: - for _, nat := range dt.Values { - d, i := collect(nat.Attribute) - data = append(data, d...) - imports = append(imports, i...) + } + if serviceAttribute != nil { + if userType, ok := serviceAttribute.Type.(expr.UserType); ok { + return protobufMessageSource{origin: userType.Origin()} } } - return data, imports + if userType, ok := attribute.Type.(expr.UserType); ok { + return protobufMessageSource{origin: userType.Origin()} + } + return protobufMessageSource{synthetic: protobufSyntheticMessage{ + endpoint: endpoint, + error: grpcError, + role: role, + }} } -// addValidation adds a validation function (if any) for the given user type -// and recurses through the user type adding other validation functions -// (if any). +// addValidation returns the validation function chosen for the given protobuf +// message in the generated server or client package. // // req if true indicates that the validation is generated for validating // request (server-side) messages. -func addValidation(att *expr.AttributeExpr, attName string, sd *ServiceData, req bool) *ValidationData { - ut, ok := att.Type.(expr.UserType) - if !ok { - return nil - } - vtx := protoBufTypeContext(sd.PkgName, sd.Scope, false) - // Validation helper names must be derived from the same protobuf-aware - // scope used by the validation templates so that function declarations - // and call sites (e.g. Message_) stay in sync regardless of traversal - // order or reserved-name handling. - name := vtx.Scope.Name(att, "", vtx.Pointer, vtx.UseDefault) - ref := protoBufGoFullTypeRef(att, sd.PkgName, sd.Scope) +func addValidation(att *expr.AttributeExpr, sd *ServiceData, req bool) *ValidationData { kind := validateClient if req { kind = validateServer } - att = userTypeAttribute(ut) - for _, n := range sd.validations { - if n.SrcName == name { - if n.Kind != kind { - n.Kind = validateBoth - collectValidations(att, attName, req, sd) - } - return n - } - } - removeMeta(att) - if def := codegen.ValidationCode(att, ut, vtx, true, expr.IsAlias(att.Type), false, attName); def != "" { - v := &ValidationData{ - // Validation function names must match the identifiers used by - // validation templates. The template uses the scoped type name - // directly (no Goify) to preserve proto-reserved names like Message_. - Name: "Validate" + name, - Def: def, - ArgName: attName, - SrcName: name, - SrcRef: ref, - Kind: kind, - } - sd.validations = append(sd.validations, v) - collectValidations(att, attName, req, sd) - return v - } - return nil -} - -// collectValidations recurses through the attribute and collects the -// validation functions. -// -// req if true indicates that the validations are generated for validating -// request messages. -func collectValidations(att *expr.AttributeExpr, attName string, req bool, sd *ServiceData) { - collectValidationsR(att, attName, req, sd, make(map[string]struct{})) -} - -// collectValidationsR recurses through the attribute and collects validation -// functions with cycle detection using a seen set of user type IDs. -func collectValidationsR(att *expr.AttributeExpr, attName string, req bool, sd *ServiceData, seen map[string]struct{}) { - gattName := codegen.Goify(attName, false) - switch dt := att.Type.(type) { - case expr.UserType: - if expr.IsPrimitive(dt) { - // Alias type - validation is generate inline in parent type validation code. - return - } - // Cycle guard: avoid infinite recursion on recursive user types. - if id := dt.ID(); id != "" { - if _, ok := seen[id]; ok { - return - } - seen[id] = struct{}{} - } - vtx := protoBufTypeContext(sd.PkgName, sd.Scope, false) - def := codegen.AttributeValidationCode(att, dt, vtx, true, false, gattName, attName) - // Match helper function identifiers with validation template calls by - // using the same protobuf-aware scope for the type name. This keeps - // names like Message_ consistent between declarations and call sites. - name := vtx.Scope.Name(att, "", vtx.Pointer, vtx.UseDefault) - kind := validateClient - if req { - kind = validateServer - } - for _, n := range sd.validations { - if n.SrcName == name { - if n.Kind != validateBoth && n.Kind != kind { - n.Kind = validateBoth - goto collect - } - return - } - } - if def != "" { - sd.validations = append(sd.validations, &ValidationData{ - // Match helper function identifiers with validation template - // calls. The template uses the scoped type name directly (no - // Goify) to preserve proto-reserved names like Message_. - Name: "Validate" + name, - Def: def, - ArgName: gattName, - SrcName: name, - SrcRef: protoBufGoFullTypeRef(att, sd.PkgName, sd.Scope), - Kind: kind, - }) - } - collect: - att := userTypeAttribute(dt) - collectValidationsR(att, attName, req, sd, seen) - case *expr.Object: - for _, nat := range *dt { - collectValidationsR(nat.Attribute, nat.Name, req, sd, seen) - } - case *expr.Array: - collectValidationsR(dt.ElemType, "elem", req, sd, seen) - case *expr.Map: - collectValidationsR(dt.KeyType, "key", req, sd, seen) - collectValidationsR(dt.ElemType, "val", req, sd, seen) - case *expr.Union: - for _, nat := range dt.Values { - collectValidationsR(nat.Attribute, nat.Name, req, sd, seen) - } - } + return sd.protobuf.validation(att, kind) } // userTypeAttribute returns the attribute of the given user type. @@ -939,6 +1254,108 @@ func userTypeAttribute(ut expr.UserType) *expr.AttributeExpr { return att } +// protobufCLIExample writes object field names exactly as they appear in the +// protobuf file. For example, a Goa field named "tenantID" is shown as +// "tenant_id" in command help. +func protobufCLIExample(attribute *expr.AttributeExpr, value any, plan *protobufServicePlan) any { + return protobufCLIExampleValue(attribute, value, plan, false) +} + +// protobufCLIExampleValue avoids adding the same protobuf object twice while +// visiting its field. Values inside arrays and maps are converted separately. +func protobufCLIExampleValue(attribute *expr.AttributeExpr, value any, plan *protobufServicePlan, skipWrapper bool) any { + if value == nil { + return nil + } + if !skipWrapper && isWrappedAttr(attribute) { + field := unwrapAttr(attribute) + fieldValue := value + if !field.Type.IsCompatible(value) { + var ok bool + fieldValue, ok = namedExampleValue(value, wrappedField) + if !ok { + panic("protobuf CLI wrapper example has no field value") + } + } + return map[string]any{ + plan.sourceFieldName(field): protobufCLIExampleValue(field, fieldValue, plan, true), + } + } + if object := expr.AsObject(attribute.Type); object != nil { + result := make(map[string]any, len(*object)) + for _, field := range *object { + fieldValue, ok := namedExampleValue(value, field.Name) + if !ok { + continue + } + if expr.AsUnion(field.Attribute.Type) != nil { + for name, branchValue := range protobufCLIExampleValue(field.Attribute, fieldValue, plan, false).(map[string]any) { + result[name] = branchValue + } + continue + } + result[plan.sourceFieldName(field.Attribute)] = protobufCLIExampleValue(field.Attribute, fieldValue, plan, false) + } + return result + } + if union := expr.AsUnion(attribute.Type); union != nil { + branchName, ok := namedExampleValue(value, union.GetTypeKey()) + if !ok { + panic("protobuf CLI union example has no branch name") + } + branchValue, ok := namedExampleValue(value, union.GetValueKey()) + if !ok { + panic("protobuf CLI union example has no branch value") + } + for _, branch := range union.Values { + if branch.Name != branchName { + continue + } + return map[string]any{ + plan.sourceFieldName(branch.Attribute): protobufCLIExampleValue(branch.Attribute, branchValue, plan, false), + } + } + panic(fmt.Sprintf("protobuf CLI union example selects unknown branch %q", branchName)) + } + if array := expr.AsArray(attribute.Type); array != nil { + items := reflect.ValueOf(value) + if items.Kind() != reflect.Array && items.Kind() != reflect.Slice { + panic(fmt.Sprintf("protobuf CLI array example has type %T", value)) + } + result := make([]any, items.Len()) + for index := range items.Len() { + result[index] = protobufCLIExampleValue(array.ElemType, items.Index(index).Interface(), plan, false) + } + return result + } + if mapped := expr.AsMap(attribute.Type); mapped != nil { + entries := reflect.ValueOf(value) + if entries.Kind() != reflect.Map { + panic(fmt.Sprintf("protobuf CLI map example has type %T", value)) + } + result := make(map[string]any, entries.Len()) + for _, key := range entries.MapKeys() { + result[fmt.Sprint(key.Interface())] = protobufCLIExampleValue(mapped.ElemType, entries.MapIndex(key).Interface(), plan, false) + } + return result + } + return value +} + +// namedExampleValue returns the value stored under one Goa object or union +// field name. +func namedExampleValue(example any, name string) (any, bool) { + fields := reflect.ValueOf(example) + if fields.Kind() != reflect.Map || fields.Type().Key().Kind() != reflect.String { + panic(fmt.Sprintf("protobuf CLI object example has type %T", example)) + } + value := fields.MapIndex(reflect.ValueOf(name).Convert(fields.Type().Key())) + if !value.IsValid() { + return nil, false + } + return value.Interface(), true +} + // buildRequestConvertData builds the convert data for the server and client // requests. // - server side - converts the one-shot gRPC request message (if any) and @@ -958,36 +1375,43 @@ func (d *ServicesData) buildRequestConvertData(request, payload *expr.AttributeE return nil } - svc := sd.Service - pkg := svc.Method(e.MethodExpr.Name).PayloadLoc.PackageNameOrDefault(svc.PkgName) - svcCtx := serviceTypeContext(pkg, svc.Scope) + side := "client" + protobufPackage := sd.ClientProtobufPkgName + if svr { + protobufPackage = sd.ServerProtobufPkgName + side = "server" + } + svcCtx := d.serviceTypeContext(sd, side).Enter(payload) if svr { // server side - data := d.buildInitData(request, payload, "message", "v", svcCtx, false, false, sd) - data.Name = fmt.Sprintf("New%sPayload", codegen.Goify(e.Name(), true)) - data.Description = fmt.Sprintf("%s builds the payload of the %q endpoint of the %q service from the gRPC request type.", data.Name, e.Name(), svc.Name) + data := d.buildInitData(request, payload, "message", "v", svcCtx, false, sd, expr.MethodPayloadExampleIdentity(e.MethodExpr), d.initDeclaration(e, true, grpcInitKey{role: grpcRequestInit})) // pass the metadata as arguments to payload constructor in server data.Args = append(data.Args, initArgsFromMetadata(md)...) - return &ConvertData{ - SrcName: protoBufGoFullTypeName(request, sd.PkgName, sd.Scope), - SrcRef: protoBufGoFullTypeRef(request, sd.PkgName, sd.Scope), - TgtName: svc.Scope.GoFullTypeName(payload, svcCtx.Pkg(payload)), - TgtRef: svc.Scope.GoFullTypeRef(payload, svcCtx.Pkg(payload)), - Init: data, - Validation: addValidation(request, "message", sd, true), + conversion := &ConvertData{ + TgtName: svcCtx.Scope.Name(payload, svcCtx.Pkg(payload), svcCtx.Pointer, svcCtx.UseDefault), + TgtRef: svcCtx.Scope.Ref(payload, svcCtx.Pkg(payload)), + Init: data, + } + if !e.MethodExpr.IsPayloadStreaming() || !isEmpty(e.Request.Type) { + conversion.SrcName = protoBufGoFullTypeName(request, protobufPackage, sd) + conversion.SrcRef = protoBufGoFullTypeRef(request, protobufPackage, sd) + conversion.Validation = addValidation(request, sd, true) } + return conversion } // client side - data := d.buildInitData(payload, request, "payload", "message", svcCtx, true, false, sd) - data.Description = fmt.Sprintf("%s builds the gRPC request type from the payload of the %q endpoint of the %q service.", data.Name, e.Name(), svc.Name) - return &ConvertData{ - SrcName: svc.Scope.GoFullTypeName(payload, pkg), - SrcRef: svc.Scope.GoFullTypeRef(payload, pkg), - TgtName: protoBufGoFullTypeName(request, sd.PkgName, sd.Scope), - TgtRef: protoBufGoFullTypeRef(request, sd.PkgName, sd.Scope), + data := d.buildInitData(payload, request, "payload", "message", svcCtx, true, sd, expr.MethodPayloadExampleIdentity(e.MethodExpr), d.initDeclaration(e, false, grpcInitKey{role: grpcRequestInit})) + conversion := &ConvertData{ + TgtName: protoBufGoFullTypeName(request, sd.ClientProtobufPkgName, sd), + TgtRef: protoBufGoFullTypeRef(request, sd.ClientProtobufPkgName, sd), Init: data, } + if !isEmpty(payload.Type) { + conversion.SrcName = svcCtx.Scope.Name(payload, svcCtx.Pkg(payload), svcCtx.Pointer, svcCtx.UseDefault) + conversion.SrcRef = svcCtx.Scope.Ref(payload, svcCtx.Pkg(payload)) + } + return conversion } // buildLegacyDecodeData computes the data needed to decode requests sent by @@ -997,88 +1421,113 @@ func (d *ServicesData) buildRequestConvertData(request, payload *expr.AttributeE // to metadata is carried under its own name and non-object payloads travel // under the reserved "goa_payload" key. func (d *ServicesData) buildLegacyDecodeData(e *expr.GRPCEndpointExpr, sd *ServiceData) *LegacyDecodeData { - svc := sd.Service payload := e.MethodExpr.Payload - legacyMD := expr.DupMappedAtt(e.Metadata) - mdObj := expr.AsObject(legacyMD.Type) - if pobj := expr.AsObject(payload.Type); pobj != nil { - for _, nat := range *pobj { - if mdObj.Attribute(nat.Name) == nil { - mdObj.Set(nat.Name, expr.DupAtt(nat.Attribute)) - } - if payload.IsRequired(nat.Name) { - legacyMD.Validation.AddRequired(nat.Name) - } - } - } else { - mdObj.Set("goa_payload", expr.DupAtt(payload)) - legacyMD.Validation.AddRequired("goa_payload") + endpointPlan := d.endpointPlans[e] + if endpointPlan == nil || endpointPlan.legacyMetadata == nil { + panic(fmt.Sprintf("saved legacy metadata is missing for gRPC endpoint %q", e.Name())) } - md := extractMetadata(legacyMD, payload, svc.Scope, *d) + owner := expr.MethodPayloadExampleIdentity(e.MethodExpr) + md := d.extractMetadata(endpointPlan.legacyMetadata, payload, sd, "server", "v", owner) + declaration := d.symbols[e.Service].endpoints[e].legacyDecode data := &LegacyDecodeData{ - FuncName: fmt.Sprintf("decode%sLegacyRequest", codegen.Goify(e.Name(), true)), - Metadata: md, + FuncName: declaration.Name(), + FuncDeclaration: declaration, + Metadata: md, } if expr.IsObject(payload.Type) { - pkg := svc.Method(e.MethodExpr.Name).PayloadLoc.PackageNameOrDefault(svc.PkgName) - svcCtx := serviceTypeContext(pkg, svc.Scope) - init := d.buildInitData(&expr.AttributeExpr{Type: expr.Empty}, payload, "message", "v", svcCtx, false, false, sd) - init.Name = fmt.Sprintf("New%sPayloadFromMetadata", codegen.Goify(e.Name(), true)) - init.Description = fmt.Sprintf("%s builds the payload of the %q endpoint of the %q service from the gRPC request metadata sent by legacy stream protocol clients.", init.Name, e.Name(), svc.Name) + svcCtx := d.serviceTypeContext(sd, "server").Enter(payload) + init := d.buildInitData(&expr.AttributeExpr{Type: expr.Empty}, payload, "message", "v", svcCtx, false, sd, owner, d.initDeclaration(e, true, grpcInitKey{role: grpcLegacyRequestInit})) init.Args = append(init.Args, initArgsFromMetadata(md)...) data.ServerConvert = &ConvertData{ - TgtName: svc.Scope.GoFullTypeName(payload, svcCtx.Pkg(payload)), - TgtRef: svc.Scope.GoFullTypeRef(payload, svcCtx.Pkg(payload)), + TgtName: svcCtx.Scope.Name(payload, svcCtx.Pkg(payload), svcCtx.Pointer, svcCtx.UseDefault), + TgtRef: svcCtx.Scope.Ref(payload, svcCtx.Pkg(payload)), Init: init, } } return data } -// buildResponseConvertData builds the convert data for the server and client -// responses. -// - server side - converts method result type to generated gRPC response -// type in *.pb.go -// - client side - converts generated gRPC response type in *.pb.go and -// response metadata to method result type. -// -// svr param indicates that the convert data is generated for server side. -func (d *ServicesData) buildResponseConvertData(response, result *expr.AttributeExpr, svcCtx *codegen.AttributeContext, hdrs, trlrs []*MetadataData, e *expr.GRPCEndpointExpr, sd *ServiceData, svr bool) *ConvertData { - if !svr && (e.MethodExpr.IsStreaming() || isEmpty(e.MethodExpr.Result.Type)) { - return nil - } - svc := sd.Service - if svr { - // server side - data := d.buildInitData(result, response, "result", "message", svcCtx, true, false, sd) - data.Description = fmt.Sprintf("%s builds the gRPC response type from the result of the %q endpoint of the %q service.", data.Name, e.Name(), svc.Name) - return &ConvertData{ - SrcName: svcCtx.Scope.Name(result, svcCtx.Pkg(result), svcCtx.Pointer, svcCtx.UseDefault), - SrcRef: svcCtx.Scope.Ref(result, svcCtx.Pkg(result)), - TgtName: protoBufGoFullTypeName(response, sd.PkgName, sd.Scope), - TgtRef: protoBufGoFullTypeRef(response, sd.PkgName, sd.Scope), - Init: data, +// buildServerResponseConverts builds one protobuf conversion for each result +// view the server may send. Results without views have one unnamed conversion. +func (d *ServicesData) buildServerResponseConverts(response, result *expr.AttributeExpr, svcCtx *codegen.AttributeContext, e *expr.GRPCEndpointExpr, sd *ServiceData) []*ViewConvertData { + views := []string{""} + if _, viewed := e.MethodExpr.Result.Type.(*expr.ResultTypeExpr); viewed { + views = grpcResultViews(e.MethodExpr) + } + converts := make([]*ViewConvertData, 0, len(views)) + for _, view := range views { + source := result + if view != "" { + var err error + source, err = grpcResultForView(result, view) + if err != nil { + panic(err) // bug + } } + key := grpcInitKey{role: grpcResponseInit, view: view} + converts = append(converts, &ViewConvertData{ + View: view, + Convert: d.buildServerResponseConvertData(response, source, svcCtx, e, sd, key), + }) } + return converts +} - // client side - data := d.buildInitData(response, result, "message", "result", svcCtx, false, false, sd) - data.Name = fmt.Sprintf("New%sResult", codegen.Goify(e.Name(), true)) - data.Description = fmt.Sprintf("%s builds the result type of the %q endpoint of the %q service from the gRPC response type.", data.Name, e.Name(), svc.Name) - // pass the headers as arguments to result constructor in client - data.Args = append(data.Args, initArgsFromMetadata(hdrs)...) - // pass the trailers as arguments to result constructor in client - data.Args = append(data.Args, initArgsFromMetadata(trlrs)...) +// buildServerResponseConvertData builds one protobuf response conversion from +// the fields selected during planning. +func (d *ServicesData) buildServerResponseConvertData(response, result *expr.AttributeExpr, svcCtx *codegen.AttributeContext, e *expr.GRPCEndpointExpr, sd *ServiceData, key grpcInitKey) *ConvertData { + data := d.buildInitData(result, response, "result", "message", svcCtx, true, sd, expr.MethodResultExampleIdentity(e.MethodExpr), d.initDeclaration(e, true, key)) return &ConvertData{ - SrcName: protoBufGoFullTypeName(response, sd.PkgName, sd.Scope), - SrcRef: protoBufGoFullTypeRef(response, sd.PkgName, sd.Scope), - TgtName: svcCtx.Scope.Name(result, svcCtx.Pkg(result), svcCtx.Pointer, svcCtx.UseDefault), - TgtRef: svcCtx.Scope.Ref(result, svcCtx.Pkg(result)), - Init: data, - Validation: addValidation(response, "message", sd, false), + SrcName: svcCtx.Scope.Name(result, svcCtx.Pkg(result), svcCtx.Pointer, svcCtx.UseDefault), + SrcRef: svcCtx.Scope.Ref(result, svcCtx.Pkg(result)), + TgtName: protoBufGoFullTypeName(response, sd.ServerProtobufPkgName, sd), + TgtRef: protoBufGoFullTypeRef(response, sd.ServerProtobufPkgName, sd), + Init: data, } } +// buildClientResponseConverts builds one service conversion for each result +// view the client may receive. Results without views have one unnamed +// conversion. +func (d *ServicesData) buildClientResponseConverts(response *expr.AttributeExpr, validations map[string]*expr.AttributeExpr, result *expr.AttributeExpr, svcCtx *codegen.AttributeContext, hdrs, trlrs []*MetadataData, e *expr.GRPCEndpointExpr, sd *ServiceData) []*ViewConvertData { + if e.MethodExpr.IsStreaming() || isEmpty(e.MethodExpr.Result.Type) { + return nil + } + views := []string{""} + if _, viewed := e.MethodExpr.Result.Type.(*expr.ResultTypeExpr); viewed { + views = grpcResultViews(e.MethodExpr) + } + converts := make([]*ViewConvertData, 0, len(views)) + for _, view := range views { + target := result + if view != "" { + var err error + target, err = grpcResultForView(result, view) + if err != nil { + panic(err) // bug + } + } + key := grpcInitKey{role: grpcResponseInit, view: view} + data := d.buildInitData(response, target, "message", "result", svcCtx, false, sd, expr.MethodResultExampleIdentity(e.MethodExpr), d.initDeclaration(e, false, key)) + data.Args = append(data.Args, initArgsFromMetadata(hdrs)...) + data.Args = append(data.Args, initArgsFromMetadata(trlrs)...) + validation := response + if validations != nil { + validation = validations[view] + } + convert := &ConvertData{ + SrcName: protoBufGoFullTypeName(response, sd.ClientProtobufPkgName, sd), + SrcRef: protoBufGoFullTypeRef(response, sd.ClientProtobufPkgName, sd), + TgtName: svcCtx.Scope.Name(target, svcCtx.Pkg(target), svcCtx.Pointer, svcCtx.UseDefault), + TgtRef: svcCtx.Scope.Ref(target, svcCtx.Pkg(target)), + Init: data, + Validation: addValidation(validation, sd, false), + } + converts = append(converts, &ViewConvertData{View: view, Convert: convert}) + } + return converts +} + // buildInitData builds the transformation code to convert source to target. // // source, target are the source and target attributes used in the @@ -1087,32 +1536,34 @@ func (d *ServicesData) buildResponseConvertData(response, result *expr.Attribute // transformation // svcCtx is the attribute context for service type // proto if true indicates the target type is a protocol buffer type -func (d *ServicesData) buildInitData(source, target *expr.AttributeExpr, sourceVar, targetVar string, svcCtx *codegen.AttributeContext, proto, usesrc bool, sd *ServiceData) *InitData { - pbCtx := protoBufTypeContext(sd.PkgName, sd.Scope, false) - name := "New" +func (d *ServicesData) buildInitData(source, target *expr.AttributeExpr, sourceVar, targetVar string, svcCtx *codegen.AttributeContext, proto bool, sd *ServiceData, owner expr.ExampleIdentity, conversion *grpcConversion) *InitData { + protobufPackage := sd.ClientProtobufPkgName + if conversion.side == grpcServerPackage { + protobufPackage = sd.ServerProtobufPkgName + } + pbCtx := protoBufTypeContext(protobufPackage, sd) srcCtx := pbCtx tgtCtx := svcCtx if proto { srcCtx = svcCtx tgtCtx = pbCtx - name += "Proto" } isStruct := expr.IsObject(target.Type) || expr.IsUnion(target.Type) - if _, ok := source.Type.(expr.UserType); ok && usesrc { - name += protoBufGoTypeName(source, sd.Scope) - } - n := protoBufGoTypeName(target, sd.Scope) - if !isStruct { - // If target is array, map, or primitive the name will be suffixed with - // the definition (e.g int, []string, map[int]string) which is incorrect. - n = protoBufGoTypeName(source, sd.Scope) + if !conversion.bound { + if err := conversion.transform.BindContexts(srcCtx, tgtCtx); err != nil { + panic(err) // bug + } + conversion.bound = true } - name += n - code, helpers, err := protoBufTransform(source, target, sourceVar, targetVar, srcCtx, tgtCtx, proto, true) + code, helpers, err := conversion.transform.Render(sourceVar, targetVar, true) if err != nil { panic(err) // bug } - sd.transformHelpers = codegen.AppendHelpers(sd.transformHelpers, helpers) + if conversion.side == grpcServerPackage { + sd.serverTransformHelpers = codegen.AppendHelpers(sd.serverTransformHelpers, helpers) + } else { + sd.clientTransformHelpers = codegen.AppendHelpers(sd.clientTransformHelpers, helpers) + } var args []*InitArgData if (!proto && !isEmpty(source.Type)) || (proto && !isEmpty(target.Type)) { args = []*InitArgData{{ @@ -1120,13 +1571,20 @@ func (d *ServicesData) buildInitData(source, target *expr.AttributeExpr, sourceV Ref: sourceVar, TypeName: srcCtx.Scope.Name(source, srcCtx.Pkg(source), srcCtx.Pointer, srcCtx.UseDefault), TypeRef: srcCtx.Scope.Ref(source, srcCtx.Pkg(source)), - Example: source.Example(d.Root.API.ExampleGenerator), + Example: d.Example(source, owner), }} } + sourceRef := "metadata values" + if !isEmpty(source.Type) { + sourceRef = srcCtx.Scope.Ref(source, srcCtx.Pkg(source)) + } + targetRef := tgtCtx.Scope.Ref(target, tgtCtx.Pkg(target)) return &InitData{ - Name: name, + Declaration: conversion.declaration, + Name: conversion.declaration.Name(), + Description: fmt.Sprintf("%s builds %s from %s.", conversion.declaration.Name(), targetRef, sourceRef), ReturnVarName: targetVar, - ReturnTypeRef: tgtCtx.Scope.Ref(target, tgtCtx.Pkg(target)), + ReturnTypeRef: targetRef, ReturnIsStruct: isStruct, ReturnTypePkg: tgtCtx.Pkg(target), Code: code, @@ -1134,6 +1592,62 @@ func (d *ServicesData) buildInitData(source, target *expr.AttributeExpr, sourceV } } +// buildCLIRequestTransform checks a parsed protobuf request and then renders +// the protobuf-to-payload conversion used by command-line clients. +func (d *ServicesData) buildCLIRequestTransform( + endpoint *expr.GRPCEndpointExpr, + request *expr.AttributeExpr, + sd *ServiceData, +) string { + conversion := d.symbols[endpoint.Service].endpoints[endpoint].cliPayload + pbCtx := protoBufTypeContext(sd.ClientProtobufPkgName, sd) + svcCtx := d.serviceTypeContext(sd, "client").Enter(endpoint.MethodExpr.Payload) + if !conversion.bound { + if err := conversion.transform.BindContexts(pbCtx, svcCtx); err != nil { + panic(err) // bug + } + conversion.bound = true + } + code, helpers, err := conversion.transform.Render("message", "v", true) + if err != nil { + panic(err) // bug + } + sd.clientTransformHelpers = codegen.AppendHelpers(sd.clientTransformHelpers, helpers) + if protobufCLIRequestNeedsMessage(request) { + if validation := sd.protobuf.validation(request, validateClient); validation != nil { + payloadRef := svcCtx.Scope.Ref(endpoint.MethodExpr.Payload, svcCtx.Pkg(endpoint.MethodExpr.Payload)) + code = fmt.Sprintf( + "if err := %s(&message); err != nil {\n\tvar zero %s\n\treturn zero, err\n}\n%s", + validation.Declaration.Name(), payloadRef, code, + ) + } + } + return code +} + +// protobufCLIRequestNeedsMessage reports whether the command-line client +// parses a protobuf request before building the service payload. +func protobufCLIRequestNeedsMessage(attribute *expr.AttributeExpr) bool { + object := expr.AsObject(attribute.Type) + return (object != nil && len(*object) > 0) || expr.IsUnion(attribute.Type) +} + +// initDeclaration returns the constructor and conversion requested for one +// endpoint value. Planning records both before generated package names are +// fixed. +func (d *ServicesData) initDeclaration(endpoint *expr.GRPCEndpointExpr, server bool, key grpcInitKey) *grpcConversion { + symbols := d.symbols[endpoint.Service].endpoints[endpoint] + declarations := symbols.clientInits + if server { + declarations = symbols.serverInits + } + init := declarations[key] + if init == nil { + panic(fmt.Sprintf("constructor name is missing for gRPC endpoint %q", endpoint.Name())) + } + return init +} + // buildErrorsData builds the error data for all the error responses in the // endpoint expression. The response message for each error response are // inferred from the method's error expression if not specified explicitly. @@ -1141,7 +1655,6 @@ func (d *ServicesData) buildInitData(source, target *expr.AttributeExpr, sourceV // response message derived by analyze; errors without a custom object type // have no entry. func (d *ServicesData) buildErrorsData(e *expr.GRPCEndpointExpr, errorMessages map[string]*expr.AttributeExpr, sd *ServiceData) []*ErrorData { - svc := sd.Service errors := make([]*ErrorData, 0, len(e.GRPCErrors)) for _, v := range e.GRPCErrors { responseData := &ResponseData{ @@ -1150,10 +1663,10 @@ func (d *ServicesData) buildErrorsData(e *expr.GRPCEndpointExpr, errorMessages m ServerConvert: d.buildErrorConvertData(v, e, errorMessages[v.Name], sd, true), ClientConvert: d.buildErrorConvertData(v, e, errorMessages[v.Name], sd, false), } - errorLoc := svc.Method(e.MethodExpr.Name).ErrorLocs[v.Name] + svcctx := d.serviceTypeContext(sd, "server").Enter(v.AttributeExpr) errors = append(errors, &ErrorData{ Name: v.Name, - Ref: svc.Scope.GoFullTypeRef(v.AttributeExpr, errorLoc.PackageNameOrDefault(svc.PkgName)), + Ref: svcctx.Scope.Ref(v.AttributeExpr, svcctx.Pkg(v.AttributeExpr)), Response: responseData, }) } @@ -1166,46 +1679,116 @@ func (d *ServicesData) buildErrorsData(e *expr.GRPCEndpointExpr, errorMessages m func (d *ServicesData) buildErrorConvertData(ge *expr.GRPCErrorExpr, e *expr.GRPCEndpointExpr, message *expr.AttributeExpr, sd *ServiceData, svr bool) *ConvertData { // No need to build transformation functions for default error or non-object // types. - if ge.Type == expr.ErrorResult || !expr.IsObject(ge.Type) { + if expr.IsErrorResult(ge.Type) || !expr.IsObject(ge.Type) { return nil } - svc := sd.Service - svcCtx := serviceTypeContext(svc.PkgName, svc.Scope) + side := "client" + if svr { + side = "server" + } + svcCtx := d.serviceTypeContext(sd, side).Enter(ge.AttributeExpr) if svr { // server side - data := d.buildInitData(ge.AttributeExpr, message, "er", "message", svcCtx, true, false, sd) - data.Name = fmt.Sprintf("New%s%sError", codegen.Goify(e.Name(), true), codegen.Goify(ge.Name, true)) - data.Description = fmt.Sprintf("%s builds the gRPC error response type from the error of the %q endpoint of the %q service.", data.Name, e.Name(), svc.Name) + owner := expr.MethodErrorExampleIdentity(e.MethodExpr, ge.ErrorExpr) + data := d.buildInitData(ge.AttributeExpr, message, "er", "message", svcCtx, true, sd, owner, d.initDeclaration(e, true, grpcInitKey{role: grpcErrorInit, subject: ge.Name})) return &ConvertData{ SrcName: svcCtx.Scope.Name(ge.AttributeExpr, svcCtx.Pkg(ge.AttributeExpr), svcCtx.Pointer, svcCtx.UseDefault), SrcRef: svcCtx.Scope.Ref(ge.AttributeExpr, svcCtx.Pkg(ge.AttributeExpr)), - TgtName: protoBufGoFullTypeName(message, sd.PkgName, sd.Scope), - TgtRef: protoBufGoFullTypeRef(message, sd.PkgName, sd.Scope), + TgtName: protoBufGoFullTypeName(message, sd.ServerProtobufPkgName, sd), + TgtRef: protoBufGoFullTypeRef(message, sd.ServerProtobufPkgName, sd), Init: data, } } // client side - data := d.buildInitData(message, ge.AttributeExpr, "message", "er", svcCtx, false, false, sd) - data.Name = fmt.Sprintf("New%s%sError", codegen.Goify(e.Name(), true), codegen.Goify(ge.Name, true)) - data.Description = fmt.Sprintf("%s builds the error type of the %q endpoint of the %q service from the gRPC error response type.", data.Name, e.Name(), svc.Name) + owner := expr.MethodErrorExampleIdentity(e.MethodExpr, ge.ErrorExpr) + data := d.buildInitData(message, ge.AttributeExpr, "message", "er", svcCtx, false, sd, owner, d.initDeclaration(e, false, grpcInitKey{role: grpcErrorInit, subject: ge.Name})) return &ConvertData{ - SrcName: protoBufGoFullTypeName(message, sd.PkgName, sd.Scope), - SrcRef: protoBufGoFullTypeRef(message, sd.PkgName, sd.Scope), + SrcName: protoBufGoFullTypeName(message, sd.ClientProtobufPkgName, sd), + SrcRef: protoBufGoFullTypeRef(message, sd.ClientProtobufPkgName, sd), TgtName: svcCtx.Scope.Name(ge.AttributeExpr, svcCtx.Pkg(ge.AttributeExpr), svcCtx.Pointer, svcCtx.UseDefault), TgtRef: svcCtx.Scope.Ref(ge.AttributeExpr, svcCtx.Pkg(ge.AttributeExpr)), Init: data, - Validation: addValidation(message, "errmsg", sd, false), + Validation: addValidation(message, sd, false), } } +// buildServerStreamSendConverts builds one protobuf conversion for each result +// view the server may send through a stream. +func (d *ServicesData) buildServerStreamSendConverts(e *expr.GRPCEndpointExpr, response, result *expr.AttributeExpr, resultCtx *codegen.AttributeContext, sd *ServiceData) []*ViewConvertData { + views := []string{""} + if _, viewed := e.MethodExpr.Result.Type.(*expr.ResultTypeExpr); viewed { + views = grpcResultViews(e.MethodExpr) + } + converts := make([]*ViewConvertData, 0, len(views)) + for _, view := range views { + source := result + sourceVar := "result" + if view != "" { + var err error + source, err = grpcResultForView(result, view) + if err != nil { + panic(err) // bug + } + sourceVar = "vresult" + } + key := grpcInitKey{role: grpcStreamingResponseInit, view: view} + convert := &ConvertData{ + SrcName: resultCtx.Scope.Name(source, resultCtx.Pkg(source), resultCtx.Pointer, resultCtx.UseDefault), + SrcRef: resultCtx.Scope.Ref(source, resultCtx.Pkg(source)), + TgtName: protoBufGoFullTypeName(response, sd.ServerProtobufPkgName, sd), + TgtRef: protoBufGoFullTypeRef(response, sd.ServerProtobufPkgName, sd), + Init: d.buildInitData(source, response, sourceVar, "v", resultCtx, true, sd, expr.MethodStreamingResultExampleIdentity(e.MethodExpr), d.initDeclaration(e, true, key)), + } + converts = append(converts, &ViewConvertData{View: view, Convert: convert}) + } + return converts +} + +// buildClientStreamRecvConverts builds one service conversion for each result +// view the client may receive through a stream. +func (d *ServicesData) buildClientStreamRecvConverts(e *expr.GRPCEndpointExpr, response *expr.AttributeExpr, validations map[string]*expr.AttributeExpr, result *expr.AttributeExpr, resultCtx *codegen.AttributeContext, sd *ServiceData) []*ViewConvertData { + views := []string{""} + if _, viewed := e.MethodExpr.Result.Type.(*expr.ResultTypeExpr); viewed { + views = grpcResultViews(e.MethodExpr) + } + converts := make([]*ViewConvertData, 0, len(views)) + for _, view := range views { + target := result + targetVar := "result" + if view != "" { + var err error + target, err = grpcResultForView(result, view) + if err != nil { + panic(err) // bug + } + targetVar = "vresult" + } + key := grpcInitKey{role: grpcStreamingResponseInit, view: view} + validation := response + if validations != nil { + validation = validations[view] + } + convert := &ConvertData{ + SrcName: protoBufGoFullTypeName(response, sd.ClientProtobufPkgName, sd), + SrcRef: protoBufGoFullTypeRef(response, sd.ClientProtobufPkgName, sd), + TgtName: resultCtx.Scope.Name(target, resultCtx.Pkg(target), resultCtx.Pointer, resultCtx.UseDefault), + TgtRef: resultCtx.Scope.Ref(target, resultCtx.Pkg(target)), + Init: d.buildInitData(response, target, "v", targetVar, resultCtx, false, sd, expr.MethodStreamingResultExampleIdentity(e.MethodExpr), d.initDeclaration(e, false, key)), + Validation: addValidation(validation, sd, false), + } + converts = append(converts, &ViewConvertData{View: view, Convert: convert}) + } + return converts +} + // buildStreamData builds the StreamData for the server and client streams. // // streamingRequest and responseMessage are the protobuf shaped copies of the // endpoint streaming request and response message derived by analyze. // // svr param indicates that the stream data is built for the server. -func (d *ServicesData) buildStreamData(e *expr.GRPCEndpointExpr, streamingRequest, responseMessage *expr.AttributeExpr, sd *ServiceData, svr bool) *StreamData { +func (d *ServicesData) buildStreamData(e *expr.GRPCEndpointExpr, streamingRequest, responseMessage *expr.AttributeExpr, responseValidations map[string]*expr.AttributeExpr, sd *ServiceData, svr bool) *StreamData { var ( varn string intName string @@ -1216,39 +1799,39 @@ func (d *ServicesData) buildStreamData(e *expr.GRPCEndpointExpr, streamingReques sendWithContextDesc string sendRef string sendConvert *ConvertData + sendConverts []*ViewConvertData recvName string recvDesc string recvWithContextName string recvWithContextDesc string recvRef string recvConvert *ConvertData + recvConverts []*ViewConvertData mustClose bool typ string ) - svc := sd.Service ed := sd.Endpoint(e.Name()) md := ed.Method - svcCtx := serviceTypeContext(svc.PkgName, svc.Scope) - result, resCtx := resultContext(e, sd) - resVar := "result" - if md.ViewedResult != nil { - resVar = "vresult" + side := "client" + if svr { + side = "server" } + svcCtx := d.serviceTypeContext(sd, side).Enter(e.MethodExpr.StreamingPayload) + result, resCtx := d.resultContext(e, sd, side) if svr { typ = "server" varn = md.ServerStream.VarName - intName = fmt.Sprintf("%s.%s_%sServer", sd.PkgName, svc.StructName, md.VarName) - svcInt = fmt.Sprintf("%s.%s", svc.PkgName, md.ServerStream.Interface) + methodDescriptor := sd.protobuf.plan.serviceFullName() + "." + sd.protobuf.plan.methods[e] + intName = sd.ServerProtobufPkgName + "." + sd.protobuf.plan.name(methodDescriptor, protocMethodServerStreamName) + svcInt = fmt.Sprintf("%s.%s", sd.ServerServicePkgName, md.ServerStream.Interface) if e.MethodExpr.Result.Type != expr.Empty { sendName = md.ServerStream.SendName sendRef = ed.ResultRef sendWithContextName = md.ServerStream.SendWithContextName - sendConvert = &ConvertData{ - SrcName: resCtx.Scope.Name(result, resCtx.Pkg(result), resCtx.Pointer, resCtx.UseDefault), - SrcRef: resCtx.Scope.Ref(result, resCtx.Pkg(result)), - TgtName: protoBufGoFullTypeName(responseMessage, sd.PkgName, sd.Scope), - TgtRef: protoBufGoFullTypeRef(responseMessage, sd.PkgName, sd.Scope), - Init: d.buildInitData(result, responseMessage, resVar, "v", resCtx, true, true, sd), + sendConverts = d.buildServerStreamSendConverts(e, responseMessage, result, resCtx, sd) + sendConvert = primaryViewConvert(sendConverts) + if md.ViewedResult == nil { + sendConverts = nil } } if e.MethodExpr.StreamingPayload.Type != expr.Empty { @@ -1256,20 +1839,21 @@ func (d *ServicesData) buildStreamData(e *expr.GRPCEndpointExpr, streamingReques recvWithContextName = md.ServerStream.RecvWithContextName recvRef = svcCtx.Scope.Ref(e.MethodExpr.StreamingPayload, svcCtx.Pkg(e.MethodExpr.StreamingPayload)) recvConvert = &ConvertData{ - SrcName: protoBufGoFullTypeName(streamingRequest, sd.PkgName, sd.Scope), - SrcRef: protoBufGoFullTypeRef(streamingRequest, sd.PkgName, sd.Scope), + SrcName: protoBufGoFullTypeName(streamingRequest, sd.ServerProtobufPkgName, sd), + SrcRef: protoBufGoFullTypeRef(streamingRequest, sd.ServerProtobufPkgName, sd), TgtName: svcCtx.Scope.Name(e.MethodExpr.StreamingPayload, svcCtx.Pkg(e.MethodExpr.StreamingPayload), svcCtx.Pointer, svcCtx.UseDefault), TgtRef: recvRef, - Init: d.buildInitData(streamingRequest, e.MethodExpr.StreamingPayload, "v", "spayload", svcCtx, false, true, sd), - Validation: addValidation(streamingRequest, "stream", sd, true), + Init: d.buildInitData(streamingRequest, e.MethodExpr.StreamingPayload, "v", "spayload", svcCtx, false, sd, expr.MethodStreamingPayloadExampleIdentity(e.MethodExpr), d.initDeclaration(e, true, grpcInitKey{role: grpcStreamingRequestInit})), + Validation: addValidation(streamingRequest, sd, true), } } mustClose = md.ServerStream.MustClose } else { typ = "client" varn = md.ClientStream.VarName - intName = fmt.Sprintf("%s.%s_%sClient", sd.PkgName, svc.StructName, md.VarName) - svcInt = fmt.Sprintf("%s.%s", svc.PkgName, md.ClientStream.Interface) + methodDescriptor := sd.protobuf.plan.serviceFullName() + "." + sd.protobuf.plan.methods[e] + intName = sd.ClientProtobufPkgName + "." + sd.protobuf.plan.name(methodDescriptor, protocMethodClientStreamName) + svcInt = fmt.Sprintf("%s.%s", sd.ClientServicePkgName, md.ClientStream.Interface) if e.MethodExpr.StreamingPayload.Type != expr.Empty { sendName = md.ClientStream.SendName sendWithContextName = md.ClientStream.SendWithContextName @@ -1277,22 +1861,19 @@ func (d *ServicesData) buildStreamData(e *expr.GRPCEndpointExpr, streamingReques sendConvert = &ConvertData{ SrcName: svcCtx.Scope.Name(e.MethodExpr.StreamingPayload, svcCtx.Pkg(e.MethodExpr.StreamingPayload), svcCtx.Pointer, svcCtx.UseDefault), SrcRef: sendRef, - TgtName: protoBufGoFullTypeName(streamingRequest, sd.PkgName, sd.Scope), - TgtRef: protoBufGoFullTypeRef(streamingRequest, sd.PkgName, sd.Scope), - Init: d.buildInitData(e.MethodExpr.StreamingPayload, streamingRequest, "spayload", "v", svcCtx, true, true, sd), + TgtName: protoBufGoFullTypeName(streamingRequest, sd.ClientProtobufPkgName, sd), + TgtRef: protoBufGoFullTypeRef(streamingRequest, sd.ClientProtobufPkgName, sd), + Init: d.buildInitData(e.MethodExpr.StreamingPayload, streamingRequest, "spayload", "v", svcCtx, true, sd, expr.MethodStreamingPayloadExampleIdentity(e.MethodExpr), d.initDeclaration(e, false, grpcInitKey{role: grpcStreamingRequestInit})), } } if e.MethodExpr.Result.Type != expr.Empty { recvName = md.ClientStream.RecvName recvWithContextName = md.ClientStream.RecvWithContextName recvRef = ed.ResultRef - recvConvert = &ConvertData{ - SrcName: protoBufGoFullTypeName(responseMessage, sd.PkgName, sd.Scope), - SrcRef: protoBufGoFullTypeRef(responseMessage, sd.PkgName, sd.Scope), - TgtName: resCtx.Scope.Name(result, resCtx.Pkg(result), resCtx.Pointer, resCtx.UseDefault), - TgtRef: resCtx.Scope.Ref(result, resCtx.Pkg(result)), - Init: d.buildInitData(responseMessage, result, "v", resVar, resCtx, false, true, sd), - Validation: addValidation(responseMessage, "stream", sd, false), + recvConverts = d.buildClientStreamRecvConverts(e, responseMessage, responseValidations, result, resCtx, sd) + recvConvert = primaryViewConvert(recvConverts) + if md.ViewedResult == nil { + recvConverts = nil } } mustClose = md.ClientStream.MustClose @@ -1317,69 +1898,166 @@ func (d *ServicesData) buildStreamData(e *expr.GRPCEndpointExpr, streamingReques SendWithContextDesc: sendWithContextDesc, SendRef: sendRef, SendConvert: sendConvert, + SendConverts: sendConverts, RecvName: recvName, RecvDesc: recvDesc, RecvWithContextName: recvWithContextName, RecvWithContextDesc: recvWithContextDesc, RecvRef: recvRef, RecvConvert: recvConvert, + RecvConverts: recvConverts, MustClose: mustClose, } } +// primaryViewConvert returns the conversion kept in the original single-value +// data field. A caller-selected result uses its default view. A fixed result +// has only its selected view. +func primaryViewConvert(converts []*ViewConvertData) *ConvertData { + if len(converts) == 0 { + return nil + } + if len(converts) == 1 { + return converts[0].Convert + } + for _, convert := range converts { + if convert.View == expr.DefaultView { + return convert.Convert + } + } + panic("caller-selected gRPC result views do not include the default view") // bug +} + // extractMetadata collects the request/response metadata from the given // metadata attribute and service type (payload/result). -func extractMetadata(a *expr.MappedAttributeExpr, service *expr.AttributeExpr, scope *codegen.NameScope, services ServicesData) []*MetadataData { - var metadata []*MetadataData - ctx := serviceTypeContext("", scope) - codegen.WalkMappedAttr(a, func(name, elem string, required bool, c *expr.AttributeExpr) error { // nolint: errcheck - arr := expr.AsArray(c.Type) - mp := expr.AsMap(c.Type) - typeRef := scope.GoTypeRef(unalias(c)) - ft := service.Type - varn := scope.Name(codegen.Goify(name, false)) - fieldName := codegen.Goify(name, true) - var pointer bool - if !expr.IsObject(service.Type) { - fieldName = "" - } else { - pointer = service.IsPrimitivePointer(name, true) - ft = service.Find(name).Type +func (d *ServicesData) extractMetadata(a *expr.MappedAttributeExpr, service *expr.AttributeExpr, sd *ServiceData, side, decodeTarget string, owner expr.ExampleIdentity) []*MetadataData { + plans, ok := d.metadataPlans[a] + if !ok { + panic("saved gRPC metadata fields are missing") + } + metadata := make([]*MetadataData, 0, len(plans)) + for _, plan := range plans { + wire := plan.wire + arr := expr.AsArray(wire.Type) + mp := expr.AsMap(wire.Type) + wireCtx := codegen.NewAttributeContext(false, false, true, "", plan.scope).Enter(wire) + var cliValidation func(string) string + if plan.validation != "" { + cliValidation = grpcCLIValidationRenderer(wire, wireCtx, plan.name) } - if pointer { + varn := codegen.Goify(plan.name, false) + fieldName := plan.fieldName + typeName := wireCtx.Scope.Name(wire, wireCtx.Pkg(wire), false, true) + typeRef := wireCtx.Scope.Ref(wire, wireCtx.Pkg(wire)) + valueTypeRef := typeRef + if plan.pointer { typeRef = "*" + typeRef } + serviceVar := "payload" + encodeSide := "client" + if side == "client" { + serviceVar = "result" + encodeSide = "server" + } + fieldRef := serviceVar + targetRef := decodeTarget + if fieldName != "" { + fieldRef += "." + fieldName + targetRef += "." + fieldName + } + wireVar := varn + "Wire" + encodeCode := d.metadataTransform(plan, fieldRef, wireVar, sd, encodeSide, true) + decodeCode := d.metadataTransform(plan, varn, targetRef, sd, side, false) + serviceContext := d.serviceTypeContext(sd, side).Enter(plan.serviceField) metadata = append(metadata, &MetadataData{ - Name: elem, - AttributeName: name, - Description: c.Description, - FieldName: fieldName, - FieldType: ft, - VarName: varn, - Required: required, - Type: c.Type, - TypeName: scope.GoTypeName(unalias(c)), - TypeRef: typeRef, - Pointer: pointer, - Slice: arr != nil, - StringSlice: arr != nil && arr.ElemType.Type.Kind() == expr.StringKind, - Map: mp != nil, + Name: plan.element, + AttributeName: plan.name, + Description: wire.Description, + FieldName: fieldName, + FieldType: plan.fieldType, + FieldTypeRef: serviceContext.Scope.Ref(plan.serviceField, serviceContext.Pkg(plan.serviceField)), + ServiceAttribute: plan.serviceField, + WireAttribute: wire, + VarName: varn, + WireVarName: wireVar, + EncodeCode: encodeCode, + DecodeCode: decodeCode, + Required: plan.required, + Type: wire.Type, + TypeName: typeName, + TypeRef: typeRef, + Pointer: plan.pointer, + Slice: arr != nil, + StringSlice: arr != nil && arr.ElemType.Type.Kind() == expr.StringKind, + Map: mp != nil, MapStringSlice: mp != nil && mp.KeyType.Type.Kind() == expr.StringKind && mp.ElemType.Type.Kind() == expr.ArrayKind && expr.AsArray(mp.ElemType.Type).ElemType.Type.Kind() == expr.StringKind, - Validate: codegen.AttributeValidationCode(c, nil, ctx, required, false, varn, name), - DefaultValue: c.DefaultValue, - Example: c.Example(services.Root.API.ExampleGenerator.Field(service, name)), + Validate: plan.validation, + CLIPlan: cli.NewFlagPlan(wire, typeName, valueTypeRef, cliValidation), + DefaultValue: wire.DefaultValue, + Example: d.FieldExample(wire, service, plan.name, owner), }) - return nil - }) + } return metadata } -// initArgsFromMetadata converts the given metadata into constructor arguments -// so the metadata values can be passed to the generated init functions. -func initArgsFromMetadata(md []*MetadataData) []*InitArgData { +// metadataTransform writes the conversion between one generated metadata +// value and its service field using the imports of the generated file. +func (d *ServicesData) metadataTransform(plan *grpcMetadataPlan, sourceVar, targetVar string, sd *ServiceData, side string, encode bool) string { + wireCtx := codegen.NewAttributeContext(false, false, true, "", plan.scope).Enter(plan.wire) + serviceCtx := d.serviceTypeContext(sd, side).Enter(plan.serviceField) + sourceCtx, targetCtx := wireCtx, serviceCtx + transform := plan.decode + if encode { + sourceCtx, targetCtx = serviceCtx, wireCtx + transform = plan.encode + } + if err := transform.BindContexts(sourceCtx, targetCtx); err != nil { + panic(err) // bug + } + valueVar := sourceVar + if plan.pointer { + valueVar = "*" + sourceVar + } + if encode { + code, helpers, err := transform.Render(valueVar, targetVar, true) + if err != nil { + panic(err) + } + sd.appendMetadataHelpers(side, helpers) + return code + } + if !plan.pointer { + code, helpers, err := transform.Render(valueVar, targetVar, false) + if err != nil { + panic(err) + } + sd.appendMetadataHelpers(side, helpers) + return code + } + converted := codegen.Goify(sourceVar, false) + "Service" + code, helpers, err := transform.Render(valueVar, converted, true) + if err != nil { + panic(err) + } + sd.appendMetadataHelpers(side, helpers) + return "if " + sourceVar + " != nil {\n" + code + "\n" + targetVar + " = &" + converted + "\n}\n" +} + +// appendMetadataHelpers writes recursive metadata conversions on the same +// client or server side as the metadata codec that calls them. +func (sd *ServiceData) appendMetadataHelpers(side string, helpers []*codegen.TransformFunctionData) { + if side == "server" { + sd.serverTransformHelpers = codegen.AppendHelpers(sd.serverTransformHelpers, helpers) + } else { + sd.clientTransformHelpers = codegen.AppendHelpers(sd.clientTransformHelpers, helpers) + } +} + +// argsFromMetadata builds arguments that expose decoded metadata values. +func argsFromMetadata(md []*MetadataData) []*InitArgData { args := make([]*InitArgData, len(md)) for i, m := range md { args[i] = &InitArgData{ @@ -1387,12 +2065,14 @@ func initArgsFromMetadata(md []*MetadataData) []*InitArgData { Ref: m.VarName, FieldName: m.FieldName, FieldType: m.FieldType, + FieldTypeRef: m.FieldTypeRef, TypeName: m.TypeName, TypeRef: m.TypeRef, Type: m.Type, Pointer: m.Pointer, Required: m.Required, Validate: m.Validate, + CLIPlan: m.CLIPlan, Example: m.Example, DefaultValue: m.DefaultValue, } @@ -1400,6 +2080,27 @@ func initArgsFromMetadata(md []*MetadataData) []*InitArgData { return args } +// grpcCLIValidationRenderer writes checks for the concrete value parsed from +// command-line metadata. Metadata fields use pointers to track presence, but +// the CLI has already proved presence and passes the parsed value itself. +func grpcCLIValidationRenderer(attribute *expr.AttributeExpr, context *codegen.AttributeContext, name string) func(string) string { + valueContext := context.Dup() + valueContext.Pointer = false + return func(target string) string { + return codegen.AttributeValidationCode(attribute, nil, valueContext, true, false, target, name) + } +} + +// initArgsFromMetadata adds the exact conversion that populates the service +// constructor result from each metadata argument. +func initArgsFromMetadata(md []*MetadataData) []*InitArgData { + args := argsFromMetadata(md) + for index, metadata := range md { + args[index].InitCode = metadata.DecodeCode + } + return args +} + // usesStreamEnvelope reports whether the transport needs a typed stream // envelope to carry both the one-shot method payload and streaming payload // items. @@ -1409,7 +2110,7 @@ func usesStreamEnvelope(e *expr.GRPCEndpointExpr) bool { // makeProtoBufStreamEnvelope builds the protobuf stream envelope that carries // the initial request payload frame and subsequent stream item frames. -func makeProtoBufStreamEnvelope(request, stream *expr.AttributeExpr, tname string, sd *ServiceData) *expr.AttributeExpr { +func makeProtoBufStreamEnvelope(request, stream *expr.AttributeExpr, tname string, owner expr.ExampleIdentity) *expr.AttributeExpr { initial := expr.DupAtt(request) initial.Meta = initial.Meta.Dup() initial.Meta["rpc:tag"] = []string{"1"} @@ -1433,61 +2134,129 @@ func makeProtoBufStreamEnvelope(request, stream *expr.AttributeExpr, tname strin }, Validation: &expr.ValidationExpr{Required: []string{"body"}}, } - return makeProtoBufMessage(envelope, tname, sd) + return makeProtoBufMessage(envelope, tname, owner) } // buildStreamEnvelopeData computes the generated Go names for the protobuf // oneof field and wrapper types of the synthesized stream envelope. -func buildStreamEnvelopeData(envelope *expr.AttributeExpr, message *service.UserTypeData, sd *ServiceData) *StreamEnvelopeData { +func buildStreamEnvelopeData(envelope *expr.AttributeExpr, sd *ServiceData) *StreamEnvelopeData { body := envelope.Find("body") union := expr.AsUnion(body.Type) - scope := &protoBufScope{scope: sd.Scope} + scope := &protoBufScope{service: sd, pkg: sd.ClientProtobufPkgName} + serverScope := &protoBufScope{service: sd, pkg: sd.ServerProtobufPkgName} fieldName := scope.Field(body, "body", true) initialFieldName := scope.Field(union.Values[0].Attribute, union.Values[0].Name, true) streamItemFieldName := scope.Field(union.Values[1].Attribute, union.Values[1].Name, true) return &StreamEnvelopeData{ - FieldName: fieldName, - InitialFieldName: initialFieldName, - InitialWrapperRef: sd.PkgName + "." + protocOneofWrapperRef(message.VarName, initialFieldName), - StreamItemFieldName: streamItemFieldName, - StreamItemWrapperRef: sd.PkgName + "." + protocOneofWrapperRef(message.VarName, streamItemFieldName), + FieldName: fieldName, + InitialFieldName: initialFieldName, + InitialWrapperRef: scope.OneofWrapper(union.Values[0].Attribute), + ClientInitialWrapperRef: scope.OneofWrapper(union.Values[0].Attribute), + ServerInitialWrapperRef: serverScope.OneofWrapper(union.Values[0].Attribute), + StreamItemFieldName: streamItemFieldName, + StreamItemWrapperRef: scope.OneofWrapper(union.Values[1].Attribute), + ClientStreamItemWrapperRef: scope.OneofWrapper(union.Values[1].Attribute), + ServerStreamItemWrapperRef: serverScope.OneofWrapper(union.Values[1].Attribute), } } -// unalias returns the underlying attribute of the given attribute when its -// type is a user type, recursing until a non user type is found. Unlike -// unAlias it also resolves user types with non-primitive bases (e.g. named -// arrays) which extractMetadata needs to compute the native metadata type -// references. -func unalias(att *expr.AttributeExpr) *expr.AttributeExpr { - if ut, ok := att.Type.(expr.UserType); ok { - if _, ok := ut.Attribute().Type.(expr.Primitive); ok { - return ut.Attribute() +// nativeMetadataAttribute copies a gRPC metadata value as a primitive or array +// of primitives. It removes named service types but keeps their default values +// and validation rules on the copy used by the transport. +func nativeMetadataAttribute(source *expr.AttributeExpr) *expr.AttributeExpr { + if userType, ok := source.Type.(expr.UserType); ok { + result := nativeMetadataAttribute(userType.Attribute()) + mergeNativeMetadataContract(result, source) + return result + } + result := &expr.AttributeExpr{ + Description: source.Description, + DefaultValue: source.DefaultValue, + UserExamples: source.UserExamples, + } + if source.Validation != nil { + result.Validation = source.Validation.Dup() + } + if source.Meta != nil { + result.Meta = source.Meta.Dup() + } + switch actual := source.Type.(type) { + case expr.Primitive: + result.Type = actual + case *expr.Array: + result.Type = &expr.Array{ + ElemType: nativeMetadataAttribute(actual.ElemType), + NonNullableElems: actual.NonNullableElems, } - return unalias(ut.Attribute()) + default: + panic(fmt.Sprintf("invalid gRPC metadata type %s", source.Type.Name())) } - return att + stripMetadataServiceNames(result) + return result +} + +// mergeNativeMetadataContract applies constraints authored on an alias use to +// the detached contract inherited from the alias declaration. +func mergeNativeMetadataContract(target, source *expr.AttributeExpr) { + if source.Description != "" { + target.Description = source.Description + } + if source.DefaultValue != nil { + target.DefaultValue = source.DefaultValue + } + if source.Validation != nil { + if target.Validation == nil { + target.Validation = source.Validation.Dup() + } else { + target.Validation.Merge(source.Validation) + } + } + if source.Meta != nil { + if target.Meta == nil { + target.Meta = make(expr.MetaExpr) + } + for name, values := range source.Meta { + target.Meta[name] = append([]string(nil), values...) + } + } + stripMetadataServiceNames(target) } -// serviceTypeContext returns a contextual attribute for service types. Service -// types are Go types and uses non-pointers to hold attributes having default -// values. -func serviceTypeContext(pkg string, scope *codegen.NameScope) *codegen.AttributeContext { - return codegen.NewAttributeContext(false, false, true, pkg, scope) +// stripMetadataServiceNames removes Go service declaration overrides from a +// value rendered entirely in the generated gRPC client or server package. +func stripMetadataServiceNames(attribute *expr.AttributeExpr) { + for name := range attribute.Meta { + if strings.HasPrefix(name, "struct:") || name == "name:original" { + delete(attribute.Meta, name) + } + } } -// resultContext returns the method result attribute and the result context for the given -// endpoint. -func resultContext(e *expr.GRPCEndpointExpr, sd *ServiceData) (*expr.AttributeExpr, *codegen.AttributeContext) { - svc := sd.Service - md := svc.Method(e.Name()) +// serviceTypeContext returns a context that resolves service declarations from +// the generated gRPC package for side. +func (d *ServicesData) serviceTypeContext(sd *ServiceData, side string) *codegen.AttributeContext { + outputPackage := path.Join(d.GenPkg(), "grpc", sd.Service.PathName, side) + return &codegen.AttributeContext{ + UseDefault: true, + Scope: d.ServiceAttributor(sd.Service.Name, outputPackage), + } +} + +// resultContext returns the method result and the final service or view type +// names used by the generated client or server package. +func (d *ServicesData) resultContext(e *expr.GRPCEndpointExpr, sd *ServiceData, side string) (*expr.AttributeExpr, *codegen.AttributeContext) { + md := sd.Service.Method(e.Name()) if md.ViewedResult != nil { vresAtt := expr.AsObject(md.ViewedResult.Type).Attribute("projected") - // return projected type context - return vresAtt, codegen.NewAttributeContext(true, false, true, svc.ViewsPkg, svc.ViewScope) + outputPackage := path.Join(d.GenPkg(), "grpc", sd.Service.PathName, side) + return vresAtt, &codegen.AttributeContext{ + Pointer: true, + UseDefault: true, + Scope: d.ViewAttributor(sd.Service.Name, outputPackage), + } } - pkg := md.ResultLoc.PackageNameOrDefault(svc.PkgName) - return e.MethodExpr.Result, serviceTypeContext(pkg, svc.Scope) + result := e.MethodExpr.Result + return result, d.serviceTypeContext(sd, side).Enter(result) } // getPrimitive returns the primitive expression if the given expression is an alias to one @@ -1541,8 +2310,14 @@ func usesAnyType(endpoints []*expr.GRPCEndpointExpr, includeErrors bool) bool { return false } -// hasAnyType recursively checks if the given attribute uses the Any type. +// hasAnyType reports whether the attribute uses Any without following a named +// type more than once. func hasAnyType(att *expr.AttributeExpr) bool { + return hasAnyTypeR(att, make(map[expr.UserType]struct{})) +} + +// hasAnyTypeR walks arrays, maps, objects, unions, and named types. +func hasAnyTypeR(att *expr.AttributeExpr, seen map[expr.UserType]struct{}) bool { if att == nil { return false } @@ -1551,20 +2326,25 @@ func hasAnyType(att *expr.AttributeExpr) bool { } switch dt := att.Type.(type) { case expr.UserType: - return hasAnyType(dt.Attribute()) + origin := dt.Origin() + if _, ok := seen[origin]; ok { + return false + } + seen[origin] = struct{}{} + return hasAnyTypeR(dt.Attribute(), seen) case *expr.Object: for _, nat := range *dt { - if hasAnyType(nat.Attribute) { + if hasAnyTypeR(nat.Attribute, seen) { return true } } case *expr.Array: - return hasAnyType(dt.ElemType) + return hasAnyTypeR(dt.ElemType, seen) case *expr.Map: - return hasAnyType(dt.KeyType) || hasAnyType(dt.ElemType) + return hasAnyTypeR(dt.KeyType, seen) || hasAnyTypeR(dt.ElemType, seen) case *expr.Union: for _, nat := range dt.Values { - if hasAnyType(nat.Attribute) { + if hasAnyTypeR(nat.Attribute, seen) { return true } } diff --git a/grpc/codegen/service_data_traversal_test.go b/grpc/codegen/service_data_traversal_test.go new file mode 100644 index 0000000000..e3d755554c --- /dev/null +++ b/grpc/codegen/service_data_traversal_test.go @@ -0,0 +1,905 @@ +// This file verifies that gRPC validation discovery distinguishes unrelated +// declarations while stopping recursion through copied declarations. +package codegen + +import ( + "testing" + + "github.com/stretchr/testify/require" + + "goa.design/goa/v3/codegen" + "goa.design/goa/v3/codegen/service" + "goa.design/goa/v3/dsl" + "goa.design/goa/v3/expr" +) + +func TestCollectMessagesDistinguishesEqualNameAndUIDOrigins(t *testing.T) { + first := grpcMessageTraversalType("Shared", "shared", expr.String, "1") + second := grpcMessageTraversalType("Shared", "shared", expr.Int, "2") + root := &expr.AttributeExpr{Type: &expr.Object{ + {Name: "first", Attribute: &expr.AttributeExpr{Type: first}}, + {Name: "second", Attribute: &expr.AttributeExpr{Type: second}}, + }} + sd := grpcTraversalServiceData() + + messages := freezeTraversalMessages(t, sd, root) + require.Len(t, messages, 2) + require.NotEqual(t, messages[0].VarName, messages[1].VarName) + require.NotEqual(t, messages[0].Ref, messages[1].Ref) + require.Contains(t, messages[0].Def, "string value = 1") + require.Contains(t, messages[1].Def, "sint32 value = 2") +} + +func TestCollectMessagesDistinguishesOneOriginWithDifferentWireShape(t *testing.T) { + original := grpcMessageTraversalType("Shared", "shared", expr.String, "1") + first := expr.Dup(original).(expr.UserType) + second := expr.Dup(original).(expr.UserType) + expr.AsObject(second).Attribute("value").Meta["rpc:tag"] = []string{"2"} + root := &expr.AttributeExpr{Type: &expr.Object{ + {Name: "first", Attribute: &expr.AttributeExpr{Type: first}}, + {Name: "second", Attribute: &expr.AttributeExpr{Type: second}}, + }} + sd := grpcTraversalServiceData() + + messages := freezeTraversalMessages(t, sd, root) + require.Len(t, messages, 2) + require.NotEqual(t, messages[0].VarName, messages[1].VarName) + require.Contains(t, messages[0].Def, "string value = 1") + require.Contains(t, messages[1].Def, "string value = 2") +} + +func TestCollectMessagesReusesIdenticalDeclaration(t *testing.T) { + original := grpcMessageTraversalType("Shared", "shared", expr.String, "1") + first := expr.Dup(original).(expr.UserType) + second := expr.Dup(original).(expr.UserType) + root := &expr.AttributeExpr{Type: &expr.Object{ + {Name: "first", Attribute: &expr.AttributeExpr{Type: first}}, + {Name: "second", Attribute: &expr.AttributeExpr{Type: second}}, + }} + + messages := freezeTraversalMessages(t, grpcTraversalServiceData(), root) + require.Len(t, messages, 1) +} + +func TestCollectMessagesDistinguishesProtoOverrides(t *testing.T) { + original := grpcMessageTraversalType("Shared", "shared", expr.String, "1") + first := &expr.AttributeExpr{ + Type: expr.Dup(original), + Meta: expr.MetaExpr{"struct:name:proto": {"FirstWire"}}, + } + second := &expr.AttributeExpr{ + Type: expr.Dup(original), + Meta: expr.MetaExpr{"struct:name:proto": {"SecondWire"}}, + } + root := &expr.AttributeExpr{Type: &expr.Object{ + {Name: "first", Attribute: first}, + {Name: "second", Attribute: second}, + }} + + messages := freezeTraversalMessages(t, grpcTraversalServiceData(), root) + require.Len(t, messages, 2) + require.Equal(t, "FirstWire", messages[0].VarName) + require.Equal(t, "SecondWire", messages[1].VarName) +} + +func TestCollectMessagesDistinguishesSharedExplicitNameWithDifferentSchemas(t *testing.T) { + first := &expr.AttributeExpr{ + Type: grpcMessageTraversalType("First", "first", expr.String, "1"), + Meta: expr.MetaExpr{"struct:name:proto": {"SharedWire"}}, + } + second := &expr.AttributeExpr{ + Type: grpcMessageTraversalType("Second", "second", expr.Int, "1"), + Meta: expr.MetaExpr{"struct:name:proto": {"SharedWire"}}, + } + root := &expr.AttributeExpr{Type: &expr.Object{ + {Name: "first", Attribute: first}, + {Name: "second", Attribute: second}, + }} + + messages := freezeTraversalMessages(t, grpcTraversalServiceData(), root) + require.Len(t, messages, 2) + require.Equal(t, "SharedWire", messages[0].VarName) + require.Equal(t, "SharedWire2", messages[1].VarName) + require.Contains(t, messages[0].Def, "string value = 1") + require.Contains(t, messages[1].Def, "sint32 value = 1") +} + +func TestCollectMessagesDistinguishesSharedExplicitNameWithDifferentOrigins(t *testing.T) { + first := &expr.AttributeExpr{ + Type: grpcMessageTraversalType("First", "first", expr.String, "1"), + Meta: expr.MetaExpr{"struct:name:proto": {"SharedWire"}}, + } + second := &expr.AttributeExpr{ + Type: grpcMessageTraversalType("Second", "second", expr.String, "1"), + Meta: expr.MetaExpr{"struct:name:proto": {"SharedWire"}}, + } + root := &expr.AttributeExpr{Type: &expr.Object{ + {Name: "first", Attribute: first}, + {Name: "second", Attribute: second}, + }} + + messages := freezeTraversalMessages(t, grpcTraversalServiceData(), root) + require.Len(t, messages, 2) + require.Equal(t, "SharedWire", messages[0].VarName) + require.Equal(t, "SharedWire2", messages[1].VarName) +} + +func TestCollectMessagesUsesUnaryResultSourceForMixedResults(t *testing.T) { + firstResult := grpcMessageTraversalType("FirstResult", "first-result", expr.String, "1") + secondResult := grpcMessageTraversalType("SecondResult", "second-result", expr.String, "1") + streamingResult := grpcMessageTraversalType("StreamingResult", "streaming-result", expr.String, "1") + firstEndpoint := &expr.GRPCEndpointExpr{MethodExpr: &expr.MethodExpr{ + Result: &expr.AttributeExpr{Type: firstResult}, + StreamingResult: &expr.AttributeExpr{Type: streamingResult}, + }} + secondEndpoint := &expr.GRPCEndpointExpr{MethodExpr: &expr.MethodExpr{ + Result: &expr.AttributeExpr{Type: secondResult}, + StreamingResult: &expr.AttributeExpr{Type: streamingResult}, + }} + firstWire := &expr.AttributeExpr{ + Type: grpcMessageTraversalType("FirstWire", "first-wire", expr.String, "1"), + Meta: expr.MetaExpr{"struct:name:proto": {"SharedWire"}}, + } + secondWire := &expr.AttributeExpr{ + Type: grpcMessageTraversalType("SecondWire", "second-wire", expr.String, "1"), + Meta: expr.MetaExpr{"struct:name:proto": {"SharedWire"}}, + } + sd := grpcTraversalServiceData() + sd.protobuf = newProtobufPackageCatalog(sd.PkgName) + require.NoError(t, sd.protobuf.collectMessage(firstWire, protobufRootMessageSource(firstWire, firstEndpoint, nil, protobufResponseMessage))) + require.NoError(t, sd.protobuf.collectMessage(secondWire, protobufRootMessageSource(secondWire, secondEndpoint, nil, protobufResponseMessage))) + planTestProtobufCatalog(t, sd) + + messages := sd.protobuf.freezeMessages(sd) + require.Len(t, messages, 2) + require.Equal(t, "SharedWire", messages[0].VarName) + require.Equal(t, "SharedWire2", messages[1].VarName) +} + +func TestCollectMessagesStopsAtRecursiveCopy(t *testing.T) { + message := grpcMessageTraversalType("Recursive", "recursive", expr.String, "1") + object := expr.AsObject(message) + *object = append(*object, &expr.NamedAttributeExpr{ + Name: "next", + Attribute: &expr.AttributeExpr{ + Type: message, + Meta: expr.MetaExpr{"rpc:tag": {"2"}}, + }, + }) + + messages := freezeTraversalMessages(t, grpcTraversalServiceData(), &expr.AttributeExpr{Type: expr.Dup(message)}) + require.Len(t, messages, 1) + require.Contains(t, messages[0].Def, "Recursive next = 2") +} + +// TestCollectValidationsStopsAtRecursivePath checks that a recursive field +// calls the private validator chosen for the first nested occurrence instead +// of creating one function name per recursion depth. +func TestCollectValidationsStopsAtRecursivePath(t *testing.T) { + minimum := 2 + message := grpcValidationTraversalType("Recursive", "recursive", &expr.AttributeExpr{ + Type: expr.String, + Validation: &expr.ValidationExpr{MinLength: &minimum}, + }) + object := expr.AsObject(message) + next := &expr.AttributeExpr{ + Type: message, + Meta: expr.MetaExpr{"rpc:tag": {"2"}}, + } + *object = append(*object, &expr.NamedAttributeExpr{Name: "next", Attribute: next}) + root := &expr.AttributeExpr{Type: message} + sd := grpcTraversalServiceData() + freezeTraversalMessages(t, sd, root) + sd.protobuf.collectValidation(root, validateServer, grpcTraversalValidationSource(), "message", "message") + planTraversalValidations(t, sd) + sd.validations = sd.protobuf.freezeValidations(sd) + + require.Len(t, sd.validations, 2) + rootValidation := addValidation(root, sd, true) + nestedValidation := addValidation(next, sd, true) + require.Equal(t, "ValidateRecursive", rootValidation.Declaration.Name()) + require.Equal(t, "validateTestAPI_TestService_Recursive_At_next", nestedValidation.Declaration.Name()) + require.Contains(t, rootValidation.Def, "validateTestAPI_TestService_Recursive_At_next(message.Next)") + require.Contains(t, nestedValidation.Def, "validateTestAPI_TestService_Recursive_At_next(next.Next)") + require.Contains(t, nestedValidation.Def, `InvalidLengthError("next.value"`) +} + +func TestProtoBufMessageNameRequiresFrozenDeclaration(t *testing.T) { + message := grpcMessageTraversalType("Unbound", "unbound", expr.String, "1") + sd := grpcTraversalServiceData() + + require.Panics(t, func() { + protoBufMessageName(&expr.AttributeExpr{Type: message}, sd) + }) +} + +func TestProtoBufMessageNameIgnoresLateScopeAllocations(t *testing.T) { + message := grpcMessageTraversalType("Shared", "shared", expr.String, "1") + attribute := &expr.AttributeExpr{Type: message} + sd := grpcTraversalServiceData() + messages := freezeTraversalMessages(t, sd, attribute) + require.Len(t, messages, 1) + + sd.Scope.HashedUnique(grpcMessageTraversalType("Other", "other", expr.Int, "1"), "Shared") + require.Equal(t, messages[0].VarName, protoBufMessageName(attribute, sd)) +} + +// TestProtobufCopiesRequireRegistration checks that a copied protobuf value +// uses names only after the copy is connected to the original value. +func TestProtobufCopiesRequireRegistration(t *testing.T) { + minimum := 2 + state := &expr.AttributeExpr{Type: &expr.Union{ + TypeName: "State", + Values: []*expr.NamedAttributeExpr{ + { + Name: "active", + Attribute: &expr.AttributeExpr{ + Type: expr.String, + Meta: expr.MetaExpr{"rpc:tag": {"1"}}, + Validation: &expr.ValidationExpr{MinLength: &minimum}, + }, + }, + }, + }} + message := &expr.UserTypeExpr{ + AttributeExpr: &expr.AttributeExpr{Type: &expr.Object{ + {Name: "state", Attribute: state}, + }}, + TypeName: "Message", + UID: "message", + } + attribute := &expr.AttributeExpr{Type: message} + sd := grpcTraversalServiceData() + freezeTraversalMessages(t, sd, attribute) + sd.protobuf.collectValidation(attribute, validateServer, grpcTraversalValidationSource(), "message", "message") + planTraversalValidations(t, sd) + sd.validations = sd.protobuf.freezeValidations(sd) + + copy := expr.DupAtt(attribute) + copyState := expr.AsObject(copy.Type.(expr.UserType)).Attribute("state") + branch := state.Type.(*expr.Union).Values[0].Attribute + copyBranch := copyState.Type.(*expr.Union).Values[0].Attribute + require.Nil(t, sd.protobuf.messageRecord(copy)) + require.Panics(t, func() { + sd.protobuf.unionName(copyState) + }) + _, ok := sd.protobuf.plan.wrapperName(copyBranch) + require.False(t, ok) + + sd.protobuf.plan.bindAttributeCopy(attribute, copy) + require.Same(t, sd.protobuf.messageRecord(attribute), sd.protobuf.messageRecord(copy)) + require.Equal(t, sd.protobuf.unionName(state), sd.protobuf.unionName(copyState)) + require.Nil(t, sd.protobuf.validationRecord(copy, validateServer)) + originalWrapper, ok := sd.protobuf.plan.wrapperName(branch) + require.True(t, ok) + copyWrapper, ok := sd.protobuf.plan.wrapperName(copyBranch) + require.True(t, ok) + require.Equal(t, originalWrapper, copyWrapper) +} + +// TestProtobufValidationScopeKeepsMessageNameSeparate checks that a validation +// function collision cannot change the protobuf message name used in its body. +func TestProtobufValidationScopeKeepsMessageNameSeparate(t *testing.T) { + minimum := 2 + message := grpcValidationTraversalType("Message", "message", &expr.AttributeExpr{ + Type: expr.String, + Validation: &expr.ValidationExpr{MinLength: &minimum}, + }) + attribute := &expr.AttributeExpr{Type: message} + sd := grpcTraversalServiceData() + freezeTraversalMessages(t, sd, attribute) + sd.protobuf.collectValidation(attribute, validateServer, grpcTraversalValidationSource(), "message", "message") + planTraversalValidations(t, sd) + record := sd.protobuf.validationRecord(attribute, validateServer) + require.NotNil(t, record) + record.message.name = "RetainedMessage2" + scope := &protobufValidationScope{ + protoBufScope: &protoBufScope{service: sd}, + catalog: sd.protobuf, + side: validateServer, + message: record.message, + parent: message, + } + + require.Equal(t, record.message.name, scope.Name(attribute, "", false, false)) +} + +func TestAddValidationDistinguishesRulesForOneWireDeclaration(t *testing.T) { + original := grpcMessageTraversalType("Shared", "shared", expr.String, "1") + first := expr.Dup(original).(expr.UserType) + second := expr.Dup(original).(expr.UserType) + firstMinimum := 2 + secondMinimum := 5 + expr.AsObject(first).Attribute("value").Validation = &expr.ValidationExpr{MinLength: &firstMinimum} + expr.AsObject(second).Attribute("value").Validation = &expr.ValidationExpr{MinLength: &secondMinimum} + sd := grpcTraversalServiceData() + firstAttribute := &expr.AttributeExpr{Type: first} + secondAttribute := &expr.AttributeExpr{Type: second} + root := &expr.AttributeExpr{Type: &expr.Object{ + {Name: "first", Attribute: firstAttribute}, + {Name: "second", Attribute: secondAttribute}, + }} + freezeTraversalMessages(t, sd, root) + source := grpcTraversalValidationSource() + sd.protobuf.collectValidation(firstAttribute, validateServer, source.field("first"), "message", "message") + sd.protobuf.collectValidation(secondAttribute, validateServer, source.field("second"), "message", "message") + planTraversalValidations(t, sd) + sd.validations = sd.protobuf.freezeValidations(sd) + + firstValidation := addValidation(firstAttribute, sd, true) + secondValidation := addValidation(secondAttribute, sd, true) + require.NotNil(t, firstValidation) + require.NotNil(t, secondValidation) + require.Len(t, sd.validations, 2) + require.NotEqual(t, firstValidation.Declaration.Name(), secondValidation.Declaration.Name()) + require.Equal(t, "validateTestAPI_TestService_Shared_At_message_From_Call_Request_Field_first", firstValidation.Declaration.Name()) + require.Equal(t, "validateTestAPI_TestService_Shared_At_message_From_Call_Request_Field_second", secondValidation.Declaration.Name()) + require.Contains(t, firstValidation.Def, `InvalidLengthError("message.value", *message.Value, utf8.RuneCountInString(*message.Value), 2, true)`) + require.Contains(t, secondValidation.Def, `InvalidLengthError("message.value", *message.Value, utf8.RuneCountInString(*message.Value), 5, true)`) +} + +// TestGRPCValidationNamesDescribeTheirRootAndPath checks the complete private +// name for each nested validator role. +func TestGRPCValidationNamesDescribeTheirRootAndPath(t *testing.T) { + message := &protobufMessageRecord{plannedName: "Message2"} + source := func(role protobufValidationRole) protobufValidationSource { + return protobufValidationSource{ + api: "Example", + service: "Storage", + method: "Store", + role: role, + } + } + request := source(protobufRequestValidation).field("first").field("value") + response := source(protobufResponseValidation).field("result").field("item") + response.view = "compact" + failure := source(protobufErrorValidation).field("cause") + failure.error = "bad_request" + stream := source(protobufStreamingRequestValidation).field("items").arrayElement() + for _, test := range []struct { + name string + source protobufValidationSource + expected string + }{ + {"request", request, "validateExample_Storage_Message2_At_value_From_Store_Request_Field_first_Field_value"}, + {"viewed response", response, "validateExample_Storage_Message2_At_value_From_Store_Response_View_compact_Field_result_Field_item"}, + {"error", failure, "validateExample_Storage_Message2_At_value_From_Store_Error_bad_5F_request_Field_cause"}, + {"stream", stream, "validateExample_Storage_Message2_At_value_From_Store_StreamingRequest_Field_items_ArrayElement"}, + } { + t.Run(test.name, func(t *testing.T) { + actual, visibility := grpcValidationName(&protobufValidationRecord{ + message: message, + source: test.source, + targetName: "value", + contextName: "value", + }, true) + require.Equal(t, test.expected, actual) + require.Equal(t, codegen.UnexportedName, visibility) + }) + } +} + +// TestGRPCValidationNamesDistinguishFieldAndCollectionPaths checks that a +// field name cannot collide with the generated label for an array element. +func TestGRPCValidationNamesDistinguishFieldAndCollectionPaths(t *testing.T) { + source := grpcTraversalValidationSource() + direct := source.field("items_element") + nested := source.field("items").arrayElement() + message := &protobufMessageRecord{plannedName: "Message"} + + directName, _ := grpcValidationName(&protobufValidationRecord{ + message: message, + source: direct, + targetName: "elem", + contextName: "elem", + }, true) + nestedName, _ := grpcValidationName(&protobufValidationRecord{ + message: message, + source: nested, + targetName: "elem", + contextName: "elem", + }, true) + require.NotEqual(t, directName, nestedName) + + dashName, _ := grpcValidationName(&protobufValidationRecord{ + message: message, + source: source.field("foo-bar"), + targetName: "elem", + contextName: "elem", + }, true) + underscoreName, _ := grpcValidationName(&protobufValidationRecord{ + message: message, + source: source.field("foo_bar"), + targetName: "elem", + contextName: "elem", + }, true) + require.NotEqual(t, dashName, underscoreName) +} + +// TestCollectValidationsKeepsRootExportedAfterNestedUse checks that endpoint +// order cannot replace a public root validator with a private nested one. +func TestCollectValidationsKeepsRootExportedAfterNestedUse(t *testing.T) { + names := func(nestedFirst bool) (string, string) { + minimum := 2 + child := grpcValidationTraversalType("Child", "child", &expr.AttributeExpr{ + Type: expr.String, + Validation: &expr.ValidationExpr{MinLength: &minimum}, + }) + nested := &expr.AttributeExpr{Type: expr.Dup(child)} + root := &expr.AttributeExpr{Type: expr.Dup(child)} + container := &expr.AttributeExpr{Type: &expr.Object{ + {Name: "nested", Attribute: nested}, + {Name: "root", Attribute: root}, + }} + sd := grpcTraversalServiceData() + freezeTraversalMessages(t, sd, container) + nestedSource := grpcTraversalValidationSource().field("message") + rootSource := grpcTraversalValidationSource() + rootSource.method = "Root" + if nestedFirst { + sd.protobuf.collectValidation(nested, validateServer, nestedSource, "message", "message") + sd.protobuf.collectValidation(root, validateServer, rootSource, "message", "message") + } else { + sd.protobuf.collectValidation(root, validateServer, rootSource, "message", "message") + sd.protobuf.collectValidation(nested, validateServer, nestedSource, "message", "message") + } + planTraversalValidations(t, sd) + sd.validations = sd.protobuf.freezeValidations(sd) + + rootValidation := addValidation(root, sd, true) + nestedValidation := addValidation(nested, sd, true) + require.NotSame(t, rootValidation, nestedValidation) + return rootValidation.Declaration.Name(), nestedValidation.Declaration.Name() + } + + rootFirst, nestedAfterRoot := names(false) + rootAfterNested, nestedFirst := names(true) + require.Equal(t, "ValidateChild", rootFirst) + require.Equal(t, rootFirst, rootAfterNested) + require.Equal(t, nestedAfterRoot, nestedFirst) +} + +// TestNestedValidationNameIgnoresInsertedSibling checks that adding another +// use of the same message cannot renumber an existing helper. +func TestNestedValidationNameIgnoresInsertedSibling(t *testing.T) { + name := func(withSibling bool) string { + root := RunGRPCDSL(t, nestedValidationInsertionDSL(withSibling)) + service := CreateGRPCServices(root).Get("StableValidationNames") + for _, validation := range service.protobuf.validators { + if validation.side == validateServer && validation.source.path == "target" { + return validation.declaration.Name() + } + } + t.Errorf("missing target validator") + return "" + } + + require.Equal(t, "validatetest_20_api_StableValidationNames_Child2_At_target", name(false)) + require.Equal(t, "validatetest_20_api_StableValidationNames_Child2_At_target", name(true)) +} + +func TestAddValidationDistinguishesRequirednessForOneWireDeclaration(t *testing.T) { + minimum := 2 + original := grpcMessageTraversalType("Shared", "shared", expr.String, "1") + first := expr.Dup(original).(expr.UserType) + second := expr.Dup(original).(expr.UserType) + first.Attribute().Validation = &expr.ValidationExpr{Required: []string{"value"}} + expr.AsObject(first).Attribute("value").Validation = &expr.ValidationExpr{MinLength: &minimum} + expr.AsObject(second).Attribute("value").Validation = &expr.ValidationExpr{MinLength: &minimum} + firstAttribute := &expr.AttributeExpr{Type: first} + secondAttribute := &expr.AttributeExpr{Type: second} + sd := grpcTraversalServiceData() + root := &expr.AttributeExpr{Type: &expr.Object{ + {Name: "first", Attribute: firstAttribute}, + {Name: "second", Attribute: secondAttribute}, + }} + freezeTraversalMessages(t, sd, root) + firstSource := grpcTraversalValidationSource() + secondSource := grpcTraversalValidationSource() + secondSource.method = "OtherCall" + sd.protobuf.collectValidation(firstAttribute, validateServer, firstSource, "message", "message") + sd.protobuf.collectValidation(secondAttribute, validateServer, secondSource, "message", "message") + planTraversalValidations(t, sd) + sd.validations = sd.protobuf.freezeValidations(sd) + + firstValidation := addValidation(firstAttribute, sd, true) + secondValidation := addValidation(secondAttribute, sd, true) + require.NotNil(t, firstValidation) + require.NotNil(t, secondValidation) + require.Len(t, sd.validations, 2) + require.NotEqual(t, firstValidation.Declaration.Name(), secondValidation.Declaration.Name()) + require.Contains(t, firstValidation.Def, `MissingFieldError("value", "message")`) + require.NotContains(t, secondValidation.Def, "MissingFieldError") + require.Contains(t, secondValidation.Def, "if message.Value != nil") +} + +func TestAddValidationDistinguishesGeneratedSide(t *testing.T) { + minimum := 2 + message := grpcMessageTraversalType("Shared", "shared", expr.String, "1") + expr.AsObject(message).Attribute("value").Validation = &expr.ValidationExpr{MinLength: &minimum} + attribute := &expr.AttributeExpr{Type: message} + sd := grpcTraversalServiceData() + freezeTraversalMessages(t, sd, attribute) + source := grpcTraversalValidationSource() + sd.protobuf.collectValidation(attribute, validateServer, source, "message", "message") + response := source + response.role = protobufResponseValidation + sd.protobuf.collectValidation(attribute, validateClient, response, "message", "message") + planTraversalValidations(t, sd) + sd.validations = sd.protobuf.freezeValidations(sd) + + server := addValidation(attribute, sd, true) + client := addValidation(attribute, sd, false) + require.NotNil(t, server) + require.NotNil(t, client) + require.Len(t, sd.validations, 2) + require.Equal(t, validateServer, server.Kind) + require.Equal(t, validateClient, client.Kind) +} + +func TestAddValidationKeepsNamesStableForIdenticalRulesAtDifferentPaths(t *testing.T) { + minimum := 2 + message := grpcMessageTraversalType("Shared", "shared", expr.String, "1") + expr.AsObject(message).Attribute("value").Validation = &expr.ValidationExpr{MinLength: &minimum} + first := &expr.AttributeExpr{Type: expr.Dup(message)} + second := &expr.AttributeExpr{Type: expr.Dup(message)} + sd := grpcTraversalServiceData() + root := &expr.AttributeExpr{Type: &expr.Object{ + {Name: "first", Attribute: first}, + {Name: "second", Attribute: second}, + }} + freezeTraversalMessages(t, sd, root) + source := grpcTraversalValidationSource() + sd.protobuf.collectValidation(first, validateServer, source.field("first"), "message", "message") + sd.protobuf.collectValidation(second, validateServer, source.field("second"), "message", "message") + planTraversalValidations(t, sd) + sd.validations = sd.protobuf.freezeValidations(sd) + + require.Len(t, sd.validations, 1) + firstValidation := addValidation(first, sd, true) + secondValidation := addValidation(second, sd, true) + require.Same(t, firstValidation, secondValidation) + require.Equal(t, "validateTestAPI_TestService_Shared_At_message", firstValidation.Declaration.Name()) +} + +// TestSharedValidationChoosesTheSameSourceAfterTraversalOrderChanges checks +// that a shared body keeps one exact name when a different body requires the +// longer source-based spelling. +func TestSharedValidationChoosesTheSameSourceAfterTraversalOrderChanges(t *testing.T) { + name := func(reverse bool) string { + minimum := 2 + original := grpcMessageTraversalType("Shared", "shared", expr.String, "1") + first := expr.Dup(original).(expr.UserType) + second := expr.Dup(original).(expr.UserType) + different := expr.Dup(original).(expr.UserType) + for _, message := range []expr.UserType{first, second} { + message.Attribute().Validation = &expr.ValidationExpr{Required: []string{"value"}} + expr.AsObject(message).Attribute("value").Validation = &expr.ValidationExpr{MinLength: &minimum} + } + expr.AsObject(different).Attribute("value").Validation = &expr.ValidationExpr{MinLength: &minimum} + firstAttribute := &expr.AttributeExpr{Type: first} + secondAttribute := &expr.AttributeExpr{Type: second} + differentAttribute := &expr.AttributeExpr{Type: different} + sd := grpcTraversalServiceData() + root := &expr.AttributeExpr{Type: &expr.Object{ + {Name: "first", Attribute: firstAttribute}, + {Name: "second", Attribute: secondAttribute}, + {Name: "different", Attribute: differentAttribute}, + }} + freezeTraversalMessages(t, sd, root) + source := grpcTraversalValidationSource() + ordered := []struct { + attribute *expr.AttributeExpr + path string + }{ + {attribute: firstAttribute, path: "zeta"}, + {attribute: secondAttribute, path: "alpha"}, + } + if reverse { + ordered[0], ordered[1] = ordered[1], ordered[0] + } + for _, item := range ordered { + sd.protobuf.collectValidation(item.attribute, validateServer, source.field(item.path), "message", "message") + } + sd.protobuf.collectValidation(differentAttribute, validateServer, source.field("different"), "message", "message") + planTraversalValidations(t, sd) + sd.validations = sd.protobuf.freezeValidations(sd) + + validation := addValidation(firstAttribute, sd, true) + require.NotNil(t, validation) + return validation.Declaration.Name() + } + + want := "validateTestAPI_TestService_Shared_At_message_From_Call_Request_Field_alpha" + require.Equal(t, want, name(false)) + require.Equal(t, want, name(true)) +} + +// TestAddValidationKeepsDistinctErrorPaths checks that one shared protobuf +// message gets separate validators when its callers need different field paths. +func TestAddValidationKeepsDistinctErrorPaths(t *testing.T) { + minimum := 2 + shared := grpcValidationTraversalType("Shared2", "shared", &expr.AttributeExpr{ + Type: expr.String, + Validation: &expr.ValidationExpr{MinLength: &minimum}, + }) + first := &expr.AttributeExpr{Type: shared, Meta: expr.MetaExpr{"rpc:tag": {"1"}}} + secondElement := &expr.AttributeExpr{Type: shared} + second := &expr.AttributeExpr{ + Type: &expr.Array{ElemType: secondElement}, + Meta: expr.MetaExpr{"rpc:tag": {"2"}}, + } + rootType := &expr.UserTypeExpr{ + TypeName: "Root", + UID: "root", + AttributeExpr: &expr.AttributeExpr{Type: &expr.Object{ + {Name: "first", Attribute: first}, + {Name: "second", Attribute: second}, + }}, + } + root := &expr.AttributeExpr{Type: rootType} + sd := grpcTraversalServiceData() + freezeTraversalMessages(t, sd, root) + sd.protobuf.collectValidation(root, validateServer, grpcTraversalValidationSource(), "message", "message") + planTraversalValidations(t, sd) + sd.validations = sd.protobuf.freezeValidations(sd) + + firstValidation := addValidation(first, sd, true) + secondValidation := addValidation(secondElement, sd, true) + require.NotNil(t, firstValidation) + require.NotNil(t, secondValidation) + require.NotSame(t, firstValidation, secondValidation) + require.Equal(t, "validateTestAPI_TestService_Shared2_At_first", firstValidation.Declaration.Name()) + require.Equal(t, "validateTestAPI_TestService_Shared2_At_elem", secondValidation.Declaration.Name()) + require.Contains(t, firstValidation.Def, `InvalidLengthError("first.value"`) + require.Contains(t, secondValidation.Def, `InvalidLengthError("elem.value"`) + rootValidation := addValidation(root, sd, true) + require.Equal(t, "ValidateRoot", rootValidation.Declaration.Name()) + require.Contains(t, rootValidation.Def, firstValidation.Declaration.Name()+"(message.First)") + require.Contains(t, rootValidation.Def, secondValidation.Declaration.Name()+"(e)") +} + +// TestCollectValidationsSharesRecursiveSiblings checks that identical array +// element checks share a name that does not depend on the first sibling. +func TestCollectValidationsSharesRecursiveSiblings(t *testing.T) { + minimum := 2 + child := grpcValidationTraversalType("Child", "child", &expr.AttributeExpr{ + Type: expr.String, + Validation: &expr.ValidationExpr{MinLength: &minimum}, + }) + childObject := expr.AsObject(child) + *childObject = append(*childObject, &expr.NamedAttributeExpr{ + Name: "next", + Attribute: &expr.AttributeExpr{ + Type: child, + Meta: expr.MetaExpr{"rpc:tag": {"2"}}, + }, + }) + firstElement := &expr.AttributeExpr{Type: child} + secondElement := &expr.AttributeExpr{Type: child} + root := &expr.AttributeExpr{Type: &expr.UserTypeExpr{ + TypeName: "Root", + UID: "root", + AttributeExpr: &expr.AttributeExpr{Type: &expr.Object{ + {Name: "first", Attribute: &expr.AttributeExpr{ + Type: &expr.Array{ElemType: firstElement}, + Meta: expr.MetaExpr{"rpc:tag": {"1"}}, + }}, + {Name: "second", Attribute: &expr.AttributeExpr{ + Type: &expr.Array{ElemType: secondElement}, + Meta: expr.MetaExpr{"rpc:tag": {"2"}}, + }}, + }}, + }} + sd := grpcTraversalServiceData() + freezeTraversalMessages(t, sd, root) + sd.protobuf.collectValidation(root, validateServer, grpcTraversalValidationSource(), "message", "message") + planTraversalValidations(t, sd) + sd.validations = sd.protobuf.freezeValidations(sd) + + firstValidation := addValidation(firstElement, sd, true) + secondValidation := addValidation(secondElement, sd, true) + require.NotNil(t, firstValidation) + require.NotNil(t, secondValidation) + require.Same(t, firstValidation, secondValidation) + require.Equal(t, "validateTestAPI_TestService_Child_At_elem", firstValidation.Declaration.Name()) +} + +func TestCollectValidationsDistinguishesEqualUIDOrigins(t *testing.T) { + minimumLength := 3 + minimum := 5.0 + first := grpcValidationTraversalType("First", "shared", &expr.AttributeExpr{ + Type: expr.String, + Validation: &expr.ValidationExpr{MinLength: &minimumLength}, + }) + second := grpcValidationTraversalType("Second", "shared", &expr.AttributeExpr{ + Type: expr.Int, + Validation: &expr.ValidationExpr{Minimum: &minimum}, + }) + root := &expr.AttributeExpr{Type: &expr.Object{ + {Name: "first", Attribute: &expr.AttributeExpr{Type: first}}, + {Name: "second", Attribute: &expr.AttributeExpr{Type: second}}, + }} + sd := &ServiceData{PkgName: "pb", Scope: codegen.NewNameScope()} + + freezeTraversalMessages(t, sd, root) + sd.protobuf.collectValidation(root, validateServer, grpcTraversalValidationSource(), "message", "message") + planTraversalValidations(t, sd) + sd.validations = sd.protobuf.freezeValidations(sd) + names := make([]string, 0, len(sd.validations)) + for _, validation := range sd.validations { + names = append(names, validation.SrcName) + } + require.ElementsMatch(t, []string{"First", "Second"}, names) +} + +// grpcTraversalValidationSource describes the request used by focused +// validation tests. +func grpcTraversalValidationSource() protobufValidationSource { + return protobufValidationSource{ + api: "TestAPI", + service: "TestService", + method: "Call", + role: protobufRequestValidation, + } +} + +// planTraversalValidations chooses the function names used by these focused +// message and validation tests before building the function bodies. +func planTraversalValidations(t *testing.T, sd *ServiceData) { + t.Helper() + generation, err := codegen.NewGeneration("generated.local/gen", nil) + require.NoError(t, err) + client, err := generation.ClaimPackage("generated.local/gen/grpc/test/client") + require.NoError(t, err) + server, err := generation.ClaimPackage("generated.local/gen/grpc/test/server") + require.NoError(t, err) + privateNameCounts := make(map[grpcValidationNameKey]int) + for _, record := range sd.protobuf.validators { + if record.source.path != "" { + privateNameCounts[grpcValidationKey(record)]++ + } + } + for _, record := range sd.protobuf.validators { + pkg := client + side := grpcClientPackage + if record.side == validateServer { + pkg = server + side = grpcServerPackage + } + id := grpcSymbolID{ + side: side, + role: grpcValidationRole, + api: record.source.api, + service: record.source.service, + method: record.source.method, + subject: record.source.error, + view: record.source.view, + path: record.source.path, + operation: int(record.source.role), + } + record.declaration = grpcValidationDeclaration( + record, + id, + privateNameCounts[grpcValidationKey(record)] > 1, + ) + require.NoError(t, pkg.DeclareName(record.declaration)) + } + require.NoError(t, generation.Freeze()) +} + +// grpcValidationTraversalType builds an authored message declaration with one +// constrained field so validation discovery must emit a helper for it. +func grpcValidationTraversalType(name, uid string, field *expr.AttributeExpr) *expr.UserTypeExpr { + if field.Meta == nil { + field.Meta = make(expr.MetaExpr) + } + field.Meta["rpc:tag"] = []string{"1"} + return &expr.UserTypeExpr{ + AttributeExpr: &expr.AttributeExpr{Type: &expr.Object{ + {Name: "value", Attribute: field}, + }}, + TypeName: name, + UID: uid, + } +} + +// nestedValidationInsertionDSL builds the same target field with or without an +// earlier sibling that uses the same nested type. +func nestedValidationInsertionDSL(withSibling bool) func() { + return func() { + child := dsl.Type("Child2", func() { + dsl.Field(1, "value", dsl.String) + dsl.Required("value") + }) + dsl.Service("StableValidationNames", func() { + dsl.Method("Store", func() { + dsl.Payload(func() { + if withSibling { + dsl.Field(1, "alpha", child) + } + dsl.Field(2, "target", child) + dsl.Required("target") + }) + dsl.GRPC(func() {}) + }) + }) + } +} + +// grpcMessageTraversalType builds a protobuf message declaration with one +// explicitly numbered field. +func grpcMessageTraversalType(name, uid string, fieldType expr.DataType, tag string) *expr.UserTypeExpr { + return &expr.UserTypeExpr{ + AttributeExpr: &expr.AttributeExpr{Type: &expr.Object{ + {Name: "value", Attribute: &expr.AttributeExpr{ + Type: fieldType, + Meta: expr.MetaExpr{"rpc:tag": {tag}}, + }}, + }}, + TypeName: name, + UID: uid, + } +} + +// grpcTraversalServiceData supplies the protobuf package and field scope used +// by focused declaration and validator catalog tests. +func grpcTraversalServiceData() *ServiceData { + return &ServiceData{ + Name: "Service", + PkgName: "servicepb", + Scope: codegen.NewNameScope(), + Service: &service.Data{}, + } +} + +// freezeTraversalMessages collects and freezes every message reachable from +// root in the focused test protobuf package. +func freezeTraversalMessages(t *testing.T, sd *ServiceData, root *expr.AttributeExpr) []*service.UserTypeData { + sd.protobuf = newProtobufPackageCatalog(sd.PkgName) + require.NoError(t, sd.protobuf.collectMessage(root, protobufMessageSource{})) + planTestProtobufCatalog(t, sd) + sd.Messages = sd.protobuf.freezeMessages(sd) + return sd.Messages +} + +// planTestProtobufCatalog chooses names for the messages and validation +// functions created directly by these focused tests. +func planTestProtobufCatalog(t *testing.T, sd *ServiceData) { + t.Helper() + require.NotEmpty(t, sd.protobuf.messages) + message := sd.protobuf.messages[0].uses[0] + serviceExpr := &expr.ServiceExpr{Name: "GoaCatalogTestService"} + grpcService := &expr.GRPCServiceExpr{ServiceExpr: serviceExpr} + method := &expr.MethodExpr{ + Name: "Call", + Service: serviceExpr, + Payload: &expr.AttributeExpr{Type: expr.Empty}, + StreamingPayload: &expr.AttributeExpr{Type: expr.Empty}, + Result: &expr.AttributeExpr{Type: expr.Empty}, + StreamingResult: &expr.AttributeExpr{Type: expr.Empty}, + } + endpoint := &expr.GRPCEndpointExpr{MethodExpr: method, Service: grpcService} + grpcService.GRPCEndpoints = []*expr.GRPCEndpointExpr{endpoint} + plan := &protobufServicePlan{ + expression: grpcService, + catalog: sd.protobuf, + messages: []*protobufEndpointMessages{{request: message, response: message}}, + protoPackage: "goa_catalog_test", + methods: map[*expr.GRPCEndpointExpr]string{}, + names: make(map[protocNameKey]*codegen.NameDeclaration), + localNames: make(map[protocNameKey]string), + fields: make(map[*expr.AttributeExpr]protocNameKey), + sourceFields: make(map[*expr.AttributeExpr]string), + sourceOneofs: make(map[*expr.AttributeExpr]string), + wrappers: make(map[*expr.AttributeExpr]protocNameKey), + oneofs: make(map[*expr.AttributeExpr]protocNameKey), + } + sd.protobuf.plan = plan + generation, err := codegen.NewGeneration("generated.local/gen", nil) + require.NoError(t, err) + pkg, err := generation.ClaimPackage("generated.local/gen/grpc/test/pb") + require.NoError(t, err) + require.NoError(t, plan.chooseNames(pkg, make(map[string]struct{}))) + require.NoError(t, generation.Freeze()) +} diff --git a/grpc/codegen/service_imports.go b/grpc/codegen/service_imports.go new file mode 100644 index 0000000000..fb96388d57 --- /dev/null +++ b/grpc/codegen/service_imports.go @@ -0,0 +1,117 @@ +// This file attaches the imports chosen for each generated gRPC file before +// package names became final. +package codegen + +import ( + "fmt" + "strings" + + "goa.design/goa/v3/codegen" + "goa.design/goa/v3/expr" +) + +// addEndpointImports replaces a generated file's provisional header imports +// with the exact linked imports saved for that file. +func addEndpointImports(file *codegen.File, services *ServicesData) *codegen.File { + imports := services.fileImports[grpcFilePathKey(file.Path)] + if imports == nil { + panic(fmt.Sprintf("gRPC file %q has no planned imports", file.Path)) + } + header := file.SectionTemplates[0].Data.(map[string]any) + header["Imports"] = imports.Imports() + return file +} + +// grpcFilePathKey makes generated file paths independent of the host path +// separator before they are stored or looked up. +func grpcFilePathKey(filePath string) string { + return strings.ReplaceAll(filePath, "\\", "/") +} + +// grpcEndpointAttributes returns the named service attributes referenced by +// the supplied gRPC endpoint sections. +func grpcEndpointAttributes(endpoints ...*expr.GRPCEndpointExpr) []*expr.AttributeExpr { + var attributes []*expr.AttributeExpr + for _, endpoint := range endpoints { + method := endpoint.MethodExpr + attributes = append(attributes, method.Payload, method.StreamingPayload, method.Result, method.StreamingResult) + for _, methodError := range method.Errors { + attributes = append(attributes, methodError.AttributeExpr) + } + for _, transportError := range endpoint.GRPCErrors { + attributes = append(attributes, transportError.ErrorExpr.AttributeExpr) + } + } + return attributes +} + +// grpcErrorAttributes returns the service error values named by generated +// gRPC handlers when they attach typed details to an error response. +func grpcErrorAttributes(endpoints ...*expr.GRPCEndpointExpr) []*expr.AttributeExpr { + var attributes []*expr.AttributeExpr + for _, endpoint := range endpoints { + for _, transportError := range endpoint.GRPCErrors { + if expr.IsErrorResult(transportError.ErrorExpr.Type) || !expr.IsObject(transportError.ErrorExpr.Type) { + continue + } + attributes = append(attributes, transportError.ErrorExpr.AttributeExpr) + } + } + return attributes +} + +// grpcCodecAttributes returns the payload and result values converted by the +// client and server encoder files. +func grpcCodecAttributes(endpoints ...*expr.GRPCEndpointExpr) []*expr.AttributeExpr { + attributes := make([]*expr.AttributeExpr, 0, 2*len(endpoints)) + for _, endpoint := range endpoints { + attributes = append(attributes, endpoint.MethodExpr.Payload, endpoint.MethodExpr.Result) + } + return attributes +} + +// grpcPayloadAttributes returns the service payloads constructed by generated +// command-line clients. +func grpcPayloadAttributes(endpoints ...*expr.GRPCEndpointExpr) []*expr.AttributeExpr { + attributes := make([]*expr.AttributeExpr, 0, len(endpoints)) + for _, endpoint := range endpoints { + attributes = append(attributes, endpoint.MethodExpr.Payload) + } + return attributes +} + +// grpcStreamAttributes returns the service values named by generated stream +// methods in client.go and server.go. +func grpcStreamAttributes(endpoints ...*expr.GRPCEndpointExpr) []*expr.AttributeExpr { + var attributes []*expr.AttributeExpr + for _, endpoint := range endpoints { + method := endpoint.MethodExpr + if !method.IsStreaming() { + continue + } + attributes = append(attributes, method.StreamingPayload, method.Result, method.StreamingResult) + } + return attributes +} + +// grpcServiceHasStreaming reports whether client.go and server.go write a +// generated service stream interface. +func grpcServiceHasStreaming(service *expr.GRPCServiceExpr) bool { + for _, endpoint := range service.GRPCEndpoints { + if endpoint.MethodExpr.IsStreaming() { + return true + } + } + return false +} + +// grpcAttributesHaveValues reports whether a generated file names at least +// one non-empty service value. +func grpcAttributesHaveValues(attributes []*expr.AttributeExpr) bool { + for _, attribute := range attributes { + if attribute != nil && attribute.Type != expr.Empty { + return true + } + } + return false +} diff --git a/grpc/codegen/service_metadata_reference_test.go b/grpc/codegen/service_metadata_reference_test.go new file mode 100644 index 0000000000..4413af8566 --- /dev/null +++ b/grpc/codegen/service_metadata_reference_test.go @@ -0,0 +1,79 @@ +// This file verifies that gRPC metadata uses detached native wire values and +// canonical conversions to frozen service declarations. +package codegen + +import ( + "testing" + + "github.com/stretchr/testify/require" + + "goa.design/goa/v3/dsl" + "goa.design/goa/v3/expr" +) + +func TestMetadataConversionUsesDetachedWireAndFrozenServiceDeclaration(t *testing.T) { + root := expr.RunDSL(t, func() { + value := dsl.Type("Value", dsl.String, func() { + dsl.Meta("struct:pkg:path", "domain/shared") + }) + payload := dsl.Type("Payload", func() { + dsl.Field(1, "value", value) + dsl.Required("value") + }) + dsl.Service("Values", func() { + dsl.Method("Read", func() { + dsl.Payload(payload) + dsl.GRPC(func() { + dsl.Metadata(func() { dsl.Attribute("value") }) + }) + }) + }) + }) + + metadata := CreateGRPCServices(root).Get("Values").Endpoint("Read").Request.Metadata + require.Len(t, metadata, 1) + require.False(t, metadata[0].Map) + require.False(t, metadata[0].MapStringSlice) + require.Equal(t, "string", metadata[0].TypeRef) + require.NotContains(t, metadata[0].WireAttribute.Meta, "struct:pkg:path") + require.Contains(t, metadata[0].EncodeCode, "string(payload.Value)") + require.Contains(t, initArgsFromMetadata(metadata)[0].InitCode, "shared.Value(value)") +} + +func TestMetadataConversionRecursivelyDetachesNamedArrayElements(t *testing.T) { + root := expr.RunDSL(t, func() { + value := dsl.Type("Value", dsl.Int, func() { + dsl.Enum(1, 2) + dsl.Meta("struct:pkg:path", "domain/shared") + }) + values := dsl.Type("Values", dsl.ArrayOf(value), func() { + dsl.Meta("struct:pkg:path", "domain/shared") + }) + payload := dsl.Type("Payload", func() { + dsl.Field(1, "values", values) + dsl.Required("values") + }) + dsl.Service("Values", func() { + dsl.Method("Read", func() { + dsl.Payload(payload) + dsl.GRPC(func() { + dsl.Metadata(func() { dsl.Attribute("values") }) + }) + }) + }) + }) + + serviceField := root.API.GRPC.Services[0].GRPCEndpoints[0].MethodExpr.Payload.Find("values") + metadata := CreateGRPCServices(root).Get("Values").Endpoint("Read").Request.Metadata + require.Len(t, metadata, 1) + wireArray := expr.AsArray(metadata[0].WireAttribute.Type) + require.NotNil(t, wireArray) + require.Equal(t, expr.Int, wireArray.ElemType.Type) + require.Equal(t, []any{1, 2}, wireArray.ElemType.Validation.Values) + require.NotContains(t, metadata[0].WireAttribute.Meta, "struct:pkg:path") + require.NotContains(t, wireArray.ElemType.Meta, "struct:pkg:path") + require.Contains(t, metadata[0].EncodeCode, "int(val)") + require.Contains(t, initArgsFromMetadata(metadata)[0].InitCode, "shared.Value(val)") + require.Contains(t, serviceField.Type.(expr.UserType).Attribute().Meta, "struct:pkg:path") + require.Contains(t, expr.AsArray(serviceField.Type).ElemType.Type.(expr.UserType).Attribute().Meta, "struct:pkg:path") +} diff --git a/grpc/codegen/service_plan.go b/grpc/codegen/service_plan.go new file mode 100644 index 0000000000..7f67d88cf7 --- /dev/null +++ b/grpc/codegen/service_plan.go @@ -0,0 +1,539 @@ +// This file copies each gRPC service and endpoint while NewPlans can still +// read the evaluated design. Link and the file builders read these copies. +package codegen + +import ( + "fmt" + "path" + "sort" + + "goa.design/goa/v3/codegen" + "goa.design/goa/v3/codegen/service" + "goa.design/goa/v3/expr" +) + +type ( + // grpcServicePlan stores one copied gRPC service and the values selected for + // its generated files. + grpcServicePlan struct { + source *expr.GRPCServiceExpr + expression *expr.GRPCServiceExpr + packages *grpcServicePackage + endpoints []*grpcEndpointPlan + endpointByExpr map[*expr.GRPCEndpointExpr]*grpcEndpointPlan + protoImports []string + protoGoImports []*codegen.ImportSpec + scope *codegen.NameScope + usesAnyInErrors bool + } + + // grpcEndpointPlan stores one copied endpoint and the metadata conversions + // that must be prepared before generated names are available. + grpcEndpointPlan struct { + expression *expr.GRPCEndpointExpr + legacyStream bool + legacyMetadata *expr.MappedAttributeExpr + metadata map[*expr.MappedAttributeExpr][]*grpcMetadataPlan + } + + // grpcMetadataPlan stores one metadata field, its Go field, and the code that + // converts the value in both directions. + grpcMetadataPlan struct { + name string + element string + required bool + fieldName string + fieldType expr.DataType + pointer bool + serviceField *expr.AttributeExpr + wire *expr.AttributeExpr + scope *codegen.NameScope + validation string + encode *codegen.TransformPlan + decode *codegen.TransformPlan + } +) + +// collectGRPCServicePlans copies the services selected for one gRPC plan and +// records the imports and metadata conversions used by their generated files. +func collectGRPCServicePlans(plan *Plan) ([]*grpcServicePlan, error) { + services := make([]*grpcServicePlan, len(plan.expressions)) + for index, source := range plan.expressions { + service, err := copyGRPCService(source) + if err != nil { + return nil, err + } + service.scope = codegen.NewNameScope() + service.usesAnyInErrors = usesAnyType(service.expression.GRPCEndpoints, true) + service.packages = plan.packages[source] + + plannedProtobuf := plan.protobuf[source] + plannedTools := plan.tools[source] + plannedSymbols := plan.symbols[source] + service.protoImports, service.protoGoImports = collectGRPCProtobufImports(plannedProtobuf) + plan.protobuf[service.expression] = plannedProtobuf + plan.tools[service.expression] = plannedTools + plan.symbols[service.expression] = plannedSymbols + for endpointIndex, endpoint := range service.endpoints { + sourceEndpoint := source.GRPCEndpoints[endpointIndex] + if plannedSymbols != nil { + plannedSymbols.endpoints[endpoint.expression] = plannedSymbols.endpoints[sourceEndpoint] + } + if plannedProtobuf != nil { + plannedProtobuf.methods[endpoint.expression] = plannedProtobuf.methods[sourceEndpoint] + } + if declaration := plan.cli.builders[sourceEndpoint]; declaration != nil { + plan.cli.builders[endpoint.expression] = declaration + } + if err := planEndpointMetadata(endpoint); err != nil { + return nil, fmt.Errorf("plan gRPC metadata for %q.%q: %w", service.expression.Name(), endpoint.expression.Name(), err) + } + } + if err := replaceGRPCTransforms(plan.service, source, service, plannedProtobuf, plannedSymbols); err != nil { + return nil, fmt.Errorf("copy gRPC conversions for service %q: %w", service.expression.Name(), err) + } + services[index] = service + } + return services, nil +} + +// collectGRPCProtobufImports records the protobuf schema files and Go packages +// selected by every saved protobuf message. +func collectGRPCProtobufImports(protobuf *protobufServicePlan) ([]string, []*codegen.ImportSpec) { + if protobuf == nil { + return nil, nil + } + var attributes []*expr.AttributeExpr + for _, messages := range protobuf.messages { + attributes = append(attributes, messages.request, messages.streamingRequest, messages.requestEnvelope, messages.response) + errorNames := make([]string, 0, len(messages.errors)) + for name := range messages.errors { + errorNames = append(errorNames, name) + } + sort.Strings(errorNames) + for _, name := range errorNames { + attributes = append(attributes, messages.errors[name]) + } + } + var protoImports []string + var goImports []*codegen.ImportSpec + seenProto := make(map[string]struct{}) + seenGo := make(map[string]struct{}) + seenTypes := make(map[expr.UserType]struct{}) + var walk func(*expr.AttributeExpr) + walk = func(attribute *expr.AttributeExpr) { + if attribute == nil { + return + } + if field := attribute.Meta["struct:field:proto"]; len(field) > 1 { + if _, ok := seenProto[field[1]]; !ok { + seenProto[field[1]] = struct{}{} + protoImports = append(protoImports, field[1]) + } + if len(field) > 3 { + if _, ok := seenGo[field[3]]; !ok { + seenGo[field[3]] = struct{}{} + goImports = append(goImports, codegen.NewImport(path.Base(field[3]), field[3])) + } + } + } + if attribute.Type.Kind() == expr.AnyKind { + const structProto = "google/protobuf/struct.proto" + if _, ok := seenProto[structProto]; !ok { + seenProto[structProto] = struct{}{} + protoImports = append(protoImports, structProto) + } + return + } + switch actual := attribute.Type.(type) { + case expr.UserType: + if _, ok := seenTypes[actual]; ok { + return + } + seenTypes[actual] = struct{}{} + walk(actual.Attribute()) + case *expr.Object: + for _, named := range *actual { + walk(named.Attribute) + } + case *expr.Array: + walk(actual.ElemType) + case *expr.Map: + walk(actual.KeyType) + walk(actual.ElemType) + case *expr.Union: + for _, named := range actual.Values { + walk(named.Attribute) + } + } + } + for _, attribute := range attributes { + walk(attribute) + } + return protoImports, goImports +} + +// replaceGRPCTransforms rebuilds every conversion from the copied method. This +// prevents later changes to the original design from changing generated code. +func replaceGRPCTransforms( + servicePlan *service.Plan, + source *expr.GRPCServiceExpr, + grpcService *grpcServicePlan, + protobuf *protobufServicePlan, + symbols *grpcSymbols, +) error { + if protobuf == nil || symbols == nil { + return fmt.Errorf("protobuf messages or Go names are missing") + } + replaced := make(map[*grpcConversion]struct{}) + replace := func(conversion *grpcConversion, source, target *expr.AttributeExpr, proto bool) error { + if conversion == nil { + return nil + } + if _, ok := replaced[conversion]; ok { + return nil + } + transform, err := newGRPCTransformPlan(source, target, proto, protobuf) + if err != nil { + return err + } + oldHelpers := conversion.transform.Helpers() + newHelpers := transform.Helpers() + if len(oldHelpers) != len(newHelpers) { + return fmt.Errorf("saved conversion helper count changed from %d to %d", len(oldHelpers), len(newHelpers)) + } + for index, helper := range newHelpers { + if err := transform.BindHelperDeclaration(helper.ID, oldHelpers[index].Declaration); err != nil { + return err + } + } + conversion.transform = transform + conversion.bound = false + replaced[conversion] = struct{}{} + return nil + } + for index, endpointPlan := range grpcService.endpoints { + endpoint := endpointPlan.expression + sourceEndpoint := source.GRPCEndpoints[index] + messages := protobuf.messages[index] + endpointSymbols := symbols.endpoints[endpoint] + result := endpoint.MethodExpr.Result + if _, viewed := sourceEndpoint.MethodExpr.Result.Type.(*expr.ResultTypeExpr); viewed { + projected, err := servicePlan.ProjectedResult(sourceEndpoint.MethodExpr) + if err != nil { + return err + } + result = expr.DupAtt(projected) + } + if endpoint.MethodExpr.Payload.Type != expr.Empty { + if err := replace(endpointSymbols.serverInits[grpcInitKey{role: grpcRequestInit}], messages.request, endpoint.MethodExpr.Payload, false); err != nil { + return err + } + if err := replace(endpointSymbols.cliPayload, messages.request, endpoint.MethodExpr.Payload, false); err != nil { + return err + } + } + if !(endpoint.MethodExpr.IsPayloadStreaming() && isEmpty(endpoint.Request.Type)) { + if err := replace(endpointSymbols.clientInits[grpcInitKey{role: grpcRequestInit}], endpoint.MethodExpr.Payload, messages.request, true); err != nil { + return err + } + } + if err := replace(endpointSymbols.serverInits[grpcInitKey{role: grpcResponseInit}], result, messages.response, true); err != nil { + return err + } + if endpoint.MethodExpr.Result.Type != expr.Empty && !endpoint.MethodExpr.IsStreaming() { + if err := replace(endpointSymbols.clientInits[grpcInitKey{role: grpcResponseInit}], messages.response, result, false); err != nil { + return err + } + } + if endpoint.MethodExpr.StreamingPayload.Type != expr.Empty { + key := grpcInitKey{role: grpcStreamingRequestInit} + if err := replace(endpointSymbols.serverInits[key], messages.streamingRequest, endpoint.MethodExpr.StreamingPayload, false); err != nil { + return err + } + if err := replace(endpointSymbols.clientInits[key], endpoint.MethodExpr.StreamingPayload, messages.streamingRequest, true); err != nil { + return err + } + } + if endpoint.MethodExpr.Result.Type != expr.Empty && endpoint.MethodExpr.IsStreaming() { + key := grpcInitKey{role: grpcStreamingResponseInit} + if err := replace(endpointSymbols.serverInits[key], result, messages.response, true); err != nil { + return err + } + if err := replace(endpointSymbols.clientInits[key], messages.response, result, false); err != nil { + return err + } + } + if endpointPlan.legacyStream && expr.IsObject(endpoint.MethodExpr.Payload.Type) { + key := grpcInitKey{role: grpcLegacyRequestInit} + if err := replace(endpointSymbols.serverInits[key], &expr.AttributeExpr{Type: expr.Empty}, endpoint.MethodExpr.Payload, false); err != nil { + return err + } + } + for _, grpcError := range endpoint.GRPCErrors { + message := messages.errors[grpcError.Name] + if message == nil { + continue + } + key := grpcInitKey{role: grpcErrorInit, subject: grpcError.Name} + if err := replace(endpointSymbols.serverInits[key], grpcError.AttributeExpr, message, true); err != nil { + return err + } + if err := replace(endpointSymbols.clientInits[key], message, grpcError.AttributeExpr, false); err != nil { + return err + } + } + } + return nil +} + +// copyGRPCService makes a private copy of the service values read by gRPC +// planning and rendering. +func copyGRPCService(source *expr.GRPCServiceExpr) (*grpcServicePlan, error) { + serviceExpr := &expr.ServiceExpr{ + Name: source.ServiceExpr.Name, + Description: source.ServiceExpr.Description, + Meta: copyGRPCMeta(source.ServiceExpr.Meta), + } + serviceExpr.Errors = make([]*expr.ErrorExpr, len(source.ServiceExpr.Errors)) + for index, sourceError := range source.ServiceExpr.Errors { + serviceExpr.Errors[index] = &expr.ErrorExpr{ + Name: sourceError.Name, + AttributeExpr: copyGRPCErrorAttribute(sourceError.AttributeExpr), + } + } + service := &expr.GRPCServiceExpr{ + ServiceExpr: serviceExpr, + ParentName: source.ParentName, + ProtoPkg: source.ProtoPkg, + Meta: copyGRPCMeta(source.Meta), + } + result := &grpcServicePlan{ + source: source, + expression: service, + endpoints: make([]*grpcEndpointPlan, len(source.GRPCEndpoints)), + endpointByExpr: make(map[*expr.GRPCEndpointExpr]*grpcEndpointPlan, len(source.GRPCEndpoints)), + } + for index, sourceEndpoint := range source.GRPCEndpoints { + method := copyGRPCMethod(sourceEndpoint.MethodExpr, serviceExpr) + serviceExpr.Methods = append(serviceExpr.Methods, method) + endpoint := &expr.GRPCEndpointExpr{ + MethodExpr: method, + Service: service, + Request: expr.DupAtt(sourceEndpoint.Request), + StreamingRequest: expr.DupAtt(sourceEndpoint.StreamingRequest), + Response: copyGRPCResponse(sourceEndpoint.Response), + Metadata: expr.DupMappedAtt(sourceEndpoint.Metadata), + Requirements: copyGRPCRequirements(sourceEndpoint.Requirements), + Meta: copyGRPCMeta(sourceEndpoint.Meta), + } + endpoint.Response.Parent = endpoint + endpoint.GRPCErrors = make([]*expr.GRPCErrorExpr, len(sourceEndpoint.GRPCErrors)) + for errorIndex, sourceError := range sourceEndpoint.GRPCErrors { + methodError := method.Error(sourceError.Name) + if methodError == nil { + return nil, fmt.Errorf("gRPC error %q is not defined by method %q", sourceError.Name, method.Name) + } + response := copyGRPCResponse(sourceError.Response) + response.Parent = endpoint + endpoint.GRPCErrors[errorIndex] = &expr.GRPCErrorExpr{ + ErrorExpr: methodError, + Name: sourceError.Name, + Response: response, + } + } + endpointPlan := &grpcEndpointPlan{ + expression: endpoint, + legacyStream: sourceEndpoint.LegacyStreamCompat(), + metadata: make(map[*expr.MappedAttributeExpr][]*grpcMetadataPlan), + } + service.GRPCEndpoints = append(service.GRPCEndpoints, endpoint) + result.endpoints[index] = endpointPlan + result.endpointByExpr[endpoint] = endpointPlan + } + return result, nil +} + +// copyGRPCMethod copies the method fields read by the gRPC transport. +func copyGRPCMethod(source *expr.MethodExpr, service *expr.ServiceExpr) *expr.MethodExpr { + method := &expr.MethodExpr{ + Name: source.Name, + Description: source.Description, + Payload: expr.DupAtt(source.Payload), + Result: expr.DupAtt(source.Result), + Requirements: copyGRPCRequirements(source.Requirements), + Service: service, + Meta: copyGRPCMeta(source.Meta), + Idempotent: source.Idempotent, + Stream: source.Stream, + StreamingPayload: expr.DupAtt(source.StreamingPayload), + } + switch { + case source.StreamingResult == nil: + method.StreamingResult = nil + case source.StreamingResult == source.Result: + method.StreamingResult = method.Result + default: + method.StreamingResult = expr.DupAtt(source.StreamingResult) + } + method.Errors = make([]*expr.ErrorExpr, len(source.Errors)) + for index, sourceError := range source.Errors { + method.Errors[index] = &expr.ErrorExpr{ + Name: sourceError.Name, + AttributeExpr: copyGRPCErrorAttribute(sourceError.AttributeExpr), + } + } + return method +} + +// copyGRPCErrorAttribute preserves Goa's built-in error type while copying +// fields from a custom error type. +func copyGRPCErrorAttribute(source *expr.AttributeExpr) *expr.AttributeExpr { + result := expr.DupAtt(source) + if expr.IsErrorResult(source.Type) { + result.Type = expr.ErrorResult + } + return result +} + +// copyGRPCResponse copies one success or error response. +func copyGRPCResponse(source *expr.GRPCResponseExpr) *expr.GRPCResponseExpr { + return &expr.GRPCResponseExpr{ + StatusCode: source.StatusCode, + Description: source.Description, + Message: expr.DupAtt(source.Message), + Headers: expr.DupMappedAtt(source.Headers), + Trailers: expr.DupMappedAtt(source.Trailers), + Meta: copyGRPCMeta(source.Meta), + } +} + +// copyGRPCRequirements copies security lists so later list edits cannot change +// generated metadata handling. +func copyGRPCRequirements(source []*expr.SecurityExpr) []*expr.SecurityExpr { + result := make([]*expr.SecurityExpr, len(source)) + for index, requirement := range source { + copy := expr.DupRequirement(requirement) + copy.Scopes = append([]string(nil), requirement.Scopes...) + for schemeIndex, scheme := range copy.Schemes { + scheme.Scopes = append([]*expr.ScopeExpr(nil), scheme.Scopes...) + scheme.Flows = append([]*expr.FlowExpr(nil), scheme.Flows...) + scheme.Meta = copyGRPCMeta(scheme.Meta) + copy.Schemes[schemeIndex] = scheme + } + result[index] = copy + } + return result +} + +// copyGRPCMeta copies every value list in one Meta map. +func copyGRPCMeta(source expr.MetaExpr) expr.MetaExpr { + if source == nil { + return nil + } + result := make(expr.MetaExpr, len(source)) + for name, values := range source { + result[name] = append([]string(nil), values...) + } + return result +} + +// planEndpointMetadata prepares every request, response, and legacy request +// metadata field used by one endpoint. +func planEndpointMetadata(endpoint *grpcEndpointPlan) error { + expression := endpoint.expression + groups := []struct { + mapped *expr.MappedAttributeExpr + service *expr.AttributeExpr + }{ + {expression.Metadata, expression.MethodExpr.Payload}, + {expression.Response.Headers, expression.MethodExpr.Result}, + {expression.Response.Trailers, expression.MethodExpr.Result}, + } + if endpoint.legacyStream { + endpoint.legacyMetadata = legacyRequestMetadata(expression) + groups = append(groups, struct { + mapped *expr.MappedAttributeExpr + service *expr.AttributeExpr + }{endpoint.legacyMetadata, expression.MethodExpr.Payload}) + } + for _, group := range groups { + plans, err := planMetadataFields(group.mapped, group.service) + if err != nil { + return err + } + endpoint.metadata[group.mapped] = plans + } + return nil +} + +// planMetadataFields prepares the Go value and both conversions for every field +// in one metadata group. +func planMetadataFields(mapped *expr.MappedAttributeExpr, service *expr.AttributeExpr) ([]*grpcMetadataPlan, error) { + var result []*grpcMetadataPlan + err := codegen.WalkMappedAttr(mapped, func(name, element string, required bool, attribute *expr.AttributeExpr) error { + wire := nativeMetadataAttribute(attribute) + scope := codegen.NewNameScope() + wireContext := codegen.NewAttributeContext(false, false, true, "", scope).Enter(wire) + serviceField := service + fieldType := service.Type + fieldName := codegen.Goify(name, true) + var pointer bool + if !expr.IsObject(service.Type) { + fieldName = "" + } else { + pointer = service.IsPrimitivePointer(name, true) + serviceField = service.Find(name) + fieldType = serviceField.Type + } + encode, err := codegen.NewTransformPlan(serviceField, wire, "", nil) + if err != nil { + return err + } + decode, err := codegen.NewTransformPlan(wire, serviceField, "", nil) + if err != nil { + return err + } + if len(encode.Helpers()) > 0 || len(decode.Helpers()) > 0 { + return fmt.Errorf("metadata field %q needs a separate conversion function", name) + } + result = append(result, &grpcMetadataPlan{ + name: name, + element: element, + required: required, + fieldName: fieldName, + fieldType: fieldType, + pointer: pointer, + serviceField: serviceField, + wire: wire, + scope: scope, + validation: codegen.AttributeValidationCode(wire, nil, wireContext, required, false, codegen.Goify(name, false), name), + encode: encode, + decode: decode, + }) + return nil + }) + return result, err +} + +// legacyRequestMetadata builds the metadata fields used by clients that send +// the first streamed payload through metadata. +func legacyRequestMetadata(endpoint *expr.GRPCEndpointExpr) *expr.MappedAttributeExpr { + payload := endpoint.MethodExpr.Payload + legacy := expr.DupMappedAtt(endpoint.Metadata) + metadataObject := expr.AsObject(legacy.Type) + if payloadObject := expr.AsObject(payload.Type); payloadObject != nil { + for _, named := range *payloadObject { + if metadataObject.Attribute(named.Name) == nil { + metadataObject.Set(named.Name, expr.DupAtt(named.Attribute)) + } + if payload.IsRequired(named.Name) { + legacy.Validation.AddRequired(named.Name) + } + } + } else { + metadataObject.Set("goa_payload", expr.DupAtt(payload)) + legacy.Validation.AddRequired("goa_payload") + } + return legacy +} diff --git a/grpc/codegen/service_plan_imports_test.go b/grpc/codegen/service_plan_imports_test.go new file mode 100644 index 0000000000..5d133981f1 --- /dev/null +++ b/grpc/codegen/service_plan_imports_test.go @@ -0,0 +1,45 @@ +// This file checks that copied gRPC values keep every package needed by their +// fields, even when the copies came from the same Goa type. +package codegen + +import ( + "testing" + + "github.com/stretchr/testify/require" + + "goa.design/goa/v3/codegen" + "goa.design/goa/v3/expr" +) + +// TestCollectGRPCProtobufImportsVisitsEachCopiedType checks that one copied +// type cannot hide an external protobuf field used by another copy. +func TestCollectGRPCProtobufImportsVisitsEachCopiedType(t *testing.T) { + first, second := grpcImportCopies() + protobufField := expr.AsObject(second).Attribute("value") + protobufField.Meta["struct:field:proto"] = []string{ + "google.protobuf.Timestamp", + "google/protobuf/timestamp.proto", + "Timestamp", + "google.golang.org/protobuf/types/known/timestamppb", + } + + for _, roots := range [][2]expr.UserType{{first, second}, {second, first}} { + plan := &protobufServicePlan{messages: []*protobufEndpointMessages{{ + request: &expr.AttributeExpr{Type: roots[0]}, + response: &expr.AttributeExpr{Type: roots[1]}, + }}} + protoImports, goImports := collectGRPCProtobufImports(plan) + + require.Contains(t, protoImports, "google/protobuf/timestamp.proto") + require.Contains(t, goImports, codegen.NewImport( + "timestamppb", + "google.golang.org/protobuf/types/known/timestamppb", + )) + } +} + +// grpcImportCopies returns two independent copies of one Goa type. +func grpcImportCopies() (expr.UserType, expr.UserType) { + original := grpcMessageTraversalType("Shared", "shared", expr.String, "1") + return expr.Dup(original).(expr.UserType), expr.Dup(original).(expr.UserType) +} diff --git a/grpc/codegen/streaming_errors_test.go b/grpc/codegen/streaming_errors_test.go index a3bcb2b9bc..08a592c521 100644 --- a/grpc/codegen/streaming_errors_test.go +++ b/grpc/codegen/streaming_errors_test.go @@ -17,12 +17,12 @@ func TestStreamingWithErrors(t *testing.T) { cases := []struct { name string dsl func() - testFunc func(t *testing.T, code string) + testFunc func(t *testing.T, code string, services *ServicesData) }{ { name: "server streaming with custom errors", dsl: testdata.ServerStreamingWithCustomErrorsDSL, - testFunc: func(t *testing.T, code string) { + testFunc: func(t *testing.T, code string, services *ServicesData) { // Verify error decoding is present assert.Contains(t, code, "goagrpc.DecodeError(err)", "should decode errors from stream") @@ -37,17 +37,20 @@ func TestStreamingWithErrors(t *testing.T) { assert.Contains(t, code, "case *goapb.ErrorResponse:", "should handle generic goa errors") - // Verify proper error construction - assert.Contains(t, code, "NewServerStreamCustomErrorError(message", - "should construct custom error") - assert.Contains(t, code, "NewServerStreamValidationErrorError(message", - "should construct validation error") + // Each custom error uses the constructor chosen for the client package. + endpoint := services.Get("StreamingErrorService").Endpoint("ServerStream") + for _, errorData := range endpoint.Errors { + if errorData.Response.ClientConvert != nil { + name := errorData.Response.ClientConvert.Init.Declaration.Name() + assert.Contains(t, code, name+"(message", "should construct "+errorData.Name) + } + } }, }, { name: "bidirectional streaming with errors", dsl: testdata.BidirectionalStreamingRPCWithErrorsDSL, - testFunc: func(t *testing.T, code string) { + testFunc: func(t *testing.T, code string, _ *ServicesData) { // Bidirectional streaming with simple errors should still decode assert.Contains(t, code, "goagrpc.DecodeError(err)", "should decode errors from bidirectional stream") @@ -61,7 +64,7 @@ func TestStreamingWithErrors(t *testing.T) { t.Run(c.name, func(t *testing.T) { root := RunGRPCDSL(t, c.dsl) services := CreateGRPCServices(root) - clientfs := ClientFiles("", services) + clientfs := clientFiles(services) require.Greater(t, len(clientfs), 0) // Get recv method implementations @@ -76,7 +79,7 @@ func TestStreamingWithErrors(t *testing.T) { code := codeBuilder.String() // Run test-specific assertions - c.testFunc(t, code) + c.testFunc(t, code, services) }) } } @@ -84,7 +87,7 @@ func TestStreamingWithErrors(t *testing.T) { func TestClientStreamingContextErrorCodegen(t *testing.T) { root := RunGRPCDSL(t, testdata.BidirectionalStreamingRPCWithErrorsDSL) services := CreateGRPCServices(root) - clientFiles := ClientFiles("", services) + clientFiles := ClientFiles(services.GenPkg(), services) require.NotEmpty(t, clientFiles) recvCode := codegen.SectionsCode(t, clientFiles[0].Section("client-stream-recv")) @@ -101,7 +104,8 @@ func TestClientStreamingContextErrorCodegen(t *testing.T) { assert.NotContains(t, closeCode, "goagrpc.ContextError") noResultRoot := RunGRPCDSL(t, testdata.ClientStreamingNoResultDSL) - noResultFiles := ClientFiles("", CreateGRPCServices(noResultRoot)) + noResultServices := CreateGRPCServices(noResultRoot) + noResultFiles := ClientFiles(noResultServices.GenPkg(), noResultServices) require.NotEmpty(t, noResultFiles) closeAndRecvCode := codegen.SectionsCode(t, noResultFiles[0].Section("client-stream-close")) assert.Contains(t, closeAndRecvCode, "s.stream.CloseAndRecv()") @@ -126,7 +130,8 @@ func TestClientStreamingContextErrorCodegen(t *testing.T) { }) } closeErrorRoot := RunGRPCDSL(t, closeErrorDSL) - closeErrorFiles := ClientFiles("", CreateGRPCServices(closeErrorRoot)) + closeErrorServices := CreateGRPCServices(closeErrorRoot) + closeErrorFiles := ClientFiles(closeErrorServices.GenPkg(), closeErrorServices) require.NotEmpty(t, closeErrorFiles) closeErrorCode := codegen.SectionsCode(t, closeErrorFiles[0].Section("client-stream-close")) typedErrorIndex := strings.Index( @@ -153,7 +158,7 @@ func TestStreamingErrorsWithValidation(t *testing.T) { require.Greater(t, len(method.Errors), 0, "method should have errors defined") // Generate client code - clientfs := ClientFiles("", services) + clientfs := clientFiles(services) require.Greater(t, len(clientfs), 0) // Check recv implementations @@ -207,7 +212,7 @@ func TestStreamingErrorComparison(t *testing.T) { root := RunGRPCDSL(t, dsl) services := CreateGRPCServices(root) - clientfs := ClientFiles("", services) + clientfs := clientFiles(services) require.Greater(t, len(clientfs), 0, "should have client files") // Find unary and streaming code in different sections diff --git a/grpc/codegen/streaming_test.go b/grpc/codegen/streaming_test.go index 8a3c3b871b..2fdf7cd0bf 100644 --- a/grpc/codegen/streaming_test.go +++ b/grpc/codegen/streaming_test.go @@ -107,11 +107,11 @@ func TestStreaming(t *testing.T) { t.Run(c.Name, func(t *testing.T) { root := RunGRPCDSL(t, c.DSL) services := CreateGRPCServices(root) - serverfs := ServerFiles("", services) + serverfs := serverFiles(services) if len(serverfs) < 2 { t.Fatalf("got %d server files, expected 2", len(serverfs)) } - clientfs := ClientFiles("", services) + clientfs := clientFiles(services) if len(clientfs) < 2 { t.Fatalf("got %d client files, expected 2", len(clientfs)) } @@ -154,11 +154,11 @@ func TestStreamingPayloadEnvelopeWithUnionPayload(t *testing.T) { root := RunGRPCDSL(t, testdata.ClientStreamingRPCWithUnionPayloadDSL) services := CreateGRPCServices(root) - clientfs := ClientFiles("", services) + clientfs := clientFiles(services) require.Len(t, clientfs, 2) - serverfs := ServerFiles("", services) + serverfs := serverFiles(services) require.Len(t, serverfs, 2) - protofs := ProtoFiles("", services) + protofs := protoFiles(services) require.Len(t, protofs, 1) requestEncoder := codegen.SectionsCode(t, clientfs[1].Section("request-encoder")) @@ -185,16 +185,16 @@ func TestStreamingPayloadEnvelopeWithUnionPayload(t *testing.T) { assert.Contains(t, proto, "MethodClientStreamingRPCWithUnionPayloadStreamItem stream_item") fpath := codegen.CreateTempFile(t, proto) - assert.NoError(t, protoc(defaultProtocCmd, fpath, nil)) + assert.NoError(t, protoc(defaultProtocCmd, fpath)) } func TestStreamingPayloadLegacyCompat(t *testing.T) { root := RunGRPCDSL(t, testdata.BidirectionalStreamingRPCWithPayloadLegacyCompatDSL) services := CreateGRPCServices(root) - serverfs := ServerFiles("", services) + serverfs := serverFiles(services) require.Len(t, serverfs, 2) - clientfs := ClientFiles("", services) + clientfs := clientFiles(services) require.Len(t, clientfs, 2) // The server stream tracks the protocol spoken by the client. @@ -216,14 +216,16 @@ func TestStreamingPayloadLegacyCompat(t *testing.T) { requestDecoder := codegen.SectionsCode(t, serverfs[1].Section("request-decoder")) assert.Contains(t, requestDecoder, "LegacyRequest(ctx, md)") assert.Contains(t, requestDecoder, `md.Get("a")`) - assert.Contains(t, requestDecoder, "PayloadFromMetadata(") + service := services.Get("ServiceBidirectionalStreamingRPCWithPayloadLegacyCompat") + legacyConstructor := service.Endpoints[0].Request.LegacyDecode.ServerConvert.Init.Declaration.Name() + assert.Contains(t, requestDecoder, legacyConstructor+"(") // Generated clients declare the envelope protocol in request metadata. requestEncoder := codegen.SectionsCode(t, clientfs[1].Section("request-encoder")) assert.Contains(t, requestEncoder, "goagrpc.StreamProtocolMetadataKey") // The wire contract for envelope clients is unchanged. - protofs := ProtoFiles("", services) + protofs := protoFiles(services) require.Len(t, protofs, 1) proto := sectionCode(t, protofs[0].SectionTemplates[1:]...) assert.Contains(t, proto, "oneof body") diff --git a/grpc/codegen/symbols.go b/grpc/codegen/symbols.go new file mode 100644 index 0000000000..b27b3c102d --- /dev/null +++ b/grpc/codegen/symbols.go @@ -0,0 +1,1225 @@ +// This file chooses every Go name written into generated gRPC client and server +// packages. A definition and every call to it share one stored name. +package codegen + +import ( + "cmp" + "fmt" + "path" + "slices" + "strings" + + "goa.design/goa/v3/codegen" + "goa.design/goa/v3/expr" +) + +type ( + // grpcSymbols contains the names written for one gRPC service. + grpcSymbols struct { + clientStruct *codegen.NameDeclaration + clientInit *codegen.NameDeclaration + serverStruct *codegen.NameDeclaration + serverInit *codegen.NameDeclaration + endpoints map[*expr.GRPCEndpointExpr]*grpcEndpointSymbols + } + + // grpcEndpointSymbols contains the names written for one gRPC endpoint. + grpcEndpointSymbols struct { + clientStream *codegen.NameDeclaration + clientBuild *codegen.NameDeclaration + clientEncode *codegen.NameDeclaration + clientDecode *codegen.NameDeclaration + serverStream *codegen.NameDeclaration + serverHandler *codegen.NameDeclaration + serverDecode *codegen.NameDeclaration + serverEncode *codegen.NameDeclaration + legacyDecode *codegen.NameDeclaration + cliPayload *grpcConversion + clientInits map[grpcInitKey]*grpcConversion + serverInits map[grpcInitKey]*grpcConversion + } + + // grpcConversion contains one top-level conversion function and the extra + // conversion functions it calls for nested values. + grpcConversion struct { + declaration *codegen.NameDeclaration + transform *codegen.TransformPlan + pkg *codegen.GeneratedPackage + order grpcSymbolOrder + preferredName string + fullName string + serviceName string + messageName string + releasedNames []string + releasedResponseNames []string + side grpcPackageSide + bound bool + } + + // grpcTransformRegistry collects every private conversion function written + // to one generated client or server package. + grpcTransformRegistry struct { + pkg *codegen.GeneratedPackage + helpers *codegen.TransformHelperRegistry + } + + // grpcConversionKey contains the package and types that decide one + // conversion function. endpoint is set when metadata changes its arguments. + grpcConversionKey struct { + pkg *codegen.GeneratedPackage + message *protobufMessageRecord + service expr.UserType + endpoint *expr.GRPCEndpointExpr + view string + proto bool + } + + // grpcSymbolID records which package, service, method, error, and field + // produced one Go name. These values decide collision order but do not appear + // in the name. + grpcSymbolID struct { + side grpcPackageSide + role grpcSymbolRole + api string + service string + method string + subject string + view string + path string + source string + target string + definition codegen.TransformHelperDefinitionLocation + operation int + } + + // grpcSymbolOrder decides which item keeps an unsuffixed Go name when several + // items request the same name. + grpcSymbolOrder grpcSymbolID + + // grpcPackageSide identifies the generated package that contains a name. + grpcPackageSide uint8 + + // grpcSymbolRole identifies what a generated name defines. + grpcSymbolRole uint8 + + // grpcInitRole identifies one conversion constructor used by an endpoint. + grpcInitRole uint8 + + // grpcInitKey says which endpoint value uses a conversion. + grpcInitKey struct { + role grpcInitRole + subject string + view string + } + + // grpcValidationNameKey contains the generated package and validation-body + // facts used in the ordinary private function name. + grpcValidationNameKey struct { + side validateKind + api string + service string + message string + targetName string + contextName string + } +) + +const ( + grpcClientPackage grpcPackageSide = iota + 1 + grpcServerPackage +) + +const ( + grpcClientStructRole grpcSymbolRole = iota + 1 + grpcClientInitRole + grpcServerStructRole + grpcServerInitRole + grpcClientStreamRole + grpcClientBuildRole + grpcClientEncodeRole + grpcClientDecodeRole + grpcServerStreamRole + grpcServerHandlerRole + grpcServerDecodeRole + grpcServerEncodeRole + grpcLegacyDecodeRole + grpcConversionInitRole + grpcValidationRole + grpcTransformHelperRole +) + +const ( + grpcRequestInit grpcInitRole = iota + 1 + grpcResponseInit + grpcStreamingRequestInit + grpcStreamingResponseInit + grpcLegacyRequestInit + grpcErrorInit +) + +// collectGRPCSymbols requests the client and server names that can be chosen +// directly from the service and endpoint designs. +func collectGRPCSymbols(generation *codegen.Generation, input PlanInput, service *expr.GRPCServiceExpr, pathName string) (*grpcSymbols, error) { + clientPackage, err := generation.ClaimPackage(path.Join(generation.GenPkg(), "grpc", pathName, "client")) + if err != nil { + return nil, err + } + serverPackage, err := generation.ClaimPackage(path.Join(generation.GenPkg(), "grpc", pathName, "server")) + if err != nil { + return nil, err + } + declare := func(pkg *codegen.GeneratedPackage, kind codegen.PackageNameKind, preferred string, visibility codegen.PackageNameVisibility, id grpcSymbolID) (*codegen.NameDeclaration, error) { + declaration := codegen.NewPreferredName(kind, preferred, visibility, grpcSymbolOrder(id)) + if err := pkg.DeclareName(declaration); err != nil { + return nil, err + } + return declaration, nil + } + serviceID := grpcSymbolID{api: input.Root.API.Name, service: service.Name()} + symbols := &grpcSymbols{endpoints: make(map[*expr.GRPCEndpointExpr]*grpcEndpointSymbols)} + if symbols.clientStruct, err = declare(clientPackage, codegen.NameType, "Client", codegen.ExportedName, serviceID.client(grpcClientStructRole)); err != nil { + return nil, err + } + if symbols.clientInit, err = declare(clientPackage, codegen.NameFunction, "NewClient", codegen.ExportedName, serviceID.client(grpcClientInitRole)); err != nil { + return nil, err + } + if symbols.serverStruct, err = declare(serverPackage, codegen.NameType, "Server", codegen.ExportedName, serviceID.server(grpcServerStructRole)); err != nil { + return nil, err + } + if symbols.serverInit, err = declare(serverPackage, codegen.NameFunction, "New", codegen.ExportedName, serviceID.server(grpcServerInitRole)); err != nil { + return nil, err + } + for _, endpoint := range service.GRPCEndpoints { + names, err := input.Service.HTTPMethodNames(endpoint.MethodExpr) + if err != nil { + return nil, err + } + id := serviceID.withMethod(endpoint.Name()) + endpointSymbols := &grpcEndpointSymbols{ + clientInits: make(map[grpcInitKey]*grpcConversion), + serverInits: make(map[grpcInitKey]*grpcConversion), + } + endpointSymbols.clientBuild, err = declare(clientPackage, codegen.NameFunction, "Build"+names.Method+"Func", codegen.ExportedName, id.client(grpcClientBuildRole)) + if err != nil { + return nil, err + } + if endpoint.MethodExpr.Payload.Type != expr.Empty { + endpointSymbols.clientEncode, err = declare(clientPackage, codegen.NameFunction, "Encode"+names.Method+"Request", codegen.ExportedName, id.client(grpcClientEncodeRole)) + if err != nil { + return nil, err + } + endpointSymbols.serverDecode, err = declare(serverPackage, codegen.NameFunction, "Decode"+names.Method+"Request", codegen.ExportedName, id.server(grpcServerDecodeRole)) + if err != nil { + return nil, err + } + } + if endpoint.MethodExpr.Result.Type != expr.Empty || endpoint.MethodExpr.IsStreaming() { + endpointSymbols.clientDecode, err = declare(clientPackage, codegen.NameFunction, "Decode"+names.Method+"Response", codegen.ExportedName, id.client(grpcClientDecodeRole)) + if err != nil { + return nil, err + } + } + endpointSymbols.serverEncode, err = declare(serverPackage, codegen.NameFunction, "Encode"+names.Method+"Response", codegen.ExportedName, id.server(grpcServerEncodeRole)) + if err != nil { + return nil, err + } + endpointSymbols.serverHandler, err = declare(serverPackage, codegen.NameFunction, "New"+names.Method+"Handler", codegen.ExportedName, id.server(grpcServerHandlerRole)) + if err != nil { + return nil, err + } + if endpoint.MethodExpr.IsStreaming() { + endpointSymbols.clientStream, err = declare(clientPackage, codegen.NameType, names.ClientStream, codegen.ExportedName, id.client(grpcClientStreamRole)) + if err != nil { + return nil, err + } + endpointSymbols.serverStream, err = declare(serverPackage, codegen.NameType, names.ServerStream, codegen.ExportedName, id.server(grpcServerStreamRole)) + if err != nil { + return nil, err + } + } + if endpoint.LegacyStreamCompat() { + preferred := "decode" + names.Method + "LegacyRequest" + endpointSymbols.legacyDecode, err = declare(serverPackage, codegen.NameFunction, preferred, codegen.UnexportedName, id.server(grpcLegacyDecodeRole)) + if err != nil { + return nil, err + } + } + symbols.endpoints[endpoint] = endpointSymbols + } + return symbols, nil +} + +// planGRPCTransforms records each conversion and requests the names of any +// nested conversion functions it will call. It does not read those names yet. +func planGRPCTransforms( + generation *codegen.Generation, + input PlanInput, + service *expr.GRPCServiceExpr, + protobuf *protobufServicePlan, + symbols *grpcSymbols, + conversions map[grpcConversionKey]*grpcConversion, + registries map[*codegen.GeneratedPackage]*grpcTransformRegistry, + pathName string, +) error { + clientPackage := generation.Package(path.Join(generation.GenPkg(), "grpc", pathName, "client")) + serverPackage := generation.Package(path.Join(generation.GenPkg(), "grpc", pathName, "server")) + for index, endpoint := range service.GRPCEndpoints { + messages := protobuf.messages[index] + endpointSymbols := symbols.endpoints[endpoint] + result := endpoint.MethodExpr.Result + _, viewed := result.Type.(*expr.ResultTypeExpr) + if viewed { + projected, err := input.Service.ProjectedResult(endpoint.MethodExpr) + if err != nil { + return err + } + result = projected + } + conversionFor := func(side grpcPackageSide, source, target *expr.AttributeExpr, proto, endpointSpecific bool, view string) (*grpcConversion, error) { + pkg := clientPackage + if side == grpcServerPackage { + pkg = serverPackage + } + protobufAttribute := source + serviceAttribute := target + if proto { + protobufAttribute = target + serviceAttribute = source + } + message := protobuf.catalog.messageRecord(protobufAttribute) + var serviceType expr.UserType + if userType, ok := serviceAttribute.Type.(expr.UserType); ok { + serviceType = userType.Origin() + } + conversionKey := grpcConversionKey{ + pkg: pkg, + message: message, + service: serviceType, + view: view, + proto: proto, + } + if endpointSpecific { + conversionKey.endpoint = endpoint + } + conversion := conversions[conversionKey] + preferred, fullName, serviceName, messageName := grpcConversionNames(serviceAttribute, message, proto) + viewKey := grpcInitKey{view: view} + preferred = grpcViewedConversionName(endpoint.MethodExpr, viewKey, preferred) + fullName = grpcViewedConversionName(endpoint.MethodExpr, viewKey, fullName) + id := grpcSymbolID{ + side: side, + role: grpcConversionInitRole, + api: input.Root.API.Name, + service: service.Name(), + subject: serviceName, + view: conversionKey.view, + path: messageName, + operation: grpcConversionDirection(proto), + } + if endpointSpecific { + id.method = endpoint.Name() + } + if conversion == nil { + transform, err := newGRPCTransformPlan(source, target, proto, protobuf) + if err != nil { + return nil, err + } + sourceLayout, targetLayout, err := planGRPCTransformLayouts( + generation, input, endpoint.MethodExpr, source, target, proto, protobuf, pathName, + ) + if err != nil { + return nil, err + } + registry := registries[pkg] + if registry == nil { + registry = &grpcTransformRegistry{pkg: pkg, helpers: codegen.NewTransformHelperRegistry()} + registries[pkg] = registry + } + helperID := id + helperID.role = grpcTransformHelperRole + if err := registry.helpers.Collect(transform, sourceLayout, targetLayout, + func(location codegen.TransformHelperDefinitionLocation) codegen.PackageNameOrder { + helperID.definition = location + return grpcSymbolOrder(helperID) + }); err != nil { + return nil, err + } + conversion = &grpcConversion{ + transform: transform, + pkg: pkg, + order: grpcSymbolOrder(id), + preferredName: preferred, + fullName: fullName, + serviceName: serviceName, + messageName: messageName, + side: side, + } + conversions[conversionKey] = conversion + } + return conversion, nil + } + plan := func(side grpcPackageSide, key grpcInitKey, source, target *expr.AttributeExpr, proto, endpointSpecific bool) error { + conversion, err := conversionFor(side, source, target, proto, endpointSpecific, key.view) + if err != nil { + return err + } + releasedName := releasedGRPCConversionName(endpoint, key, source, target, proto, conversion) + conversion.releasedNames = append(conversion.releasedNames, releasedName) + if key.role == grpcResponseInit && !slices.Contains(conversion.releasedResponseNames, releasedName) { + conversion.releasedResponseNames = append(conversion.releasedResponseNames, releasedName) + } + inits := endpointSymbols.clientInits + if side == grpcServerPackage { + inits = endpointSymbols.serverInits + } + inits[key] = conversion + return nil + } + if endpoint.MethodExpr.Payload.Type != expr.Empty { + if err := plan(grpcServerPackage, grpcInitKey{role: grpcRequestInit}, messages.request, endpoint.MethodExpr.Payload, false, !endpoint.Metadata.IsEmpty()); err != nil { + return err + } + cliConversion, err := conversionFor(grpcClientPackage, messages.request, endpoint.MethodExpr.Payload, false, false, "") + if err != nil { + return err + } + endpointSymbols.cliPayload = cliConversion + } + if !(endpoint.MethodExpr.IsPayloadStreaming() && isEmpty(endpoint.Request.Type)) { + if err := plan(grpcClientPackage, grpcInitKey{role: grpcRequestInit}, endpoint.MethodExpr.Payload, messages.request, true, false); err != nil { + return err + } + } + if viewed { + for _, view := range grpcResultViews(endpoint.MethodExpr) { + viewResult, err := grpcResultForView(result, view) + if err != nil { + return err + } + if err := plan(grpcServerPackage, grpcInitKey{role: grpcResponseInit, view: view}, viewResult, messages.response, true, false); err != nil { + return err + } + } + } else if err := plan(grpcServerPackage, grpcInitKey{role: grpcResponseInit}, result, messages.response, true, false); err != nil { + return err + } + if endpoint.MethodExpr.Result.Type != expr.Empty && !endpoint.MethodExpr.IsStreaming() { + responseMetadata := !endpoint.Response.Headers.IsEmpty() || !endpoint.Response.Trailers.IsEmpty() + if viewed { + for _, view := range grpcResultViews(endpoint.MethodExpr) { + viewResult, err := grpcResultForView(result, view) + if err != nil { + return err + } + if err := plan(grpcClientPackage, grpcInitKey{role: grpcResponseInit, view: view}, messages.response, viewResult, false, responseMetadata); err != nil { + return err + } + } + } else if err := plan(grpcClientPackage, grpcInitKey{role: grpcResponseInit}, messages.response, result, false, responseMetadata); err != nil { + return err + } + } + if endpoint.MethodExpr.StreamingPayload.Type != expr.Empty { + key := grpcInitKey{role: grpcStreamingRequestInit} + if err := plan(grpcServerPackage, key, messages.streamingRequest, endpoint.MethodExpr.StreamingPayload, false, false); err != nil { + return err + } + if err := plan(grpcClientPackage, key, endpoint.MethodExpr.StreamingPayload, messages.streamingRequest, true, false); err != nil { + return err + } + } + if endpoint.MethodExpr.Result.Type != expr.Empty && endpoint.MethodExpr.IsStreaming() { + if viewed { + for _, view := range grpcResultViews(endpoint.MethodExpr) { + viewResult, err := grpcResultForView(result, view) + if err != nil { + return err + } + key := grpcInitKey{role: grpcStreamingResponseInit, view: view} + if err := plan(grpcServerPackage, key, viewResult, messages.response, true, false); err != nil { + return err + } + } + } else { + key := grpcInitKey{role: grpcStreamingResponseInit} + if err := plan(grpcServerPackage, key, result, messages.response, true, false); err != nil { + return err + } + } + if viewed { + for _, view := range grpcResultViews(endpoint.MethodExpr) { + viewResult, err := grpcResultForView(result, view) + if err != nil { + return err + } + key := grpcInitKey{role: grpcStreamingResponseInit, view: view} + if err := plan(grpcClientPackage, key, messages.response, viewResult, false, false); err != nil { + return err + } + } + } else { + key := grpcInitKey{role: grpcStreamingResponseInit} + if err := plan(grpcClientPackage, key, messages.response, result, false, false); err != nil { + return err + } + } + } + if endpoint.LegacyStreamCompat() && expr.IsObject(endpoint.MethodExpr.Payload.Type) { + key := grpcInitKey{role: grpcLegacyRequestInit} + if err := plan(grpcServerPackage, key, &expr.AttributeExpr{Type: expr.Empty}, endpoint.MethodExpr.Payload, false, true); err != nil { + return err + } + } + for _, grpcError := range endpoint.GRPCErrors { + message := messages.errors[grpcError.Name] + if message == nil { + continue + } + key := grpcInitKey{role: grpcErrorInit, subject: grpcError.Name} + if err := plan(grpcServerPackage, key, grpcError.AttributeExpr, message, true, false); err != nil { + return err + } + if err := plan(grpcClientPackage, key, message, grpcError.AttributeExpr, false, false); err != nil { + return err + } + } + } + return nil +} + +// grpcResultViews returns the views the server may send for method. A view set +// in the design is the only possible value. Otherwise callers may select any +// view declared by the result type. +func grpcResultViews(method *expr.MethodExpr) []string { + if method.Result.Meta != nil { + if view, ok := method.Result.Meta.Last(expr.ViewMetaKey); ok { + return []string{view} + } + } + resultType := method.Result.Type.(*expr.ResultTypeExpr) + views := make([]string, len(resultType.Views)) + for index, view := range resultType.Views { + views[index] = view.Name + } + return views +} + +// grpcResultForView keeps the generated projected Go type but limits the +// conversion plan to fields included in view. +func grpcResultForView(result *expr.AttributeExpr, view string) (*expr.AttributeExpr, error) { + resultType := result.Type.(*expr.ResultTypeExpr) + selected, err := expr.Project(resultType, view) + if err != nil { + return nil, err + } + selectedResult := expr.DupAtt(result) + selectedResult.Type = selected + return grpcViewAttribute(result, selectedResult, make(map[expr.UserType]expr.UserType)), nil +} + +// grpcViewAttribute copies only the selected fields while reusing the Go types +// already generated for the complete result. +func grpcViewAttribute(full, selected *expr.AttributeExpr, seen map[expr.UserType]expr.UserType) *expr.AttributeExpr { + filtered := expr.DupAtt(selected) + switch selectedType := selected.Type.(type) { + case expr.UserType: + if existing, ok := seen[selectedType]; ok { + filtered.Type = existing + return filtered + } + fullType := full.Type.(expr.UserType) + copy := fullType.Dup(expr.DupAtt(selectedType.Attribute())) + seen[selectedType] = copy + copy.SetAttribute(grpcViewAttribute(fullType.Attribute(), selectedType.Attribute(), seen)) + filtered.Type = copy + case *expr.Array: + fullType := full.Type.(*expr.Array) + filtered.Type = &expr.Array{ + ElemType: grpcViewAttribute(fullType.ElemType, selectedType.ElemType, seen), + NonNullableElems: selectedType.NonNullableElems, + } + case *expr.Map: + fullType := full.Type.(*expr.Map) + filtered.Type = &expr.Map{ + KeyType: grpcViewAttribute(fullType.KeyType, selectedType.KeyType, seen), + ElemType: grpcViewAttribute(fullType.ElemType, selectedType.ElemType, seen), + } + case *expr.Object: + fullType := full.Type.(*expr.Object) + object := make(expr.Object, 0, len(*selectedType)) + for _, field := range *selectedType { + object = append(object, &expr.NamedAttributeExpr{ + Name: field.Name, + Attribute: grpcViewAttribute(fullType.Attribute(field.Name), field.Attribute, seen), + }) + } + filtered.Type = &object + case *expr.Union: + fullType := full.Type.(*expr.Union) + union := &expr.Union{ + TypeName: selectedType.TypeName, + TypeKey: selectedType.TypeKey, + ValueKey: selectedType.ValueKey, + Values: make([]*expr.NamedAttributeExpr, 0, len(selectedType.Values)), + } + for index, branch := range selectedType.Values { + union.Values = append(union.Values, &expr.NamedAttributeExpr{ + Name: branch.Name, + Attribute: grpcViewAttribute(fullType.Values[index].Attribute, branch.Attribute, seen), + }) + } + filtered.Type = union + } + return filtered +} + +// grpcProtobufValidationForView keeps the full protobuf message layout while +// retaining checks only for fields selected by one result view. +func grpcProtobufValidationForView(response, selected *expr.AttributeExpr) *expr.AttributeExpr { + validation := expr.DupAtt(response) + limitGRPCProtobufValidation(validation, selected, make(map[protobufAttributePair]struct{})) + return validation +} + +// limitGRPCProtobufValidation removes checks for response fields that the +// selected result view does not carry. +func limitGRPCProtobufValidation(response, selected *expr.AttributeExpr, seen map[protobufAttributePair]struct{}) { + pair := protobufAttributePair{left: response, right: selected} + if _, ok := seen[pair]; ok { + return + } + seen[pair] = struct{}{} + if responseType, ok := response.Type.(expr.UserType); ok { + if isWrappedAttr(response) { + selected = unwrapSelectedGRPCView(selected, unwrapAttr(response).Type.Kind()) + limitGRPCProtobufValidation(unwrapAttr(response), selected, seen) + return + } + if selectedType, ok := selected.Type.(expr.UserType); ok { + limitGRPCProtobufValidation(responseType.Attribute(), selectedType.Attribute(), seen) + return + } + limitGRPCProtobufValidation(responseType.Attribute(), selected, seen) + return + } + if selectedType, ok := selected.Type.(expr.UserType); ok { + limitGRPCProtobufValidation(response, selectedType.Attribute(), seen) + return + } + + switch responseType := response.Type.(type) { + case *expr.Object: + selectedObject := expr.AsObject(selected.Type) + if selectedObject == nil { + panic("selected gRPC result view does not match its protobuf response") + } + if response.Validation != nil { + required := response.Validation.Required[:0] + for _, name := range response.Validation.Required { + if selectedObject.Attribute(name) != nil && selected.IsRequired(name) { + required = append(required, name) + } + } + response.Validation.Required = required + } + for _, field := range *responseType { + selectedField := selectedObject.Attribute(field.Name) + if selectedField == nil { + // A selected sibling may use the same nested type. Copy this + // field before removing checks so the sibling keeps its rules. + field.Attribute = expr.DupAtt(field.Attribute) + clearGRPCProtobufValidation(field.Attribute, make(map[*expr.AttributeExpr]struct{})) + continue + } + limitGRPCProtobufValidation(field.Attribute, selectedField, seen) + } + case *expr.Array: + selectedArray := expr.AsArray(selected.Type) + if selectedArray == nil { + panic("selected gRPC result view does not match its protobuf response array") + } + limitGRPCProtobufValidation(responseType.ElemType, selectedArray.ElemType, seen) + case *expr.Map: + selectedMap := expr.AsMap(selected.Type) + if selectedMap == nil { + panic("selected gRPC result view does not match its protobuf response map") + } + limitGRPCProtobufValidation(responseType.KeyType, selectedMap.KeyType, seen) + limitGRPCProtobufValidation(responseType.ElemType, selectedMap.ElemType, seen) + case *expr.Union: + selectedUnion := expr.AsUnion(selected.Type) + if selectedUnion == nil || len(responseType.Values) != len(selectedUnion.Values) { + panic("selected gRPC result view does not match its protobuf response union") + } + for index, branch := range responseType.Values { + limitGRPCProtobufValidation(branch.Attribute, selectedUnion.Values[index].Attribute, seen) + } + } +} + +// unwrapSelectedGRPCView follows generated service type names until the value +// has the same collection shape as a protobuf wrapper field. +func unwrapSelectedGRPCView(selected *expr.AttributeExpr, kind expr.Kind) *expr.AttributeExpr { + for selected.Type.Kind() != kind { + userType, ok := selected.Type.(expr.UserType) + if !ok { + panic("selected gRPC result view does not match its protobuf wrapper") + } + selected = userType.Attribute() + } + return selected +} + +// clearGRPCProtobufValidation removes checks from an unselected protobuf field +// without changing the message fields or their generated Go types. +func clearGRPCProtobufValidation(attribute *expr.AttributeExpr, seen map[*expr.AttributeExpr]struct{}) { + if _, ok := seen[attribute]; ok { + return + } + seen[attribute] = struct{}{} + attribute.Validation = nil + switch actual := attribute.Type.(type) { + case expr.UserType: + clearGRPCProtobufValidation(actual.Attribute(), seen) + case *expr.Object: + for _, field := range *actual { + clearGRPCProtobufValidation(field.Attribute, seen) + } + case *expr.Array: + clearGRPCProtobufValidation(actual.ElemType, seen) + case *expr.Map: + clearGRPCProtobufValidation(actual.KeyType, seen) + clearGRPCProtobufValidation(actual.ElemType, seen) + case *expr.Union: + for _, branch := range actual.Values { + clearGRPCProtobufValidation(branch.Attribute, seen) + } + } +} + +// declareGRPCTransforms keeps a released response name when one method owns +// it. Conversions shared by several methods use names based on their types. +func declareGRPCTransforms(conversions map[grpcConversionKey]*grpcConversion, registries map[*codegen.GeneratedPackage]*grpcTransformRegistry) error { + type nameKey struct { + pkg *codegen.GeneratedPackage + name string + } + counts := make(map[nameKey]int) + for _, conversion := range conversions { + if len(conversion.releasedNames) > 1 { + counts[nameKey{pkg: conversion.pkg, name: conversion.preferredName}]++ + } + } + for _, conversion := range conversions { + if len(conversion.releasedNames) == 0 { + continue + } + name := conversion.releasedNames[0] + useResponseName := len(conversion.releasedResponseNames) == 1 + useTypeName := !useResponseName && len(conversion.releasedNames) > 1 + if useResponseName { + name = conversion.releasedResponseNames[0] + } else if useTypeName { + name = conversion.preferredName + } + if useTypeName && counts[nameKey{pkg: conversion.pkg, name: name}] > 1 { + name = conversion.fullName + } + declaration := codegen.NewPreferredName(codegen.NameFunction, name, codegen.ExportedName, conversion.order) + if err := conversion.pkg.DeclareName(declaration); err != nil { + return err + } + conversion.declaration = declaration + } + for _, registry := range registries { + groups, err := registry.helpers.Finalize() + if err != nil { + return err + } + for _, group := range groups { + order := group.Order().(grpcSymbolOrder) + name := grpcTransformHelperName(group.Definition(), order.operation == grpcConversionDirection(true)) + declaration := codegen.NewPreferredName(codegen.NameFunction, name, codegen.UnexportedName, order) + if err := registry.pkg.DeclareName(declaration); err != nil { + return err + } + if err := group.Bind(declaration); err != nil { + return err + } + } + } + return nil +} + +// releasedGRPCConversionName returns the constructor name generated before +// conversions shared by several methods were combined. +func releasedGRPCConversionName(endpoint *expr.GRPCEndpointExpr, key grpcInitKey, source, target *expr.AttributeExpr, proto bool, conversion *grpcConversion) string { + method := codegen.Goify(endpoint.Name(), true) + switch key.role { + case grpcRequestInit: + if !proto { + return "New" + method + "Payload" + } + return "NewProto" + conversion.messageName + case grpcResponseInit: + if !proto { + return grpcViewedConversionName(endpoint.MethodExpr, key, "New"+method+"Result") + } + name := conversion.messageName + bodyIsStruct := expr.IsUnion(target.Type) + if object := expr.AsObject(target.Type); object != nil { + bodyIsStruct = len(*object) > 0 + } + if !bodyIsStruct && key.view == "" { + name = conversion.serviceName + } + return grpcViewedConversionName(endpoint.MethodExpr, key, "NewProto"+name) + case grpcStreamingRequestInit, grpcStreamingResponseInit: + name := releasedGRPCStreamConversionName(source, target, proto, conversion) + return grpcViewedConversionName(endpoint.MethodExpr, key, name) + case grpcLegacyRequestInit: + return "New" + method + "PayloadFromMetadata" + case grpcErrorInit: + return "New" + method + codegen.Goify(key.subject, true) + "Error" + default: + panic("unknown gRPC conversion role") + } +} + +// grpcViewedConversionName keeps the existing constructor name for the only +// view selected by a design. When callers choose a view, additional +// constructors include the view name. +func grpcViewedConversionName(method *expr.MethodExpr, key grpcInitKey, name string) string { + if key.view == "" || key.view == expr.DefaultView { + return name + } + if method.Result.Meta != nil { + if _, fixed := method.Result.Meta.Last(expr.ViewMetaKey); fixed { + return name + } + } + return name + codegen.Goify(key.view, true) +} + +// releasedGRPCStreamConversionName returns the name used by released Goa +// versions for a conversion of one streamed value. +func releasedGRPCStreamConversionName(source, target *expr.AttributeExpr, proto bool, conversion *grpcConversion) string { + name := "New" + if proto { + name += "Proto" + } + if _, ok := source.Type.(expr.UserType); ok { + if proto { + name += conversion.serviceName + } else { + name += conversion.messageName + } + } + targetName := conversion.serviceName + if proto { + targetName = conversion.messageName + } + if !expr.IsObject(target.Type) && !expr.IsUnion(target.Type) { + targetName = conversion.messageName + if proto { + targetName = conversion.serviceName + } + } + return name + targetName +} + +// grpcConversionNames returns the short and complete constructor names and +// the type names used to order colliding requests. +func grpcConversionNames(serviceAttribute *expr.AttributeExpr, message *protobufMessageRecord, proto bool) (string, string, string, string) { + var messageName string + if message != nil { + messageName = message.plannedName + } + serviceName := messageName + if userType, ok := serviceAttribute.Type.(expr.UserType); ok && serviceAttribute.Type != expr.Empty { + serviceName = codegen.Goify(userType.Name(), true) + } + if serviceName == "" { + serviceName = codegen.Goify(serviceAttribute.Type.Name(), true) + } + name := "New" + serviceName + fullName := "New" + serviceName + "FromProto" + messageName + if message == nil { + fullName = "New" + serviceName + "FromMetadata" + } + if proto { + name = "NewProto" + serviceName + fullName = "NewProto" + messageName + "From" + serviceName + } + return name, fullName, serviceName, messageName +} + +// grpcTransformHelperName returns the private name derived from the two nested +// types converted by one helper. +func grpcTransformHelperName(definition codegen.TransformHelperDefinition, proto bool) string { + source := grpcTransformTypeName(definition.Source) + target := grpcTransformTypeName(definition.Target) + if proto { + return codegen.Goify("transform"+source+"ToProto"+target, false) + } + return codegen.Goify("transformProto"+source+"To"+target, false) +} + +// grpcTransformTypeName returns the declared type name used in a private +// conversion function signature. +func grpcTransformTypeName(attribute *expr.AttributeExpr) string { + if userType, ok := attribute.Type.(expr.UserType); ok { + return codegen.Goify(userType.Name(), true) + } + return codegen.Goify(attribute.Type.Name(), true) +} + +// grpcConversionDirection returns the fixed number used to order conversions +// to and from protobuf messages. +func grpcConversionDirection(proto bool) int { + if proto { + return 1 + } + return 2 +} + +// planGRPCValidations records each validation function in the client or server +// package that writes it. +func planGRPCValidations(generation *codegen.Generation, input PlanInput, service *expr.GRPCServiceExpr, protobuf *protobufServicePlan, pathName string) error { + clientPackage := generation.Package(path.Join(generation.GenPkg(), "grpc", pathName, "client")) + serverPackage := generation.Package(path.Join(generation.GenPkg(), "grpc", pathName, "server")) + for index, endpoint := range service.GRPCEndpoints { + messages := protobuf.messages[index] + source := protobufValidationSource{ + api: input.Root.API.Name, + service: service.Name(), + method: endpoint.Name(), + } + if protobuf.catalog.messageRecord(messages.request) != nil { + request := source + request.role = protobufRequestValidation + protobuf.catalog.collectValidation(messages.request, validateServer, request, "message", "message") + if protobufCLIRequestNeedsMessage(messages.request) { + // Command-line clients parse this request without a server, so + // they need the same check before converting it. + protobuf.catalog.collectValidation(messages.request, validateClient, request, "message", "message") + } + } + if protobuf.catalog.messageRecord(messages.response) != nil { + response := source + response.role = protobufResponseValidation + if messages.responseValidations == nil { + protobuf.catalog.collectValidation(messages.response, validateClient, response, "message", "message") + } else { + for _, view := range grpcResultViews(endpoint.MethodExpr) { + viewSource := response + viewSource.view = view + protobuf.catalog.collectValidation(messages.responseValidations[view], validateClient, viewSource, "message", "message") + } + } + } + for _, grpcError := range endpoint.GRPCErrors { + message := messages.errors[grpcError.Name] + if message == nil { + continue + } + errorSource := source + errorSource.error = grpcError.Name + errorSource.role = protobufErrorValidation + protobuf.catalog.collectValidation(message, validateClient, errorSource, "errmsg", "errmsg") + } + if endpoint.MethodExpr.StreamingPayload.Type != expr.Empty { + stream := source + stream.role = protobufStreamingRequestValidation + protobuf.catalog.collectValidation(messages.streamingRequest, validateServer, stream, "stream", "stream") + } + } + privateNameCounts := make(map[grpcValidationNameKey]int) + for _, validator := range protobuf.catalog.validators { + if validator.source.path != "" { + privateNameCounts[grpcValidationKey(validator)]++ + } + } + for _, validator := range protobuf.catalog.validators { + pkg := clientPackage + side := grpcClientPackage + if validator.side == validateServer { + pkg = serverPackage + side = grpcServerPackage + } + id := grpcSymbolID{ + side: side, + role: grpcValidationRole, + api: validator.source.api, + service: validator.source.service, + method: validator.source.method, + subject: validator.source.error, + view: validator.source.view, + path: validator.source.path, + operation: int(validator.source.role), + } + validator.declaration = grpcValidationDeclaration( + validator, + id, + privateNameCounts[grpcValidationKey(validator)] > 1, + ) + if err := pkg.DeclareName(validator.declaration); err != nil { + return err + } + } + return nil +} + +// grpcValidationDeclaration keeps private semantic names exact. A collision is +// a planning error instead of silently turning the name into a numbered name. +func grpcValidationDeclaration(validator *protobufValidationRecord, id grpcSymbolID, includeSource bool) *codegen.NameDeclaration { + name, visibility := grpcValidationName(validator, includeSource) + if visibility == codegen.UnexportedName { + return codegen.NewExactName(codegen.NameFunction, name) + } + return codegen.NewPreferredName( + codegen.NameFunction, + name, + visibility, + grpcSymbolOrder(id), + ) +} + +// grpcValidationName keeps the released name for a validator called directly +// by generated transport code. A nested validator uses the API, service, +// message, argument, and error context that determine its body. The complete +// method and field source is added only when distinct bodies would otherwise +// receive the same name. +func grpcValidationName(validator *protobufValidationRecord, includeSource bool) (string, codegen.PackageNameVisibility) { + if validator.source.path == "" { + name := "Validate" + validator.message.plannedName + if validator.source.view != "" && validator.source.view != expr.DefaultView { + name += codegen.Goify(validator.source.view, true) + } + return name, codegen.ExportedName + } + + name := "validate" + grpcValidationNamePart(validator.source.api) + + "_" + grpcValidationNamePart(validator.source.service) + + "_" + grpcValidationNamePart(validator.message.plannedName) + if validator.targetName == validator.contextName { + name += "_At_" + grpcValidationNamePart(validator.contextName) + } else { + name += "_Target_" + grpcValidationNamePart(validator.targetName) + + "_Context_" + grpcValidationNamePart(validator.contextName) + } + if !includeSource { + return name, codegen.UnexportedName + } + name += "_From_" + grpcValidationNamePart(validator.source.method) + switch validator.source.role { + case protobufRequestValidation: + name += "_Request" + case protobufResponseValidation: + name += "_Response" + case protobufErrorValidation: + name += "_Error_" + grpcValidationNamePart(validator.source.error) + case protobufStreamingRequestValidation: + name += "_StreamingRequest" + default: + panic("unknown protobuf validation role") + } + if validator.source.view != "" && validator.source.view != expr.DefaultView { + name += "_View_" + grpcValidationNamePart(validator.source.view) + } + for _, step := range validator.source.pathSteps { + name += "_" + grpcValidationPathStepName(step) + } + return name, codegen.UnexportedName +} + +// grpcValidationKey returns the stable facts used to detect private names that +// need their endpoint source added. +func grpcValidationKey(validator *protobufValidationRecord) grpcValidationNameKey { + return grpcValidationNameKey{ + side: validator.side, + api: validator.source.api, + service: validator.source.service, + message: validator.message.plannedName, + targetName: validator.targetName, + contextName: validator.contextName, + } +} + +// grpcValidationPathStepName writes one unambiguous part of a private +// validation function name. +func grpcValidationPathStepName(step protobufValidationPathStep) string { + switch step.kind { + case protobufValidationField: + return "Field_" + grpcValidationNamePart(step.name) + case protobufValidationArrayElement: + return "ArrayElement" + case protobufValidationMapKey: + return "MapKey" + case protobufValidationMapValue: + return "MapValue" + case protobufValidationUnionBranch: + return "Union_" + grpcValidationNamePart(step.name) + default: + panic("unknown protobuf validation path kind") + } +} + +// grpcValidationNamePart writes one authored label into a Go identifier. ASCII +// letters and digits stay readable. Every other byte uses its hexadecimal value +// between underscores, so two different labels can never produce one spelling. +func grpcValidationNamePart(value string) string { + if value == "" { + panic("empty protobuf validation name part") + } + const hexadecimal = "0123456789ABCDEF" + var name strings.Builder + for index := range len(value) { + character := value[index] + if character >= 'a' && character <= 'z' || + character >= 'A' && character <= 'Z' || + character >= '0' && character <= '9' { + name.WriteByte(character) + continue + } + name.WriteByte('_') + name.WriteByte(hexadecimal[character>>4]) + name.WriteByte(hexadecimal[character&0x0f]) + name.WriteByte('_') + } + return name.String() +} + +// newGRPCTransformPlan copies the protobuf input or output type, then records +// every nested conversion function that the generated code will call. +func newGRPCTransformPlan(source, target *expr.AttributeExpr, proto bool, protobuf *protobufServicePlan) (*codegen.TransformPlan, error) { + prefix := "protobuf" + if proto { + original := target + target = expr.DupAtt(target) + protobuf.bindAttributeCopy(original, target) + removeMeta(target) + prefix = "svc" + } else { + original := source + source = expr.DupAtt(source) + protobuf.bindAttributeCopy(original, source) + removeMeta(source) + } + return grpcTransformProgram(proto).Plan(source, target, prefix) +} + +// planGRPCTransformLayouts records the exact service and protobuf Go types +// supplied to one conversion. The helper registry compares these retained +// declarations and field pointer rules before sharing a private function. +func planGRPCTransformLayouts( + generation *codegen.Generation, + input PlanInput, + method *expr.MethodExpr, + source, target *expr.AttributeExpr, + proto bool, + protobuf *protobufServicePlan, + pathName string, +) (*codegen.GoTypePlan, *codegen.GoTypePlan, error) { + serviceAttribute, protobufAttribute := target, source + if proto { + serviceAttribute, protobufAttribute = source, target + } + serviceLayout, err := input.Service.MethodTypeLayout(method, serviceAttribute) + if err != nil { + return nil, nil, err + } + protobufLayout, err := planGRPCProtobufLayout(generation, protobuf, protobufAttribute, pathName) + if err != nil { + return nil, nil, err + } + if proto { + return serviceLayout, protobufLayout, nil + } + return protobufLayout, serviceLayout, nil +} + +// planGRPCProtobufLayout records the message, oneof, field, and pointer form +// produced by protoc for attribute before those declarations receive names. +func planGRPCProtobufLayout(generation *codegen.Generation, protobuf *protobufServicePlan, attribute *expr.AttributeExpr, pathName string) (*codegen.GoTypePlan, error) { + owner := path.Join(generation.GenPkg(), "grpc", pathName, pbPkgName) + planned := expr.DupAtt(attribute) + protobuf.bindAttributeCopy(attribute, planned) + removeMeta(planned) + if err := codegen.Walk(planned, func(candidate *expr.AttributeExpr) error { + for { + userType, ok := candidate.Type.(expr.UserType) + if !ok || protobuf.catalog.messageRecord(candidate) != nil { + break + } + candidate.Type = userType.Attribute().Type + } + return nil + }); err != nil { + return nil, fmt.Errorf("plan protobuf aliases: %w", err) + } + return codegen.PlanGoType(planned, codegen.GoTypePlanOptions{ + Owner: owner, + RetainNamedValue: true, + Policy: codegen.GoLayoutPolicy{Pointer: true}, + Bind: func(request codegen.GoTypeBindingRequest) (codegen.GoTypeBinding, error) { + switch request.Kind { + case codegen.GoNamed: + record := protobuf.catalog.messageRecord(request.Attribute) + if record == nil || record.declaration == nil { + return codegen.GoTypeBinding{}, fmt.Errorf("protobuf type %q has no generated declaration", request.Attribute.Type.Name()) + } + return codegen.GoTypeBinding{Owner: owner, Declaration: record.declaration}, nil + case codegen.GoUnion: + key, ok := protobuf.oneofs[request.Attribute] + if !ok || protobuf.names[key] == nil { + return codegen.GoTypeBinding{}, fmt.Errorf("protobuf oneof %q has no generated declaration", request.Attribute.Type.Name()) + } + return codegen.GoTypeBinding{Owner: owner, Declaration: protobuf.names[key]}, nil + default: + return codegen.GoTypeBinding{}, fmt.Errorf("bind unsupported protobuf Go type kind %s", request.Kind) + } + }, + }) +} + +// ComparePackageName orders generated declarations by package, purpose, API, +// service, method, error, and field. +func (left grpcSymbolOrder) ComparePackageName(other codegen.PackageNameOrder) int { + right := other.(grpcSymbolOrder) + return cmp.Or( + cmp.Compare(left.side, right.side), + cmp.Compare(left.role, right.role), + strings.Compare(left.api, right.api), + strings.Compare(left.service, right.service), + strings.Compare(left.method, right.method), + strings.Compare(left.subject, right.subject), + strings.Compare(left.view, right.view), + strings.Compare(left.path, right.path), + strings.Compare(left.source, right.source), + strings.Compare(left.target, right.target), + left.definition.Compare(right.definition), + cmp.Compare(left.operation, right.operation), + ) +} + +// withMethod returns the declaration details for one method in the same +// service. +func (id grpcSymbolID) withMethod(method string) grpcSymbolID { + id.method = method + return id +} + +// client selects the generated client package and the kind of declaration. +func (id grpcSymbolID) client(role grpcSymbolRole) grpcSymbolID { + id.side = grpcClientPackage + id.role = role + return id +} + +// server selects the generated server package and the kind of declaration. +func (id grpcSymbolID) server(role grpcSymbolRole) grpcSymbolID { + id.side = grpcServerPackage + id.role = role + return id +} diff --git a/grpc/codegen/templates.go b/grpc/codegen/templates.go index ff9574d22f..e17610a963 100644 --- a/grpc/codegen/templates.go +++ b/grpc/codegen/templates.go @@ -63,8 +63,8 @@ const ( // Partial template constants const ( - grpcConvertTypeToStringP = "convert_type_to_string" - grpcConvertStringToTypeP = "convert_string_to_type" + grpcConvertStringToTypeP = "convert_string_to_type" + grpcTypeToStringExpressionP = "type_to_string_expression" ) // Common template constants diff --git a/grpc/codegen/templates/client_endpoint_init.go.tpl b/grpc/codegen/templates/client_endpoint_init.go.tpl index cf9037c79e..aa7f7be7ca 100644 --- a/grpc/codegen/templates/client_endpoint_init.go.tpl +++ b/grpc/codegen/templates/client_endpoint_init.go.tpl @@ -1,15 +1,15 @@ {{- $retry := and .Method.Idempotent (eq .Method.StreamKind 1) }} -{{ printf "%s calls the %q function in %s.%s interface." .Method.VarName .Method.VarName .PkgName .ClientInterface | comment }} -func (c *{{ .ClientStruct }}) {{ .Method.VarName }}() goa.Endpoint { +{{ printf "%s calls the %q function in %s.%s interface." .Method.VarName .Method.VarName .ClientProtobufPkgName .ClientInterface | comment }} +func (c *{{ .ClientStructDeclaration.Name }}) {{ .Method.VarName }}() goa.Endpoint { {{- if $retry }} endpoint := func(ctx context.Context, v any) (any, error) { {{- else }} return func(ctx context.Context, v any) (any, error) { {{- end }} inv := goagrpc.NewInvoker( - Build{{ .Method.VarName }}Func(c.grpccli, c.opts...), - {{ if .PayloadRef }}Encode{{ .Method.VarName }}Request{{ else }}nil{{ end }}, - {{ if or .ResultRef .ClientStream }}Decode{{ .Method.VarName }}Response{{ else }}nil{{ end }}) + {{ .ClientBuildDeclaration.Name }}(c.grpccli, c.opts...), + {{ if .PayloadRef }}{{ .ClientEncodeDeclaration.Name }}{{ else }}nil{{ end }}, + {{ if or .ResultRef .ClientStream }}{{ .ClientDecodeDeclaration.Name }}{{ else }}nil{{ end }}) res, err := inv.Invoke(ctx, v) if err != nil { {{- if .Errors }} @@ -19,11 +19,11 @@ func (c *{{ .ClientStruct }}) {{ .Method.VarName }}() goa.Endpoint { {{- if .Response.ClientConvert }} case {{ .Response.ClientConvert.SrcRef }}: {{- if .Response.ClientConvert.Validation }} - if err := {{ .Response.ClientConvert.Validation.Name }}(message); err != nil { + if err := {{ .Response.ClientConvert.Validation.Declaration.Name }}(message); err != nil { return nil, err } {{- end }} - return nil, {{ .Response.ClientConvert.Init.Name }}({{ range .Response.ClientConvert.Init.Args }}{{ .Name }}, {{ end }}) + return nil, {{ .Response.ClientConvert.Init.Declaration.Name }}({{ range .Response.ClientConvert.Init.Args }}{{ .Name }}, {{ end }}) {{- end }} {{- end }} case *goapb.ErrorResponse: diff --git a/grpc/codegen/templates/client_init.go.tpl b/grpc/codegen/templates/client_init.go.tpl index 0f4ad98adf..bc7ff086cf 100644 --- a/grpc/codegen/templates/client_init.go.tpl +++ b/grpc/codegen/templates/client_init.go.tpl @@ -1,6 +1,6 @@ -{{ printf "New%s instantiates gRPC client for all the %s service servers." .ClientStruct .Service.Name | comment }} -func New{{ .ClientStruct }}(cc *grpc.ClientConn, opts ...grpc.CallOption) *{{ .ClientStruct }} { - return &{{ .ClientStruct }}{ +{{ printf "%s instantiates gRPC client for all the %s service servers." .ClientInitDeclaration.Name .Service.Name | comment }} +func {{ .ClientInitDeclaration.Name }}(cc *grpc.ClientConn, opts ...grpc.CallOption) *{{ .ClientStructDeclaration.Name }} { + return &{{ .ClientStructDeclaration.Name }}{ grpccli: {{ .ClientInterfaceInit }}(cc), opts: opts, } diff --git a/grpc/codegen/templates/client_struct.go.tpl b/grpc/codegen/templates/client_struct.go.tpl index e167dd4183..e15620871d 100644 --- a/grpc/codegen/templates/client_struct.go.tpl +++ b/grpc/codegen/templates/client_struct.go.tpl @@ -1,5 +1,5 @@ -{{ printf "%s lists the service endpoint gRPC clients." .ClientStruct | comment }} -type {{ .ClientStruct }} struct { - grpccli {{ .PkgName }}.{{ .ClientInterface }} +{{ printf "%s lists the service endpoint gRPC clients." .ClientStructDeclaration.Name | comment }} +type {{ .ClientStructDeclaration.Name }} struct { + grpccli {{ .ClientProtobufPkgName }}.{{ .ClientInterface }} opts []grpc.CallOption } diff --git a/grpc/codegen/templates/do_grpc_cli.go.tpl b/grpc/codegen/templates/do_grpc_cli.go.tpl index 706280ddec..02997fe62f 100644 --- a/grpc/codegen/templates/do_grpc_cli.go.tpl +++ b/grpc/codegen/templates/do_grpc_cli.go.tpl @@ -1,29 +1,80 @@ -func doGRPC(_, host string, _ int, _ bool) (goa.Endpoint, any, error) { +func doGRPC(ctx context.Context, _ string, host string, _ int, _ bool, stdout io.Writer) (err error) { +{{- if hasAnyInputStreams .Services }} + switch flag.Arg(0) { + {{- range .Services }} + {{- if hasInputStreams . }} + case {{ printf "%q" (kebab .Service.Name) }}: + switch flag.Arg(1) { + {{- range .Endpoints }} + {{- if streamsInput .Method }} + case {{ printf "%q" (kebab .Method.Name) }}: + return errors.New({{ printf "%q" (printf "example client does not support streamed input for service %q method %q" .ServiceName .Method.Name) }}) + {{- end }} + {{- end }} + } + {{- end }} + {{- end }} + } +{{- end }} conn, err := grpc.NewClient(host, grpc.WithTransportCredentials(insecure.NewCredentials())) if err != nil { - fmt.Fprintf(os.Stderr, "could not connect to gRPC server at %s: %v\n", host, err) - } + return fmt.Errorf("connect to gRPC server at %s: %w", host, err) + } + defer func() { + err = errors.Join(err, conn.Close()) + }() {{- range .Services }} {{- if .Service.ClientInterceptors }} {{ .Service.VarName }}Interceptors := {{ $.InterceptorsPkg }}.New{{ .Service.StructName }}ClientInterceptors() {{- end }} {{- end }} - return cli.ParseEndpoint( +{{- if hasRunnable .Services }} + endpoint, payload, err := {{ .CLIPkg }}.{{ .Parser.ParseEndpoint.Name }}( +{{- else }} + _, _, err = {{ .CLIPkg }}.{{ .Parser.ParseEndpoint.Name }}( +{{- end }} conn, {{- range .Services }} {{- if .Service.ClientInterceptors }} {{ .Service.VarName }}Interceptors, {{- end }} -{{- end }} + {{- end }} ) -} + if err != nil { + return fmt.Errorf("parse endpoint: %w", err) + } -{{ if eq .DefaultTransport.Type "grpc" }} -func grpcUsageCommands() []string { - return cli.UsageCommands() +{{ if hasRunnable .Services }} + switch flag.Arg(0) { + {{- range .Services }} + {{- if hasRunnableService . }} + case {{ printf "%q" (kebab .Service.Name) }}: + switch flag.Arg(1) { + {{- range .Endpoints }} + {{- if not (streamsInput .Method) }} + case {{ printf "%q" (kebab .Method.Name) }}: + {{- if streamsOutput .Method }} + data, err := endpoint(ctx, payload) + if err != nil { + return err + } + stream := data.({{ .ServicePkgName }}.{{ .Method.ClientStream.Interface }}) + return writeStreamResults(ctx, stdout, stream.{{ .Method.ClientStream.RecvWithContextName }}) + {{- else }} + return writeEndpointResult(ctx, stdout, endpoint, payload) + {{- end }} + {{- end }} + {{- end }} + } + {{- end }} + {{- end }} + } + {{- end }} + panic("parsed gRPC command has no generated result writer") } +{{ if eq .DefaultTransport.Type "grpc" }} func grpcUsageExamples() string { - return cli.UsageExamples() + return {{ .CLIPkg }}.{{ .Parser.UsageExamples.Name }}() } {{- end }} diff --git a/grpc/codegen/templates/grpc_handler_init.go.tpl b/grpc/codegen/templates/grpc_handler_init.go.tpl index 8c962c6298..1f951530ed 100644 --- a/grpc/codegen/templates/grpc_handler_init.go.tpl +++ b/grpc/codegen/templates/grpc_handler_init.go.tpl @@ -1,7 +1,7 @@ -{{ printf "New%sHandler creates a gRPC handler which serves the %q service %q endpoint." .Method.VarName .ServiceName .Method.Name | comment }} -func New{{ .Method.VarName }}Handler(endpoint goa.Endpoint, h goagrpc.{{ if .ServerStream }}Stream{{ else }}Unary{{ end }}Handler) goagrpc.{{ if .ServerStream }}Stream{{ else }}Unary{{ end }}Handler { +{{ printf "%s creates a gRPC handler which serves the %q service %q endpoint." .ServerHandlerDeclaration.Name .ServiceName .Method.Name | comment }} +func {{ .ServerHandlerDeclaration.Name }}(endpoint goa.Endpoint, h goagrpc.{{ if .ServerStream }}Stream{{ else }}Unary{{ end }}Handler) goagrpc.{{ if .ServerStream }}Stream{{ else }}Unary{{ end }}Handler { if h == nil { - h = goagrpc.New{{ if .ServerStream }}Stream{{ else }}Unary{{ end }}Handler(endpoint, {{ if .Method.Payload }}Decode{{ .Method.VarName }}Request{{ else }}nil{{ end }}{{ if not .ServerStream }}, Encode{{ .Method.VarName }}Response{{ end }}) + h = goagrpc.New{{ if .ServerStream }}Stream{{ else }}Unary{{ end }}Handler(endpoint, {{ if .Method.Payload }}{{ .ServerDecodeDeclaration.Name }}{{ else }}nil{{ end }}{{ if not .ServerStream }}, {{ .ServerEncodeDeclaration.Name }}{{ end }}) } return h } diff --git a/grpc/codegen/templates/grpc_service.go.tpl b/grpc/codegen/templates/grpc_service.go.tpl index 4dbff130b6..b6af05f61d 100644 --- a/grpc/codegen/templates/grpc_service.go.tpl +++ b/grpc/codegen/templates/grpc_service.go.tpl @@ -5,7 +5,7 @@ service {{ .Name }} { {{ if .Method.Description }}{{ .Method.Description | comment }}{{ end }} {{- $serverStream := or (eq .Method.StreamKind 3) (eq .Method.StreamKind 4) }} {{- $clientStream := or (eq .Method.StreamKind 2) (eq .Method.StreamKind 4) }} - rpc {{ .Method.VarName }} ({{ if $clientStream }}stream {{ end }}{{ .Request.Message.VarName }}) returns ({{ if $serverStream }}stream {{ end }}{{ .Response.Message.VarName }}){{ if .Method.Idempotent }} { + rpc {{ .ProtoMethodName }} ({{ if $clientStream }}stream {{ end }}{{ .Request.ProtoMessageName }}) returns ({{ if $serverStream }}stream {{ end }}{{ .Response.ProtoMessageName }}){{ if .Method.Idempotent }} { option idempotency_level = IDEMPOTENT; }{{ else }};{{ end }} {{- end }} diff --git a/grpc/codegen/templates/parse_endpoint.go.tpl b/grpc/codegen/templates/parse_endpoint.go.tpl index 54d923a3f6..4110252345 100644 --- a/grpc/codegen/templates/parse_endpoint.go.tpl +++ b/grpc/codegen/templates/parse_endpoint.go.tpl @@ -1,35 +1,35 @@ // ParseEndpoint returns the endpoint and payload as specified on the command // line. -func ParseEndpoint( - cc *grpc.ClientConn, +func {{ .Declaration.Name }}( + {{ .Variables.Connection }} *grpc.ClientConn, {{- range .Commands }} {{- if .Interceptors }} - {{ .Interceptors.VarName }} {{ .Interceptors.PkgName }}.ClientInterceptors, + {{ .Interceptors.ParserVar }} {{ .Interceptors.PkgName }}.{{ .Interceptors.ClientInterceptorsDeclaration.Name }}, {{- end }} {{- end }} - opts ...grpc.CallOption, + {{ .Variables.Options }} ...grpc.CallOption, ) (goa.Endpoint, any, error) { {{ .FlagsCode }} var ( - data any - endpoint goa.Endpoint - err error + {{ .Variables.Data }} any + {{ .Variables.Endpoint }} goa.Endpoint + {{ .Variables.Error }} error ) { - switch svcn { + switch {{ .Variables.ServiceName }} { {{- range .Commands }} case "{{ .Name }}": - c := {{ .PkgName }}.NewClient(cc, opts...) - switch epn { + {{ $.Variables.Client }} := {{ .PkgName }}.{{ .ClientInit.Name }}({{ $.Variables.Connection }}, {{ $.Variables.Options }}...) + switch {{ $.Variables.MethodName }} { {{- $pkgName := .PkgName }} {{- range .Subcommands }} case "{{ .Name }}": - endpoint = c.{{ .MethodVarName }}() + {{ $.Variables.Endpoint }} = {{ $.Variables.Client }}.{{ .MethodVarName }}() {{- if .Interceptors }} - endpoint = {{ .Interceptors.PkgName }}.Wrap{{ .MethodVarName }}ClientEndpoint(endpoint, {{ .Interceptors.VarName }}) + {{ $.Variables.Endpoint }} = {{ .Interceptors.PkgName }}.{{ .Interceptors.ClientEndpointWrapperDeclaration.Name }}({{ $.Variables.Endpoint }}, {{ .Interceptors.ParserVar }}) {{- end }} {{- if .BuildFunction }} - data, err = {{ $pkgName}}.{{ .BuildFunction.Name }}({{ range .BuildFunction.ActualParams }}*{{ . }}Flag, {{ end }}) + {{ $.Variables.Data }}, {{ $.Variables.Error }} = {{ $pkgName}}.{{ .BuildFunction.Name }}({{- if .ActualArgs }}{{ range $index, $argument := .ActualArgs }}{{ if $index }}, {{ end }}{{ $argument }}{{ end }}{{ else }}{{ range $index, $variable := .ActualPointerVars }}{{ if $index }}, {{ end }}*{{ $variable }}{{ end }}{{ end }}) {{- else if .Conversion }} {{ .Conversion }} {{- end }} @@ -38,9 +38,9 @@ func ParseEndpoint( {{- end }} } } - if err != nil { - return nil, nil, err + if {{ .Variables.Error }} != nil { + return nil, nil, {{ .Variables.Error }} } - return endpoint, data, nil + return {{ .Variables.Endpoint }}, {{ .Variables.Data }}, nil } diff --git a/grpc/codegen/templates/partial/convert_string_to_type.go.tpl b/grpc/codegen/templates/partial/convert_string_to_type.go.tpl index bc7edae3e3..fa7a905562 100644 --- a/grpc/codegen/templates/partial/convert_string_to_type.go.tpl +++ b/grpc/codegen/templates/partial/convert_string_to_type.go.tpl @@ -79,6 +79,4 @@ err = goa.MergeErrors(err, goa.InvalidFieldTypeError({{ printf "%q" .VarName }}, {{ .VarName}}Raw, "boolean")) } {{ .VarName }} = {{ if .Pointer }}&{{ end }}v -{{- else }} - // unsupported type {{ .Type.Name }} for var {{ .VarName }} {{- end }} diff --git a/grpc/codegen/templates/partial/convert_type_to_string.go.tpl b/grpc/codegen/templates/partial/convert_type_to_string.go.tpl deleted file mode 100644 index 8cefc6771e..0000000000 --- a/grpc/codegen/templates/partial/convert_type_to_string.go.tpl +++ /dev/null @@ -1,27 +0,0 @@ -{{- if eq .Type.Name "boolean" -}} - {{ .VarName }} := strconv.FormatBool({{ .Target }}) -{{- else if eq .Type.Name "int" -}} - {{ .VarName }} := strconv.Itoa({{ .Target }}) -{{- else if eq .Type.Name "int32" -}} - {{ .VarName }} := strconv.FormatInt(int64({{ .Target }}), 10) -{{- else if eq .Type.Name "int64" -}} - {{ .VarName }} := strconv.FormatInt({{ .Target }}, 10) -{{- else if eq .Type.Name "uint" -}} - {{ .VarName }} := strconv.FormatUint(uint64({{ .Target }}), 10) -{{- else if eq .Type.Name "uint32" -}} - {{ .VarName }} := strconv.FormatUint(uint64({{ .Target }}), 10) -{{- else if eq .Type.Name "uint64" -}} - {{ .VarName }} := strconv.FormatUint({{ .Target }}, 10) -{{- else if eq .Type.Name "float32" -}} - {{ .VarName }} := strconv.FormatFloat(float64({{ .Target }}), 'f', -1, 32) -{{- else if eq .Type.Name "float64" -}} - {{ .VarName }} := strconv.FormatFloat({{ .Target }}, 'f', -1, 64) -{{- else if eq .Type.Name "string" -}} - {{ .VarName }} := {{ .Target }} -{{- else if eq .Type.Name "bytes" -}} - {{ .VarName }} := string({{ .Target }}) -{{- else if eq .Type.Name "any" -}} - {{ .VarName }} := fmt.Sprintf("%v", {{ .Target }}) -{{- else }} - // unsupported type {{ .Type.Name }} for field {{ .FieldName }} -{{- end }} diff --git a/grpc/codegen/templates/partial/slice_item_conversion.go.tpl b/grpc/codegen/templates/partial/slice_item_conversion.go.tpl index 1e07c691c4..77b186d578 100644 --- a/grpc/codegen/templates/partial/slice_item_conversion.go.tpl +++ b/grpc/codegen/templates/partial/slice_item_conversion.go.tpl @@ -58,6 +58,4 @@ {{ .VarName }}[i] = v {{- else if eq .Type.ElemType.Type.Name "any" }} {{ .VarName }}[i] = rv -{{- else }} - // unsupported slice type {{ .Type.ElemType.Type.Name }} for var {{ .VarName }} {{- end }} diff --git a/grpc/codegen/templates/partial/string_conversion.go.tpl b/grpc/codegen/templates/partial/string_conversion.go.tpl deleted file mode 100644 index 8cefc6771e..0000000000 --- a/grpc/codegen/templates/partial/string_conversion.go.tpl +++ /dev/null @@ -1,27 +0,0 @@ -{{- if eq .Type.Name "boolean" -}} - {{ .VarName }} := strconv.FormatBool({{ .Target }}) -{{- else if eq .Type.Name "int" -}} - {{ .VarName }} := strconv.Itoa({{ .Target }}) -{{- else if eq .Type.Name "int32" -}} - {{ .VarName }} := strconv.FormatInt(int64({{ .Target }}), 10) -{{- else if eq .Type.Name "int64" -}} - {{ .VarName }} := strconv.FormatInt({{ .Target }}, 10) -{{- else if eq .Type.Name "uint" -}} - {{ .VarName }} := strconv.FormatUint(uint64({{ .Target }}), 10) -{{- else if eq .Type.Name "uint32" -}} - {{ .VarName }} := strconv.FormatUint(uint64({{ .Target }}), 10) -{{- else if eq .Type.Name "uint64" -}} - {{ .VarName }} := strconv.FormatUint({{ .Target }}, 10) -{{- else if eq .Type.Name "float32" -}} - {{ .VarName }} := strconv.FormatFloat(float64({{ .Target }}), 'f', -1, 32) -{{- else if eq .Type.Name "float64" -}} - {{ .VarName }} := strconv.FormatFloat({{ .Target }}, 'f', -1, 64) -{{- else if eq .Type.Name "string" -}} - {{ .VarName }} := {{ .Target }} -{{- else if eq .Type.Name "bytes" -}} - {{ .VarName }} := string({{ .Target }}) -{{- else if eq .Type.Name "any" -}} - {{ .VarName }} := fmt.Sprintf("%v", {{ .Target }}) -{{- else }} - // unsupported type {{ .Type.Name }} for field {{ .FieldName }} -{{- end }} diff --git a/grpc/codegen/templates/partial/type_conversion.go.tpl b/grpc/codegen/templates/partial/type_conversion.go.tpl index bc7edae3e3..fa7a905562 100644 --- a/grpc/codegen/templates/partial/type_conversion.go.tpl +++ b/grpc/codegen/templates/partial/type_conversion.go.tpl @@ -79,6 +79,4 @@ err = goa.MergeErrors(err, goa.InvalidFieldTypeError({{ printf "%q" .VarName }}, {{ .VarName}}Raw, "boolean")) } {{ .VarName }} = {{ if .Pointer }}&{{ end }}v -{{- else }} - // unsupported type {{ .Type.Name }} for var {{ .VarName }} {{- end }} diff --git a/grpc/codegen/templates/partial/type_to_string_expression.go.tpl b/grpc/codegen/templates/partial/type_to_string_expression.go.tpl new file mode 100644 index 0000000000..c25f17139e --- /dev/null +++ b/grpc/codegen/templates/partial/type_to_string_expression.go.tpl @@ -0,0 +1,25 @@ +{{- if eq .Type.Name "boolean" -}} +strconv.FormatBool({{ .Target }}) +{{- else if eq .Type.Name "int" -}} +strconv.Itoa({{ .Target }}) +{{- else if eq .Type.Name "int32" -}} +strconv.FormatInt(int64({{ .Target }}), 10) +{{- else if eq .Type.Name "int64" -}} +strconv.FormatInt({{ .Target }}, 10) +{{- else if eq .Type.Name "uint" -}} +strconv.FormatUint(uint64({{ .Target }}), 10) +{{- else if eq .Type.Name "uint32" -}} +strconv.FormatUint(uint64({{ .Target }}), 10) +{{- else if eq .Type.Name "uint64" -}} +strconv.FormatUint({{ .Target }}, 10) +{{- else if eq .Type.Name "float32" -}} +strconv.FormatFloat(float64({{ .Target }}), 'f', -1, 32) +{{- else if eq .Type.Name "float64" -}} +strconv.FormatFloat({{ .Target }}, 'f', -1, 64) +{{- else if eq .Type.Name "string" -}} +{{ .Target }} +{{- else if eq .Type.Name "bytes" -}} +string({{ .Target }}) +{{- else if eq .Type.Name "any" -}} +fmt.Sprintf("%v", {{ .Target }}) +{{- end }} diff --git a/grpc/codegen/templates/remote_method_builder.go.tpl b/grpc/codegen/templates/remote_method_builder.go.tpl index c96d079b03..2c9b2891f4 100644 --- a/grpc/codegen/templates/remote_method_builder.go.tpl +++ b/grpc/codegen/templates/remote_method_builder.go.tpl @@ -1,25 +1,25 @@ -{{ printf "Build%sFunc builds the remote method to invoke for %q service %q endpoint." .Method.VarName .ServiceName .Method.Name | comment }} -func Build{{ .Method.VarName }}Func(grpccli {{ .PkgName }}.{{ .ClientInterface }}, cliopts ...grpc.CallOption) goagrpc.RemoteFunc { +{{ printf "%s builds the remote method to invoke for %q service %q endpoint." .ClientBuildDeclaration.Name .ServiceName .Method.Name | comment }} +func {{ .ClientBuildDeclaration.Name }}(grpccli {{ .ClientProtobufPkgName }}.{{ .ClientInterface }}, cliopts ...grpc.CallOption) goagrpc.RemoteFunc { return func(ctx context.Context, reqpb any, opts ...grpc.CallOption) (any, error) { for _, opt := range cliopts { opts = append(opts, opt) } {{- if .Request.StreamEnvelope }} - stream, err := grpccli.{{ .ClientMethodName }}(ctx, opts...) + stream, err := grpccli.{{ .GRPCMethodName }}(ctx, opts...) if err != nil { return nil, err } if reqpb != nil { - if err := stream.Send(reqpb.({{ .Request.Message.Ref }})); err != nil { + if err := stream.Send(reqpb.({{ .Request.ClientMessageRef }})); err != nil { return nil, err } } return stream, nil {{- else }} if reqpb != nil { - return grpccli.{{ .ClientMethodName }}(ctx{{ if not .Method.StreamingPayload }}, reqpb.({{ .Request.ClientConvert.TgtRef }}){{ end }}, opts...) + return grpccli.{{ .GRPCMethodName }}(ctx{{ if not .Method.StreamingPayload }}, reqpb.({{ .Request.ClientConvert.TgtRef }}){{ end }}, opts...) } - return grpccli.{{ .ClientMethodName }}(ctx{{ if not .Method.StreamingPayload }}, &{{ .Request.ClientConvert.TgtName }}{}{{ end }}, opts...) + return grpccli.{{ .GRPCMethodName }}(ctx{{ if not .Method.StreamingPayload }}, &{{ .Request.ClientConvert.TgtName }}{}{{ end }}, opts...) {{- end }} } } diff --git a/grpc/codegen/templates/request_decoder.go.tpl b/grpc/codegen/templates/request_decoder.go.tpl index 9e3bb95486..cb2c78b877 100644 --- a/grpc/codegen/templates/request_decoder.go.tpl +++ b/grpc/codegen/templates/request_decoder.go.tpl @@ -1,14 +1,14 @@ -{{ printf "Decode%sRequest decodes requests sent to %q service %q endpoint." .Method.VarName .ServiceName .Method.Name | comment }} -func Decode{{ .Method.VarName }}Request(ctx context.Context, v any, md metadata.MD) (any, error) { +{{ printf "%s decodes requests sent to %q service %q endpoint." .ServerDecodeDeclaration.Name .ServiceName .Method.Name | comment }} +func {{ .ServerDecodeDeclaration.Name }}(ctx context.Context, v any, md metadata.MD) (any, error) { {{- if .Request.LegacyDecode }} if !goagrpc.UsesStreamEnvelope(ctx) { - return {{ .Request.LegacyDecode.FuncName }}(ctx, md) + return {{ .Request.LegacyDecode.FuncDeclaration.Name }}(ctx, md) } {{- end }} {{- template "partial_metadata_decode" .Request.Metadata }} {{- if .Request.PayloadMessage }} var ( - message {{ .Request.PayloadMessage.Ref }} + message {{ .Request.ServerPayloadMessageRef }} ok bool ) { @@ -16,37 +16,37 @@ func Decode{{ .Method.VarName }}Request(ctx context.Context, v any, md metadata. if v == nil { return nil, goa.MissingFieldError("initial_payload", "stream") } - var envelope {{ .Request.Message.Ref }} - if envelope, ok = v.({{ .Request.Message.Ref }}); !ok { - return nil, goagrpc.ErrInvalidType("{{ .ServiceName }}", "{{ .Method.Name }}", "{{ .Request.Message.Ref }}", v) + var envelope {{ .Request.ServerMessageRef }} + if envelope, ok = v.({{ .Request.ServerMessageRef }}); !ok { + return nil, goagrpc.ErrInvalidType("{{ .ServiceName }}", "{{ .Method.Name }}", "{{ .Request.ServerMessageRef }}", v) } switch body := envelope.{{ .Request.StreamEnvelope.FieldName }}.(type) { - case *{{ .Request.StreamEnvelope.InitialWrapperRef }}: + case *{{ .Request.StreamEnvelope.ServerInitialWrapperRef }}: if body.{{ .Request.StreamEnvelope.InitialFieldName }} == nil { return nil, goa.MissingFieldError("initial_payload", "stream") } message = body.{{ .Request.StreamEnvelope.InitialFieldName }} - case *{{ .Request.StreamEnvelope.StreamItemWrapperRef }}: + case *{{ .Request.StreamEnvelope.ServerStreamItemWrapperRef }}: return nil, goa.InvalidFieldTypeError("body", "stream_item", "initial_payload") default: return nil, goa.MissingFieldError("initial_payload", "stream") } {{- else }} - if message, ok = v.({{ .Request.PayloadMessage.Ref }}); !ok { - return nil, goagrpc.ErrInvalidType("{{ .ServiceName }}", "{{ .Method.Name }}", "{{ .Request.PayloadMessage.Ref }}", v) + if message, ok = v.({{ .Request.ServerPayloadMessageRef }}); !ok { + return nil, goagrpc.ErrInvalidType("{{ .ServiceName }}", "{{ .Method.Name }}", "{{ .Request.ServerPayloadMessageRef }}", v) } {{- end }} {{- if .Request.ServerConvert.Validation }} - if err {{ if .Request.Metadata }}={{ else }}:={{ end }} {{ .Request.ServerConvert.Validation.Name }}(message); err != nil { + if err {{ if .Request.Metadata }}={{ else }}:={{ end }} {{ .Request.ServerConvert.Validation.Declaration.Name }}(message); err != nil { return nil, err } {{- end }} } {{- end }} - var payload {{ .PayloadRef }} + var payload {{ .ServerPayloadRef }} { {{- if .Request.ServerConvert }} - payload = {{ .Request.ServerConvert.Init.Name }}({{ range .Request.ServerConvert.Init.Args }}{{ .Name }}, {{ end }}) + payload = {{ .Request.ServerConvert.Init.Declaration.Name }}({{ range .Request.ServerConvert.Init.Args }}{{ .Name }}, {{ end }}) {{- else }} payload = {{ (index .Request.Metadata 0).VarName }} {{- end }} @@ -56,13 +56,13 @@ func Decode{{ .Method.VarName }}Request(ctx context.Context, v any, md metadata. } {{- if .Request.LegacyDecode }} -{{ printf "%s decodes requests sent to %q service %q endpoint by clients that speak the legacy stream protocol which carries the method payload in gRPC request metadata." .Request.LegacyDecode.FuncName .ServiceName .Method.Name | comment }} -func {{ .Request.LegacyDecode.FuncName }}(ctx context.Context, md metadata.MD) (any, error) { +{{ printf "%s decodes requests sent to %q service %q endpoint by clients that speak the legacy stream protocol which carries the method payload in gRPC request metadata." .Request.LegacyDecode.FuncDeclaration.Name .ServiceName .Method.Name | comment }} +func {{ .Request.LegacyDecode.FuncDeclaration.Name }}(ctx context.Context, md metadata.MD) (any, error) { {{- template "partial_metadata_decode" .Request.LegacyDecode.Metadata }} - var payload {{ .PayloadRef }} + var payload {{ .ServerPayloadRef }} { {{- if .Request.LegacyDecode.ServerConvert }} - payload = {{ .Request.LegacyDecode.ServerConvert.Init.Name }}({{ range .Request.LegacyDecode.ServerConvert.Init.Args }}{{ .Name }}, {{ end }}) + payload = {{ .Request.LegacyDecode.ServerConvert.Init.Declaration.Name }}({{ range .Request.LegacyDecode.ServerConvert.Init.Args }}{{ .Name }}, {{ end }}) {{- else }} payload = {{ (index .Request.LegacyDecode.Metadata 0).VarName }} {{- end }} @@ -77,14 +77,18 @@ func {{ .Request.LegacyDecode.FuncName }}(ctx context.Context, md metadata.MD) ( {{- if ne .Type "Basic" }} {{- if not .CredRequired }} if payload.{{ .CredField }} != nil { - {{- end }} - if strings.Contains({{ if .CredPointer }}*{{ end }}payload.{{ .CredField }}, " ") { + cred := *payload.{{ .CredField }} + if index := strings.IndexByte(string(cred), ' '); index >= 0 { + // Remove authorization scheme prefix (e.g. "Bearer") + cred = cred[index+1:] + payload.{{ .CredField }} = &cred + } + } + {{- else }} + if index := strings.IndexByte(string(payload.{{ .CredField }}), ' '); index >= 0 { // Remove authorization scheme prefix (e.g. "Bearer") - cred := strings.SplitN({{ if .CredPointer }}*{{ end }}payload.{{ .CredField }}, " ", 2)[1] - payload.{{ .CredField }} = {{ if .CredPointer }}&{{ end }}cred + payload.{{ .CredField }} = payload.{{ .CredField }}[index+1:] } - {{- if not .CredRequired }} - } {{- end }} {{- end }} {{- end }} diff --git a/grpc/codegen/templates/request_encoder.go.tpl b/grpc/codegen/templates/request_encoder.go.tpl index 35ea5fa7d0..32bbfc74f5 100644 --- a/grpc/codegen/templates/request_encoder.go.tpl +++ b/grpc/codegen/templates/request_encoder.go.tpl @@ -1,41 +1,36 @@ -{{ printf "Encode%sRequest encodes requests sent to %s %s endpoint." .Method.VarName .ServiceName .Method.Name | comment }} -func Encode{{ .Method.VarName }}Request(ctx context.Context, v any, md *metadata.MD) (any, error) { - payload, ok := v.({{ .PayloadRef }}) +{{ printf "%s encodes requests sent to %s %s endpoint." .ClientEncodeDeclaration.Name .ServiceName .Method.Name | comment }} +func {{ .ClientEncodeDeclaration.Name }}(ctx context.Context, v any, md *metadata.MD) (any, error) { + payload, ok := v.({{ .ClientPayloadRef }}) if !ok { - return nil, goagrpc.ErrInvalidType("{{ .ServiceName }}", "{{ .Method.Name }}", "{{ .PayloadRef }}", v) + return nil, goagrpc.ErrInvalidType("{{ .ServiceName }}", "{{ .Method.Name }}", "{{ .ClientPayloadRef }}", v) } {{- range .Request.Metadata }} + {{- if .Pointer }} + if payload{{ if .FieldName }}.{{ .FieldName }}{{ end }} != nil { + {{- end }} + {{ .EncodeCode }} {{- if .StringSlice }} - for _, value := range payload{{ if .FieldName }}.{{ .FieldName }}{{ end }} { + for _, value := range {{ .WireVarName }} { (*md).Append({{ printf "%q" .Name }}, value) } {{- else if .Slice }} - for _, value := range payload{{ if .FieldName }}.{{ .FieldName }}{{ end }} { - {{ template "partial_convert_type_to_string" (typeConversionData .Type.ElemType.Type "valueStr" "value") }} + for _, value := range {{ .WireVarName }} { + valueStr := {{ template "partial_type_to_string_expression" (typeStringExpressionData .Type.ElemType.Type "value") }} (*md).Append({{ printf "%q" .Name }}, valueStr) } {{- else }} - {{- if .Pointer }} - if payload{{ if .FieldName }}.{{ .FieldName }}{{ end }} != nil { - {{- end }} {{- if (and (eq .Name "Authorization") (isBearer $.MetadataSchemes)) }} - if !strings.Contains({{ if .Pointer }}*{{ end }}payload{{ if .FieldName }}.{{ .FieldName }}{{ end }}, " ") { - (*md).Append(ctx, {{ printf "%q" .Name }}, "Bearer "+{{ if .Pointer }}*{{ end }}payload{{ if .FieldName }}.{{ .FieldName }}{{ end }}) + if !strings.Contains({{ .WireVarName }}, " ") { + (*md).Append(ctx, {{ printf "%q" .Name }}, "Bearer "+{{ .WireVarName }}) } else { {{- end }} - (*md).Append({{ printf "%q" .Name }}, - {{- if eq .Type.Name "bytes" }} string( - {{- else if not (eq .Type.Name "string") }} fmt.Sprintf("%v", - {{- end }} - {{- if .Pointer }}*{{ end }}payload{{ if .FieldName }}.{{ .FieldName }}{{ end }} - {{- if or (eq .Type.Name "bytes") (not (eq .Type.Name "string")) }}) - {{- end }}) + (*md).Append({{ printf "%q" .Name }}, {{ template "partial_type_to_string_expression" (typeStringExpressionData .Type .WireVarName) }}) {{- if (and (eq .Name "Authorization") (isBearer $.MetadataSchemes)) }} } {{- end }} - {{- if .Pointer }} - } - {{- end }} + {{- end }} + {{- if .Pointer }} + } {{- end }} {{- end }} {{- if .Request.StreamEnvelope }} @@ -43,14 +38,14 @@ func Encode{{ .Method.VarName }}Request(ctx context.Context, v any, md *metadata {{- end }} {{- if .Request.ClientConvert }} {{- if .Request.StreamEnvelope }} - message := {{ .Request.ClientConvert.Init.Name }}({{ range .Request.ClientConvert.Init.Args }}{{ .Name }}, {{ end }}) - return &{{ .PkgName }}.{{ .Request.Message.VarName }}{ - {{ .Request.StreamEnvelope.FieldName }}: &{{ .Request.StreamEnvelope.InitialWrapperRef }}{ + message := {{ .Request.ClientConvert.Init.Declaration.Name }}({{ range .Request.ClientConvert.Init.Args }}{{ .Name }}, {{ end }}) + return &{{ .ClientProtobufPkgName }}.{{ .Request.Message.VarName }}{ + {{ .Request.StreamEnvelope.FieldName }}: &{{ .Request.StreamEnvelope.ClientInitialWrapperRef }}{ {{ .Request.StreamEnvelope.InitialFieldName }}: message, }, }, nil {{- else }} - return {{ .Request.ClientConvert.Init.Name }}({{ range .Request.ClientConvert.Init.Args }}{{ .Name }}, {{ end }}), nil + return {{ .Request.ClientConvert.Init.Declaration.Name }}({{ range .Request.ClientConvert.Init.Args }}{{ .Name }}, {{ end }}), nil {{- end }} {{- else }} return nil, nil diff --git a/grpc/codegen/templates/response_decoder.go.tpl b/grpc/codegen/templates/response_decoder.go.tpl index 21d1d49842..81d1da0208 100644 --- a/grpc/codegen/templates/response_decoder.go.tpl +++ b/grpc/codegen/templates/response_decoder.go.tpl @@ -1,5 +1,5 @@ -{{ printf "Decode%sResponse decodes responses from the %s %s endpoint." .Method.VarName .ServiceName .Method.Name | comment }} -func Decode{{ .Method.VarName }}Response(ctx context.Context, v any, hdr, trlr metadata.MD) (any, error) { +{{ printf "%s decodes responses from the %s %s endpoint." .ClientDecodeDeclaration.Name .ServiceName .Method.Name | comment }} +func {{ .ClientDecodeDeclaration.Name }}(ctx context.Context, v any, hdr, trlr metadata.MD) (any, error) { {{- if or .Response.Headers .Response.Trailers }} var ( {{- range .Response.Headers }} @@ -28,7 +28,7 @@ func Decode{{ .Method.VarName }}Response(ctx context.Context, v any, hdr, trlr m return nil, err } {{- end }} -{{- if .ViewedResultRef }} +{{- if and .ViewedResultRef (not .Method.ViewedResult.ViewName) (not .ClientStream) }} var view string { if vals := hdr.Get("goa-view"); len(vals) > 0 { @@ -37,30 +37,47 @@ func Decode{{ .Method.VarName }}Response(ctx context.Context, v any, hdr, trlr m } {{- end }} {{- if .ClientStream }} - return &{{ .ClientStream.VarName }}{ + return &{{ .ClientStream.Declaration.Name }}{ stream: v.({{ .ClientStream.Interface }}), - ctx: ctx, - {{- if .ViewedResultRef }} - view: view, - {{- end }} + ctx: ctx, }, nil {{- else }} - message, ok := v.({{ .Response.ClientConvert.SrcRef }}) + {{ if hasInitArg .Response.ClientConvert.Init.Args "message" }}message{{ else }}_{{ end }}, ok := v.({{ .Response.ClientConvert.SrcRef }}) if !ok { return nil, goagrpc.ErrInvalidType("{{ .ServiceName }}", "{{ .Method.Name }}", "{{ .Response.ClientConvert.SrcRef }}", v) } {{- if and .Response.ClientConvert.Validation (not .ViewedResultRef) }} - if err {{ if or .Response.Headers .Response.Trailers }}={{ else }}:={{ end }} {{ .Response.ClientConvert.Validation.Name }}(message); err != nil { + if err {{ if or .Response.Headers .Response.Trailers }}={{ else }}:={{ end }} {{ .Response.ClientConvert.Validation.Declaration.Name }}(message); err != nil { return nil, err } {{- end }} - res := {{ .Response.ClientConvert.Init.Name }}({{ range .Response.ClientConvert.Init.Args }}{{ .Name }}, {{ end }}) + {{- if gt (len .Response.ClientConverts) 1 }} + var res {{ .Response.ClientConvert.TgtRef }} + switch view { + {{- range .Response.ClientConverts }} + case {{ printf "%q" .View }}{{ if eq .View "default" }}, ""{{ end }}: + {{- if .Convert.Validation }} + if err {{ if or $.Response.Headers $.Response.Trailers }}={{ else }}:={{ end }} {{ .Convert.Validation.Declaration.Name }}(message); err != nil { + return nil, err + } + {{- end }} + res = {{ .Convert.Init.Declaration.Name }}({{ range .Convert.Init.Args }}{{ .Name }}, {{ end }}) + {{- end }} + } + {{- else }} + {{- if and .Response.ClientConvert.Validation .ViewedResultRef }} + if err {{ if or .Response.Headers .Response.Trailers }}={{ else }}:={{ end }} {{ .Response.ClientConvert.Validation.Declaration.Name }}(message); err != nil { + return nil, err + } + {{- end }} + res := {{ .Response.ClientConvert.Init.Declaration.Name }}({{ range .Response.ClientConvert.Init.Args }}{{ .Name }}, {{ end }}) + {{- end }} {{- if .ViewedResultRef }} - vres := {{ if not .Method.ViewedResult.IsCollection }}&{{ end }}{{ .Method.ViewedResult.FullName }}{Projected: res, View: view} + vres := {{ if not .Method.ViewedResult.IsCollection }}&{{ end }}{{ .Method.ViewedResult.FullName }}{Projected: res, View: {{ if .Method.ViewedResult.ViewName }}{{ printf "%q" .Method.ViewedResult.ViewName }}{{ else }}view{{ end }}} if err {{ if or .Response.Headers .Response.Trailers }}={{ else }}:={{ end }} {{ .Method.ViewedResult.ViewsPkg }}.Validate{{ .Method.Result }}(vres); err != nil { return nil, err } - return {{ .ServicePkgName }}.{{ .Method.ViewedResult.ResultInit.Name }}({{ range .Method.ViewedResult.ResultInit.Args}}{{ .Name }}, {{ end }}), nil + return {{ .ClientServicePkgName }}.{{ .Method.ViewedResult.ResultInit.Declaration.Name }}({{ range .Method.ViewedResult.ResultInit.Args}}{{ .Name }}, {{ end }}), nil {{- else }} return res, nil {{- end }} @@ -77,7 +94,7 @@ func Decode{{ .Method.VarName }}Response(ctx context.Context, v any, hdr, trlr m } {{- else }} if vals := {{ .VarName }}.Get({{ printf "%q" .Metadata.Name }}); len(vals) > 0 { - {{ .Metadata.VarName }} = vals[0] + {{ .Metadata.VarName }} = {{ if .Metadata.Pointer }}&{{ end }}vals[0] } {{- end }} {{- else if .Metadata.StringSlice }} @@ -107,12 +124,12 @@ func Decode{{ .Method.VarName }}Response(ctx context.Context, v any, hdr, trlr m if vals := {{ .VarName }}.Get({{ printf "%q" .Metadata.Name }}); len(vals) == 0 { err = goa.MergeErrors(err, goa.MissingFieldError({{ printf "%q" .Metadata.Name }}, "metadata")) } else { - {{ .Metadata.VarName }}Raw = vals[0] + {{ .Metadata.VarName }}Raw := vals[0] {{ template "partial_type_conversion" .Metadata }} } {{- else }} if vals := {{ .VarName }}.Get({{ printf "%q" .Metadata.Name }}); len(vals) > 0 { - {{ .Metadata.VarName }}Raw = vals[0] + {{ .Metadata.VarName }}Raw := vals[0] {{ template "partial_type_conversion" .Metadata }} } {{- end }} diff --git a/grpc/codegen/templates/response_encoder.go.tpl b/grpc/codegen/templates/response_encoder.go.tpl index 32099cc1ef..076bf94c60 100644 --- a/grpc/codegen/templates/response_encoder.go.tpl +++ b/grpc/codegen/templates/response_encoder.go.tpl @@ -1,19 +1,35 @@ -{{ printf "Encode%sResponse encodes responses from the %q service %q endpoint." .Method.VarName .ServiceName .Method.Name | comment }} -func Encode{{ .Method.VarName }}Response(ctx context.Context, v any, hdr, trlr *metadata.MD) (any, error) { +{{ printf "%s encodes responses from the %q service %q endpoint." .ServerEncodeDeclaration.Name .ServiceName .Method.Name | comment }} +func {{ .ServerEncodeDeclaration.Name }}(ctx context.Context, v any, hdr, trlr *metadata.MD) (any, error) { {{- if .ViewedResultRef }} vres, ok := v.({{ .ViewedResultRef }}) if !ok { return nil, goagrpc.ErrInvalidType("{{ .ServiceName }}", "{{ .Method.Name }}", "{{ .ViewedResultRef }}", v) } result := vres.Projected - (*hdr).Append("goa-view", vres.View) -{{- else if .ResultRef }} - result, ok := v.({{ .ResultRef }}) +{{- else if .ServerResultRef }} + result, ok := v.({{ .ServerResultRef }}) if !ok { - return nil, goagrpc.ErrInvalidType("{{ .ServiceName }}", "{{ .Method.Name }}", "{{ .ResultRef }}", v) + return nil, goagrpc.ErrInvalidType("{{ .ServiceName }}", "{{ .Method.Name }}", "{{ .ServerResultRef }}", v) } {{- end }} - resp := {{ .Response.ServerConvert.Init.Name }}({{ range .Response.ServerConvert.Init.Args }}{{ .Name }}, {{ end }}) +{{- if gt (len .Response.ServerConverts) 1 }} + var resp {{ .Response.ServerConvert.TgtRef }} + switch vres.View { + {{- range .Response.ServerConverts }} + case {{ printf "%q" .View }}{{ if eq .View "default" }}, ""{{ end }}: + resp = {{ .Convert.Init.Declaration.Name }}({{ range .Convert.Init.Args }}{{ .Name }}, {{ end }}) + {{- end }} + {{- if and .ViewedResultRef (not .Method.ViewedResult.ViewName) }} + default: + return nil, goa.InvalidEnumValueError("view", vres.View, []any{ {{ range .Response.ServerConverts }}{{ printf "%q" .View }}, {{ end }} }) + {{- end }} + } +{{- else }} +resp := {{ .Response.ServerConvert.Init.Declaration.Name }}({{ range .Response.ServerConvert.Init.Args }}{{ .Name }}, {{ end }}) +{{- end }} +{{- if .ViewedResultRef }} + (*hdr).Append("goa-view", {{ if .Method.ViewedResult.ViewName }}{{ printf "%q" .Method.ViewedResult.ViewName }}{{ else }}vres.View{{ end }}) +{{- end }} {{- range .Response.Headers }} {{ template "metadata_encoder" (metadataEncodeDecodeData . "(*hdr)") }} {{- end }} @@ -24,26 +40,21 @@ func Encode{{ .Method.VarName }}Response(ctx context.Context, v any, hdr, trlr * } {{- define "metadata_encoder" }} + {{- if .Metadata.Pointer }} + if result.{{ .Metadata.FieldName }} != nil { + {{- end }} + {{ .Metadata.EncodeCode }} {{- if .Metadata.StringSlice }} - {{ .VarName }}.Append({{ printf "%q" .Metadata.Name }}, res.{{ .Metadata.FieldName }}...) + {{ .VarName }}.Append({{ printf "%q" .Metadata.Name }}, {{ .Metadata.WireVarName }}...) {{- else if .Metadata.Slice }} - for _, value := range res.{{ .Metadata.FieldName }} { - {{ template "partial_convert_type_to_string" (typeConversionData .Metadata.Type.ElemType.Type "valueStr" "value") }} + for _, value := range {{ .Metadata.WireVarName }} { + valueStr := {{ template "partial_type_to_string_expression" (typeStringExpressionData .Metadata.Type.ElemType.Type "value") }} {{ .VarName }}.Append({{ printf "%q" .Metadata.Name }}, valueStr) } {{- else }} - {{- if .Metadata.Pointer }} - if res.{{ .Metadata.FieldName }} != nil { - {{- end }} - {{ .VarName }}.Append({{ printf "%q" .Metadata.Name }}, - {{- if eq .Metadata.Type.Name "bytes" }} string( - {{- else if not (eq .Metadata.TypeName "string") }} fmt.Sprintf("%v", - {{- end }} - {{- if .Metadata.Pointer }}*{{ end }}p.{{ .Metadata.FieldName }} - {{- if or (eq .Metadata.Type.Name "bytes") (not (eq .Metadata.TypeName "string")) }}) - {{- end }}) - {{- if .Metadata.Pointer }} - } - {{- end }} + {{ .VarName }}.Append({{ printf "%q" .Metadata.Name }}, {{ template "partial_type_to_string_expression" (typeStringExpressionData .Metadata.Type .Metadata.WireVarName) }}) + {{- end }} + {{- if .Metadata.Pointer }} + } {{- end }} {{- end }} diff --git a/grpc/codegen/templates/server_grpc_init.go.tpl b/grpc/codegen/templates/server_grpc_init.go.tpl index 439bf7bf43..f40633514a 100644 --- a/grpc/codegen/templates/server_grpc_init.go.tpl +++ b/grpc/codegen/templates/server_grpc_init.go.tpl @@ -5,15 +5,15 @@ // responses. var ( {{- range .Services }} - {{ .Service.VarName }}Server *{{.Service.PkgName}}svr.Server + {{ .Service.VarName }}Server *{{ .ServerPkgName }}.{{ .ServerStructDeclaration.Name }} {{- end }} ) { {{- range .Services }} {{- if .Endpoints }} - {{ .Service.VarName }}Server = {{ .Service.PkgName }}svr.New({{ .Service.VarName }}Endpoints{{ if .HasUnaryEndpoint }}, nil{{ end }}{{ if .HasStreamingEndpoint }}, nil{{ end }}) + {{ .Service.VarName }}Server = {{ .ServerPkgName }}.{{ .ServerInitDeclaration.Name }}({{ .Service.VarName }}Endpoints{{ if .HasUnaryEndpoint }}, nil{{ end }}{{ if .HasStreamingEndpoint }}, nil{{ end }}) {{- else }} - {{ .Service.VarName }}Server = {{ .Service.PkgName }}svr.New(nil{{ if .HasUnaryEndpoint }}, nil{{ end }}{{ if .HasStreamingEndpoint }}, nil{{ end }}) + {{ .Service.VarName }}Server = {{ .ServerPkgName }}.{{ .ServerInitDeclaration.Name }}(nil{{ if .HasUnaryEndpoint }}, nil{{ end }}{{ if .HasStreamingEndpoint }}, nil{{ end }}) {{- end }} {{- end }} } diff --git a/grpc/codegen/templates/server_grpc_interface.go.tpl b/grpc/codegen/templates/server_grpc_interface.go.tpl index 77cb305553..76b7ca3ec2 100644 --- a/grpc/codegen/templates/server_grpc_interface.go.tpl +++ b/grpc/codegen/templates/server_grpc_interface.go.tpl @@ -1,8 +1,8 @@ -{{ printf "%s implements the %q method in %s.%s interface." .Method.VarName .Method.VarName .PkgName .ServerInterface | comment }} -func (s *{{ .ServerStruct }}) {{ .Method.VarName }}( +{{ printf "%s implements the %q method in %s.%s interface." .GRPCMethodName .GRPCMethodName .ServerProtobufPkgName .ServerInterface | comment }} +func (s *{{ .ServerStructDeclaration.Name }}) {{ .GRPCMethodName }}( {{- if not .ServerStream }}ctx context.Context, {{ end }} - {{- if not .Method.StreamingPayload }}message {{ .Request.Message.Ref }}{{ if .ServerStream }}, {{ end }}{{ end }} - {{- if .ServerStream }}stream {{ .ServerStream.Interface }}{{ end }}) {{ if .ServerStream }}error{{ else if .Response.Message }}({{ .Response.Message.Ref }}, error{{ if .Response.Message }}){{ end }}{{ end }} { + {{- if not .Method.StreamingPayload }}message {{ .Request.ServerMessageRef }}{{ if .ServerStream }}, {{ end }}{{ end }} + {{- if .ServerStream }}stream {{ .ServerStream.Interface }}{{ end }}) {{ if .ServerStream }}error{{ else if .Response.Message }}({{ .Response.ServerMessageRef }}, error{{ if .Response.Message }}){{ end }}{{ end }} { {{- if .ServerStream }} ctx := stream.Context() {{- end }} @@ -40,10 +40,10 @@ func (s *{{ .ServerStruct }}) {{ .Method.VarName }}( {{if .PayloadRef }}p{{ else }}_{{ end }}, err := s.{{ .Method.VarName }}H.Decode(ctx, {{ if .Method.StreamingPayload }}nil{{ else }}message{{ end }}) {{- end }} {{- template "handle_error" . }} - ep := &{{ .ServicePkgName }}.{{ .Method.VarName }}EndpointInput{ - Stream: &{{ .ServerStream.VarName }}{stream: stream{{ if .Request.LegacyDecode }}, legacy: !envelope{{ end }}}, + ep := &{{ .ServerServicePkgName }}.{{ .Method.EndpointInputDeclaration.Name }}{ + Stream: &{{ .ServerStream.Declaration.Name }}{stream: stream{{ if .Request.LegacyDecode }}, legacy: !envelope{{ end }}}, {{- if .PayloadRef }} - Payload: p.({{ .PayloadRef }}), + Payload: p.({{ .ServerPayloadRef }}), {{- end }} } err = s.{{ .Method.VarName }}H.Handle(ctx, ep) @@ -66,7 +66,7 @@ func (s *{{ .ServerStruct }}) {{ .Method.VarName }}( var er {{ .Response.ServerConvert.SrcRef }} errors.As(err, &er) {{- end }} - return {{ if not $.ServerStream }}nil, {{ end }}goagrpc.NewStatusError({{ .Response.StatusCode }}, err, {{ if .Response.ServerConvert }}{{ .Response.ServerConvert.Init.Name }}({{ range .Response.ServerConvert.Init.Args }}{{ .Name }}, {{ end }}){{ else }}goagrpc.NewErrorResponse(err){{ end }}) + return {{ if not $.ServerStream }}nil, {{ end }}goagrpc.NewStatusError({{ .Response.StatusCode }}, err, {{ if .Response.ServerConvert }}{{ .Response.ServerConvert.Init.Declaration.Name }}({{ range .Response.ServerConvert.Init.Args }}{{ .Name }}, {{ end }}){{ else }}goagrpc.NewErrorResponse(err){{ end }}) {{- end }} } } diff --git a/grpc/codegen/templates/server_grpc_register.go.tpl b/grpc/codegen/templates/server_grpc_register.go.tpl index 5a0d4df0bd..e8e34823f3 100644 --- a/grpc/codegen/templates/server_grpc_register.go.tpl +++ b/grpc/codegen/templates/server_grpc_register.go.tpl @@ -17,14 +17,14 @@ // Register the servers. {{- range .Services }} - {{ .PkgName }}.Register{{ goify .Service.VarName true }}Server(srv, {{ .Service.VarName }}Server) + {{ .ServerProtobufPkgName }}.{{ .RegisterFunction }}(srv, {{ .Service.VarName }}Server) {{- end }} - for svc, info := range srv.GetServiceInfo() { - for _, m := range info.Methods { - log.Printf(ctx, "serving gRPC method %s", svc + "/" + m.Name) - } - } + {{- range .Services }} + {{- range .Endpoints }} + log.Printf(ctx, "serving gRPC method %s", {{ printf "%q" .FullMethodName }}) + {{- end }} + {{- end }} // Register the server reflection service on the server. // See https://grpc.github.io/grpc/core/md_doc_server-reflection.html. diff --git a/grpc/codegen/templates/server_grpc_start.go.tpl b/grpc/codegen/templates/server_grpc_start.go.tpl index c963aed74b..93c10af375 100644 --- a/grpc/codegen/templates/server_grpc_start.go.tpl +++ b/grpc/codegen/templates/server_grpc_start.go.tpl @@ -1,2 +1,2 @@ {{ comment "handleGRPCServer starts configures and starts a gRPC server on the given URL. It shuts down the server if any error is received in the error channel." }} -func handleGRPCServer(ctx context.Context, u *url.URL{{ range $.Services }}{{ if .Service.Methods }}, {{ .Service.VarName }}Endpoints *{{ .Service.PkgName }}.Endpoints{{ end }}{{ end }}, wg *sync.WaitGroup, errc chan error, dbg bool) { +func handleGRPCServer(ctx context.Context, u *url.URL{{ range $.Services }}{{ if .Service.Methods }}, {{ .Service.VarName }}Endpoints *{{ .Service.PkgName }}.{{ .Service.EndpointsDeclaration.Name }}{{ end }}{{ end }}, wg *sync.WaitGroup, errc chan error, dbg bool) { diff --git a/grpc/codegen/templates/server_init.go.tpl b/grpc/codegen/templates/server_init.go.tpl index 7b01402a56..2a7e0309b8 100644 --- a/grpc/codegen/templates/server_init.go.tpl +++ b/grpc/codegen/templates/server_init.go.tpl @@ -1,8 +1,8 @@ -{{ printf "%s instantiates the server struct with the %s service endpoints." .ServerInit .Service.Name | comment }} -func {{ .ServerInit }}(e *{{ .Service.PkgName }}.Endpoints{{ if .HasUnaryEndpoint }}, uh goagrpc.UnaryHandler{{ end }}{{ if .HasStreamingEndpoint }}, sh goagrpc.StreamHandler{{ end }}) *{{ .ServerStruct }} { - return &{{ .ServerStruct }}{ +{{ printf "%s instantiates the server struct with the %s service endpoints." .ServerInitDeclaration.Name .Service.Name | comment }} +func {{ .ServerInitDeclaration.Name }}(e *{{ .ServerServicePkgName }}.{{ .Service.EndpointsDeclaration.Name }}{{ if .HasUnaryEndpoint }}, uh goagrpc.UnaryHandler{{ end }}{{ if .HasStreamingEndpoint }}, sh goagrpc.StreamHandler{{ end }}) *{{ .ServerStructDeclaration.Name }} { + return &{{ .ServerStructDeclaration.Name }}{ {{- range .Endpoints }} - {{ .Method.VarName }}H: New{{ .Method.VarName }}Handler(e.{{ .Method.VarName }}{{ if .ServerStream }}, sh{{ else }}, uh{{ end }}), + {{ .Method.VarName }}H: {{ .ServerHandlerDeclaration.Name }}(e.{{ .Method.VarName }}{{ if .ServerStream }}, sh{{ else }}, uh{{ end }}), {{- end }} } } diff --git a/grpc/codegen/templates/server_struct_type.go.tpl b/grpc/codegen/templates/server_struct_type.go.tpl index 3aa54d829e..bd377e96d4 100644 --- a/grpc/codegen/templates/server_struct_type.go.tpl +++ b/grpc/codegen/templates/server_struct_type.go.tpl @@ -1,7 +1,7 @@ -{{ printf "%s implements the %s.%s interface." .ServerStruct .PkgName .ServerInterface | comment }} -type {{ .ServerStruct }} struct { +{{ printf "%s implements the %s.%s interface." .ServerStructDeclaration.Name .ServerProtobufPkgName .ServerInterface | comment }} +type {{ .ServerStructDeclaration.Name }} struct { {{- range .Endpoints }} {{ .Method.VarName }}H {{ if .ServerStream }}goagrpc.StreamHandler{{ else }}goagrpc.UnaryHandler{{ end }} {{- end }} - {{ .PkgName }}.Unimplemented{{ .ServerInterface }} + {{ .ServerProtobufPkgName }}.{{ .UnimplementedServer }} } diff --git a/grpc/codegen/templates/stream_close.go.tpl b/grpc/codegen/templates/stream_close.go.tpl index 14a402a551..991c55c64a 100644 --- a/grpc/codegen/templates/stream_close.go.tpl +++ b/grpc/codegen/templates/stream_close.go.tpl @@ -1,5 +1,5 @@ -func (s *{{ .VarName }}) Close() error { +func (s *{{ .Declaration.Name }}) Close() error { {{- if eq .Type "client" }} {{- if .Endpoint.Method.Result }} {{ comment "Close the send direction of the stream" }} @@ -15,11 +15,11 @@ func (s *{{ .VarName }}) Close() error { {{- if .Response.ClientConvert }} case {{ .Response.ClientConvert.SrcRef }}: {{- if .Response.ClientConvert.Validation }} - if err := {{ .Response.ClientConvert.Validation.Name }}(message); err != nil { + if err := {{ .Response.ClientConvert.Validation.Declaration.Name }}(message); err != nil { return err } {{- end }} - return {{ .Response.ClientConvert.Init.Name }}({{ range .Response.ClientConvert.Init.Args }}{{ .Name }}, {{ end }}) + return {{ .Response.ClientConvert.Init.Declaration.Name }}({{ range .Response.ClientConvert.Init.Args }}{{ .Name }}, {{ end }}) {{- end }} {{- end }} case *goapb.ErrorResponse: diff --git a/grpc/codegen/templates/stream_recv.go.tpl b/grpc/codegen/templates/stream_recv.go.tpl index 65c5207ee1..a8c1f640e9 100644 --- a/grpc/codegen/templates/stream_recv.go.tpl +++ b/grpc/codegen/templates/stream_recv.go.tpl @@ -1,5 +1,5 @@ {{ comment .RecvDesc }} -func (s *{{ .VarName }}) {{ .RecvName }}() ({{ .RecvRef }}, error) { +func (s *{{ .Declaration.Name }}) {{ .RecvName }}() ({{ .RecvRef }}, error) { var res {{ .RecvRef }} {{- if and (eq .Type "server") .Endpoint.Request.LegacyDecode }} if s.legacy { @@ -8,11 +8,11 @@ func (s *{{ .VarName }}) {{ .RecvName }}() ({{ .RecvRef }}, error) { return res, err } {{- if .RecvConvert.Validation }} - if err := {{ .RecvConvert.Validation.Name }}(v); err != nil { + if err := {{ .RecvConvert.Validation.Declaration.Name }}(v); err != nil { return res, err } {{- end }} - return {{ .RecvConvert.Init.Name }}({{ range .RecvConvert.Init.Args }}{{ .Name }}, {{ end }}), nil + return {{ .RecvConvert.Init.Declaration.Name }}({{ range .RecvConvert.Init.Args }}{{ .Name }}, {{ end }}), nil } {{- end }} {{- if and (eq .Type "server") .Endpoint.Request.StreamEnvelope }} @@ -28,11 +28,11 @@ func (s *{{ .VarName }}) {{ .RecvName }}() ({{ .RecvRef }}, error) { {{- if .Response.ClientConvert }} case {{ .Response.ClientConvert.SrcRef }}: {{- if .Response.ClientConvert.Validation }} - if err := {{ .Response.ClientConvert.Validation.Name }}(message); err != nil { + if err := {{ .Response.ClientConvert.Validation.Declaration.Name }}(message); err != nil { return res, err } {{- end }} - return res, {{ .Response.ClientConvert.Init.Name }}({{ range .Response.ClientConvert.Init.Args }}{{ .Name }}, {{ end }}) + return res, {{ .Response.ClientConvert.Init.Declaration.Name }}({{ range .Response.ClientConvert.Init.Args }}{{ .Name }}, {{ end }}) {{- end }} {{- end }} case *goapb.ErrorResponse: @@ -52,11 +52,25 @@ func (s *{{ .VarName }}) {{ .RecvName }}() ({{ .RecvRef }}, error) { return res, err {{- end }} } + {{- if and .Endpoint.Method.ViewedResult (eq .Type "client") (not .Endpoint.Method.ViewedResult.ViewName) }} + if !s.viewSet { + hdr, err := s.stream.Header() + if err != nil { + return res, err + } + views := hdr.Get("goa-view") + if len(views) == 0 { + return res, goa.MissingFieldError("goa-view", "metadata") + } + s.view = views[0] + s.viewSet = true + } + {{- end }} {{- if and (eq .Type "server") .Endpoint.Request.StreamEnvelope }} - body, ok := message.{{ .Endpoint.Request.StreamEnvelope.FieldName }}.(*{{ .Endpoint.Request.StreamEnvelope.StreamItemWrapperRef }}) + body, ok := message.{{ .Endpoint.Request.StreamEnvelope.FieldName }}.(*{{ .Endpoint.Request.StreamEnvelope.ServerStreamItemWrapperRef }}) if !ok { switch message.{{ .Endpoint.Request.StreamEnvelope.FieldName }}.(type) { - case *{{ .Endpoint.Request.StreamEnvelope.InitialWrapperRef }}: + case *{{ .Endpoint.Request.StreamEnvelope.ServerInitialWrapperRef }}: return res, goa.InvalidFieldTypeError("body", "initial_payload", "stream_item") default: return res, goa.MissingFieldError("stream_item", "stream") @@ -68,23 +82,43 @@ func (s *{{ .VarName }}) {{ .RecvName }}() ({{ .RecvRef }}, error) { v := body.{{ .Endpoint.Request.StreamEnvelope.StreamItemFieldName }} {{- end }} {{- if and .Endpoint.Method.ViewedResult (eq .Type "client") }} - proj := {{ .RecvConvert.Init.Name }}({{ range .RecvConvert.Init.Args }}{{ .Name }}, {{ end }}) + {{- if gt (len .RecvConverts) 1 }} + var proj {{ .RecvConvert.TgtRef }} + switch s.view { + {{- range .RecvConverts }} + case {{ printf "%q" .View }}{{ if eq .View "default" }}, ""{{ end }}: + {{- if .Convert.Validation }} + if err := {{ .Convert.Validation.Declaration.Name }}(v); err != nil { + return res, err + } + {{- end }} + proj = {{ .Convert.Init.Declaration.Name }}({{ range .Convert.Init.Args }}{{ .Name }}, {{ end }}) + {{- end }} + } + {{- else }} + {{- if .RecvConvert.Validation }} + if err := {{ .RecvConvert.Validation.Declaration.Name }}(v); err != nil { + return res, err + } + {{- end }} + proj := {{ .RecvConvert.Init.Declaration.Name }}({{ range .RecvConvert.Init.Args }}{{ .Name }}, {{ end }}) + {{- end }} vres := {{ if not .Endpoint.Method.ViewedResult.IsCollection }}&{{ end }}{{ .Endpoint.Method.ViewedResult.FullName }}{Projected: proj, View: {{ if .Endpoint.Method.ViewedResult.ViewName }}"{{ .Endpoint.Method.ViewedResult.ViewName }}"{{ else }}s.view{{ end }} } if err := {{ .Endpoint.Method.ViewedResult.ViewsPkg }}.Validate{{ .Endpoint.Method.Result }}(vres); err != nil { return nil, err } - return {{ .Endpoint.ServicePkgName }}.{{ .Endpoint.Method.ViewedResult.ResultInit.Name }}(vres), nil + return {{ .Endpoint.ClientServicePkgName }}.{{ .Endpoint.Method.ViewedResult.ResultInit.Declaration.Name }}(vres), nil {{- else }} {{- if .RecvConvert.Validation }} - if err = {{ .RecvConvert.Validation.Name }}(v); err != nil { + if err = {{ .RecvConvert.Validation.Declaration.Name }}(v); err != nil { return res, err } {{- end }} - return {{ .RecvConvert.Init.Name }}({{ range .RecvConvert.Init.Args }}{{ .Name }}, {{ end }}), nil + return {{ .RecvConvert.Init.Declaration.Name }}({{ range .RecvConvert.Init.Args }}{{ .Name }}, {{ end }}), nil {{- end }} } {{ comment .RecvWithContextDesc }} -func (s *{{ .VarName }}) {{ .RecvWithContextName }}(ctx context.Context) ({{ .RecvRef }}, error) { +func (s *{{ .Declaration.Name }}) {{ .RecvWithContextName }}(ctx context.Context) ({{ .RecvRef }}, error) { return s.{{ .RecvName }}() } diff --git a/grpc/codegen/templates/stream_send.go.tpl b/grpc/codegen/templates/stream_send.go.tpl index ef0146a0ed..4347ddaeea 100644 --- a/grpc/codegen/templates/stream_send.go.tpl +++ b/grpc/codegen/templates/stream_send.go.tpl @@ -1,16 +1,47 @@ {{ comment .SendDesc }} -func (s *{{ .VarName }}) {{ .SendName }}(res {{ .SendRef }}) error { +func (s *{{ .Declaration.Name }}) {{ .SendName }}(res {{ .SendRef }}) error { {{- if and .Endpoint.Method.ViewedResult (eq .Type "server") }} + {{- if not .Endpoint.Method.ViewedResult.ViewName }} + view := s.view + if view == "" { + view = "default" + } + if s.sentView != "" && view != s.sentView { + return goa.InvalidEnumValueError("view", view, []any{s.sentView}) + } + {{- end }} {{- if .Endpoint.Method.ViewedResult.ViewName }} - vres := {{ .Endpoint.ServicePkgName }}.{{ .Endpoint.Method.ViewedResult.Init.Name }}(res, {{ printf "%q" .Endpoint.Method.ViewedResult.ViewName }}) + vres := {{ .Endpoint.ServerServicePkgName }}.{{ .Endpoint.Method.ViewedResult.Init.Declaration.Name }}(res, {{ printf "%q" .Endpoint.Method.ViewedResult.ViewName }}) {{- else }} - vres := {{ .Endpoint.ServicePkgName }}.{{ .Endpoint.Method.ViewedResult.Init.Name }}(res, s.view) + vres := {{ .Endpoint.ServerServicePkgName }}.{{ .Endpoint.Method.ViewedResult.Init.Declaration.Name }}(res, view) {{- end }} {{- end }} - v := {{ .SendConvert.Init.Name }}({{ if and .Endpoint.Method.ViewedResult (eq .Type "server") }}vres.Projected{{ else }}res{{ end }}) + {{- if gt (len .SendConverts) 1 }} + var v {{ .SendConvert.TgtRef }} + switch view { + {{- range .SendConverts }} + case {{ printf "%q" .View }}{{ if eq .View "default" }}, ""{{ end }}: + v = {{ .Convert.Init.Declaration.Name }}(vres.Projected) + {{- end }} + {{- if and .Endpoint.Method.ViewedResult (eq .Type "server") (not .Endpoint.Method.ViewedResult.ViewName) }} + default: + return goa.InvalidEnumValueError("view", view, []any{ {{ range .SendConverts }}{{ printf "%q" .View }}, {{ end }} }) + {{- end }} + } + {{- else }} + v := {{ .SendConvert.Init.Declaration.Name }}({{ if and .Endpoint.Method.ViewedResult (eq .Type "server") }}vres.Projected{{ else }}res{{ end }}) + {{- end }} + {{- if and .Endpoint.Method.ViewedResult (eq .Type "server") (not .Endpoint.Method.ViewedResult.ViewName) }} + if s.sentView == "" { + if err := s.stream.SetHeader(metadata.Pairs("goa-view", view)); err != nil { + return err + } + s.sentView = view + } + {{- end }} {{- if and (eq .Type "client") .Endpoint.Request.StreamEnvelope }} - return s.stream.{{ .SendName }}(&{{ .Endpoint.PkgName }}.{{ .Endpoint.Request.Message.VarName }}{ - {{ .Endpoint.Request.StreamEnvelope.FieldName }}: &{{ .Endpoint.Request.StreamEnvelope.StreamItemWrapperRef }}{ + return s.stream.{{ .SendName }}(&{{ .Endpoint.ClientProtobufPkgName }}.{{ .Endpoint.Request.Message.VarName }}{ + {{ .Endpoint.Request.StreamEnvelope.FieldName }}: &{{ .Endpoint.Request.StreamEnvelope.ClientStreamItemWrapperRef }}{ {{ .Endpoint.Request.StreamEnvelope.StreamItemFieldName }}: v, }, }) @@ -20,6 +51,6 @@ func (s *{{ .VarName }}) {{ .SendName }}(res {{ .SendRef }}) error { } {{ comment .SendWithContextDesc }} -func (s *{{ .VarName }}) {{ .SendWithContextName }}(ctx context.Context, res {{ .SendRef }}) error { +func (s *{{ .Declaration.Name }}) {{ .SendWithContextName }}(ctx context.Context, res {{ .SendRef }}) error { return s.{{ .SendName }}(res) } diff --git a/grpc/codegen/templates/stream_set_view.go.tpl b/grpc/codegen/templates/stream_set_view.go.tpl index 3ac250a764..97cef29e6c 100644 --- a/grpc/codegen/templates/stream_set_view.go.tpl +++ b/grpc/codegen/templates/stream_set_view.go.tpl @@ -1,4 +1,7 @@ {{ printf "SetView sets the view." | comment }} -func (s *{{ .VarName }}) SetView(view string) { +func (s *{{ .Declaration.Name }}) SetView(view string) { s.view = view + {{- if eq .Type "client" }} + s.viewSet = true + {{- end }} } diff --git a/grpc/codegen/templates/stream_struct_type.go.tpl b/grpc/codegen/templates/stream_struct_type.go.tpl index 2799e00686..9bc000f545 100644 --- a/grpc/codegen/templates/stream_struct_type.go.tpl +++ b/grpc/codegen/templates/stream_struct_type.go.tpl @@ -1,5 +1,5 @@ -{{ printf "%s implements the %s interface." .VarName .ServiceInterface | comment }} -type {{ .VarName }} struct { +{{ printf "%s implements the %s interface." .Declaration.Name .ServiceInterface | comment }} +type {{ .Declaration.Name }} struct { stream {{ .Interface }} {{- if eq .Type "client" }} ctx context.Context @@ -9,7 +9,13 @@ type {{ .VarName }} struct { // which sends raw stream item frames instead of typed envelopes. legacy bool {{- end }} -{{- if .Endpoint.Method.ViewedResult }} +{{- if and .Endpoint.Method.ViewedResult (not .Endpoint.Method.ViewedResult.ViewName) }} view string + {{- if eq .Type "server" }} + {{ comment "sentView is the result view named in the response header. Later sends must use the same view." }} + sentView string + {{- else }} + viewSet bool + {{- end }} {{- end }} } diff --git a/grpc/codegen/templates/transform_go_map.go.tpl b/grpc/codegen/templates/transform_go_map.go.tpl index b87923303f..a457c0cfce 100644 --- a/grpc/codegen/templates/transform_go_map.go.tpl +++ b/grpc/codegen/templates/transform_go_map.go.tpl @@ -3,7 +3,7 @@ {{ $m }} := make(map[{{ .KeyTypeRef }}]{{ .ElemTypeRef }}, len({{ .SourceVar }})) for key, val := range {{ .SourceVar }} { {{ transformAttribute .SourceKey .TargetKey "key" "tk" true .TransformAttrs -}} - {{ transformAttribute .SourceElem .TargetElem "val" (printf "tv%s" .LoopVar) true .TransformAttrs -}} + {{ .ElemTransform -}} {{ $m }}[tk] = {{ printf "tv%s" .LoopVar }} } {{ .TargetVar }} = &{{ $m }} @@ -11,7 +11,7 @@ for key, val := range {{ .SourceVar }} { {{ .TargetVar }} {{ if .NewVar }}:={{ else }}={{ end }} make(map[{{ .KeyTypeRef }}]{{ .ElemTypeRef }}, len({{ .SourceVar }})) for key, val := range {{ .SourceVar }} { {{ transformAttribute .SourceKey .TargetKey "key" "tk" true .TransformAttrs -}} - {{ transformAttribute .SourceElem .TargetElem "val" (printf "tv%s" .LoopVar) true .TransformAttrs -}} + {{ .ElemTransform -}} {{ .TargetVar }}[tk] = {{ printf "tv%s" .LoopVar }} } {{- end -}} diff --git a/grpc/codegen/templates/transform_helper.go.tpl b/grpc/codegen/templates/transform_helper.go.tpl index 5b2c37b65a..51132bddd9 100644 --- a/grpc/codegen/templates/transform_helper.go.tpl +++ b/grpc/codegen/templates/transform_helper.go.tpl @@ -1,5 +1,5 @@ -{{ printf "%s builds a value of type %s from a value of type %s." .Name .ResultTypeRef .ParamTypeRef | comment }} -func {{ .Name }}(v {{ .ParamTypeRef }}) {{ .ResultTypeRef }} { +{{ printf "%s builds a value of type %s from a value of type %s." .Declaration.Name .ResultTypeRef .ParamTypeRef | comment }} +func {{ .Declaration.Name }}(v {{ .ParamTypeRef }}) {{ .ResultTypeRef }} { {{ .Code }} return res } diff --git a/grpc/codegen/templates/type_init.go.tpl b/grpc/codegen/templates/type_init.go.tpl index befe07f307..e5f2752498 100644 --- a/grpc/codegen/templates/type_init.go.tpl +++ b/grpc/codegen/templates/type_init.go.tpl @@ -1,10 +1,12 @@ {{ comment .Description }} -func {{ .Name }}({{ range .Args }}{{ .Name }} {{ .TypeRef }}, {{ end }}) {{ .ReturnTypeRef }} { +func {{ .Declaration.Name }}({{ range .Args }}{{ .Name }} {{ .TypeRef }}, {{ end }}) {{ .ReturnTypeRef }} { {{ .Code }} {{- if .ReturnIsStruct }} {{- range .Args }} - {{- if .FieldName }} - {{ $.ReturnVarName }}.{{ .FieldName }} = {{ if isAlias .FieldType }}{{ fullName .FieldType }}({{ end }}{{ .Name }}{{ if isAlias .FieldType }}){{ end }} + {{- if .InitCode }} + {{ .InitCode }} + {{- else if .FieldName }} + {{ $.ReturnVarName }}.{{ .FieldName }} = {{ .Name }} {{- end }} {{- end }} {{- end }} diff --git a/grpc/codegen/templates/validate.go.tpl b/grpc/codegen/templates/validate.go.tpl index 93fb841e5e..ec4a2d25ba 100644 --- a/grpc/codegen/templates/validate.go.tpl +++ b/grpc/codegen/templates/validate.go.tpl @@ -1,5 +1,5 @@ -{{ printf "%s runs the validations defined on %s." .Name .SrcName | comment }} -func {{ .Name }}({{ .ArgName }} {{ .SrcRef }}) (err error) { +{{ printf "%s runs the validations defined on %s." .Declaration.Name .SrcName | comment }} +func {{ .Declaration.Name }}({{ .ArgName }} {{ .SrcRef }}) (err error) { {{ .Def }} return } diff --git a/grpc/codegen/testdata/client-bidirectional-streaming.golden b/grpc/codegen/testdata/client-bidirectional-streaming.golden new file mode 100644 index 0000000000..c28ba6a08f --- /dev/null +++ b/grpc/codegen/testdata/client-bidirectional-streaming.golden @@ -0,0 +1,40 @@ +import ( + "context" + "errors" + "flag" + "fmt" + "io" + + cli "generated.local/gen/grpc/cli/test_api" + "google.golang.org/grpc" + "google.golang.org/grpc/credentials/insecure" +) + +func doGRPC(ctx context.Context, _ string, host string, _ int, _ bool, stdout io.Writer) (err error) { + switch flag.Arg(0) { + case "service-bidirectional-streaming-rpc": + switch flag.Arg(1) { + case "method-bidirectional-streaming-rpc": + return errors.New("example client does not support streamed input for service \"ServiceBidirectionalStreamingRPC\" method \"MethodBidirectionalStreamingRPC\"") + } + } + conn, err := grpc.NewClient(host, grpc.WithTransportCredentials(insecure.NewCredentials())) + if err != nil { + return fmt.Errorf("connect to gRPC server at %s: %w", host, err) + } + defer func() { + err = errors.Join(err, conn.Close()) + }() + _, _, err = cli.ParseEndpoint( + conn, + ) + if err != nil { + return fmt.Errorf("parse endpoint: %w", err) + } + + panic("parsed gRPC command has no generated result writer") +} + +func grpcUsageExamples() string { + return cli.UsageExamples() +} diff --git a/grpc/codegen/testdata/client-client-streaming.golden b/grpc/codegen/testdata/client-client-streaming.golden new file mode 100644 index 0000000000..392997e0ab --- /dev/null +++ b/grpc/codegen/testdata/client-client-streaming.golden @@ -0,0 +1,40 @@ +import ( + "context" + "errors" + "flag" + "fmt" + "io" + + cli "generated.local/gen/grpc/cli/test_api" + "google.golang.org/grpc" + "google.golang.org/grpc/credentials/insecure" +) + +func doGRPC(ctx context.Context, _ string, host string, _ int, _ bool, stdout io.Writer) (err error) { + switch flag.Arg(0) { + case "service-client-streaming-rpc": + switch flag.Arg(1) { + case "method-client-streaming-rpc": + return errors.New("example client does not support streamed input for service \"ServiceClientStreamingRPC\" method \"MethodClientStreamingRPC\"") + } + } + conn, err := grpc.NewClient(host, grpc.WithTransportCredentials(insecure.NewCredentials())) + if err != nil { + return fmt.Errorf("connect to gRPC server at %s: %w", host, err) + } + defer func() { + err = errors.Join(err, conn.Close()) + }() + _, _, err = cli.ParseEndpoint( + conn, + ) + if err != nil { + return fmt.Errorf("parse endpoint: %w", err) + } + + panic("parsed gRPC command has no generated result writer") +} + +func grpcUsageExamples() string { + return cli.UsageExamples() +} diff --git a/grpc/codegen/testdata/client-interceptors.golden b/grpc/codegen/testdata/client-interceptors.golden index a391d06b41..a10d66044e 100644 --- a/grpc/codegen/testdata/client-interceptors.golden +++ b/grpc/codegen/testdata/client-interceptors.golden @@ -1,28 +1,43 @@ import ( + "context" + "errors" + "flag" "fmt" - cli "grpc/cli/test" - "os" + "io" - "./interceptors" - goa "goa.design/goa/v3/pkg" + cli "generated.local/gen/grpc/cli/test" + interceptors "generated.local/interceptors" "google.golang.org/grpc" "google.golang.org/grpc/credentials/insecure" ) -func doGRPC(_, host string, _ int, _ bool) (goa.Endpoint, any, error) { +func doGRPC(ctx context.Context, _ string, host string, _ int, _ bool, stdout io.Writer) (err error) { conn, err := grpc.NewClient(host, grpc.WithTransportCredentials(insecure.NewCredentials())) if err != nil { - fmt.Fprintf(os.Stderr, "could not connect to gRPC server at %s: %v\n", host, err) + return fmt.Errorf("connect to gRPC server at %s: %w", host, err) } + defer func() { + err = errors.Join(err, conn.Close()) + }() serviceWithInterceptorsInterceptors := interceptors.NewServiceWithInterceptorsClientInterceptors() - return cli.ParseEndpoint( + endpoint, payload, err := cli.ParseEndpoint( conn, serviceWithInterceptorsInterceptors, ) -} + if err != nil { + return fmt.Errorf("parse endpoint: %w", err) + } -func grpcUsageCommands() []string { - return cli.UsageCommands() + switch flag.Arg(0) { + case "service-with-interceptors": + switch flag.Arg(1) { + case "method-a": + return writeEndpointResult(ctx, stdout, endpoint, payload) + case "method-b": + return writeEndpointResult(ctx, stdout, endpoint, payload) + } + } + panic("parsed gRPC command has no generated result writer") } func grpcUsageExamples() string { diff --git a/grpc/codegen/testdata/client-no-server-pkgpath.golden b/grpc/codegen/testdata/client-no-server-pkgpath.golden index d1033172ff..5b2939cf9f 100644 --- a/grpc/codegen/testdata/client-no-server-pkgpath.golden +++ b/grpc/codegen/testdata/client-no-server-pkgpath.golden @@ -1,19 +1,36 @@ import ( + "context" + "errors" + "flag" "fmt" + "io" cli "my/pkg/path/grpc/cli/test_api" - "os" - goa "goa.design/goa/v3/pkg" "google.golang.org/grpc" "google.golang.org/grpc/credentials/insecure" ) -func doGRPC(_, host string, _ int, _ bool) (goa.Endpoint, any, error) { +func doGRPC(ctx context.Context, _ string, host string, _ int, _ bool, stdout io.Writer) (err error) { conn, err := grpc.NewClient(host, grpc.WithTransportCredentials(insecure.NewCredentials())) if err != nil { - fmt.Fprintf(os.Stderr, "could not connect to gRPC server at %s: %v\n", host, err) + return fmt.Errorf("connect to gRPC server at %s: %w", host, err) } - return cli.ParseEndpoint( + defer func() { + err = errors.Join(err, conn.Close()) + }() + endpoint, payload, err := cli.ParseEndpoint( conn, ) + if err != nil { + return fmt.Errorf("parse endpoint: %w", err) + } + + switch flag.Arg(0) { + case "service": + switch flag.Arg(1) { + case "method": + return writeEndpointResult(ctx, stdout, endpoint, payload) + } + } + panic("parsed gRPC command has no generated result writer") } diff --git a/grpc/codegen/testdata/client-no-server.golden b/grpc/codegen/testdata/client-no-server.golden index e9259418ae..51834398dd 100644 --- a/grpc/codegen/testdata/client-no-server.golden +++ b/grpc/codegen/testdata/client-no-server.golden @@ -1,19 +1,36 @@ import ( + "context" + "errors" + "flag" "fmt" - cli "grpc/cli/test_api" - "os" + "io" - goa "goa.design/goa/v3/pkg" + cli "generated.local/gen/grpc/cli/test_api" "google.golang.org/grpc" "google.golang.org/grpc/credentials/insecure" ) -func doGRPC(_, host string, _ int, _ bool) (goa.Endpoint, any, error) { +func doGRPC(ctx context.Context, _ string, host string, _ int, _ bool, stdout io.Writer) (err error) { conn, err := grpc.NewClient(host, grpc.WithTransportCredentials(insecure.NewCredentials())) if err != nil { - fmt.Fprintf(os.Stderr, "could not connect to gRPC server at %s: %v\n", host, err) + return fmt.Errorf("connect to gRPC server at %s: %w", host, err) } - return cli.ParseEndpoint( + defer func() { + err = errors.Join(err, conn.Close()) + }() + endpoint, payload, err := cli.ParseEndpoint( conn, ) + if err != nil { + return fmt.Errorf("parse endpoint: %w", err) + } + + switch flag.Arg(0) { + case "service": + switch flag.Arg(1) { + case "method": + return writeEndpointResult(ctx, stdout, endpoint, payload) + } + } + panic("parsed gRPC command has no generated result writer") } diff --git a/grpc/codegen/testdata/client-server-hosting-multiple-services-pkgpath.golden b/grpc/codegen/testdata/client-server-hosting-multiple-services-pkgpath.golden index 5324d085af..5d3eec4ec5 100644 --- a/grpc/codegen/testdata/client-server-hosting-multiple-services-pkgpath.golden +++ b/grpc/codegen/testdata/client-server-hosting-multiple-services-pkgpath.golden @@ -1,19 +1,41 @@ import ( + "context" + "errors" + "flag" "fmt" + "io" cli "my/pkg/path/grpc/cli/single_host" - "os" - goa "goa.design/goa/v3/pkg" "google.golang.org/grpc" "google.golang.org/grpc/credentials/insecure" ) -func doGRPC(_, host string, _ int, _ bool) (goa.Endpoint, any, error) { +func doGRPC(ctx context.Context, _ string, host string, _ int, _ bool, stdout io.Writer) (err error) { conn, err := grpc.NewClient(host, grpc.WithTransportCredentials(insecure.NewCredentials())) if err != nil { - fmt.Fprintf(os.Stderr, "could not connect to gRPC server at %s: %v\n", host, err) + return fmt.Errorf("connect to gRPC server at %s: %w", host, err) } - return cli.ParseEndpoint( + defer func() { + err = errors.Join(err, conn.Close()) + }() + endpoint, payload, err := cli.ParseEndpoint( conn, ) + if err != nil { + return fmt.Errorf("parse endpoint: %w", err) + } + + switch flag.Arg(0) { + case "service": + switch flag.Arg(1) { + case "method": + return writeEndpointResult(ctx, stdout, endpoint, payload) + } + case "another-service": + switch flag.Arg(1) { + case "method": + return writeEndpointResult(ctx, stdout, endpoint, payload) + } + } + panic("parsed gRPC command has no generated result writer") } diff --git a/grpc/codegen/testdata/client-server-hosting-multiple-services.golden b/grpc/codegen/testdata/client-server-hosting-multiple-services.golden index dcfe3180eb..bf204eb478 100644 --- a/grpc/codegen/testdata/client-server-hosting-multiple-services.golden +++ b/grpc/codegen/testdata/client-server-hosting-multiple-services.golden @@ -1,19 +1,41 @@ import ( + "context" + "errors" + "flag" "fmt" - cli "grpc/cli/single_host" - "os" + "io" - goa "goa.design/goa/v3/pkg" + cli "generated.local/gen/grpc/cli/single_host" "google.golang.org/grpc" "google.golang.org/grpc/credentials/insecure" ) -func doGRPC(_, host string, _ int, _ bool) (goa.Endpoint, any, error) { +func doGRPC(ctx context.Context, _ string, host string, _ int, _ bool, stdout io.Writer) (err error) { conn, err := grpc.NewClient(host, grpc.WithTransportCredentials(insecure.NewCredentials())) if err != nil { - fmt.Fprintf(os.Stderr, "could not connect to gRPC server at %s: %v\n", host, err) + return fmt.Errorf("connect to gRPC server at %s: %w", host, err) } - return cli.ParseEndpoint( + defer func() { + err = errors.Join(err, conn.Close()) + }() + endpoint, payload, err := cli.ParseEndpoint( conn, ) + if err != nil { + return fmt.Errorf("parse endpoint: %w", err) + } + + switch flag.Arg(0) { + case "service": + switch flag.Arg(1) { + case "method": + return writeEndpointResult(ctx, stdout, endpoint, payload) + } + case "another-service": + switch flag.Arg(1) { + case "method": + return writeEndpointResult(ctx, stdout, endpoint, payload) + } + } + panic("parsed gRPC command has no generated result writer") } diff --git a/grpc/codegen/testdata/client-server-hosting-service-subset-pkgpath.golden b/grpc/codegen/testdata/client-server-hosting-service-subset-pkgpath.golden index 5324d085af..2e9a1f2266 100644 --- a/grpc/codegen/testdata/client-server-hosting-service-subset-pkgpath.golden +++ b/grpc/codegen/testdata/client-server-hosting-service-subset-pkgpath.golden @@ -1,19 +1,36 @@ import ( + "context" + "errors" + "flag" "fmt" + "io" cli "my/pkg/path/grpc/cli/single_host" - "os" - goa "goa.design/goa/v3/pkg" "google.golang.org/grpc" "google.golang.org/grpc/credentials/insecure" ) -func doGRPC(_, host string, _ int, _ bool) (goa.Endpoint, any, error) { +func doGRPC(ctx context.Context, _ string, host string, _ int, _ bool, stdout io.Writer) (err error) { conn, err := grpc.NewClient(host, grpc.WithTransportCredentials(insecure.NewCredentials())) if err != nil { - fmt.Fprintf(os.Stderr, "could not connect to gRPC server at %s: %v\n", host, err) + return fmt.Errorf("connect to gRPC server at %s: %w", host, err) } - return cli.ParseEndpoint( + defer func() { + err = errors.Join(err, conn.Close()) + }() + endpoint, payload, err := cli.ParseEndpoint( conn, ) + if err != nil { + return fmt.Errorf("parse endpoint: %w", err) + } + + switch flag.Arg(0) { + case "service": + switch flag.Arg(1) { + case "method": + return writeEndpointResult(ctx, stdout, endpoint, payload) + } + } + panic("parsed gRPC command has no generated result writer") } diff --git a/grpc/codegen/testdata/client-server-hosting-service-subset.golden b/grpc/codegen/testdata/client-server-hosting-service-subset.golden index dcfe3180eb..85b41f0223 100644 --- a/grpc/codegen/testdata/client-server-hosting-service-subset.golden +++ b/grpc/codegen/testdata/client-server-hosting-service-subset.golden @@ -1,19 +1,36 @@ import ( + "context" + "errors" + "flag" "fmt" - cli "grpc/cli/single_host" - "os" + "io" - goa "goa.design/goa/v3/pkg" + cli "generated.local/gen/grpc/cli/single_host" "google.golang.org/grpc" "google.golang.org/grpc/credentials/insecure" ) -func doGRPC(_, host string, _ int, _ bool) (goa.Endpoint, any, error) { +func doGRPC(ctx context.Context, _ string, host string, _ int, _ bool, stdout io.Writer) (err error) { conn, err := grpc.NewClient(host, grpc.WithTransportCredentials(insecure.NewCredentials())) if err != nil { - fmt.Fprintf(os.Stderr, "could not connect to gRPC server at %s: %v\n", host, err) + return fmt.Errorf("connect to gRPC server at %s: %w", host, err) } - return cli.ParseEndpoint( + defer func() { + err = errors.Join(err, conn.Close()) + }() + endpoint, payload, err := cli.ParseEndpoint( conn, ) + if err != nil { + return fmt.Errorf("parse endpoint: %w", err) + } + + switch flag.Arg(0) { + case "service": + switch flag.Arg(1) { + case "method": + return writeEndpointResult(ctx, stdout, endpoint, payload) + } + } + panic("parsed gRPC command has no generated result writer") } diff --git a/grpc/codegen/testdata/client-server-streaming.golden b/grpc/codegen/testdata/client-server-streaming.golden new file mode 100644 index 0000000000..b9f7b82826 --- /dev/null +++ b/grpc/codegen/testdata/client-server-streaming.golden @@ -0,0 +1,46 @@ +import ( + "context" + "errors" + "flag" + "fmt" + "io" + + cli "generated.local/gen/grpc/cli/test_api" + serviceserverstreamingrpc "generated.local/gen/service_server_streaming_rpc" + "google.golang.org/grpc" + "google.golang.org/grpc/credentials/insecure" +) + +func doGRPC(ctx context.Context, _ string, host string, _ int, _ bool, stdout io.Writer) (err error) { + conn, err := grpc.NewClient(host, grpc.WithTransportCredentials(insecure.NewCredentials())) + if err != nil { + return fmt.Errorf("connect to gRPC server at %s: %w", host, err) + } + defer func() { + err = errors.Join(err, conn.Close()) + }() + endpoint, payload, err := cli.ParseEndpoint( + conn, + ) + if err != nil { + return fmt.Errorf("parse endpoint: %w", err) + } + + switch flag.Arg(0) { + case "service-server-streaming-rpc": + switch flag.Arg(1) { + case "method-server-streaming-rpc": + data, err := endpoint(ctx, payload) + if err != nil { + return err + } + stream := data.(serviceserverstreamingrpc.MethodServerStreamingRPCClientStream) + return writeStreamResults(ctx, stdout, stream.RecvWithContext) + } + } + panic("parsed gRPC command has no generated result writer") +} + +func grpcUsageExamples() string { + return cli.UsageExamples() +} diff --git a/grpc/codegen/testdata/dsls.go b/grpc/codegen/testdata/dsls.go index a38858a387..554287909a 100644 --- a/grpc/codegen/testdata/dsls.go +++ b/grpc/codegen/testdata/dsls.go @@ -1,3 +1,5 @@ +// This file defines gRPC DSL fixtures used to exercise message, metadata, +// streaming, validation, and generated package ownership behavior. package testdata import ( @@ -201,10 +203,7 @@ var ServerStreamingResultWithViewsDSL = func() { Attributes(func() { Field(1, "IntField", Int) Field(2, "DoubleField", Float64) - }) - View("default", func() { - Attribute("IntField") - Attribute("DoubleField") + Required("IntField", "DoubleField") }) View("tiny", func() { Attribute("IntField") @@ -243,6 +242,32 @@ var ServerStreamingResultCollectionWithExplicitViewDSL = func() { }) } +var ClientStreamingResultCollectionWithExplicitViewDSL = func() { + var RT = ResultType("application/vnd.client-streaming-result", func() { + TypeName("ResultType") + Attributes(func() { + Field(1, "IntField", Int) + Field(2, "DoubleField", Float64) + }) + View("default", func() { + Attribute("IntField") + Attribute("DoubleField") + }) + View("tiny", func() { + Attribute("IntField") + }) + }) + Service("ServiceClientStreamingResultTypeCollectionWithExplicitView", func() { + Method("MethodClientStreamingResultTypeCollectionWithExplicitView", func() { + StreamingPayload(String) + Result(CollectionOf(RT), func() { + View("tiny") + }) + GRPC(func() {}) + }) + }) +} + var ClientStreamingRPCDSL = func() { Service("ServiceClientStreamingRPC", func() { Method("MethodClientStreamingRPC", func() { @@ -264,6 +289,24 @@ var ClientStreamingRPCWithPayloadDSL = func() { }) } +var ClientStreamingRPCWithMetadataOnlyPayloadDSL = func() { + Service("ServiceClientStreamingRPCWithMetadataOnlyPayload", func() { + Method("MethodClientStreamingRPCWithMetadataOnlyPayload", func() { + Payload(func() { + Field(1, "token", String) + Required("token") + }) + StreamingPayload(String) + Result(String) + GRPC(func() { + Metadata(func() { + Attribute("token") + }) + }) + }) + }) +} + var ClientStreamingRPCWithPayloadLegacyCompatDSL = func() { Service("ServiceClientStreamingRPCWithPayloadLegacyCompat", func() { Method("MethodClientStreamingRPCWithPayloadLegacyCompat", func() { @@ -506,6 +549,44 @@ var MessageUserTypeWithAliasMessageDSL = func() { }) } +// RequiredPrimitivePresenceDSL defines required request and response fields +// whose zero values must remain distinguishable from omitted protobuf fields. +var RequiredPrimitivePresenceDSL = func() { + var StringAlias = Type("StringAlias", String) + var BytesAlias = Type("BytesAlias", Bytes) + var AnyAlias = Type("AnyAlias", Any) + var Message = Type("PresenceMessage", func() { + Field(1, "BooleanField", Boolean) + Field(2, "IntegerField", Int) + Field(3, "StringField", String) + Field(4, "BytesField", Bytes) + Field(5, "StringAliasField", StringAlias) + Field(6, "BytesAliasField", BytesAlias) + Field(7, "AnyAliasField", AnyAlias) + Required( + "BooleanField", + "IntegerField", + "StringField", + "BytesField", + "StringAliasField", + "BytesAliasField", + "AnyAliasField", + ) + }) + Service("RequiredPrimitivePresence", func() { + Method("Exchange", func() { + Payload(Message) + Result(Message) + GRPC(func() {}) + }) + Method("Echo", func() { + Payload(StringAlias) + Result(StringAlias) + GRPC(func() {}) + }) + }) +} + var MessageUserTypeWithNestedUserTypesDSL = func() { var UTLevel2 = Type("UTLevel2", func() { Field(2, "Int64Field", Int64) @@ -541,10 +622,7 @@ var MessageResultTypeWithViewsDSL = func() { Attributes(func() { Field(1, "IntField", Int) Field(2, "StringField", String) - }) - View("default", func() { - Attribute("IntField") - Attribute("StringField") + Required("IntField", "StringField") }) View("tiny", func() { Attribute("IntField") @@ -564,6 +642,7 @@ var MessageResultTypeWithExplicitViewDSL = func() { Attributes(func() { Field(1, "IntField", Int) Field(2, "StringField", String) + Required("IntField", "StringField") }) View("default", func() { Attribute("IntField") @@ -671,6 +750,23 @@ var ResultWithAliasValidation = func() { }) } +// ResultFieldNameCollisionDSL defines a required alias field whose Go name can +// match the source parameter of the generated response conversion function. +var ResultFieldNameCollisionDSL = func() { + var JSON = Type("JSON", String) + var ToolResult = Type("ToolResult", func() { + Field(1, "result", JSON) + Required("result") + }) + + Service("ResultFieldNameCollision", func() { + Method("show", func() { + Result(ToolResult) + GRPC(func() {}) + }) + }) +} + var PayloadWithMixedAttributesDSL = func() { var APayload = Type("APayload", func() { Field(1, "optional", Int) @@ -891,6 +987,7 @@ var MessageWithValidateDSL = func() { } var MessageWithSecurityAttrsDSL = func() { + var Token = Type("Token", String) var JWTAuth = JWTSecurity("jwt", func() { Scope("api:read", "Read-only access") }) @@ -901,7 +998,7 @@ var MessageWithSecurityAttrsDSL = func() { }) var RequestUT = Type("RequestUT", func() { Field(1, "BooleanField", Boolean) - TokenField(2, "token", String) + TokenField(2, "token", Token) AccessTokenField(3, "oauth_token", String) APIKey("api_key", "key", String) Username("username", String) @@ -920,6 +1017,32 @@ var MessageWithSecurityAttrsDSL = func() { }) } +var NamedSecurityMetadataDSL = func() { + var Credential = Type("Credential", String) + var JWTAuth = JWTSecurity("jwt") + var APIKeyAuth = APIKeySecurity("api_key") + var BasicAuth = BasicAuthSecurity("basic") + var BearerAuth = BearerSecurity("bearer") + var OAuth2Auth = OAuth2Security("oauth2") + var Request = Type("NamedSecurityRequest", func() { + Field(1, "BooleanField", Boolean) + TokenField(2, "jwt_token", Credential) + AccessTokenField(3, "oauth_token", Credential) + APIKey("api_key", "key", Credential) + Username("username", Credential) + Password("password", Credential) + BearerTokenField(7, "bearer_token", Credential) + Required("jwt_token", "key", "username") + }) + Service("NamedSecurityMetadata", func() { + Method("Authenticate", func() { + Security(JWTAuth, OAuth2Auth, APIKeyAuth, BasicAuth, BearerAuth) + Payload(Request) + GRPC(func() {}) + }) + }) +} + var MessageWithServiceNameDSL = func() { var UT = Type("MyNameConflicts", func() { Field(1, "BooleanField", Boolean) @@ -1021,6 +1144,23 @@ var PayloadWithValidationsDSL = func() { }) } +var PayloadWithMessageDSL = func() { + Service("PayloadWithMessage", func() { + Method("show", func() { + Payload(func() { + Field(1, "tenantID", String, func() { + Example("tenant") + }) + Field(2, "recordID", String, func() { + Example("record") + }) + Required("tenantID", "recordID") + }) + GRPC(func() {}) + }) + }) +} + var StructMetaTypeDSL = func() { Service("UsingMetaTypes", func() { Method("Method", func() { @@ -1137,6 +1277,29 @@ var CustomMessageNameDSL = func() { }) } +var DistinctCustomMessageNamesDSL = func() { + var First = Type("First", func() { + Meta("struct:name:proto", "Shared") + Field(1, "value", String) + }) + var Second = Type("Second", func() { + Meta("struct:name:proto", "Shared") + Field(1, "value", String) + }) + Service("DistinctCustomMessageNames", func() { + Method("UseFirst", func() { + Payload(First) + Result(First) + GRPC(func() {}) + }) + Method("UseSecond", func() { + Payload(Second) + Result(Second) + GRPC(func() {}) + }) + }) +} + var InterceptorsDSL = func() { var LogInterceptor = Interceptor("Log", func() { Description("Logs request and response details") @@ -1156,12 +1319,16 @@ var InterceptorsDSL = func() { ClientInterceptor(LogInterceptor) Method("MethodA", func() { ClientInterceptor(MetricsInterceptor) - Payload(String) + Payload(String, func() { + Example("hello") + }) Result(String) GRPC(func() {}) }) Method("MethodB", func() { - Payload(Int) + Payload(Int, func() { + Example(42) + }) Result(Int) GRPC(func() {}) }) diff --git a/grpc/codegen/testdata/endpoint-endpoint-with-interceptors.golden b/grpc/codegen/testdata/endpoint-endpoint-with-interceptors.golden index 43aecefa91..4c7533150f 100644 --- a/grpc/codegen/testdata/endpoint-endpoint-with-interceptors.golden +++ b/grpc/codegen/testdata/endpoint-endpoint-with-interceptors.golden @@ -6,12 +6,12 @@ package cli import ( - servicewithinterceptors "/service_with_interceptors" "flag" "fmt" - servicewithinterceptorsc "grpc/service_with_interceptors/client" "os" + servicewithinterceptorsc "generated.local/gen/grpc/service_with_interceptors/client" + servicewithinterceptors "generated.local/gen/service_with_interceptors" goa "goa.design/goa/v3/pkg" grpc "google.golang.org/grpc" ) @@ -27,10 +27,29 @@ func UsageCommands() []string { // UsageExamples produces an example of a valid invocation of the CLI tool. func UsageExamples() string { - return os.Args[0] + " " + "service-with-interceptors method-a --message '{\n \"field\": \"Quidem molestiae possimus et vel vel perspiciatis.\"\n }'" + "\n" + + return os.Args[0] + " " + "service-with-interceptors method-a --message '{\n \"field\": \"hello\"\n }'" + "\n" + "" } +// cliStringFlag keeps an omitted command-line flag distinct from an explicitly empty flag. +type cliStringFlag struct { + value *string +} + +// String returns the flag text shown by the standard flag package. +func (f *cliStringFlag) String() string { + if f.value == nil { + return "" + } + return *f.value +} + +// Set records that the user supplied the flag, even when value is empty. +func (f *cliStringFlag) Set(value string) error { + f.value = &value + return nil +} + // ParseEndpoint returns the endpoint and payload as specified on the command // line. func ParseEndpoint( @@ -42,11 +61,14 @@ func ParseEndpoint( serviceWithInterceptorsFlags = flag.NewFlagSet("service-with-interceptors", flag.ContinueOnError) serviceWithInterceptorsMethodAFlags = flag.NewFlagSet("method-a", flag.ExitOnError) - serviceWithInterceptorsMethodAMessageFlag = serviceWithInterceptorsMethodAFlags.String("message", "", "") + serviceWithInterceptorsMethodAMessageFlag = new(cliStringFlag) serviceWithInterceptorsMethodBFlags = flag.NewFlagSet("method-b", flag.ExitOnError) - serviceWithInterceptorsMethodBMessageFlag = serviceWithInterceptorsMethodBFlags.String("message", "", "") + serviceWithInterceptorsMethodBMessageFlag = new(cliStringFlag) ) + serviceWithInterceptorsMethodAFlags.Var(serviceWithInterceptorsMethodAMessageFlag, "message", "") + serviceWithInterceptorsMethodBFlags.Var(serviceWithInterceptorsMethodBMessageFlag, "message", "") + serviceWithInterceptorsFlags.Usage = serviceWithInterceptorsUsage serviceWithInterceptorsMethodAFlags.Usage = serviceWithInterceptorsMethodAUsage serviceWithInterceptorsMethodBFlags.Usage = serviceWithInterceptorsMethodBUsage @@ -119,11 +141,11 @@ func ParseEndpoint( case "method-a": endpoint = c.MethodA() endpoint = servicewithinterceptors.WrapMethodAClientEndpoint(endpoint, serviceWithInterceptorsInter) - data, err = servicewithinterceptorsc.BuildMethodAPayload(*serviceWithInterceptorsMethodAMessageFlag) + data, err = servicewithinterceptorsc.BuildMethodAPayload(serviceWithInterceptorsMethodAMessageFlag.value) case "method-b": endpoint = c.MethodB() endpoint = servicewithinterceptors.WrapMethodBClientEndpoint(endpoint, serviceWithInterceptorsInter) - data, err = servicewithinterceptorsc.BuildMethodBPayload(*serviceWithInterceptorsMethodBMessageFlag) + data, err = servicewithinterceptorsc.BuildMethodBPayload(serviceWithInterceptorsMethodBMessageFlag.value) } } } @@ -161,7 +183,7 @@ func serviceWithInterceptorsMethodAUsage() { fmt.Fprintln(os.Stderr) fmt.Fprintln(os.Stderr, "Example:") - fmt.Fprintf(os.Stderr, " %s %s\n", os.Args[0], "service-with-interceptors method-a --message '{\n \"field\": \"Quidem molestiae possimus et vel vel perspiciatis.\"\n }'") + fmt.Fprintf(os.Stderr, " %s %s\n", os.Args[0], "service-with-interceptors method-a --message '{\n \"field\": \"hello\"\n }'") } func serviceWithInterceptorsMethodBUsage() { @@ -179,5 +201,5 @@ func serviceWithInterceptorsMethodBUsage() { fmt.Fprintln(os.Stderr) fmt.Fprintln(os.Stderr, "Example:") - fmt.Fprintf(os.Stderr, " %s %s\n", os.Args[0], "service-with-interceptors method-b --message '{\n \"field\": 8804614586670373312\n }'") + fmt.Fprintf(os.Stderr, " %s %s\n", os.Args[0], "service-with-interceptors method-b --message '{\n \"field\": 42\n }'") } diff --git a/grpc/codegen/testdata/golden/client_cli_flag-presence-build.go.golden b/grpc/codegen/testdata/golden/client_cli_flag-presence-build.go.golden new file mode 100644 index 0000000000..7f3a507710 --- /dev/null +++ b/grpc/codegen/testdata/golden/client_cli_flag-presence-build.go.golden @@ -0,0 +1,46 @@ +// BuildCheckPayload builds the payload for the FlagPresence check endpoint +// from CLI flags. +func BuildCheckPayload(flagPresenceCheckRequired *string, flagPresenceCheckOptional *string, flagPresenceCheckEmpty string, flagPresenceCheckDisabled string, flagPresenceCheckZero string) (*flagpresence.CheckPayload, error) { + var err error + var required string + { + if flagPresenceCheckRequired == nil { + return nil, fmt.Errorf("missing required flag --required") + } + required = *flagPresenceCheckRequired + } + var optional *string + { + if flagPresenceCheckOptional != nil { + optional = flagPresenceCheckOptional + } + } + var empty string + { + empty = flagPresenceCheckEmpty + } + var disabled bool + { + disabled, err = strconv.ParseBool(flagPresenceCheckDisabled) + if err != nil { + return nil, fmt.Errorf("invalid value for disabled, must be BOOL") + } + } + var zero int + { + var v int64 + v, err = strconv.ParseInt(flagPresenceCheckZero, 10, strconv.IntSize) + zero = int(v) + if err != nil { + return nil, fmt.Errorf("invalid value for zero, must be INT") + } + } + v := &flagpresence.CheckPayload{} + v.Required = required + v.Optional = optional + v.Empty = empty + v.Disabled = disabled + v.Zero = zero + + return v, nil +} diff --git a/grpc/codegen/testdata/golden/client_cli_flag-presence-parse.go.golden b/grpc/codegen/testdata/golden/client_cli_flag-presence-parse.go.golden new file mode 100644 index 0000000000..83555873c5 --- /dev/null +++ b/grpc/codegen/testdata/golden/client_cli_flag-presence-parse.go.golden @@ -0,0 +1,115 @@ +// cliStringFlag keeps an omitted command-line flag distinct from an explicitly empty flag. +type cliStringFlag struct { + value *string +} + +// String returns the flag text shown by the standard flag package. +func (f *cliStringFlag) String() string { + if f.value == nil { + return "" + } + return *f.value +} + +// Set records that the user supplied the flag, even when value is empty. +func (f *cliStringFlag) Set(value string) error { + f.value = &value + return nil +} + +// ParseEndpoint returns the endpoint and payload as specified on the command +// line. +func ParseEndpoint( + cc *grpc.ClientConn, + opts ...grpc.CallOption, +) (goa.Endpoint, any, error) { + var ( + flagPresenceFlags = flag.NewFlagSet("flag-presence", flag.ContinueOnError) + + flagPresenceCheckFlags = flag.NewFlagSet("check", flag.ExitOnError) + flagPresenceCheckRequiredFlag = new(cliStringFlag) + flagPresenceCheckOptionalFlag = new(cliStringFlag) + flagPresenceCheckEmptyFlag = flagPresenceCheckFlags.String("empty", "", "") + flagPresenceCheckDisabledFlag = flagPresenceCheckFlags.String("disabled", "false", "") + flagPresenceCheckZeroFlag = flagPresenceCheckFlags.String("zero", "0", "") + ) + flagPresenceCheckFlags.Var(flagPresenceCheckRequiredFlag, "required", "") + flagPresenceCheckFlags.Var(flagPresenceCheckOptionalFlag, "optional", "") + + flagPresenceFlags.Usage = flagPresenceUsage + flagPresenceCheckFlags.Usage = flagPresenceCheckUsage + + if err := flag.CommandLine.Parse(os.Args[1:]); err != nil { + return nil, nil, err + } + + if flag.NArg() < 2 { // two non flag args are required: SERVICE and ENDPOINT (aka COMMAND) + return nil, nil, fmt.Errorf("not enough arguments") + } + + var ( + svcn string + svcf *flag.FlagSet + ) + { + svcn = flag.Arg(0) + switch svcn { + case "flag-presence": + svcf = flagPresenceFlags + default: + return nil, nil, fmt.Errorf("unknown service %q", svcn) + } + } + if err := svcf.Parse(flag.Args()[1:]); err != nil { + return nil, nil, err + } + + var ( + epn string + epf *flag.FlagSet + ) + { + epn = svcf.Arg(0) + switch svcn { + case "flag-presence": + switch epn { + case "check": + epf = flagPresenceCheckFlags + + } + + } + } + if epf == nil { + return nil, nil, fmt.Errorf("unknown %q endpoint %q", svcn, epn) + } + + // Parse endpoint flags if any + if svcf.NArg() > 1 { + if err := epf.Parse(svcf.Args()[1:]); err != nil { + return nil, nil, err + } + } + + var ( + data any + endpoint goa.Endpoint + err error + ) + { + switch svcn { + case "flag-presence": + c := flagpresencec.NewClient(cc, opts...) + switch epn { + case "check": + endpoint = c.Check() + data, err = flagpresencec.BuildCheckPayload(flagPresenceCheckRequiredFlag.value, flagPresenceCheckOptionalFlag.value, *flagPresenceCheckEmptyFlag, *flagPresenceCheckDisabledFlag, *flagPresenceCheckZeroFlag) + } + } + } + if err != nil { + return nil, nil, err + } + + return endpoint, data, nil +} diff --git a/grpc/codegen/testdata/golden/client_cli_payload-with-message.go.golden b/grpc/codegen/testdata/golden/client_cli_payload-with-message.go.golden new file mode 100644 index 0000000000..5928ebfcd6 --- /dev/null +++ b/grpc/codegen/testdata/golden/client_cli_payload-with-message.go.golden @@ -0,0 +1,39 @@ +// PayloadWithMessage gRPC client CLI support package +// +// Command: +// goa + +package client + +import ( + "fmt" + + payload_with_messagepb "generated.local/gen/grpc/payload_with_message/pb" + payloadwithmessage "generated.local/gen/payload_with_message" + "google.golang.org/protobuf/encoding/protojson" +) + +// BuildShowPayload builds the payload for the PayloadWithMessage show endpoint +// from CLI flags. +func BuildShowPayload(payloadWithMessageShowMessage *string) (*payloadwithmessage.ShowPayload, error) { + var err error + var message payload_with_messagepb.ShowRequest + { + if payloadWithMessageShowMessage != nil { + err = protojson.Unmarshal([]byte(*payloadWithMessageShowMessage), &message) + if err != nil { + return nil, fmt.Errorf("invalid JSON for message, \nerror: %s, \nexample of valid JSON:\n%s", err, "'{\n \"record_id\": \"record\",\n \"tenant_id\": \"tenant\"\n }'") + } + } + } + if err := ValidateShowRequest(&message); err != nil { + var zero *payloadwithmessage.ShowPayload + return zero, err + } + v := &payloadwithmessage.ShowPayload{ + TenantID: *message.TenantId, + RecordID: *message.RecordId, + } + + return v, nil +} diff --git a/grpc/codegen/testdata/golden/client_cli_payload-with-validations.go.golden b/grpc/codegen/testdata/golden/client_cli_payload-with-validations.go.golden index 8f436a645a..850b7da92a 100644 --- a/grpc/codegen/testdata/golden/client_cli_payload-with-validations.go.golden +++ b/grpc/codegen/testdata/golden/client_cli_payload-with-validations.go.golden @@ -7,32 +7,32 @@ package client import ( "fmt" - payloadwithvalidation "payload_with_validation" "strconv" "unicode/utf8" + payloadwithvalidation "generated.local/gen/payload_with_validation" goa "goa.design/goa/v3/pkg" ) // BuildMethodAPayload builds the payload for the PayloadWithValidation // method_a endpoint from CLI flags. -func BuildMethodAPayload(payloadWithValidationMethodAMetadataInt string, payloadWithValidationMethodAMetadataString string) (*payloadwithvalidation.MethodAPayload, error) { +func BuildMethodAPayload(payloadWithValidationMethodAMetadataInt *string, payloadWithValidationMethodAMetadataString *string) (*payloadwithvalidation.MethodAPayload, error) { var err error var metadataInt *int { - if payloadWithValidationMethodAMetadataInt != "" { + if payloadWithValidationMethodAMetadataInt != nil { var v int64 - v, err = strconv.ParseInt(payloadWithValidationMethodAMetadataInt, 10, strconv.IntSize) + v, err = strconv.ParseInt(*payloadWithValidationMethodAMetadataInt, 10, strconv.IntSize) val := int(v) metadataInt = &val if err != nil { return nil, fmt.Errorf("invalid value for metadataInt, must be INT") } - if *metadataInt < 0 { - err = goa.MergeErrors(err, goa.InvalidRangeError("MetadataInt", *metadataInt, 0, true)) + if val < 0 { + err = goa.MergeErrors(err, goa.InvalidRangeError("MetadataInt", val, 0, true)) } - if *metadataInt > 100 { - err = goa.MergeErrors(err, goa.InvalidRangeError("MetadataInt", *metadataInt, 100, false)) + if val > 100 { + err = goa.MergeErrors(err, goa.InvalidRangeError("MetadataInt", val, 100, false)) } if err != nil { return nil, err @@ -41,13 +41,13 @@ func BuildMethodAPayload(payloadWithValidationMethodAMetadataInt string, payload } var metadataString *string { - if payloadWithValidationMethodAMetadataString != "" { - metadataString = &payloadWithValidationMethodAMetadataString - if utf8.RuneCountInString(*metadataString) < 5 { - err = goa.MergeErrors(err, goa.InvalidLengthError("MetadataString", *metadataString, utf8.RuneCountInString(*metadataString), 5, true)) + if payloadWithValidationMethodAMetadataString != nil { + metadataString = payloadWithValidationMethodAMetadataString + if utf8.RuneCountInString(*payloadWithValidationMethodAMetadataString) < 5 { + err = goa.MergeErrors(err, goa.InvalidLengthError("MetadataString", *payloadWithValidationMethodAMetadataString, utf8.RuneCountInString(*payloadWithValidationMethodAMetadataString), 5, true)) } - if utf8.RuneCountInString(*metadataString) > 10 { - err = goa.MergeErrors(err, goa.InvalidLengthError("MetadataString", *metadataString, utf8.RuneCountInString(*metadataString), 10, false)) + if utf8.RuneCountInString(*payloadWithValidationMethodAMetadataString) > 10 { + err = goa.MergeErrors(err, goa.InvalidLengthError("MetadataString", *payloadWithValidationMethodAMetadataString, utf8.RuneCountInString(*payloadWithValidationMethodAMetadataString), 10, false)) } if err != nil { return nil, err diff --git a/grpc/codegen/testdata/golden/client_types_client-alias-validation.go.golden b/grpc/codegen/testdata/golden/client_types_client-alias-validation.go.golden index 1129af7b45..c831d195d6 100644 --- a/grpc/codegen/testdata/golden/client_types_client-alias-validation.go.golden +++ b/grpc/codegen/testdata/golden/client_types_client-alias-validation.go.golden @@ -1,21 +1,26 @@ -// NewProtoMethodResultWithAliasValidationRequest builds the gRPC request type -// from the payload of the "MethodResultWithAliasValidation" endpoint of the -// "ServiceResultWithAliasValidation" service. +// NewProtoMethodResultWithAliasValidationRequest builds +// *service_result_with_alias_validationpb.MethodResultWithAliasValidationRequest +// from metadata values. func NewProtoMethodResultWithAliasValidationRequest() *service_result_with_alias_validationpb.MethodResultWithAliasValidationRequest { message := &service_result_with_alias_validationpb.MethodResultWithAliasValidationRequest{} return message } -// NewMethodResultWithAliasValidationResult builds the result type of the -// "MethodResultWithAliasValidation" endpoint of the -// "ServiceResultWithAliasValidation" service from the gRPC response type. +// NewMethodResultWithAliasValidationResult builds +// serviceresultwithaliasvalidation.UUID from +// *service_result_with_alias_validationpb.UUID. func NewMethodResultWithAliasValidationResult(message *service_result_with_alias_validationpb.UUID) serviceresultwithaliasvalidation.UUID { - result := serviceresultwithaliasvalidation.UUID(message.Field) + result := serviceresultwithaliasvalidation.UUID(*message.Field) return result } // ValidateUUID runs the validations defined on UUID. func ValidateUUID(message *service_result_with_alias_validationpb.UUID) (err error) { - err = goa.MergeErrors(err, goa.ValidateFormat("message.field", message.Field, goa.FormatUUID)) + if message.Field == nil { + err = goa.MergeErrors(err, goa.MissingFieldError("field", "message")) + } + if message.Field != nil { + err = goa.MergeErrors(err, goa.ValidateFormat("message.field", *message.Field, goa.FormatUUID)) + } return } diff --git a/grpc/codegen/testdata/golden/client_types_client-bidirectional-streaming-same-type.go.golden b/grpc/codegen/testdata/golden/client_types_client-bidirectional-streaming-same-type.go.golden index 4bbda5f295..b58fed76f1 100644 --- a/grpc/codegen/testdata/golden/client_types_client-bidirectional-streaming-same-type.go.golden +++ b/grpc/codegen/testdata/golden/client_types_client-bidirectional-streaming-same-type.go.golden @@ -1,3 +1,6 @@ +// NewMethodBidirectionalStreamingRPCSameTypeResponseUserType builds +// *servicebidirectionalstreamingrpcsametype.UserType from +// *service_bidirectional_streaming_rpc_same_typepb.MethodBidirectionalStreamingRPCSameTypeResponse. func NewMethodBidirectionalStreamingRPCSameTypeResponseUserType(v *service_bidirectional_streaming_rpc_same_typepb.MethodBidirectionalStreamingRPCSameTypeResponse) *servicebidirectionalstreamingrpcsametype.UserType { result := &servicebidirectionalstreamingrpcsametype.UserType{ B: v.B, @@ -9,6 +12,10 @@ func NewMethodBidirectionalStreamingRPCSameTypeResponseUserType(v *service_bidir return result } +// NewProtoUserTypeMethodBidirectionalStreamingRPCSameTypeStreamingRequest +// builds +// *service_bidirectional_streaming_rpc_same_typepb.MethodBidirectionalStreamingRPCSameTypeStreamingRequest +// from *servicebidirectionalstreamingrpcsametype.UserType. func NewProtoUserTypeMethodBidirectionalStreamingRPCSameTypeStreamingRequest(spayload *servicebidirectionalstreamingrpcsametype.UserType) *service_bidirectional_streaming_rpc_same_typepb.MethodBidirectionalStreamingRPCSameTypeStreamingRequest { v := &service_bidirectional_streaming_rpc_same_typepb.MethodBidirectionalStreamingRPCSameTypeStreamingRequest{ B: spayload.B, diff --git a/grpc/codegen/testdata/golden/client_types_client-default-fields.go.golden b/grpc/codegen/testdata/golden/client_types_client-default-fields.go.golden index 07f660310c..5c785a78e4 100644 --- a/grpc/codegen/testdata/golden/client_types_client-default-fields.go.golden +++ b/grpc/codegen/testdata/golden/client_types_client-default-fields.go.golden @@ -1,20 +1,34 @@ -// NewProtoMethodRequest builds the gRPC request type from the payload of the -// "Method" endpoint of the "DefaultFields" service. +// NewProtoMethodRequest builds *default_fieldspb.MethodRequest from +// *defaultfields.MethodPayload. func NewProtoMethodRequest(payload *defaultfields.MethodPayload) *default_fieldspb.MethodRequest { message := &default_fieldspb.MethodRequest{ - Req: payload.Req, + Req: &payload.Req, Opt: payload.Opt, Def0: &payload.Def0, Def1: &payload.Def1, Def2: &payload.Def2, - Reqs: payload.Reqs, + Reqs: &payload.Reqs, Opts: payload.Opts, Defs: &payload.Defs, Defe: &payload.Defe, - Rat: payload.Rat, + Rat: &payload.Rat, Flt: payload.Flt, Flt0: &payload.Flt0, Flt1: &payload.Flt1, } return message } + +// ValidateMethodRequest runs the validations defined on MethodRequest. +func ValidateMethodRequest(message *default_fieldspb.MethodRequest) (err error) { + if message.Req == nil { + err = goa.MergeErrors(err, goa.MissingFieldError("req", "message")) + } + if message.Reqs == nil { + err = goa.MergeErrors(err, goa.MissingFieldError("reqs", "message")) + } + if message.Rat == nil { + err = goa.MergeErrors(err, goa.MissingFieldError("rat", "message")) + } + return +} diff --git a/grpc/codegen/testdata/golden/client_types_client-payload-with-alias-type.go.golden b/grpc/codegen/testdata/golden/client_types_client-payload-with-alias-type.go.golden index f5b2947f9d..c42e7b3d91 100644 --- a/grpc/codegen/testdata/golden/client_types_client-payload-with-alias-type.go.golden +++ b/grpc/codegen/testdata/golden/client_types_client-payload-with-alias-type.go.golden @@ -1,10 +1,10 @@ -// NewProtoMethodMessageUserTypeWithAliasRequest builds the gRPC request type -// from the payload of the "MethodMessageUserTypeWithAlias" endpoint of the -// "ServiceMessageUserTypeWithAlias" service. +// NewProtoMethodMessageUserTypeWithAliasRequest builds +// *service_message_user_type_with_aliaspb.MethodMessageUserTypeWithAliasRequest +// from *servicemessageusertypewithalias.PayloadAliasT. func NewProtoMethodMessageUserTypeWithAliasRequest(payload *servicemessageusertypewithalias.PayloadAliasT) *service_message_user_type_with_aliaspb.MethodMessageUserTypeWithAliasRequest { - message := &service_message_user_type_with_aliaspb.MethodMessageUserTypeWithAliasRequest{ - IntAliasField: int32(payload.IntAliasField), - } + message := &service_message_user_type_with_aliaspb.MethodMessageUserTypeWithAliasRequest{} + intAliasField := int32(payload.IntAliasField) + message.IntAliasField = &intAliasField if payload.OptionalIntAliasField != nil { optionalIntAliasField := int32(*payload.OptionalIntAliasField) message.OptionalIntAliasField = &optionalIntAliasField @@ -12,12 +12,12 @@ func NewProtoMethodMessageUserTypeWithAliasRequest(payload *servicemessageuserty return message } -// NewMethodMessageUserTypeWithAliasResult builds the result type of the -// "MethodMessageUserTypeWithAlias" endpoint of the -// "ServiceMessageUserTypeWithAlias" service from the gRPC response type. +// NewMethodMessageUserTypeWithAliasResult builds +// *servicemessageusertypewithalias.PayloadAliasT from +// *service_message_user_type_with_aliaspb.MethodMessageUserTypeWithAliasResponse. func NewMethodMessageUserTypeWithAliasResult(message *service_message_user_type_with_aliaspb.MethodMessageUserTypeWithAliasResponse) *servicemessageusertypewithalias.PayloadAliasT { result := &servicemessageusertypewithalias.PayloadAliasT{ - IntAliasField: servicemessageusertypewithalias.IntAlias(message.IntAliasField), + IntAliasField: servicemessageusertypewithalias.IntAlias(*message.IntAliasField), } if message.OptionalIntAliasField != nil { optionalIntAliasField := servicemessageusertypewithalias.IntAlias(*message.OptionalIntAliasField) @@ -25,3 +25,21 @@ func NewMethodMessageUserTypeWithAliasResult(message *service_message_user_type_ } return result } + +// ValidateMethodMessageUserTypeWithAliasRequest runs the validations defined +// on MethodMessageUserTypeWithAliasRequest. +func ValidateMethodMessageUserTypeWithAliasRequest(message *service_message_user_type_with_aliaspb.MethodMessageUserTypeWithAliasRequest) (err error) { + if message.IntAliasField == nil { + err = goa.MergeErrors(err, goa.MissingFieldError("IntAliasField", "message")) + } + return +} + +// ValidateMethodMessageUserTypeWithAliasResponse runs the validations defined +// on MethodMessageUserTypeWithAliasResponse. +func ValidateMethodMessageUserTypeWithAliasResponse(message *service_message_user_type_with_aliaspb.MethodMessageUserTypeWithAliasResponse) (err error) { + if message.IntAliasField == nil { + err = goa.MergeErrors(err, goa.MissingFieldError("IntAliasField", "message")) + } + return +} diff --git a/grpc/codegen/testdata/golden/client_types_client-payload-with-duplicate-use.go.golden b/grpc/codegen/testdata/golden/client_types_client-payload-with-duplicate-use.go.golden index 45aa6d3796..d20e28462a 100644 --- a/grpc/codegen/testdata/golden/client_types_client-payload-with-duplicate-use.go.golden +++ b/grpc/codegen/testdata/golden/client_types_client-payload-with-duplicate-use.go.golden @@ -1,8 +1,16 @@ -// NewProtoDupePayload builds the gRPC request type from the payload of the -// "MethodPayloadDuplicateA" endpoint of the "ServicePayloadWithNestedTypes" -// service. +// NewProtoDupePayload builds *service_payload_with_nested_typespb.DupePayload +// from servicepayloadwithnestedtypes.DupePayload. func NewProtoDupePayload(payload servicepayloadwithnestedtypes.DupePayload) *service_payload_with_nested_typespb.DupePayload { message := &service_payload_with_nested_typespb.DupePayload{} - message.Field = string(payload) + message.Field = new(string) + *message.Field = string(payload) return message } + +// ValidateDupePayload runs the validations defined on DupePayload. +func ValidateDupePayload(message *service_payload_with_nested_typespb.DupePayload) (err error) { + if message.Field == nil { + err = goa.MergeErrors(err, goa.MissingFieldError("field", "message")) + } + return +} diff --git a/grpc/codegen/testdata/golden/client_types_client-payload-with-nested-types.go.golden b/grpc/codegen/testdata/golden/client_types_client-payload-with-nested-types.go.golden index 2945ad9abf..78612ba34c 100644 --- a/grpc/codegen/testdata/golden/client_types_client-payload-with-nested-types.go.golden +++ b/grpc/codegen/testdata/golden/client_types_client-payload-with-nested-types.go.golden @@ -1,32 +1,30 @@ -// NewProtoMethodPayloadWithNestedTypesRequest builds the gRPC request type -// from the payload of the "MethodPayloadWithNestedTypes" endpoint of the -// "ServicePayloadWithNestedTypes" service. +// NewProtoMethodPayloadWithNestedTypesRequest builds +// *service_payload_with_nested_typespb.MethodPayloadWithNestedTypesRequest +// from *servicepayloadwithnestedtypes.MethodPayloadWithNestedTypesPayload. func NewProtoMethodPayloadWithNestedTypesRequest(payload *servicepayloadwithnestedtypes.MethodPayloadWithNestedTypesPayload) *service_payload_with_nested_typespb.MethodPayloadWithNestedTypesRequest { message := &service_payload_with_nested_typespb.MethodPayloadWithNestedTypesRequest{} if payload.AParams != nil { - message.AParams = svcServicepayloadwithnestedtypesAParamsToServicePayloadWithNestedTypespbAParams(payload.AParams) + message.AParams = transformAParamsToProtoAParams(payload.AParams) } if payload.BParams != nil { - message.BParams = svcServicepayloadwithnestedtypesBParamsToServicePayloadWithNestedTypespbBParams(payload.BParams) + message.BParams = transformBParamsToProtoBParams(payload.BParams) } return message } -// protobufServicePayloadWithNestedTypespbAParamsToServicepayloadwithnestedtypesAParams -// builds a value of type *servicepayloadwithnestedtypes.AParams from a value -// of type *service_payload_with_nested_typespb.AParams. -func protobufServicePayloadWithNestedTypespbAParamsToServicepayloadwithnestedtypesAParams(v *service_payload_with_nested_typespb.AParams) *servicepayloadwithnestedtypes.AParams { - if v == nil { - return nil - } - res := &servicepayloadwithnestedtypes.AParams{} +// transformAParamsToProtoAParams builds a value of type +// *service_payload_with_nested_typespb.AParams from a value of type +// *servicepayloadwithnestedtypes.AParams. +func transformAParamsToProtoAParams(v *servicepayloadwithnestedtypes.AParams) *service_payload_with_nested_typespb.AParams { + res := &service_payload_with_nested_typespb.AParams{} if v.A != nil { - res.A = make(map[string][]string, len(v.A)) + res.A = make(map[string]*service_payload_with_nested_typespb.ArrayOfString, len(v.A)) for key, val := range v.A { tk := key - tv := make([]string, len(val.Field)) - for i, val := range val.Field { - tv[i] = val + tv := &service_payload_with_nested_typespb.ArrayOfString{} + tv.Field = make([]string, len(val)) + for i, val := range val { + tv.Field[i] = val } res.A[tk] = tv } @@ -35,14 +33,11 @@ func protobufServicePayloadWithNestedTypespbAParamsToServicepayloadwithnestedtyp return res } -// protobufServicePayloadWithNestedTypespbBParamsToServicepayloadwithnestedtypesBParams -// builds a value of type *servicepayloadwithnestedtypes.BParams from a value -// of type *service_payload_with_nested_typespb.BParams. -func protobufServicePayloadWithNestedTypespbBParamsToServicepayloadwithnestedtypesBParams(v *service_payload_with_nested_typespb.BParams) *servicepayloadwithnestedtypes.BParams { - if v == nil { - return nil - } - res := &servicepayloadwithnestedtypes.BParams{} +// transformBParamsToProtoBParams builds a value of type +// *service_payload_with_nested_typespb.BParams from a value of type +// *servicepayloadwithnestedtypes.BParams. +func transformBParamsToProtoBParams(v *servicepayloadwithnestedtypes.BParams) *service_payload_with_nested_typespb.BParams { + res := &service_payload_with_nested_typespb.BParams{} if v.B != nil { res.B = make(map[string]string, len(v.B)) for key, val := range v.B { @@ -55,22 +50,21 @@ func protobufServicePayloadWithNestedTypespbBParamsToServicepayloadwithnestedtyp return res } -// svcServicepayloadwithnestedtypesAParamsToServicePayloadWithNestedTypespbAParams -// builds a value of type *service_payload_with_nested_typespb.AParams from a -// value of type *servicepayloadwithnestedtypes.AParams. -func svcServicepayloadwithnestedtypesAParamsToServicePayloadWithNestedTypespbAParams(v *servicepayloadwithnestedtypes.AParams) *service_payload_with_nested_typespb.AParams { - if v == nil { - return nil - } - res := &service_payload_with_nested_typespb.AParams{} +// transformProtoAParamsToAParams builds a value of type +// *servicepayloadwithnestedtypes.AParams from a value of type +// *service_payload_with_nested_typespb.AParams. +func transformProtoAParamsToAParams(v *service_payload_with_nested_typespb.AParams) *servicepayloadwithnestedtypes.AParams { + res := &servicepayloadwithnestedtypes.AParams{} if v.A != nil { - res.A = make(map[string]*service_payload_with_nested_typespb.ArrayOfString, len(v.A)) + res.A = make(map[string][]string, len(v.A)) for key, val := range v.A { tk := key - tv := &service_payload_with_nested_typespb.ArrayOfString{} - tv.Field = make([]string, len(val)) - for i, val := range val { - tv.Field[i] = val + var tv []string + if val != nil { + tv = make([]string, len(val.Field)) + for i, val := range val.Field { + tv[i] = val + } } res.A[tk] = tv } @@ -79,14 +73,11 @@ func svcServicepayloadwithnestedtypesAParamsToServicePayloadWithNestedTypespbAPa return res } -// svcServicepayloadwithnestedtypesBParamsToServicePayloadWithNestedTypespbBParams -// builds a value of type *service_payload_with_nested_typespb.BParams from a -// value of type *servicepayloadwithnestedtypes.BParams. -func svcServicepayloadwithnestedtypesBParamsToServicePayloadWithNestedTypespbBParams(v *servicepayloadwithnestedtypes.BParams) *service_payload_with_nested_typespb.BParams { - if v == nil { - return nil - } - res := &service_payload_with_nested_typespb.BParams{} +// transformProtoBParamsToBParams builds a value of type +// *servicepayloadwithnestedtypes.BParams from a value of type +// *service_payload_with_nested_typespb.BParams. +func transformProtoBParamsToBParams(v *service_payload_with_nested_typespb.BParams) *servicepayloadwithnestedtypes.BParams { + res := &servicepayloadwithnestedtypes.BParams{} if v.B != nil { res.B = make(map[string]string, len(v.B)) for key, val := range v.B { diff --git a/grpc/codegen/testdata/golden/client_types_client-required-union-validation.go.golden b/grpc/codegen/testdata/golden/client_types_client-required-union-validation.go.golden new file mode 100644 index 0000000000..d5f53af345 --- /dev/null +++ b/grpc/codegen/testdata/golden/client_types_client-required-union-validation.go.golden @@ -0,0 +1,70 @@ +// ValidateExchangeResponse runs the validations defined on ExchangeResponse. +func ValidateExchangeResponse(message *union_validationpb.ExchangeResponse) (err error) { + if message.Choice == nil { + err = goa.MergeErrors(err, goa.MissingFieldError("choice", "message")) + } + switch v := message.Choice.(type) { + case *union_validationpb.ExchangeResponse_Number: + if v == nil { + err = goa.MergeErrors(err, goa.MissingFieldError("number", "message.choice")) + break + } + if int(v.Number) < 1 { + err = goa.MergeErrors(err, goa.InvalidRangeError("message.choice.value", int(v.Number), 1, true)) + } + + case *union_validationpb.ExchangeResponse_Detail: + if v == nil { + err = goa.MergeErrors(err, goa.MissingFieldError("detail", "message.choice")) + break + } + if v.Detail == nil { + err = goa.MergeErrors(err, goa.MissingFieldError("detail", "message.choice")) + break + } + if v.Detail != nil { + if err2 := validatetest_20_api_UnionValidation_Detail_At_detail(v.Detail); err2 != nil { + err = goa.MergeErrors(err, err2) + } + } + + case *union_validationpb.ExchangeResponse_Inactive: + if v == nil { + err = goa.MergeErrors(err, goa.MissingFieldError("inactive", "message.choice")) + break + } + if v.Inactive == nil { + err = goa.MergeErrors(err, goa.MissingFieldError("inactive", "message.choice")) + break + } + + case *union_validationpb.ExchangeResponse_Blob: + if v == nil { + err = goa.MergeErrors(err, goa.MissingFieldError("blob", "message.choice")) + break + } + if v.Blob == nil { + err = goa.MergeErrors(err, goa.MissingFieldError("blob", "message.choice")) + break + } + + case *union_validationpb.ExchangeResponse_Token: + if v == nil { + err = goa.MergeErrors(err, goa.MissingFieldError("token", "message.choice")) + break + } + + case *union_validationpb.ExchangeResponse_Metadata: + if v == nil { + err = goa.MergeErrors(err, goa.MissingFieldError("metadata", "message.choice")) + break + } + if v.Metadata == nil { + err = goa.MergeErrors(err, goa.MissingFieldError("metadata", "message.choice")) + break + } + + } + + return +} diff --git a/grpc/codegen/testdata/golden/client_types_client-result-collection.go.golden b/grpc/codegen/testdata/golden/client_types_client-result-collection.go.golden index e0dfc5b766..b428fb009c 100644 --- a/grpc/codegen/testdata/golden/client_types_client-result-collection.go.golden +++ b/grpc/codegen/testdata/golden/client_types_client-result-collection.go.golden @@ -1,52 +1,26 @@ -// NewProtoMethodResultWithCollectionRequest builds the gRPC request type from -// the payload of the "MethodResultWithCollection" endpoint of the -// "ServiceResultWithCollection" service. +// NewProtoMethodResultWithCollectionRequest builds +// *service_result_with_collectionpb.MethodResultWithCollectionRequest from +// metadata values. func NewProtoMethodResultWithCollectionRequest() *service_result_with_collectionpb.MethodResultWithCollectionRequest { message := &service_result_with_collectionpb.MethodResultWithCollectionRequest{} return message } -// NewMethodResultWithCollectionResult builds the result type of the -// "MethodResultWithCollection" endpoint of the "ServiceResultWithCollection" -// service from the gRPC response type. +// NewMethodResultWithCollectionResult builds +// *serviceresultwithcollection.MethodResultWithCollectionResult from +// *service_result_with_collectionpb.MethodResultWithCollectionResponse. func NewMethodResultWithCollectionResult(message *service_result_with_collectionpb.MethodResultWithCollectionResponse) *serviceresultwithcollection.MethodResultWithCollectionResult { result := &serviceresultwithcollection.MethodResultWithCollectionResult{} if message.Result != nil { - result.Result = protobufServiceResultWithCollectionpbResultTToServiceresultwithcollectionResultT(message.Result) + result.Result = transformProtoResultTToResultT(message.Result) } return result } -// svcServiceresultwithcollectionResultTToServiceResultWithCollectionpbResultT -// builds a value of type *service_result_with_collectionpb.ResultT from a -// value of type *serviceresultwithcollection.ResultT. -func svcServiceresultwithcollectionResultTToServiceResultWithCollectionpbResultT(v *serviceresultwithcollection.ResultT) *service_result_with_collectionpb.ResultT { - if v == nil { - return nil - } - res := &service_result_with_collectionpb.ResultT{} - if v.CollectionField != nil { - res.CollectionField = &service_result_with_collectionpb.RTCollection{} - res.CollectionField.Field = make([]*service_result_with_collectionpb.RT, len(v.CollectionField)) - for i, val := range v.CollectionField { - res.CollectionField.Field[i] = &service_result_with_collectionpb.RT{} - if val.IntField != nil { - intField := int32(*val.IntField) - res.CollectionField.Field[i].IntField = &intField - } - } - } - - return res -} - -// protobufServiceResultWithCollectionpbResultTToServiceresultwithcollectionResultT -// builds a value of type *serviceresultwithcollection.ResultT from a value of -// type *service_result_with_collectionpb.ResultT. -func protobufServiceResultWithCollectionpbResultTToServiceresultwithcollectionResultT(v *service_result_with_collectionpb.ResultT) *serviceresultwithcollection.ResultT { - if v == nil { - return nil - } +// transformProtoResultTToResultT builds a value of type +// *serviceresultwithcollection.ResultT from a value of type +// *service_result_with_collectionpb.ResultT. +func transformProtoResultTToResultT(v *service_result_with_collectionpb.ResultT) *serviceresultwithcollection.ResultT { res := &serviceresultwithcollection.ResultT{} if v.CollectionField != nil { res.CollectionField = make([]*serviceresultwithcollection.RT, len(v.CollectionField.Field)) diff --git a/grpc/codegen/testdata/golden/client_types_client-result-with-explicit-view.go.golden b/grpc/codegen/testdata/golden/client_types_client-result-with-explicit-view.go.golden new file mode 100644 index 0000000000..484ab81ac1 --- /dev/null +++ b/grpc/codegen/testdata/golden/client_types_client-result-with-explicit-view.go.golden @@ -0,0 +1,26 @@ +// NewProtoMethodMessageResultTypeWithExplicitViewRequest builds +// *service_message_result_type_with_explicit_viewpb.MethodMessageResultTypeWithExplicitViewRequest +// from metadata values. +func NewProtoMethodMessageResultTypeWithExplicitViewRequest() *service_message_result_type_with_explicit_viewpb.MethodMessageResultTypeWithExplicitViewRequest { + message := &service_message_result_type_with_explicit_viewpb.MethodMessageResultTypeWithExplicitViewRequest{} + return message +} + +// NewMethodMessageResultTypeWithExplicitViewResult builds +// *servicemessageresulttypewithexplicitviewviews.RTView from +// *service_message_result_type_with_explicit_viewpb.MethodMessageResultTypeWithExplicitViewResponse. +func NewMethodMessageResultTypeWithExplicitViewResult(message *service_message_result_type_with_explicit_viewpb.MethodMessageResultTypeWithExplicitViewResponse) *servicemessageresulttypewithexplicitviewviews.RTView { + result := &servicemessageresulttypewithexplicitviewviews.RTView{} + intField := int(*message.IntField) + result.IntField = &intField + return result +} + +// ValidateMethodMessageResultTypeWithExplicitViewResponseTiny runs the +// validations defined on MethodMessageResultTypeWithExplicitViewResponse. +func ValidateMethodMessageResultTypeWithExplicitViewResponseTiny(message *service_message_result_type_with_explicit_viewpb.MethodMessageResultTypeWithExplicitViewResponse) (err error) { + if message.IntField == nil { + err = goa.MergeErrors(err, goa.MissingFieldError("IntField", "message")) + } + return +} diff --git a/grpc/codegen/testdata/golden/client_types_client-result-with-views.go.golden b/grpc/codegen/testdata/golden/client_types_client-result-with-views.go.golden new file mode 100644 index 0000000000..898623dcba --- /dev/null +++ b/grpc/codegen/testdata/golden/client_types_client-result-with-views.go.golden @@ -0,0 +1,50 @@ +// NewProtoMethodMessageResultTypeWithViewsRequest builds +// *service_message_result_type_with_viewspb.MethodMessageResultTypeWithViewsRequest +// from metadata values. +func NewProtoMethodMessageResultTypeWithViewsRequest() *service_message_result_type_with_viewspb.MethodMessageResultTypeWithViewsRequest { + message := &service_message_result_type_with_viewspb.MethodMessageResultTypeWithViewsRequest{} + return message +} + +// NewMethodMessageResultTypeWithViewsResult builds +// *servicemessageresulttypewithviewsviews.RTView from +// *service_message_result_type_with_viewspb.MethodMessageResultTypeWithViewsResponse. +func NewMethodMessageResultTypeWithViewsResult(message *service_message_result_type_with_viewspb.MethodMessageResultTypeWithViewsResponse) *servicemessageresulttypewithviewsviews.RTView { + result := &servicemessageresulttypewithviewsviews.RTView{ + StringField: message.StringField, + } + intField := int(*message.IntField) + result.IntField = &intField + return result +} + +// NewMethodMessageResultTypeWithViewsResultTiny builds +// *servicemessageresulttypewithviewsviews.RTView from +// *service_message_result_type_with_viewspb.MethodMessageResultTypeWithViewsResponse. +func NewMethodMessageResultTypeWithViewsResultTiny(message *service_message_result_type_with_viewspb.MethodMessageResultTypeWithViewsResponse) *servicemessageresulttypewithviewsviews.RTView { + result := &servicemessageresulttypewithviewsviews.RTView{} + intField := int(*message.IntField) + result.IntField = &intField + return result +} + +// ValidateMethodMessageResultTypeWithViewsResponseTiny runs the validations +// defined on MethodMessageResultTypeWithViewsResponse. +func ValidateMethodMessageResultTypeWithViewsResponseTiny(message *service_message_result_type_with_viewspb.MethodMessageResultTypeWithViewsResponse) (err error) { + if message.IntField == nil { + err = goa.MergeErrors(err, goa.MissingFieldError("IntField", "message")) + } + return +} + +// ValidateMethodMessageResultTypeWithViewsResponse runs the validations +// defined on MethodMessageResultTypeWithViewsResponse. +func ValidateMethodMessageResultTypeWithViewsResponse(message *service_message_result_type_with_viewspb.MethodMessageResultTypeWithViewsResponse) (err error) { + if message.IntField == nil { + err = goa.MergeErrors(err, goa.MissingFieldError("IntField", "message")) + } + if message.StringField == nil { + err = goa.MergeErrors(err, goa.MissingFieldError("StringField", "message")) + } + return +} diff --git a/grpc/codegen/testdata/golden/client_types_client-streaming-result-with-views.go.golden b/grpc/codegen/testdata/golden/client_types_client-streaming-result-with-views.go.golden new file mode 100644 index 0000000000..7ad3ecec77 --- /dev/null +++ b/grpc/codegen/testdata/golden/client_types_client-streaming-result-with-views.go.golden @@ -0,0 +1,50 @@ +// NewProtoMethodServerStreamingUserTypeRPCRequest builds +// *service_server_streaming_user_type_rpcpb.MethodServerStreamingUserTypeRPCRequest +// from metadata values. +func NewProtoMethodServerStreamingUserTypeRPCRequest() *service_server_streaming_user_type_rpcpb.MethodServerStreamingUserTypeRPCRequest { + message := &service_server_streaming_user_type_rpcpb.MethodServerStreamingUserTypeRPCRequest{} + return message +} + +// NewMethodServerStreamingUserTypeRPCResponseResultTypeView builds +// *serviceserverstreamingusertyperpcviews.ResultTypeView from +// *service_server_streaming_user_type_rpcpb.MethodServerStreamingUserTypeRPCResponse. +func NewMethodServerStreamingUserTypeRPCResponseResultTypeView(v *service_server_streaming_user_type_rpcpb.MethodServerStreamingUserTypeRPCResponse) *serviceserverstreamingusertyperpcviews.ResultTypeView { + vresult := &serviceserverstreamingusertyperpcviews.ResultTypeView{ + DoubleField: v.DoubleField, + } + intField := int(*v.IntField) + vresult.IntField = &intField + return vresult +} + +// NewMethodServerStreamingUserTypeRPCResponseResultTypeViewTiny builds +// *serviceserverstreamingusertyperpcviews.ResultTypeView from +// *service_server_streaming_user_type_rpcpb.MethodServerStreamingUserTypeRPCResponse. +func NewMethodServerStreamingUserTypeRPCResponseResultTypeViewTiny(v *service_server_streaming_user_type_rpcpb.MethodServerStreamingUserTypeRPCResponse) *serviceserverstreamingusertyperpcviews.ResultTypeView { + vresult := &serviceserverstreamingusertyperpcviews.ResultTypeView{} + intField := int(*v.IntField) + vresult.IntField = &intField + return vresult +} + +// ValidateMethodServerStreamingUserTypeRPCResponseTiny runs the validations +// defined on MethodServerStreamingUserTypeRPCResponse. +func ValidateMethodServerStreamingUserTypeRPCResponseTiny(message *service_server_streaming_user_type_rpcpb.MethodServerStreamingUserTypeRPCResponse) (err error) { + if message.IntField == nil { + err = goa.MergeErrors(err, goa.MissingFieldError("IntField", "message")) + } + return +} + +// ValidateMethodServerStreamingUserTypeRPCResponse runs the validations +// defined on MethodServerStreamingUserTypeRPCResponse. +func ValidateMethodServerStreamingUserTypeRPCResponse(message *service_server_streaming_user_type_rpcpb.MethodServerStreamingUserTypeRPCResponse) (err error) { + if message.IntField == nil { + err = goa.MergeErrors(err, goa.MissingFieldError("IntField", "message")) + } + if message.DoubleField == nil { + err = goa.MergeErrors(err, goa.MissingFieldError("DoubleField", "message")) + } + return +} diff --git a/grpc/codegen/testdata/golden/client_types_client-struct-field-name-meta-type.go.golden b/grpc/codegen/testdata/golden/client_types_client-struct-field-name-meta-type.go.golden index 6614d43394..ea53d08fc3 100644 --- a/grpc/codegen/testdata/golden/client_types_client-struct-field-name-meta-type.go.golden +++ b/grpc/codegen/testdata/golden/client_types_client-struct-field-name-meta-type.go.golden @@ -1,5 +1,5 @@ -// NewProtoMethodRequest builds the gRPC request type from the payload of the -// "Method" endpoint of the "UsingMetaTypes" service. +// NewProtoMethodRequest builds *using_meta_typespb.MethodRequest from +// *usingmetatypes.MethodPayload. func NewProtoMethodRequest(payload *usingmetatypes.MethodPayload) *using_meta_typespb.MethodRequest { message := &using_meta_typespb.MethodRequest{ A: &payload.Foo, @@ -13,8 +13,8 @@ func NewProtoMethodRequest(payload *usingmetatypes.MethodPayload) *using_meta_ty return message } -// NewMethodResult builds the result type of the "Method" endpoint of the -// "UsingMetaTypes" service from the gRPC response type. +// NewMethodResult builds *usingmetatypes.MethodResult from +// *using_meta_typespb.MethodResponse. func NewMethodResult(message *using_meta_typespb.MethodResponse) *usingmetatypes.MethodResult { result := &usingmetatypes.MethodResult{} if message.A != nil { @@ -31,11 +31,3 @@ func NewMethodResult(message *using_meta_typespb.MethodResponse) *usingmetatypes } return result } - -// ValidateMethodResponse runs the validations defined on MethodResponse. -func ValidateMethodResponse(message *using_meta_typespb.MethodResponse) (err error) { - if message.B == nil { - err = goa.MergeErrors(err, goa.MissingFieldError("b", "message")) - } - return -} diff --git a/grpc/codegen/testdata/golden/client_types_client-struct-meta-type.go.golden b/grpc/codegen/testdata/golden/client_types_client-struct-meta-type.go.golden index 38bac435c2..5c557cb6eb 100644 --- a/grpc/codegen/testdata/golden/client_types_client-struct-meta-type.go.golden +++ b/grpc/codegen/testdata/golden/client_types_client-struct-meta-type.go.golden @@ -1,5 +1,5 @@ -// NewProtoMethodRequest builds the gRPC request type from the payload of the -// "Method" endpoint of the "UsingMetaTypes" service. +// NewProtoMethodRequest builds *using_meta_typespb.MethodRequest from +// *usingmetatypes.MethodPayload. func NewProtoMethodRequest(payload *usingmetatypes.MethodPayload) *using_meta_typespb.MethodRequest { message := &using_meta_typespb.MethodRequest{} a := int64(payload.A) @@ -19,8 +19,8 @@ func NewProtoMethodRequest(payload *usingmetatypes.MethodPayload) *using_meta_ty return message } -// NewMethodResult builds the result type of the "Method" endpoint of the -// "UsingMetaTypes" service from the gRPC response type. +// NewMethodResult builds *usingmetatypes.MethodResult from +// *using_meta_typespb.MethodResponse. func NewMethodResult(message *using_meta_typespb.MethodResponse) *usingmetatypes.MethodResult { result := &usingmetatypes.MethodResult{} if message.A != nil { diff --git a/grpc/codegen/testdata/golden/client_types_client-with-errors.go.golden b/grpc/codegen/testdata/golden/client_types_client-with-errors.go.golden index dc512d56b5..38051943f8 100644 --- a/grpc/codegen/testdata/golden/client_types_client-with-errors.go.golden +++ b/grpc/codegen/testdata/golden/client_types_client-with-errors.go.golden @@ -1,45 +1,44 @@ -// NewProtoMethodUnaryRPCWithErrorsRequest builds the gRPC request type from -// the payload of the "MethodUnaryRPCWithErrors" endpoint of the -// "ServiceUnaryRPCWithErrors" service. +// NewProtoMethodUnaryRPCWithErrorsRequest builds +// *service_unary_rpc_with_errorspb.MethodUnaryRPCWithErrorsRequest from string. func NewProtoMethodUnaryRPCWithErrorsRequest(payload string) *service_unary_rpc_with_errorspb.MethodUnaryRPCWithErrorsRequest { message := &service_unary_rpc_with_errorspb.MethodUnaryRPCWithErrorsRequest{} - message.Field = payload + message.Field = new(string) + *message.Field = payload return message } -// NewMethodUnaryRPCWithErrorsResult builds the result type of the -// "MethodUnaryRPCWithErrors" endpoint of the "ServiceUnaryRPCWithErrors" -// service from the gRPC response type. +// NewMethodUnaryRPCWithErrorsResult builds string from +// *service_unary_rpc_with_errorspb.MethodUnaryRPCWithErrorsResponse. func NewMethodUnaryRPCWithErrorsResult(message *service_unary_rpc_with_errorspb.MethodUnaryRPCWithErrorsResponse) string { - result := message.Field + result := *message.Field return result } -// NewMethodUnaryRPCWithErrorsInternalError builds the error type of the -// "MethodUnaryRPCWithErrors" endpoint of the "ServiceUnaryRPCWithErrors" -// service from the gRPC error response type. +// NewMethodUnaryRPCWithErrorsInternalError builds +// *serviceunaryrpcwitherrors.AnotherError from +// *service_unary_rpc_with_errorspb.MethodUnaryRPCWithErrorsInternalError. func NewMethodUnaryRPCWithErrorsInternalError(message *service_unary_rpc_with_errorspb.MethodUnaryRPCWithErrorsInternalError) *serviceunaryrpcwitherrors.AnotherError { er := &serviceunaryrpcwitherrors.AnotherError{ - Name: message.Name, + Name: *message.Name, Description: message.Description, } return er } -// NewMethodUnaryRPCWithErrorsBadRequestError builds the error type of the -// "MethodUnaryRPCWithErrors" endpoint of the "ServiceUnaryRPCWithErrors" -// service from the gRPC error response type. +// NewMethodUnaryRPCWithErrorsBadRequestError builds +// *serviceunaryrpcwitherrors.AnotherError from +// *service_unary_rpc_with_errorspb.MethodUnaryRPCWithErrorsBadRequestError. func NewMethodUnaryRPCWithErrorsBadRequestError(message *service_unary_rpc_with_errorspb.MethodUnaryRPCWithErrorsBadRequestError) *serviceunaryrpcwitherrors.AnotherError { er := &serviceunaryrpcwitherrors.AnotherError{ - Name: message.Name, + Name: *message.Name, Description: message.Description, } return er } -// NewMethodUnaryRPCWithErrorsCustomErrorError builds the error type of the -// "MethodUnaryRPCWithErrors" endpoint of the "ServiceUnaryRPCWithErrors" -// service from the gRPC error response type. +// NewMethodUnaryRPCWithErrorsCustomErrorError builds +// *serviceunaryrpcwitherrors.ErrorType from +// *service_unary_rpc_with_errorspb.MethodUnaryRPCWithErrorsCustomErrorError. func NewMethodUnaryRPCWithErrorsCustomErrorError(message *service_unary_rpc_with_errorspb.MethodUnaryRPCWithErrorsCustomErrorError) *serviceunaryrpcwitherrors.ErrorType { er := &serviceunaryrpcwitherrors.ErrorType{ A: message.A, @@ -47,11 +46,34 @@ func NewMethodUnaryRPCWithErrorsCustomErrorError(message *service_unary_rpc_with return er } +// ValidateMethodUnaryRPCWithErrorsRequest runs the validations defined on +// MethodUnaryRPCWithErrorsRequest. +func ValidateMethodUnaryRPCWithErrorsRequest(message *service_unary_rpc_with_errorspb.MethodUnaryRPCWithErrorsRequest) (err error) { + if message.Field == nil { + err = goa.MergeErrors(err, goa.MissingFieldError("field", "message")) + } + return +} + +// ValidateMethodUnaryRPCWithErrorsResponse runs the validations defined on +// MethodUnaryRPCWithErrorsResponse. +func ValidateMethodUnaryRPCWithErrorsResponse(message *service_unary_rpc_with_errorspb.MethodUnaryRPCWithErrorsResponse) (err error) { + if message.Field == nil { + err = goa.MergeErrors(err, goa.MissingFieldError("field", "message")) + } + return +} + // ValidateMethodUnaryRPCWithErrorsInternalError runs the validations defined // on MethodUnaryRPCWithErrorsInternalError. func ValidateMethodUnaryRPCWithErrorsInternalError(errmsg *service_unary_rpc_with_errorspb.MethodUnaryRPCWithErrorsInternalError) (err error) { - if !(errmsg.Name == "this" || errmsg.Name == "that") { - err = goa.MergeErrors(err, goa.InvalidEnumValueError("errmsg.name", errmsg.Name, []any{"this", "that"})) + if errmsg.Name == nil { + err = goa.MergeErrors(err, goa.MissingFieldError("name", "errmsg")) + } + if errmsg.Name != nil { + if !(*errmsg.Name == "this" || *errmsg.Name == "that") { + err = goa.MergeErrors(err, goa.InvalidEnumValueError("errmsg.name", *errmsg.Name, []any{"this", "that"})) + } } return } @@ -59,8 +81,13 @@ func ValidateMethodUnaryRPCWithErrorsInternalError(errmsg *service_unary_rpc_wit // ValidateMethodUnaryRPCWithErrorsBadRequestError runs the validations defined // on MethodUnaryRPCWithErrorsBadRequestError. func ValidateMethodUnaryRPCWithErrorsBadRequestError(errmsg *service_unary_rpc_with_errorspb.MethodUnaryRPCWithErrorsBadRequestError) (err error) { - if !(errmsg.Name == "this" || errmsg.Name == "that") { - err = goa.MergeErrors(err, goa.InvalidEnumValueError("errmsg.name", errmsg.Name, []any{"this", "that"})) + if errmsg.Name == nil { + err = goa.MergeErrors(err, goa.MissingFieldError("name", "errmsg")) + } + if errmsg.Name != nil { + if !(*errmsg.Name == "this" || *errmsg.Name == "that") { + err = goa.MergeErrors(err, goa.InvalidEnumValueError("errmsg.name", *errmsg.Name, []any{"this", "that"})) + } } return } diff --git a/grpc/codegen/testdata/golden/metadata_validation_client_codec.go.golden b/grpc/codegen/testdata/golden/metadata_validation_client_codec.go.golden new file mode 100644 index 0000000000..54cadd4169 --- /dev/null +++ b/grpc/codegen/testdata/golden/metadata_validation_client_codec.go.golden @@ -0,0 +1,75 @@ +// Code generated by goa, DO NOT EDIT. +// +// Metadata Validation gRPC client encoders and decoders +// +// Command: +// goa + +package client + +import ( + "context" + "unicode/utf8" + + metadata_validationpb "generated.local/gen/grpc/metadata_validation/pb" + metadatavalidation "generated.local/gen/metadata_validation" + goagrpc "goa.design/goa/v3/grpc" + goa "goa.design/goa/v3/pkg" + "google.golang.org/grpc" + "google.golang.org/grpc/metadata" +) + +// BuildCheckFunc builds the remote method to invoke for "Metadata Validation" +// service "Check" endpoint. +func BuildCheckFunc(grpccli metadata_validationpb.MetadataValidationClient, cliopts ...grpc.CallOption) goagrpc.RemoteFunc { + return func(ctx context.Context, reqpb any, opts ...grpc.CallOption) (any, error) { + for _, opt := range cliopts { + opts = append(opts, opt) + } + if reqpb != nil { + return grpccli.Check(ctx, reqpb.(*metadata_validationpb.CheckRequest), opts...) + } + return grpccli.Check(ctx, &metadata_validationpb.CheckRequest{}, opts...) + } +} + +// EncodeCheckRequest encodes requests sent to Metadata Validation Check +// endpoint. +func EncodeCheckRequest(ctx context.Context, v any, md *metadata.MD) (any, error) { + payload, ok := v.(*metadatavalidation.CheckPayload) + if !ok { + return nil, goagrpc.ErrInvalidType("Metadata Validation", "Check", "*metadatavalidation.CheckPayload", v) + } + requestWire := payload.Request + (*md).Append("x-request", requestWire) + return NewProtoCheckRequest(), nil +} + +// DecodeCheckResponse decodes responses from the Metadata Validation Check +// endpoint. +func DecodeCheckResponse(ctx context.Context, v any, hdr, trlr metadata.MD) (any, error) { + var ( + response string + err error + ) + { + + if vals := hdr.Get("x-response"); len(vals) == 0 { + err = goa.MergeErrors(err, goa.MissingFieldError("x-response", "metadata")) + } else { + response = vals[0] + } + if utf8.RuneCountInString(response) < 3 { + err = goa.MergeErrors(err, goa.InvalidLengthError("response", response, utf8.RuneCountInString(response), 3, true)) + } + } + if err != nil { + return nil, err + } + _, ok := v.(*metadata_validationpb.CheckResponse) + if !ok { + return nil, goagrpc.ErrInvalidType("Metadata Validation", "Check", "*metadata_validationpb.CheckResponse", v) + } + res := NewCheckResult(response) + return res, nil +} diff --git a/grpc/codegen/testdata/golden/metadata_validation_server_codec.go.golden b/grpc/codegen/testdata/golden/metadata_validation_server_codec.go.golden new file mode 100644 index 0000000000..002107d87b --- /dev/null +++ b/grpc/codegen/testdata/golden/metadata_validation_server_codec.go.golden @@ -0,0 +1,59 @@ +// Code generated by goa, DO NOT EDIT. +// +// Metadata Validation gRPC server encoders and decoders +// +// Command: +// goa + +package server + +import ( + "context" + "unicode/utf8" + + metadatavalidation "generated.local/gen/metadata_validation" + goagrpc "goa.design/goa/v3/grpc" + goa "goa.design/goa/v3/pkg" + "google.golang.org/grpc/metadata" +) + +// EncodeCheckResponse encodes responses from the "Metadata Validation" service +// "Check" endpoint. +func EncodeCheckResponse(ctx context.Context, v any, hdr, trlr *metadata.MD) (any, error) { + result, ok := v.(*metadatavalidation.CheckResult) + if !ok { + return nil, goagrpc.ErrInvalidType("Metadata Validation", "Check", "*metadatavalidation.CheckResult", v) + } + resp := NewProtoCheckResult() + + responseWire := result.Response + (*hdr).Append("x-response", responseWire) + return resp, nil +} + +// DecodeCheckRequest decodes requests sent to "Metadata Validation" service +// "Check" endpoint. +func DecodeCheckRequest(ctx context.Context, v any, md metadata.MD) (any, error) { + var ( + request string + err error + ) + { + if vals := md.Get("x-request"); len(vals) == 0 { + err = goa.MergeErrors(err, goa.MissingFieldError("x-request", "metadata")) + } else { + request = vals[0] + } + if utf8.RuneCountInString(request) < 2 { + err = goa.MergeErrors(err, goa.InvalidLengthError("request", request, utf8.RuneCountInString(request), 2, true)) + } + } + if err != nil { + return nil, err + } + var payload *metadatavalidation.CheckPayload + { + payload = NewCheckPayload(request) + } + return payload, nil +} diff --git a/grpc/codegen/testdata/golden/planned_name_collisions.go.golden b/grpc/codegen/testdata/golden/planned_name_collisions.go.golden new file mode 100644 index 0000000000..5b071d67dc --- /dev/null +++ b/grpc/codegen/testdata/golden/planned_name_collisions.go.golden @@ -0,0 +1,289 @@ +// BuildWatchFunc2 builds the remote method to invoke for "SavedTransport" +// service "Watch" endpoint. +func BuildWatchFunc2(grpccli saved_transportpb.SavedTransportClient, cliopts ...grpc.CallOption) goagrpc.RemoteFunc { + return func(ctx context.Context, reqpb any, opts ...grpc.CallOption) (any, error) { + for _, opt := range cliopts { + opts = append(opts, opt) + } + stream, err := grpccli.Watch(ctx, opts...) + if err != nil { + return nil, err + } + if reqpb != nil { + if err := stream.Send(reqpb.(*saved_transportpb.WatchStreamingRequest)); err != nil { + return nil, err + } + } + return stream, nil + } +} + +// EncodeWatchRequest2 encodes requests sent to SavedTransport Watch endpoint. +func EncodeWatchRequest2(ctx context.Context, v any, md *metadata.MD) (any, error) { + payload, ok := v.(*savedtransport.SavedPayload) + if !ok { + return nil, goagrpc.ErrInvalidType("SavedTransport", "Watch", "*savedtransport.SavedPayload", v) + } + tokenWire := payload.Token + (*md).Append("authorization", tokenWire) + (*md).Append(goagrpc.StreamProtocolMetadataKey, goagrpc.StreamProtocolEnvelope) + message := NewProtoWatchRequest2(payload) + return &saved_transportpb.WatchStreamingRequest{ + Body: &saved_transportpb.WatchStreamingRequest_InitialPayload{ + InitialPayload: message, + }, + }, nil +} + +// DecodeWatchResponse2 decodes responses from the SavedTransport Watch +// endpoint. +func DecodeWatchResponse2(ctx context.Context, v any, hdr, trlr metadata.MD) (any, error) { + var ( + count int + value string + err error + ) + { + + if vals := hdr.Get("x-count"); len(vals) == 0 { + err = goa.MergeErrors(err, goa.MissingFieldError("x-count", "metadata")) + } else { + countRaw := vals[0] + + v, err2 := strconv.ParseInt(countRaw, 10, strconv.IntSize) + if err2 != nil { + err = goa.MergeErrors(err, goa.InvalidFieldTypeError("count", countRaw, "integer")) + } + count = int(v) + } + + if vals := trlr.Get("x-value"); len(vals) == 0 { + err = goa.MergeErrors(err, goa.MissingFieldError("x-value", "metadata")) + } else { + value = vals[0] + } + } + if err != nil { + return nil, err + } + return &WatchClientStream2{ + stream: v.(saved_transportpb.SavedTransport_WatchClient), + ctx: ctx, + }, nil +} + +// Watch calls the "Watch" function in saved_transportpb.SavedTransportClient +// interface. +func (c *Client) Watch() goa.Endpoint { + return func(ctx context.Context, v any) (any, error) { + inv := goagrpc.NewInvoker( + BuildWatchFunc2(c.grpccli, c.opts...), + EncodeWatchRequest2, + DecodeWatchResponse2) + res, err := inv.Invoke(ctx, v) + if err != nil { + // Decode a Goa error detail before returning a matching context error or falling back to Fault. + resp := goagrpc.DecodeError(err) + if eresp, ok := resp.(*goapb.ErrorResponse); ok { + return nil, goagrpc.NewServiceError(eresp) + } + if ctxErr := goagrpc.ContextError(ctx, err); ctxErr != nil { + return nil, ctxErr + } + return nil, goa.Fault("%s", err.Error()) + } + return res, nil + } +} + +// WatchClientStream2 implements the savedtransport.WatchClientStream interface. +type WatchClientStream2 struct { + stream saved_transportpb.SavedTransport_WatchClient + ctx context.Context +} + +// DecodeWatchRequest2 decodes requests sent to "SavedTransport" service +// "Watch" endpoint. +func DecodeWatchRequest2(ctx context.Context, v any, md metadata.MD) (any, error) { + var ( + token string + err error + ) + { + if vals := md.Get("authorization"); len(vals) == 0 { + err = goa.MergeErrors(err, goa.MissingFieldError("authorization", "metadata")) + } else { + token = vals[0] + } + } + if err != nil { + return nil, err + } + var ( + message *saved_transportpb.WatchRequest + ok bool + ) + { + if v == nil { + return nil, goa.MissingFieldError("initial_payload", "stream") + } + var envelope *saved_transportpb.WatchStreamingRequest + if envelope, ok = v.(*saved_transportpb.WatchStreamingRequest); !ok { + return nil, goagrpc.ErrInvalidType("SavedTransport", "Watch", "*saved_transportpb.WatchStreamingRequest", v) + } + switch body := envelope.Body.(type) { + case *saved_transportpb.WatchStreamingRequest_InitialPayload: + if body.InitialPayload == nil { + return nil, goa.MissingFieldError("initial_payload", "stream") + } + message = body.InitialPayload + case *saved_transportpb.WatchStreamingRequest_StreamItem: + return nil, goa.InvalidFieldTypeError("body", "stream_item", "initial_payload") + default: + return nil, goa.MissingFieldError("initial_payload", "stream") + } + if err = ValidateWatchRequest2(message); err != nil { + return nil, err + } + } + var payload *savedtransport.SavedPayload + { + payload = NewWatchPayload(message, token) + } + return payload, nil +} + +// EncodeWatchResponse2 encodes responses from the "SavedTransport" service +// "Watch" endpoint. +func EncodeWatchResponse2(ctx context.Context, v any, hdr, trlr *metadata.MD) (any, error) { + result, ok := v.(*savedtransport.SavedResult) + if !ok { + return nil, goagrpc.ErrInvalidType("SavedTransport", "Watch", "*savedtransport.SavedResult", v) + } + resp := NewProtoSavedResult() + + countWire := result.Count + (*hdr).Append("x-count", strconv.Itoa(countWire)) + + valueWire := result.Value + (*trlr).Append("x-value", valueWire) + return resp, nil +} + +// NewWatchHandler2 creates a gRPC handler which serves the "SavedTransport" +// service "Watch" endpoint. +func NewWatchHandler2(endpoint goa.Endpoint, h goagrpc.StreamHandler) goagrpc.StreamHandler { + if h == nil { + h = goagrpc.NewStreamHandler(endpoint, DecodeWatchRequest2) + } + return h +} + +// Watch implements the "Watch" method in +// saved_transportpb.SavedTransportServer interface. +func (s *Server) Watch(stream saved_transportpb.SavedTransport_WatchServer) error { + ctx := stream.Context() + ctx = context.WithValue(ctx, goa.MethodKey, "Watch") + ctx = context.WithValue(ctx, goa.ServiceKey, "SavedTransport") + var reqpb any + message, err := stream.Recv() + if err != nil { + if errors.Is(err, io.EOF) { + reqpb = nil + } else { + return goagrpc.EncodeError(err) + } + } else { + reqpb = message + } + p, err := s.WatchH.Decode(ctx, reqpb) + if err != nil { + return goagrpc.EncodeError(err) + } + ep := &savedtransport.WatchEndpointInput{ + Stream: &WatchServerStream2{stream: stream}, + Payload: p.(*savedtransport.SavedPayload), + } + err = s.WatchH.Handle(ctx, ep) + if err != nil { + return goagrpc.EncodeError(err) + } + return nil +} + +// WatchServerStream2 implements the savedtransport.WatchServerStream interface. +type WatchServerStream2 struct { + stream saved_transportpb.SavedTransport_WatchServer +} + +// NewProtoWatchRequest2 builds *saved_transportpb.WatchRequest from +// *savedtransport.SavedPayload. +func NewProtoWatchRequest2(payload *savedtransport.SavedPayload) *saved_transportpb.WatchRequest { + message := &saved_transportpb.WatchRequest{ + Value: &payload.Value, + } + return message +} + +// NewWatchResponseSavedResult builds *savedtransport.SavedResult from metadata +// values. +func NewWatchResponseSavedResult() *savedtransport.SavedResult { + result := &savedtransport.SavedResult{} + return result +} + +// NewProtoSavedStreamWatchStreamItem builds *saved_transportpb.WatchStreamItem +// from *savedtransport.SavedStream. +func NewProtoSavedStreamWatchStreamItem(spayload *savedtransport.SavedStream) *saved_transportpb.WatchStreamItem { + v := &saved_transportpb.WatchStreamItem{ + Value: &spayload.Value, + } + return v +} + +// NewWatchPayload builds *savedtransport.SavedPayload from +// *saved_transportpb.WatchRequest. +func NewWatchPayload(message *saved_transportpb.WatchRequest, token string) *savedtransport.SavedPayload { + v := &savedtransport.SavedPayload{ + Value: *message.Value, + } + v.Token = token + return v +} + +// NewProtoSavedResult builds *saved_transportpb.WatchResponse from +// *savedtransport.SavedResult. +func NewProtoSavedResult() *saved_transportpb.WatchResponse { + message := &saved_transportpb.WatchResponse{} + return message +} + +// NewWatchStreamItemSavedStream builds *savedtransport.SavedStream from +// *saved_transportpb.WatchStreamItem. +func NewWatchStreamItemSavedStream(v *saved_transportpb.WatchStreamItem) *savedtransport.SavedStream { + spayload := &savedtransport.SavedStream{ + Value: *v.Value, + } + return spayload +} + +// ValidateWatchRequest2 runs the validations defined on WatchRequest. +func ValidateWatchRequest2(message *saved_transportpb.WatchRequest) (err error) { + if message.Value == nil { + err = goa.MergeErrors(err, goa.MissingFieldError("value", "message")) + } + if message.Value != nil { + if utf8.RuneCountInString(*message.Value) < 2 { + err = goa.MergeErrors(err, goa.InvalidLengthError("message.value", *message.Value, utf8.RuneCountInString(*message.Value), 2, true)) + } + } + return +} + +// ValidateWatchStreamItem runs the validations defined on WatchStreamItem. +func ValidateWatchStreamItem(stream *saved_transportpb.WatchStreamItem) (err error) { + if stream.Value == nil { + err = goa.MergeErrors(err, goa.MissingFieldError("value", "stream")) + } + return +} diff --git a/grpc/codegen/testdata/golden/proto_primitive.proto.golden b/grpc/codegen/testdata/golden/proto_primitive.proto.golden index 0b4dae1844..c1a1c88e88 100644 --- a/grpc/codegen/testdata/golden/proto_primitive.proto.golden +++ b/grpc/codegen/testdata/golden/proto_primitive.proto.golden @@ -12,9 +12,9 @@ package service_message_primitive; option go_package = "/service_message_primitivepb"; message MethodMessagePrimitiveRequest { - uint32 field = 1; + optional uint32 field = 1; } message MethodMessagePrimitiveResponse { - sint32 field = 1; + optional sint32 field = 1; } diff --git a/grpc/codegen/testdata/golden/proto_protofiles-bidirectional-streaming-rpc.proto.golden b/grpc/codegen/testdata/golden/proto_protofiles-bidirectional-streaming-rpc.proto.golden index 78a2c86c73..2b2eee925a 100644 --- a/grpc/codegen/testdata/golden/proto_protofiles-bidirectional-streaming-rpc.proto.golden +++ b/grpc/codegen/testdata/golden/proto_protofiles-bidirectional-streaming-rpc.proto.golden @@ -12,7 +12,7 @@ service ServiceBidirectionalStreamingRPC { } message MethodBidirectionalStreamingRPCStreamingRequest { - sint32 field = 1; + optional sint32 field = 1; } message MethodBidirectionalStreamingRPCResponse { diff --git a/grpc/codegen/testdata/golden/proto_protofiles-client-streaming-rpc.proto.golden b/grpc/codegen/testdata/golden/proto_protofiles-client-streaming-rpc.proto.golden index 3e93ce7f10..7cba623316 100644 --- a/grpc/codegen/testdata/golden/proto_protofiles-client-streaming-rpc.proto.golden +++ b/grpc/codegen/testdata/golden/proto_protofiles-client-streaming-rpc.proto.golden @@ -12,9 +12,9 @@ service ServiceClientStreamingRPC { } message MethodClientStreamingRPCStreamingRequest { - sint32 field = 1; + optional sint32 field = 1; } message MethodClientStreamingRPCResponse { - string field = 1; + optional string field = 1; } diff --git a/grpc/codegen/testdata/golden/proto_protofiles-default-fields.proto.golden b/grpc/codegen/testdata/golden/proto_protofiles-default-fields.proto.golden index 33cabea86c..bfd3df6ae4 100644 --- a/grpc/codegen/testdata/golden/proto_protofiles-default-fields.proto.golden +++ b/grpc/codegen/testdata/golden/proto_protofiles-default-fields.proto.golden @@ -12,16 +12,16 @@ service DefaultFields { } message MethodRequest { - sint64 req = 1; + optional sint64 req = 1; optional sint64 opt = 2; optional sint64 def0 = 3; optional sint64 def1 = 4; optional sint64 def2 = 5; - string reqs = 6; + optional string reqs = 6; optional string opts = 7; optional string defs = 8; optional string defe = 9; - double rat = 10; + optional double rat = 10; optional double flt = 11; optional double flt0 = 12; optional double flt1 = 13; diff --git a/grpc/codegen/testdata/golden/proto_protofiles-distinct-custom-message-names.proto.golden b/grpc/codegen/testdata/golden/proto_protofiles-distinct-custom-message-names.proto.golden new file mode 100644 index 0000000000..214a75cdb0 --- /dev/null +++ b/grpc/codegen/testdata/golden/proto_protofiles-distinct-custom-message-names.proto.golden @@ -0,0 +1,22 @@ + +syntax = "proto3"; + +package distinct_custom_message_names; + +option go_package = "/distinct_custom_message_namespb"; + +// Service is the DistinctCustomMessageNames service interface. +service DistinctCustomMessageNames { + // UseFirst implements UseFirst. + rpc UseFirst (Shared) returns (Shared); + // UseSecond implements UseSecond. + rpc UseSecond (Shared2) returns (Shared2); +} + +message Shared { + optional string value = 1; +} + +message Shared2 { + optional string value = 1; +} diff --git a/grpc/codegen/testdata/golden/proto_protofiles-required-primitive-presence.proto.golden b/grpc/codegen/testdata/golden/proto_protofiles-required-primitive-presence.proto.golden new file mode 100644 index 0000000000..6f6d9aa9ac --- /dev/null +++ b/grpc/codegen/testdata/golden/proto_protofiles-required-primitive-presence.proto.golden @@ -0,0 +1,39 @@ + +syntax = "proto3"; + +package required_primitive_presence; + +option go_package = "/required_primitive_presencepb"; +import "google/protobuf/struct.proto"; + +// Service is the RequiredPrimitivePresence service interface. +service RequiredPrimitivePresence { + // Exchange implements Exchange. + rpc Exchange (ExchangeRequest) returns (ExchangeResponse); + // Echo implements Echo. + rpc Echo (StringAlias) returns (StringAlias); +} + +message ExchangeRequest { + optional bool boolean_field = 1; + optional sint32 integer_field = 2; + optional string string_field = 3; + optional bytes bytes_field = 4; + optional string string_alias_field = 5; + optional bytes bytes_alias_field = 6; + google.protobuf.Value any_alias_field = 7; +} + +message ExchangeResponse { + optional bool boolean_field = 1; + optional sint32 integer_field = 2; + optional string string_field = 3; + optional bytes bytes_field = 4; + optional string string_alias_field = 5; + optional bytes bytes_alias_field = 6; + google.protobuf.Value any_alias_field = 7; +} + +message StringAlias { + optional string field = 1; +} diff --git a/grpc/codegen/testdata/golden/proto_protofiles-server-streaming-rpc.proto.golden b/grpc/codegen/testdata/golden/proto_protofiles-server-streaming-rpc.proto.golden index 16b453ad65..dd3a633eb9 100644 --- a/grpc/codegen/testdata/golden/proto_protofiles-server-streaming-rpc.proto.golden +++ b/grpc/codegen/testdata/golden/proto_protofiles-server-streaming-rpc.proto.golden @@ -12,9 +12,9 @@ service ServiceServerStreamingRPC { } message MethodServerStreamingRPCRequest { - sint32 field = 1; + optional sint32 field = 1; } message MethodServerStreamingRPCResponse { - string field = 1; + optional string field = 1; } diff --git a/grpc/codegen/testdata/golden/proto_protofiles-unary-rpc-no-payload.proto.golden b/grpc/codegen/testdata/golden/proto_protofiles-unary-rpc-no-payload.proto.golden index 50a6c6dce0..39110886aa 100644 --- a/grpc/codegen/testdata/golden/proto_protofiles-unary-rpc-no-payload.proto.golden +++ b/grpc/codegen/testdata/golden/proto_protofiles-unary-rpc-no-payload.proto.golden @@ -15,5 +15,5 @@ message MethodUnaryRPCNoPayloadRequest { } message MethodUnaryRPCNoPayloadResponse { - string field = 1; + optional string field = 1; } diff --git a/grpc/codegen/testdata/golden/proto_user-type-with-alias.proto.golden b/grpc/codegen/testdata/golden/proto_user-type-with-alias.proto.golden index 9eee5305b2..6e5c94d0bf 100644 --- a/grpc/codegen/testdata/golden/proto_user-type-with-alias.proto.golden +++ b/grpc/codegen/testdata/golden/proto_user-type-with-alias.proto.golden @@ -12,7 +12,7 @@ package service_message_user_type_with_alias; option go_package = "/service_message_user_type_with_aliaspb"; message MethodMessageUserTypeWithAliasRequest { - sint32 int_alias_field = 1; + optional sint32 int_alias_field = 1; optional sint32 optional_int_alias_field = 2; } diff --git a/grpc/codegen/testdata/golden/protobuf_type_encode_to-protobuf-type_composite-to-custom-field.go.golden b/grpc/codegen/testdata/golden/protobuf_type_encode_to-protobuf-type_composite-to-custom-field.go.golden index 446bc8da68..9c3c779107 100644 --- a/grpc/codegen/testdata/golden/protobuf_type_encode_to-protobuf-type_composite-to-custom-field.go.golden +++ b/grpc/codegen/testdata/golden/protobuf_type_encode_to-protobuf-type_composite-to-custom-field.go.golden @@ -1,13 +1,10 @@ func transform() { - target := &proto.CompositeWithCustomField{} - if source.RequiredString != nil { - target.RequiredString = *source.RequiredString + target := &proto.CompositeWithCustomField{ + RequiredString: source.RequiredString, } if source.DefaultInt != nil { - target.DefaultInt = int32(*source.DefaultInt) - } - if source.DefaultInt == nil { - target.DefaultInt = 100 + defaultInt := int32(*source.DefaultInt) + target.DefaultInt = &defaultInt } if source.Type != nil { target.Type = svcProtoSimpleToProtoSimple(source.Type) diff --git a/grpc/codegen/testdata/golden/protobuf_type_encode_to-protobuf-type_customtype-to-customtype.go.golden b/grpc/codegen/testdata/golden/protobuf_type_encode_to-protobuf-type_customtype-to-customtype.go.golden index 24b984eba9..f25c8ebf5b 100644 --- a/grpc/codegen/testdata/golden/protobuf_type_encode_to-protobuf-type_customtype-to-customtype.go.golden +++ b/grpc/codegen/testdata/golden/protobuf_type_encode_to-protobuf-type_customtype-to-customtype.go.golden @@ -1,16 +1,11 @@ func transform() { - target := &proto.CustomTypes{ - RequiredString: string(source.RequiredString), - DefaultBool: bool(source.DefaultBool), - } + target := &proto.CustomTypes{} + requiredString := string(source.RequiredString) + target.RequiredString = &requiredString + defaultBool := bool(source.DefaultBool) + target.DefaultBool = &defaultBool if source.Integer != nil { integer := int32(*source.Integer) target.Integer = &integer } - { - var zero bool - if target.DefaultBool == zero { - target.DefaultBool = true - } - } } diff --git a/grpc/codegen/testdata/golden/protobuf_type_encode_to-protobuf-type_default-array-to-default-array.go.golden b/grpc/codegen/testdata/golden/protobuf_type_encode_to-protobuf-type_default-array-to-default-array.go.golden index c7d758cecb..85f0407533 100644 --- a/grpc/codegen/testdata/golden/protobuf_type_encode_to-protobuf-type_default-array-to-default-array.go.golden +++ b/grpc/codegen/testdata/golden/protobuf_type_encode_to-protobuf-type_default-array-to-default-array.go.golden @@ -6,7 +6,4 @@ func transform() { target.StringArray[i] = val } } - if source.StringArray == nil { - target.StringArray = []string{"foo", "bar"} - } } diff --git a/grpc/codegen/testdata/golden/protobuf_type_encode_to-protobuf-type_default-map-to-default-map.go.golden b/grpc/codegen/testdata/golden/protobuf_type_encode_to-protobuf-type_default-map-to-default-map.go.golden index 58544abe58..3f6893337a 100644 --- a/grpc/codegen/testdata/golden/protobuf_type_encode_to-protobuf-type_default-map-to-default-map.go.golden +++ b/grpc/codegen/testdata/golden/protobuf_type_encode_to-protobuf-type_default-map-to-default-map.go.golden @@ -8,7 +8,4 @@ func transform() { target.Simple[tk] = tv } } - if source.Simple == nil { - target.Simple = map[string]int{"foo": 1} - } } diff --git a/grpc/codegen/testdata/golden/protobuf_type_encode_to-protobuf-type_default-to-simple.go.golden b/grpc/codegen/testdata/golden/protobuf_type_encode_to-protobuf-type_default-to-simple.go.golden index a1e1710acb..2b1c0b1bb3 100644 --- a/grpc/codegen/testdata/golden/protobuf_type_encode_to-protobuf-type_default-to-simple.go.golden +++ b/grpc/codegen/testdata/golden/protobuf_type_encode_to-protobuf-type_default-to-simple.go.golden @@ -1,14 +1,8 @@ func transform() { target := &proto.Simple{ - RequiredString: source.RequiredString, - DefaultBool: source.DefaultBool, + RequiredString: &source.RequiredString, + DefaultBool: &source.DefaultBool, } integer := int32(source.Integer) target.Integer = &integer - { - var zero bool - if target.DefaultBool == zero { - target.DefaultBool = true - } - } } diff --git a/grpc/codegen/testdata/golden/protobuf_type_encode_to-protobuf-type_defaults-to-defaults.go.golden b/grpc/codegen/testdata/golden/protobuf_type_encode_to-protobuf-type_defaults-to-defaults.go.golden index f22dc74bba..2e02ddecda 100644 --- a/grpc/codegen/testdata/golden/protobuf_type_encode_to-protobuf-type_defaults-to-defaults.go.golden +++ b/grpc/codegen/testdata/golden/protobuf_type_encode_to-protobuf-type_defaults-to-defaults.go.golden @@ -1,53 +1,24 @@ func transform() { target := &proto.WithDefaults{ - Int: int32(source.Int), - RawJson: string(source.RawJSON), - RequiredInt: int32(source.RequiredInt), - String_: source.String, - RequiredString: source.RequiredString, + String_: &source.String, + RequiredString: &source.RequiredString, Bytes_: source.Bytes, RequiredBytes: source.RequiredBytes, - Any: source.Any, - RequiredAny: source.RequiredAny, - } - { - var zero int32 - if target.Int == zero { - target.Int = 100 - } - } - { - var zero string - if target.RawJson == zero { - target.RawJson = json.RawMessage{0x66, 0x6f, 0x6f} - } - } - { - var zero string - if target.String_ == zero { - target.String_ = "foo" - } - } - { - if target.Bytes_ == nil { - target.Bytes_ = []byte{0x66, 0x6f, 0x6f, 0x62, 0x61, 0x72} - } - } - { - var zero string - if target.Any == zero { - target.Any = "something" - } - } + Any: &source.Any, + RequiredAny: &source.RequiredAny, + } + int_ := int32(source.Int) + target.Int = &int_ + rawJSON := string(source.RawJSON) + target.RawJson = &rawJSON + requiredInt := int32(source.RequiredInt) + target.RequiredInt = &requiredInt if source.Array != nil { target.Array = make([]string, len(source.Array)) for i, val := range source.Array { target.Array[i] = val } } - if source.Array == nil { - target.Array = []string{"foo", "bar"} - } if source.RequiredArray != nil { target.RequiredArray = make([]string, len(source.RequiredArray)) for i, val := range source.RequiredArray { @@ -62,9 +33,6 @@ func transform() { target.Map_[tk] = tv } } - if source.Map == nil { - target.Map_ = map[int]string{1: "foo"} - } if source.RequiredMap != nil { target.RequiredMap = make(map[int32]string, len(source.RequiredMap)) for key, val := range source.RequiredMap { diff --git a/grpc/codegen/testdata/golden/protobuf_type_encode_to-protobuf-type_embedded-oneof-to-embedded-oneof.go.golden b/grpc/codegen/testdata/golden/protobuf_type_encode_to-protobuf-type_embedded-oneof-to-embedded-oneof.go.golden index f096a8fa5e..a6db5e3516 100644 --- a/grpc/codegen/testdata/golden/protobuf_type_encode_to-protobuf-type_embedded-oneof-to-embedded-oneof.go.golden +++ b/grpc/codegen/testdata/golden/protobuf_type_encode_to-protobuf-type_embedded-oneof-to-embedded-oneof.go.golden @@ -6,7 +6,7 @@ func transform() { switch string(source.EmbeddedOneOf.Kind()) { case "string": actual, _ := source.EmbeddedOneOf.AsString() - target.EmbeddedOneOf = &proto.EmbeddedOneOf_String_{String_: string(actual)} + target.EmbeddedOneOf = &proto.EmbeddedOneOf_String_2{String_2: string(actual)} case "integer": actual, _ := source.EmbeddedOneOf.AsInteger() target.EmbeddedOneOf = &proto.EmbeddedOneOf_Integer{Integer: int32(actual)} @@ -18,10 +18,10 @@ func transform() { target.EmbeddedOneOf = &proto.EmbeddedOneOf_Number{Number: int32(actual)} case "array": actual, _ := source.EmbeddedOneOf.AsArray() - target.EmbeddedOneOf = &proto.EmbeddedOneOf_Array{Array: svcProtoEmbeddedOneOfArrayToProtoEmbeddedOneOfArray(actual)} + target.EmbeddedOneOf = &proto.EmbeddedOneOf_Array{Array: svcProtoEmbeddedOneOfArrayToProtoEmbeddedOneOfArray2(actual)} case "map": actual, _ := source.EmbeddedOneOf.AsMap() - target.EmbeddedOneOf = &proto.EmbeddedOneOf_Map_{Map_: svcProtoEmbeddedOneOfMapToProtoEmbeddedOneOfMap(actual)} + target.EmbeddedOneOf = &proto.EmbeddedOneOf_Map_{Map_: svcProtoEmbeddedOneOfMapToProtoEmbeddedOneOfMap2(actual)} case "user_type": actual, _ := source.EmbeddedOneOf.AsUserType() target.EmbeddedOneOf = &proto.EmbeddedOneOf_UserType{UserType: svcProtoSimpleOneOfToProtoSimpleOneOf(actual)} diff --git a/grpc/codegen/testdata/golden/protobuf_type_encode_to-protobuf-type_primitive-to-primitive.go.golden b/grpc/codegen/testdata/golden/protobuf_type_encode_to-protobuf-type_primitive-to-primitive.go.golden index e83180901f..c2e9be060a 100644 --- a/grpc/codegen/testdata/golden/protobuf_type_encode_to-protobuf-type_primitive-to-primitive.go.golden +++ b/grpc/codegen/testdata/golden/protobuf_type_encode_to-protobuf-type_primitive-to-primitive.go.golden @@ -1,4 +1,5 @@ func transform() { target := &proto.Int{} - target.Field = int32(source) + target.Field = new(int32) + *target.Field = int32(source) } diff --git a/grpc/codegen/testdata/golden/protobuf_type_encode_to-protobuf-type_recursive-to-recursive.go.golden b/grpc/codegen/testdata/golden/protobuf_type_encode_to-protobuf-type_recursive-to-recursive.go.golden index 4d6e6d5f3d..1786d87350 100644 --- a/grpc/codegen/testdata/golden/protobuf_type_encode_to-protobuf-type_recursive-to-recursive.go.golden +++ b/grpc/codegen/testdata/golden/protobuf_type_encode_to-protobuf-type_recursive-to-recursive.go.golden @@ -1,6 +1,6 @@ func transform() { target := &proto.Recursive{ - RequiredString: source.RequiredString, + RequiredString: &source.RequiredString, } if source.Recursive != nil { target.Recursive = svcProtoRecursiveToProtoRecursive(source.Recursive) diff --git a/grpc/codegen/testdata/golden/protobuf_type_encode_to-protobuf-type_required-ptr-to-simple.go.golden b/grpc/codegen/testdata/golden/protobuf_type_encode_to-protobuf-type_required-ptr-to-simple.go.golden index 5b55ecfd7c..d9de1abaf1 100644 --- a/grpc/codegen/testdata/golden/protobuf_type_encode_to-protobuf-type_required-ptr-to-simple.go.golden +++ b/grpc/codegen/testdata/golden/protobuf_type_encode_to-protobuf-type_required-ptr-to-simple.go.golden @@ -1,7 +1,7 @@ func transform() { target := &proto.Simple{ - RequiredString: *source.RequiredString, - DefaultBool: *source.DefaultBool, + RequiredString: source.RequiredString, + DefaultBool: source.DefaultBool, } integer := int32(*source.Integer) target.Integer = &integer diff --git a/grpc/codegen/testdata/golden/protobuf_type_encode_to-protobuf-type_required-to-simple.go.golden b/grpc/codegen/testdata/golden/protobuf_type_encode_to-protobuf-type_required-to-simple.go.golden index a6f4533025..2b1c0b1bb3 100644 --- a/grpc/codegen/testdata/golden/protobuf_type_encode_to-protobuf-type_required-to-simple.go.golden +++ b/grpc/codegen/testdata/golden/protobuf_type_encode_to-protobuf-type_required-to-simple.go.golden @@ -1,7 +1,7 @@ func transform() { target := &proto.Simple{ - RequiredString: source.RequiredString, - DefaultBool: source.DefaultBool, + RequiredString: &source.RequiredString, + DefaultBool: &source.DefaultBool, } integer := int32(source.Integer) target.Integer = &integer diff --git a/grpc/codegen/testdata/golden/protobuf_type_encode_to-protobuf-type_result-type-collection-to-result-type-collection.go.golden b/grpc/codegen/testdata/golden/protobuf_type_encode_to-protobuf-type_result-type-collection-to-result-type-collection.go.golden index 372d2c3c91..16ec026b91 100644 --- a/grpc/codegen/testdata/golden/protobuf_type_encode_to-protobuf-type_result-type-collection-to-result-type-collection.go.golden +++ b/grpc/codegen/testdata/golden/protobuf_type_encode_to-protobuf-type_result-type-collection-to-result-type-collection.go.golden @@ -1,7 +1,7 @@ func transform() { target := &proto.ResultTypeCollection{} if source.Collection != nil { - target.Collection = &proto.ResultTypeCollection{} + target.Collection = &proto.ResultTypeCollection2{} target.Collection.Field = make([]*proto.ResultType, len(source.Collection)) for i, val := range source.Collection { target.Collection.Field[i] = &proto.ResultType{} diff --git a/grpc/codegen/testdata/golden/protobuf_type_encode_to-protobuf-type_simple-to-customtype.go.golden b/grpc/codegen/testdata/golden/protobuf_type_encode_to-protobuf-type_simple-to-customtype.go.golden index 17f03a054e..03c100d888 100644 --- a/grpc/codegen/testdata/golden/protobuf_type_encode_to-protobuf-type_simple-to-customtype.go.golden +++ b/grpc/codegen/testdata/golden/protobuf_type_encode_to-protobuf-type_simple-to-customtype.go.golden @@ -1,16 +1,11 @@ func transform() { - target := &proto.Simple{ - RequiredString: string(source.RequiredString), - DefaultBool: bool(source.DefaultBool), - } + target := &proto.Simple{} + requiredString := string(source.RequiredString) + target.RequiredString = &requiredString + defaultBool := bool(source.DefaultBool) + target.DefaultBool = &defaultBool if source.Integer != nil { integer := int32(*source.Integer) target.Integer = &integer } - { - var zero bool - if target.DefaultBool == zero { - target.DefaultBool = true - } - } } diff --git a/grpc/codegen/testdata/golden/protobuf_type_encode_to-protobuf-type_simple-to-default.go.golden b/grpc/codegen/testdata/golden/protobuf_type_encode_to-protobuf-type_simple-to-default.go.golden index b4d4533db3..8b94e35640 100644 --- a/grpc/codegen/testdata/golden/protobuf_type_encode_to-protobuf-type_simple-to-default.go.golden +++ b/grpc/codegen/testdata/golden/protobuf_type_encode_to-protobuf-type_simple-to-default.go.golden @@ -1,18 +1,10 @@ func transform() { target := &proto.Default{ - RequiredString: source.RequiredString, - DefaultBool: source.DefaultBool, + RequiredString: &source.RequiredString, + DefaultBool: &source.DefaultBool, } if source.Integer != nil { - target.Integer = int32(*source.Integer) - } - { - var zero bool - if target.DefaultBool == zero { - target.DefaultBool = true - } - } - if source.Integer == nil { - target.Integer = 1 + integer := int32(*source.Integer) + target.Integer = &integer } } diff --git a/grpc/codegen/testdata/golden/protobuf_type_encode_to-protobuf-type_simple-to-required.go.golden b/grpc/codegen/testdata/golden/protobuf_type_encode_to-protobuf-type_simple-to-required.go.golden index a6638f9634..56721c6fb7 100644 --- a/grpc/codegen/testdata/golden/protobuf_type_encode_to-protobuf-type_simple-to-required.go.golden +++ b/grpc/codegen/testdata/golden/protobuf_type_encode_to-protobuf-type_simple-to-required.go.golden @@ -1,15 +1,10 @@ func transform() { target := &proto.Required{ - RequiredString: source.RequiredString, - DefaultBool: source.DefaultBool, + RequiredString: &source.RequiredString, + DefaultBool: &source.DefaultBool, } if source.Integer != nil { - target.Integer = int32(*source.Integer) - } - { - var zero bool - if target.DefaultBool == zero { - target.DefaultBool = true - } + integer := int32(*source.Integer) + target.Integer = &integer } } diff --git a/grpc/codegen/testdata/golden/protobuf_type_encode_to-protobuf-type_simple-to-simple.go.golden b/grpc/codegen/testdata/golden/protobuf_type_encode_to-protobuf-type_simple-to-simple.go.golden index f9bff6798b..253e0c613c 100644 --- a/grpc/codegen/testdata/golden/protobuf_type_encode_to-protobuf-type_simple-to-simple.go.golden +++ b/grpc/codegen/testdata/golden/protobuf_type_encode_to-protobuf-type_simple-to-simple.go.golden @@ -1,16 +1,10 @@ func transform() { target := &proto.Simple{ - RequiredString: source.RequiredString, - DefaultBool: source.DefaultBool, + RequiredString: &source.RequiredString, + DefaultBool: &source.DefaultBool, } if source.Integer != nil { integer := int32(*source.Integer) target.Integer = &integer } - { - var zero bool - if target.DefaultBool == zero { - target.DefaultBool = true - } - } } diff --git a/grpc/codegen/testdata/golden/protobuf_type_encode_to-service-type_array-map-to-array-map.go.golden b/grpc/codegen/testdata/golden/protobuf_type_encode_to-service-type_array-map-to-array-map.go.golden index 2d521c993b..fc56d1a3e8 100644 --- a/grpc/codegen/testdata/golden/protobuf_type_encode_to-service-type_array-map-to-array-map.go.golden +++ b/grpc/codegen/testdata/golden/protobuf_type_encode_to-service-type_array-map-to-array-map.go.golden @@ -4,9 +4,12 @@ func transform() { target.ArrayMap = make(map[uint32][]float32, len(source.ArrayMap)) for key, val := range source.ArrayMap { tk := key - tv := make([]float32, len(val.Field)) - for i, val := range val.Field { - tv[i] = val + var tv []float32 + if val != nil { + tv = make([]float32, len(val.Field)) + for i, val := range val.Field { + tv[i] = val + } } target.ArrayMap[tk] = tv } diff --git a/grpc/codegen/testdata/golden/protobuf_type_encode_to-service-type_custom-field-to-composite.go.golden b/grpc/codegen/testdata/golden/protobuf_type_encode_to-service-type_custom-field-to-composite.go.golden index 820b1df3b0..36c4e3ec61 100644 --- a/grpc/codegen/testdata/golden/protobuf_type_encode_to-service-type_custom-field-to-composite.go.golden +++ b/grpc/codegen/testdata/golden/protobuf_type_encode_to-service-type_custom-field-to-composite.go.golden @@ -1,9 +1,11 @@ func transform() { target := &proto.Composite{ - RequiredString: &source.RequiredString, + RequiredString: source.RequiredString, + } + if source.DefaultInt != nil { + defaultInt := int(*source.DefaultInt) + target.DefaultInt = &defaultInt } - defaultInt := int(source.DefaultInt) - target.DefaultInt = &defaultInt if source.Type != nil { target.Type = protobufProtoSimpleToProtoSimple(source.Type) } diff --git a/grpc/codegen/testdata/golden/protobuf_type_encode_to-service-type_customtype-to-customtype.go.golden b/grpc/codegen/testdata/golden/protobuf_type_encode_to-service-type_customtype-to-customtype.go.golden index b37235c00b..93f2156f2b 100644 --- a/grpc/codegen/testdata/golden/protobuf_type_encode_to-service-type_customtype-to-customtype.go.golden +++ b/grpc/codegen/testdata/golden/protobuf_type_encode_to-service-type_customtype-to-customtype.go.golden @@ -1,16 +1,15 @@ func transform() { target := &proto.CustomTypes{ - RequiredString: tdtypes.CustomString(source.RequiredString), - DefaultBool: tdtypes.CustomBool(source.DefaultBool), + RequiredString: tdtypes.CustomString(*source.RequiredString), + } + if source.DefaultBool != nil { + target.DefaultBool = tdtypes.CustomBool(*source.DefaultBool) } if source.Integer != nil { integer := tdtypes.CustomInt(*source.Integer) target.Integer = &integer } - { - var zero tdtypes.CustomBool - if target.DefaultBool == zero { - target.DefaultBool = true - } + if source.DefaultBool == nil { + target.DefaultBool = true } } diff --git a/grpc/codegen/testdata/golden/protobuf_type_encode_to-service-type_default-to-simple.go.golden b/grpc/codegen/testdata/golden/protobuf_type_encode_to-service-type_default-to-simple.go.golden index c45fe75f27..2a3f71773f 100644 --- a/grpc/codegen/testdata/golden/protobuf_type_encode_to-service-type_default-to-simple.go.golden +++ b/grpc/codegen/testdata/golden/protobuf_type_encode_to-service-type_default-to-simple.go.golden @@ -1,14 +1,14 @@ func transform() { - target := &proto.Simple{ - RequiredString: source.RequiredString, - DefaultBool: source.DefaultBool, + target := &proto.Simple{} + if source.RequiredString != nil { + target.RequiredString = *source.RequiredString } - integer := int(source.Integer) + if source.DefaultBool != nil { + target.DefaultBool = *source.DefaultBool + } + integer := int(*source.Integer) target.Integer = &integer - { - var zero bool - if target.DefaultBool == zero { - target.DefaultBool = true - } + if source.DefaultBool == nil { + target.DefaultBool = true } } diff --git a/grpc/codegen/testdata/golden/protobuf_type_encode_to-service-type_defaults-to-defaults.go.golden b/grpc/codegen/testdata/golden/protobuf_type_encode_to-service-type_defaults-to-defaults.go.golden index a77e32ff1f..9e8e0c7a40 100644 --- a/grpc/codegen/testdata/golden/protobuf_type_encode_to-service-type_defaults-to-defaults.go.golden +++ b/grpc/codegen/testdata/golden/protobuf_type_encode_to-service-type_defaults-to-defaults.go.golden @@ -1,44 +1,37 @@ func transform() { target := &proto.WithDefaults{ - Int: int(source.Int), - RawJSON: json.RawMessage(source.RawJson), - RequiredInt: int(source.RequiredInt), - String: source.String_, - RequiredString: source.RequiredString, + RequiredInt: int(*source.RequiredInt), + RequiredString: *source.RequiredString, Bytes: source.Bytes_, RequiredBytes: source.RequiredBytes, - Any: source.Any, - RequiredAny: source.RequiredAny, + RequiredAny: *source.RequiredAny, } - { - var zero int - if target.Int == zero { - target.Int = 100 - } + if source.Int != nil { + target.Int = int(*source.Int) } - { - var zero json.RawMessage - if target.RawJSON == zero { - target.RawJSON = json.RawMessage{0x66, 0x6f, 0x6f} - } + if source.RawJson != nil { + target.RawJSON = json.RawMessage(*source.RawJson) } - { - var zero string - if target.String == zero { - target.String = "foo" - } + if source.String_ != nil { + target.String = *source.String_ } - { - var zero []byte - if target.Bytes == zero { - target.Bytes = []byte{0x66, 0x6f, 0x6f, 0x62, 0x61, 0x72} - } + if source.Any != nil { + target.Any = *source.Any } - { - var zero string - if target.Any == zero { - target.Any = "something" - } + if source.Int == nil { + target.Int = 100 + } + if source.RawJson == nil { + target.RawJSON = json.RawMessage{0x66, 0x6f, 0x6f} + } + if source.String_ == nil { + target.String = "foo" + } + if source.Bytes_ == nil { + target.Bytes = []byte{0x66, 0x6f, 0x6f, 0x62, 0x61, 0x72} + } + if source.Any == nil { + target.Any = "something" } if source.Array != nil { target.Array = make([]string, len(source.Array)) diff --git a/grpc/codegen/testdata/golden/protobuf_type_encode_to-service-type_embedded-oneof-to-embedded-oneof.go.golden b/grpc/codegen/testdata/golden/protobuf_type_encode_to-service-type_embedded-oneof-to-embedded-oneof.go.golden index 4e75f8833f..8b9b815437 100644 --- a/grpc/codegen/testdata/golden/protobuf_type_encode_to-service-type_embedded-oneof-to-embedded-oneof.go.golden +++ b/grpc/codegen/testdata/golden/protobuf_type_encode_to-service-type_embedded-oneof-to-embedded-oneof.go.golden @@ -4,10 +4,10 @@ func transform() { } if source.EmbeddedOneOf != nil { switch val := source.EmbeddedOneOf.(type) { - case *proto.EmbeddedOneOf_String_: + case *proto.EmbeddedOneOf_String_2: { u := target.EmbeddedOneOf - u.SetString(proto.EmbeddedOneOfString(val.String_)) + u.SetString(proto.EmbeddedOneOfString(val.String_2)) target.EmbeddedOneOf = u } case *proto.EmbeddedOneOf_Integer: @@ -31,13 +31,13 @@ func transform() { case *proto.EmbeddedOneOf_Array: { u := target.EmbeddedOneOf - u.SetArray(protobufProtoEmbeddedOneOfArrayToProtoEmbeddedOneOfArray(val.Array)) + u.SetArray(protobufProtoEmbeddedOneOfArray2ToProtoEmbeddedOneOfArray(val.Array)) target.EmbeddedOneOf = u } case *proto.EmbeddedOneOf_Map_: { u := target.EmbeddedOneOf - u.SetMap(protobufProtoEmbeddedOneOfMapToProtoEmbeddedOneOfMap(val.Map_)) + u.SetMap(protobufProtoEmbeddedOneOfMap2ToProtoEmbeddedOneOfMap(val.Map_)) target.EmbeddedOneOf = u } case *proto.EmbeddedOneOf_UserType: diff --git a/grpc/codegen/testdata/golden/protobuf_type_encode_to-service-type_nested-map-to-nested-map.go.golden b/grpc/codegen/testdata/golden/protobuf_type_encode_to-service-type_nested-map-to-nested-map.go.golden index ed3909864c..a85bd2908e 100644 --- a/grpc/codegen/testdata/golden/protobuf_type_encode_to-service-type_nested-map-to-nested-map.go.golden +++ b/grpc/codegen/testdata/golden/protobuf_type_encode_to-service-type_nested-map-to-nested-map.go.golden @@ -4,16 +4,22 @@ func transform() { target.NestedMap = make(map[float64]map[int]map[float64]uint64, len(source.NestedMap)) for key, val := range source.NestedMap { tk := key - tvc := make(map[int]map[float64]uint64, len(val.Field)) - for key, val := range val.Field { - tk := int(key) - tvb := make(map[float64]uint64, len(val.Field)) + var tvc map[int]map[float64]uint64 + if val != nil { + tvc = make(map[int]map[float64]uint64, len(val.Field)) for key, val := range val.Field { - tk := key - tv := val - tvb[tk] = tv + tk := int(key) + var tvb map[float64]uint64 + if val != nil { + tvb = make(map[float64]uint64, len(val.Field)) + for key, val := range val.Field { + tk := key + tv := val + tvb[tk] = tv + } + } + tvc[tk] = tvb } - tvc[tk] = tvb } target.NestedMap[tk] = tvc } diff --git a/grpc/codegen/testdata/golden/protobuf_type_encode_to-service-type_nilable-defaults.go.golden b/grpc/codegen/testdata/golden/protobuf_type_encode_to-service-type_nilable-defaults.go.golden new file mode 100644 index 0000000000..c0c07767a1 --- /dev/null +++ b/grpc/codegen/testdata/golden/protobuf_type_encode_to-service-type_nilable-defaults.go.golden @@ -0,0 +1,18 @@ +func transform() { + target := &service.NilableDefaults{ + Bytes: source.Bytes_, + Value: func() any { + // Convert protobuf Value to Go any directly + if source.Value != nil { + return source.Value.AsInterface() + } + return nil + }(), + } + if source.Bytes_ == nil { + target.Bytes = []byte{0x66, 0x61, 0x6c, 0x6c, 0x62, 0x61, 0x63, 0x6b} + } + if source.Value == nil { + target.Value = "fallback" + } +} diff --git a/grpc/codegen/testdata/golden/protobuf_type_encode_to-service-type_primitive-to-primitive.go.golden b/grpc/codegen/testdata/golden/protobuf_type_encode_to-service-type_primitive-to-primitive.go.golden index 466fb5c35f..52ad2fc829 100644 --- a/grpc/codegen/testdata/golden/protobuf_type_encode_to-service-type_primitive-to-primitive.go.golden +++ b/grpc/codegen/testdata/golden/protobuf_type_encode_to-service-type_primitive-to-primitive.go.golden @@ -1,3 +1,3 @@ func transform() { - target := int(source.Field) + target := int(*source.Field) } diff --git a/grpc/codegen/testdata/golden/protobuf_type_encode_to-service-type_recursive-to-recursive.go.golden b/grpc/codegen/testdata/golden/protobuf_type_encode_to-service-type_recursive-to-recursive.go.golden index aba68bb6ab..c0abf3a1d9 100644 --- a/grpc/codegen/testdata/golden/protobuf_type_encode_to-service-type_recursive-to-recursive.go.golden +++ b/grpc/codegen/testdata/golden/protobuf_type_encode_to-service-type_recursive-to-recursive.go.golden @@ -1,6 +1,6 @@ func transform() { target := &proto.Recursive{ - RequiredString: source.RequiredString, + RequiredString: *source.RequiredString, } if source.Recursive != nil { target.Recursive = protobufProtoRecursiveToProtoRecursive(source.Recursive) diff --git a/grpc/codegen/testdata/golden/protobuf_type_encode_to-service-type_required-to-simple.go.golden b/grpc/codegen/testdata/golden/protobuf_type_encode_to-service-type_required-to-simple.go.golden index dad99cbd43..b2a14a9e55 100644 --- a/grpc/codegen/testdata/golden/protobuf_type_encode_to-service-type_required-to-simple.go.golden +++ b/grpc/codegen/testdata/golden/protobuf_type_encode_to-service-type_required-to-simple.go.golden @@ -1,8 +1,8 @@ func transform() { target := &proto.Simple{ - RequiredString: source.RequiredString, - DefaultBool: source.DefaultBool, + RequiredString: *source.RequiredString, + DefaultBool: *source.DefaultBool, } - integer := int(source.Integer) + integer := int(*source.Integer) target.Integer = &integer } diff --git a/grpc/codegen/testdata/golden/protobuf_type_encode_to-service-type_simple-to-customtype.go.golden b/grpc/codegen/testdata/golden/protobuf_type_encode_to-service-type_simple-to-customtype.go.golden index b37235c00b..93f2156f2b 100644 --- a/grpc/codegen/testdata/golden/protobuf_type_encode_to-service-type_simple-to-customtype.go.golden +++ b/grpc/codegen/testdata/golden/protobuf_type_encode_to-service-type_simple-to-customtype.go.golden @@ -1,16 +1,15 @@ func transform() { target := &proto.CustomTypes{ - RequiredString: tdtypes.CustomString(source.RequiredString), - DefaultBool: tdtypes.CustomBool(source.DefaultBool), + RequiredString: tdtypes.CustomString(*source.RequiredString), + } + if source.DefaultBool != nil { + target.DefaultBool = tdtypes.CustomBool(*source.DefaultBool) } if source.Integer != nil { integer := tdtypes.CustomInt(*source.Integer) target.Integer = &integer } - { - var zero tdtypes.CustomBool - if target.DefaultBool == zero { - target.DefaultBool = true - } + if source.DefaultBool == nil { + target.DefaultBool = true } } diff --git a/grpc/codegen/testdata/golden/protobuf_type_encode_to-service-type_simple-to-default.go.golden b/grpc/codegen/testdata/golden/protobuf_type_encode_to-service-type_simple-to-default.go.golden index 14e06175d3..c5e0cba9c0 100644 --- a/grpc/codegen/testdata/golden/protobuf_type_encode_to-service-type_simple-to-default.go.golden +++ b/grpc/codegen/testdata/golden/protobuf_type_encode_to-service-type_simple-to-default.go.golden @@ -1,16 +1,15 @@ func transform() { target := &proto.Default{ - RequiredString: source.RequiredString, - DefaultBool: source.DefaultBool, + RequiredString: *source.RequiredString, + } + if source.DefaultBool != nil { + target.DefaultBool = *source.DefaultBool } if source.Integer != nil { target.Integer = int(*source.Integer) } - { - var zero bool - if target.DefaultBool == zero { - target.DefaultBool = true - } + if source.DefaultBool == nil { + target.DefaultBool = true } if source.Integer == nil { target.Integer = 1 diff --git a/grpc/codegen/testdata/golden/protobuf_type_encode_to-service-type_simple-to-required-ptr.go.golden b/grpc/codegen/testdata/golden/protobuf_type_encode_to-service-type_simple-to-required-ptr.go.golden index 1ac4037972..776d593d64 100644 --- a/grpc/codegen/testdata/golden/protobuf_type_encode_to-service-type_simple-to-required-ptr.go.golden +++ b/grpc/codegen/testdata/golden/protobuf_type_encode_to-service-type_simple-to-required-ptr.go.golden @@ -1,7 +1,7 @@ func transform() { target := &proto.Required{ - RequiredString: &source.RequiredString, - DefaultBool: &source.DefaultBool, + RequiredString: source.RequiredString, + DefaultBool: source.DefaultBool, } if source.Integer != nil { integer := int(*source.Integer) diff --git a/grpc/codegen/testdata/golden/protobuf_type_encode_to-service-type_simple-to-required.go.golden b/grpc/codegen/testdata/golden/protobuf_type_encode_to-service-type_simple-to-required.go.golden index 7b1dff2316..290af0315b 100644 --- a/grpc/codegen/testdata/golden/protobuf_type_encode_to-service-type_simple-to-required.go.golden +++ b/grpc/codegen/testdata/golden/protobuf_type_encode_to-service-type_simple-to-required.go.golden @@ -1,15 +1,14 @@ func transform() { target := &proto.Required{ - RequiredString: source.RequiredString, - DefaultBool: source.DefaultBool, + RequiredString: *source.RequiredString, + } + if source.DefaultBool != nil { + target.DefaultBool = *source.DefaultBool } if source.Integer != nil { target.Integer = int(*source.Integer) } - { - var zero bool - if target.DefaultBool == zero { - target.DefaultBool = true - } + if source.DefaultBool == nil { + target.DefaultBool = true } } diff --git a/grpc/codegen/testdata/golden/protobuf_type_encode_to-service-type_simple-to-simple.go.golden b/grpc/codegen/testdata/golden/protobuf_type_encode_to-service-type_simple-to-simple.go.golden index a5560dd364..61a8a52a7d 100644 --- a/grpc/codegen/testdata/golden/protobuf_type_encode_to-service-type_simple-to-simple.go.golden +++ b/grpc/codegen/testdata/golden/protobuf_type_encode_to-service-type_simple-to-simple.go.golden @@ -1,16 +1,15 @@ func transform() { target := &proto.Simple{ - RequiredString: source.RequiredString, - DefaultBool: source.DefaultBool, + RequiredString: *source.RequiredString, + } + if source.DefaultBool != nil { + target.DefaultBool = *source.DefaultBool } if source.Integer != nil { integer := int(*source.Integer) target.Integer = &integer } - { - var zero bool - if target.DefaultBool == zero { - target.DefaultBool = true - } + if source.DefaultBool == nil { + target.DefaultBool = true } } diff --git a/grpc/codegen/testdata/golden/released_fixed_view_collection_constructor.go.golden b/grpc/codegen/testdata/golden/released_fixed_view_collection_constructor.go.golden new file mode 100644 index 0000000000..c988387ffe --- /dev/null +++ b/grpc/codegen/testdata/golden/released_fixed_view_collection_constructor.go.golden @@ -0,0 +1,57 @@ +// NewProtoResultTypeCollection builds +// *service_client_streaming_result_type_collection_with_explicit_viewpb.ResultTypeCollection +// from +// serviceclientstreamingresulttypecollectionwithexplicitviewviews.ResultTypeCollectionView. +func NewProtoResultTypeCollection(result serviceclientstreamingresulttypecollectionwithexplicitviewviews.ResultTypeCollectionView) *service_client_streaming_result_type_collection_with_explicit_viewpb.ResultTypeCollection { + message := &service_client_streaming_result_type_collection_with_explicit_viewpb.ResultTypeCollection{} + message.Field = make([]*service_client_streaming_result_type_collection_with_explicit_viewpb.ResultType, len(result)) + for i, val := range result { + message.Field[i] = &service_client_streaming_result_type_collection_with_explicit_viewpb.ResultType{} + if val.IntField != nil { + intField := int32(*val.IntField) + message.Field[i].IntField = &intField + } + } + return message +} + +// NewMethodClientStreamingResultTypeCollectionWithExplicitViewStreamingRequestMethodClientStreamingResultTypeCollectionWithExplicitViewStreamingRequest +// builds string from +// *service_client_streaming_result_type_collection_with_explicit_viewpb.MethodClientStreamingResultTypeCollectionWithExplicitViewStreamingRequest. +func NewMethodClientStreamingResultTypeCollectionWithExplicitViewStreamingRequestMethodClientStreamingResultTypeCollectionWithExplicitViewStreamingRequest(v *service_client_streaming_result_type_collection_with_explicit_viewpb.MethodClientStreamingResultTypeCollectionWithExplicitViewStreamingRequest) string { + spayload := *v.Field + return spayload +} + +// EncodeMethodClientStreamingResultTypeCollectionWithExplicitViewResponse +// encodes responses from the +// "ServiceClientStreamingResultTypeCollectionWithExplicitView" service +// "MethodClientStreamingResultTypeCollectionWithExplicitView" endpoint. +func EncodeMethodClientStreamingResultTypeCollectionWithExplicitViewResponse(ctx context.Context, v any, hdr, trlr *metadata.MD) (any, error) { + vres, ok := v.(serviceclientstreamingresulttypecollectionwithexplicitviewviews.ResultTypeCollection) + if !ok { + return nil, goagrpc.ErrInvalidType("ServiceClientStreamingResultTypeCollectionWithExplicitView", "MethodClientStreamingResultTypeCollectionWithExplicitView", "serviceclientstreamingresulttypecollectionwithexplicitviewviews.ResultTypeCollection", v) + } + result := vres.Projected + resp := NewProtoResultTypeCollection(result) + (*hdr).Append("goa-view", "tiny") + return resp, nil +} + +// SendAndClose streams instances of +// "service_client_streaming_result_type_collection_with_explicit_viewpb.ResultTypeCollection" +// to the "MethodClientStreamingResultTypeCollectionWithExplicitView" endpoint +// gRPC stream. +func (s *MethodClientStreamingResultTypeCollectionWithExplicitViewServerStream) SendAndClose(res serviceclientstreamingresulttypecollectionwithexplicitview.ResultTypeCollection) error { + vres := serviceclientstreamingresulttypecollectionwithexplicitview.NewViewedResultTypeCollection(res, "tiny") + v := NewProtoResultTypeCollection(vres.Projected) + return s.stream.SendAndClose(v) +} + +// SendAndCloseWithContext streams instances of +// "service_client_streaming_result_type_collection_with_explicit_viewpb.ResultTypeCollection" +// to the "MethodClientStreamingResultTypeCollectionWithExplicitView" endpoint +// gRPC stream with context. +func (s *MethodClientStreamingResultTypeCollectionWithExplicitViewServerStream) SendAndCloseWithContext(ctx context.Context, res serviceclientstreamingresulttypecollectionwithexplicitview.ResultTypeCollection) error { + return s.SendAndClose(res) +} diff --git a/grpc/codegen/testdata/golden/released_streaming_response_constructors.go.golden b/grpc/codegen/testdata/golden/released_streaming_response_constructors.go.golden new file mode 100644 index 0000000000..753643d7c3 --- /dev/null +++ b/grpc/codegen/testdata/golden/released_streaming_response_constructors.go.golden @@ -0,0 +1,80 @@ +// NewProtoMethodServerStreamingUserTypeRPCResponse builds +// *service_server_streaming_user_type_rpcpb.MethodServerStreamingUserTypeRPCResponse +// from *serviceserverstreamingusertyperpcviews.ResultTypeView. +func NewProtoMethodServerStreamingUserTypeRPCResponse(result *serviceserverstreamingusertyperpcviews.ResultTypeView) *service_server_streaming_user_type_rpcpb.MethodServerStreamingUserTypeRPCResponse { + message := &service_server_streaming_user_type_rpcpb.MethodServerStreamingUserTypeRPCResponse{ + DoubleField: result.DoubleField, + } + intField := int32(*result.IntField) + message.IntField = &intField + return message +} + +// NewProtoMethodServerStreamingUserTypeRPCResponseTiny builds +// *service_server_streaming_user_type_rpcpb.MethodServerStreamingUserTypeRPCResponse +// from *serviceserverstreamingusertyperpcviews.ResultTypeView. +func NewProtoMethodServerStreamingUserTypeRPCResponseTiny(result *serviceserverstreamingusertyperpcviews.ResultTypeView) *service_server_streaming_user_type_rpcpb.MethodServerStreamingUserTypeRPCResponse { + message := &service_server_streaming_user_type_rpcpb.MethodServerStreamingUserTypeRPCResponse{} + intField := int32(*result.IntField) + message.IntField = &intField + return message +} + +// EncodeMethodServerStreamingUserTypeRPCResponse encodes responses from the +// "ServiceServerStreamingUserTypeRPC" service +// "MethodServerStreamingUserTypeRPC" endpoint. +func EncodeMethodServerStreamingUserTypeRPCResponse(ctx context.Context, v any, hdr, trlr *metadata.MD) (any, error) { + vres, ok := v.(*serviceserverstreamingusertyperpcviews.ResultType) + if !ok { + return nil, goagrpc.ErrInvalidType("ServiceServerStreamingUserTypeRPC", "MethodServerStreamingUserTypeRPC", "*serviceserverstreamingusertyperpcviews.ResultType", v) + } + result := vres.Projected + var resp *service_server_streaming_user_type_rpcpb.MethodServerStreamingUserTypeRPCResponse + switch vres.View { + case "tiny": + resp = NewProtoMethodServerStreamingUserTypeRPCResponseTiny(result) + case "default", "": + resp = NewProtoMethodServerStreamingUserTypeRPCResponse(result) + default: + return nil, goa.InvalidEnumValueError("view", vres.View, []any{"tiny", "default"}) + } + (*hdr).Append("goa-view", vres.View) + return resp, nil +} + +// Send streams instances of +// "service_server_streaming_user_type_rpcpb.MethodServerStreamingUserTypeRPCResponse" +// to the "MethodServerStreamingUserTypeRPC" endpoint gRPC stream. +func (s *MethodServerStreamingUserTypeRPCServerStream) Send(res *serviceserverstreamingusertyperpc.ResultType) error { + view := s.view + if view == "" { + view = "default" + } + if s.sentView != "" && view != s.sentView { + return goa.InvalidEnumValueError("view", view, []any{s.sentView}) + } + vres := serviceserverstreamingusertyperpc.NewViewedResultType(res, view) + var v *service_server_streaming_user_type_rpcpb.MethodServerStreamingUserTypeRPCResponse + switch view { + case "tiny": + v = NewProtoMethodServerStreamingUserTypeRPCResponseTiny(vres.Projected) + case "default", "": + v = NewProtoMethodServerStreamingUserTypeRPCResponse(vres.Projected) + default: + return goa.InvalidEnumValueError("view", view, []any{"tiny", "default"}) + } + if s.sentView == "" { + if err := s.stream.SetHeader(metadata.Pairs("goa-view", view)); err != nil { + return err + } + s.sentView = view + } + return s.stream.Send(v) +} + +// SendWithContext streams instances of +// "service_server_streaming_user_type_rpcpb.MethodServerStreamingUserTypeRPCResponse" +// to the "MethodServerStreamingUserTypeRPC" endpoint gRPC stream with context. +func (s *MethodServerStreamingUserTypeRPCServerStream) SendWithContext(ctx context.Context, res *serviceserverstreamingusertyperpc.ResultType) error { + return s.Send(res) +} diff --git a/grpc/codegen/testdata/golden/request_decoder_request-decoder-metadata-only-payload-with-streaming-payload.go.golden b/grpc/codegen/testdata/golden/request_decoder_request-decoder-metadata-only-payload-with-streaming-payload.go.golden new file mode 100644 index 0000000000..02a139a599 --- /dev/null +++ b/grpc/codegen/testdata/golden/request_decoder_request-decoder-metadata-only-payload-with-streaming-payload.go.golden @@ -0,0 +1,24 @@ +// DecodeMethodClientStreamingRPCWithMetadataOnlyPayloadRequest decodes +// requests sent to "ServiceClientStreamingRPCWithMetadataOnlyPayload" service +// "MethodClientStreamingRPCWithMetadataOnlyPayload" endpoint. +func DecodeMethodClientStreamingRPCWithMetadataOnlyPayloadRequest(ctx context.Context, v any, md metadata.MD) (any, error) { + var ( + token string + err error + ) + { + if vals := md.Get("token"); len(vals) == 0 { + err = goa.MergeErrors(err, goa.MissingFieldError("token", "metadata")) + } else { + token = vals[0] + } + } + if err != nil { + return nil, err + } + var payload *serviceclientstreamingrpcwithmetadataonlypayload.MethodClientStreamingRPCWithMetadataOnlyPayloadPayload + { + payload = NewMethodClientStreamingRPCWithMetadataOnlyPayloadPayload(token) + } + return payload, nil +} diff --git a/grpc/codegen/testdata/golden/request_decoder_request-decoder-named-security-metadata.go.golden b/grpc/codegen/testdata/golden/request_decoder_request-decoder-named-security-metadata.go.golden new file mode 100644 index 0000000000..72f650e3a4 --- /dev/null +++ b/grpc/codegen/testdata/golden/request_decoder_request-decoder-named-security-metadata.go.golden @@ -0,0 +1,80 @@ +// DecodeAuthenticateRequest decodes requests sent to "NamedSecurityMetadata" +// service "Authenticate" endpoint. +func DecodeAuthenticateRequest(ctx context.Context, v any, md metadata.MD) (any, error) { + var ( + jwtToken string + oauthToken *string + key string + username string + password *string + bearerToken *string + err error + ) + { + if vals := md.Get("authorization"); len(vals) == 0 { + err = goa.MergeErrors(err, goa.MissingFieldError("authorization", "metadata")) + } else { + jwtToken = vals[0] + } + if vals := md.Get("authorization"); len(vals) > 0 { + oauthToken = &vals[0] + } + if vals := md.Get("authorization"); len(vals) == 0 { + err = goa.MergeErrors(err, goa.MissingFieldError("authorization", "metadata")) + } else { + key = vals[0] + } + if vals := md.Get("username"); len(vals) == 0 { + err = goa.MergeErrors(err, goa.MissingFieldError("username", "metadata")) + } else { + username = vals[0] + } + if vals := md.Get("password"); len(vals) > 0 { + password = &vals[0] + } + if vals := md.Get("authorization"); len(vals) > 0 { + bearerToken = &vals[0] + } + } + if err != nil { + return nil, err + } + var ( + message *named_security_metadatapb.AuthenticateRequest + ok bool + ) + { + if message, ok = v.(*named_security_metadatapb.AuthenticateRequest); !ok { + return nil, goagrpc.ErrInvalidType("NamedSecurityMetadata", "Authenticate", "*named_security_metadatapb.AuthenticateRequest", v) + } + } + var payload *namedsecuritymetadata.NamedSecurityRequest + { + payload = NewAuthenticatePayload(message, jwtToken, oauthToken, key, username, password, bearerToken) + if index := strings.IndexByte(string(payload.JWTToken), ' '); index >= 0 { + // Remove authorization scheme prefix (e.g. "Bearer") + payload.JWTToken = payload.JWTToken[index+1:] + } + if payload.OauthToken != nil { + cred := *payload.OauthToken + if index := strings.IndexByte(string(cred), ' '); index >= 0 { + // Remove authorization scheme prefix (e.g. "Bearer") + cred = cred[index+1:] + payload.OauthToken = &cred + } + } + if index := strings.IndexByte(string(payload.Key), ' '); index >= 0 { + // Remove authorization scheme prefix (e.g. "Bearer") + payload.Key = payload.Key[index+1:] + } + if payload.BearerToken != nil { + cred := *payload.BearerToken + if index := strings.IndexByte(string(cred), ' '); index >= 0 { + // Remove authorization scheme prefix (e.g. "Bearer") + cred = cred[index+1:] + payload.BearerToken = &cred + } + } + } + return payload, nil +} diff --git a/grpc/codegen/testdata/golden/request_decoder_request-decoder-payload-map.go.golden b/grpc/codegen/testdata/golden/request_decoder_request-decoder-payload-map.go.golden index aa32de6847..77a11631c3 100644 --- a/grpc/codegen/testdata/golden/request_decoder_request-decoder-payload-map.go.golden +++ b/grpc/codegen/testdata/golden/request_decoder_request-decoder-payload-map.go.golden @@ -9,9 +9,6 @@ func DecodeMethodMessageMapRequest(ctx context.Context, v any, md metadata.MD) ( if message, ok = v.(*service_message_mappb.MethodMessageMapRequest); !ok { return nil, goagrpc.ErrInvalidType("ServiceMessageMap", "MethodMessageMap", "*service_message_mappb.MethodMessageMapRequest", v) } - if err := ValidateMethodMessageMapRequest(message); err != nil { - return nil, err - } } var payload map[int]*servicemessagemap.UT { diff --git a/grpc/codegen/testdata/golden/request_decoder_request-decoder-payload-primitive-with-streaming-payload-legacy-compat.go.golden b/grpc/codegen/testdata/golden/request_decoder_request-decoder-payload-primitive-with-streaming-payload-legacy-compat.go.golden index 52f3c0a1f1..f9f5fd23f2 100644 --- a/grpc/codegen/testdata/golden/request_decoder_request-decoder-payload-primitive-with-streaming-payload-legacy-compat.go.golden +++ b/grpc/codegen/testdata/golden/request_decoder_request-decoder-payload-primitive-with-streaming-payload-legacy-compat.go.golden @@ -28,6 +28,9 @@ func DecodeMethodClientStreamingRPCWithPayloadLegacyCompatRequest(ctx context.Co default: return nil, goa.MissingFieldError("initial_payload", "stream") } + if err := ValidateMethodClientStreamingRPCWithPayloadLegacyCompatRequest(message); err != nil { + return nil, err + } } var payload int { diff --git a/grpc/codegen/testdata/golden/request_decoder_request-decoder-payload-primitive-with-streaming-payload.go.golden b/grpc/codegen/testdata/golden/request_decoder_request-decoder-payload-primitive-with-streaming-payload.go.golden index d5b5e567c3..36bca6dca5 100644 --- a/grpc/codegen/testdata/golden/request_decoder_request-decoder-payload-primitive-with-streaming-payload.go.golden +++ b/grpc/codegen/testdata/golden/request_decoder_request-decoder-payload-primitive-with-streaming-payload.go.golden @@ -25,6 +25,9 @@ func DecodeMethodClientStreamingRPCWithPayloadRequest(ctx context.Context, v any default: return nil, goa.MissingFieldError("initial_payload", "stream") } + if err := ValidateMethodClientStreamingRPCWithPayloadRequest(message); err != nil { + return nil, err + } } var payload int { diff --git a/grpc/codegen/testdata/golden/request_decoder_request-decoder-payload-primitive.go.golden b/grpc/codegen/testdata/golden/request_decoder_request-decoder-payload-primitive.go.golden index 3cf436d0eb..509945e736 100644 --- a/grpc/codegen/testdata/golden/request_decoder_request-decoder-payload-primitive.go.golden +++ b/grpc/codegen/testdata/golden/request_decoder_request-decoder-payload-primitive.go.golden @@ -9,6 +9,9 @@ func DecodeMethodServerStreamingRPCRequest(ctx context.Context, v any, md metada if message, ok = v.(*service_server_streaming_rpcpb.MethodServerStreamingRPCRequest); !ok { return nil, goagrpc.ErrInvalidType("ServiceServerStreamingRPC", "MethodServerStreamingRPC", "*service_server_streaming_rpcpb.MethodServerStreamingRPCRequest", v) } + if err := ValidateMethodServerStreamingRPCRequest(message); err != nil { + return nil, err + } } var payload int { diff --git a/grpc/codegen/testdata/golden/request_decoder_request-decoder-payload-with-security-attributes.go.golden b/grpc/codegen/testdata/golden/request_decoder_request-decoder-payload-with-security-attributes.go.golden index 7890e60943..c4a1842666 100644 --- a/grpc/codegen/testdata/golden/request_decoder_request-decoder-payload-with-security-attributes.go.golden +++ b/grpc/codegen/testdata/golden/request_decoder_request-decoder-payload-with-security-attributes.go.golden @@ -38,16 +38,18 @@ func DecodeMethodMessageWithSecurityRequest(ctx context.Context, v any, md metad { payload = NewMethodMessageWithSecurityPayload(message, token, key, username, password) if payload.Token != nil { - if strings.Contains(*payload.Token, " ") { + cred := *payload.Token + if index := strings.IndexByte(string(cred), ' '); index >= 0 { // Remove authorization scheme prefix (e.g. "Bearer") - cred := strings.SplitN(*payload.Token, " ", 2)[1] + cred = cred[index+1:] payload.Token = &cred } } if payload.Key != nil { - if strings.Contains(*payload.Key, " ") { + cred := *payload.Key + if index := strings.IndexByte(string(cred), ' '); index >= 0 { // Remove authorization scheme prefix (e.g. "Bearer") - cred := strings.SplitN(*payload.Key, " ", 2)[1] + cred = cred[index+1:] payload.Key = &cred } } diff --git a/grpc/codegen/testdata/golden/request_encoder_request-encoder-named-security-metadata.go.golden b/grpc/codegen/testdata/golden/request_encoder_request-encoder-named-security-metadata.go.golden new file mode 100644 index 0000000000..1777aab4c2 --- /dev/null +++ b/grpc/codegen/testdata/golden/request_encoder_request-encoder-named-security-metadata.go.golden @@ -0,0 +1,27 @@ +// EncodeAuthenticateRequest encodes requests sent to NamedSecurityMetadata +// Authenticate endpoint. +func EncodeAuthenticateRequest(ctx context.Context, v any, md *metadata.MD) (any, error) { + payload, ok := v.(*namedsecuritymetadata.NamedSecurityRequest) + if !ok { + return nil, goagrpc.ErrInvalidType("NamedSecurityMetadata", "Authenticate", "*namedsecuritymetadata.NamedSecurityRequest", v) + } + jwtTokenWire := string(payload.JWTToken) + (*md).Append("authorization", jwtTokenWire) + if payload.OauthToken != nil { + oauthTokenWire := string(*payload.OauthToken) + (*md).Append("authorization", oauthTokenWire) + } + keyWire := string(payload.Key) + (*md).Append("authorization", keyWire) + usernameWire := string(payload.Username) + (*md).Append("username", usernameWire) + if payload.Password != nil { + passwordWire := string(*payload.Password) + (*md).Append("password", passwordWire) + } + if payload.BearerToken != nil { + bearerTokenWire := string(*payload.BearerToken) + (*md).Append("authorization", bearerTokenWire) + } + return NewProtoAuthenticateRequest(payload), nil +} diff --git a/grpc/codegen/testdata/golden/request_encoder_request-encoder-payload-with-metadata.go.golden b/grpc/codegen/testdata/golden/request_encoder_request-encoder-payload-with-metadata.go.golden index 3111d6d787..7c8f78f74a 100644 --- a/grpc/codegen/testdata/golden/request_encoder_request-encoder-payload-with-metadata.go.golden +++ b/grpc/codegen/testdata/golden/request_encoder_request-encoder-payload-with-metadata.go.golden @@ -6,7 +6,8 @@ func EncodeMethodMessageWithMetadataRequest(ctx context.Context, v any, md *meta return nil, goagrpc.ErrInvalidType("ServiceMessageWithMetadata", "MethodMessageWithMetadata", "*servicemessagewithmetadata.RequestUT", v) } if payload.InMetadata != nil { - (*md).Append("Authorization", fmt.Sprintf("%v", *payload.InMetadata)) + inMetadataWire := *payload.InMetadata + (*md).Append("Authorization", strconv.Itoa(inMetadataWire)) } return NewProtoMethodMessageWithMetadataRequest(payload), nil } diff --git a/grpc/codegen/testdata/golden/request_encoder_request-encoder-payload-with-security-attributes.go.golden b/grpc/codegen/testdata/golden/request_encoder_request-encoder-payload-with-security-attributes.go.golden index be0c374124..f49293492b 100644 --- a/grpc/codegen/testdata/golden/request_encoder_request-encoder-payload-with-security-attributes.go.golden +++ b/grpc/codegen/testdata/golden/request_encoder_request-encoder-payload-with-security-attributes.go.golden @@ -6,16 +6,20 @@ func EncodeMethodMessageWithSecurityRequest(ctx context.Context, v any, md *meta return nil, goagrpc.ErrInvalidType("ServiceMessageWithSecurity", "MethodMessageWithSecurity", "*servicemessagewithsecurity.RequestUT", v) } if payload.Token != nil { - (*md).Append("authorization", *payload.Token) + tokenWire := string(*payload.Token) + (*md).Append("authorization", tokenWire) } if payload.Key != nil { - (*md).Append("authorization", *payload.Key) + keyWire := *payload.Key + (*md).Append("authorization", keyWire) } if payload.Username != nil { - (*md).Append("username", *payload.Username) + usernameWire := *payload.Username + (*md).Append("username", usernameWire) } if payload.Password != nil { - (*md).Append("password", *payload.Password) + passwordWire := *payload.Password + (*md).Append("password", passwordWire) } return NewProtoMethodMessageWithSecurityRequest(payload), nil } diff --git a/grpc/codegen/testdata/golden/request_encoder_request-encoder-payload-with-validate.go.golden b/grpc/codegen/testdata/golden/request_encoder_request-encoder-payload-with-validate.go.golden index 006be77c31..444c7cdf3d 100644 --- a/grpc/codegen/testdata/golden/request_encoder_request-encoder-payload-with-validate.go.golden +++ b/grpc/codegen/testdata/golden/request_encoder_request-encoder-payload-with-validate.go.golden @@ -6,7 +6,8 @@ func EncodeMethodMessageWithValidateRequest(ctx context.Context, v any, md *meta return nil, goagrpc.ErrInvalidType("ServiceMessageWithValidate", "MethodMessageWithValidate", "*servicemessagewithvalidate.RequestUT", v) } if payload.InMetadata != nil { - (*md).Append("Authorization", fmt.Sprintf("%v", *payload.InMetadata)) + inMetadataWire := *payload.InMetadata + (*md).Append("Authorization", strconv.Itoa(inMetadataWire)) } return NewProtoMethodMessageWithValidateRequest(payload), nil } diff --git a/grpc/codegen/testdata/golden/response_decoder_response-decoder-bidirectional-streaming.go.golden b/grpc/codegen/testdata/golden/response_decoder_response-decoder-bidirectional-streaming.go.golden index a4be3322fb..25e9747823 100644 --- a/grpc/codegen/testdata/golden/response_decoder_response-decoder-bidirectional-streaming.go.golden +++ b/grpc/codegen/testdata/golden/response_decoder_response-decoder-bidirectional-streaming.go.golden @@ -1,15 +1,8 @@ // DecodeMethodBidirectionalStreamingRPCResponse decodes responses from the // ServiceBidirectionalStreamingRPC MethodBidirectionalStreamingRPC endpoint. func DecodeMethodBidirectionalStreamingRPCResponse(ctx context.Context, v any, hdr, trlr metadata.MD) (any, error) { - var view string - { - if vals := hdr.Get("goa-view"); len(vals) > 0 { - view = vals[0] - } - } return &MethodBidirectionalStreamingRPCClientStream{ stream: v.(service_bidirectional_streaming_rpcpb.ServiceBidirectionalStreamingRPC_MethodBidirectionalStreamingRPCClient), ctx: ctx, - view: view, }, nil } diff --git a/grpc/codegen/testdata/golden/response_decoder_response-decoder-result-array.go.golden b/grpc/codegen/testdata/golden/response_decoder_response-decoder-result-array.go.golden index 250c19d528..ac25f00cae 100644 --- a/grpc/codegen/testdata/golden/response_decoder_response-decoder-result-array.go.golden +++ b/grpc/codegen/testdata/golden/response_decoder_response-decoder-result-array.go.golden @@ -5,9 +5,6 @@ func DecodeMethodMessageArrayResponse(ctx context.Context, v any, hdr, trlr meta if !ok { return nil, goagrpc.ErrInvalidType("ServiceMessageArray", "MethodMessageArray", "*service_message_arraypb.MethodMessageArrayResponse", v) } - if err := ValidateMethodMessageArrayResponse(message); err != nil { - return nil, err - } res := NewMethodMessageArrayResult(message) return res, nil } diff --git a/grpc/codegen/testdata/golden/response_decoder_response-decoder-result-collection.go.golden b/grpc/codegen/testdata/golden/response_decoder_response-decoder-result-collection.go.golden index 67e0bf115d..9e1d02139c 100644 --- a/grpc/codegen/testdata/golden/response_decoder_response-decoder-result-collection.go.golden +++ b/grpc/codegen/testdata/golden/response_decoder_response-decoder-result-collection.go.golden @@ -12,7 +12,13 @@ func DecodeMethodMessageUserTypeWithNestedUserTypesResponse(ctx context.Context, if !ok { return nil, goagrpc.ErrInvalidType("ServiceMessageUserTypeWithNestedUserTypes", "MethodMessageUserTypeWithNestedUserTypes", "*service_message_user_type_with_nested_user_typespb.RTCollection", v) } - res := NewMethodMessageUserTypeWithNestedUserTypesResult(message) + var res servicemessageusertypewithnestedusertypesviews.RTCollectionView + switch view { + case "default", "": + res = NewMethodMessageUserTypeWithNestedUserTypesResult(message) + case "tiny": + res = NewMethodMessageUserTypeWithNestedUserTypesResultTiny(message) + } vres := servicemessageusertypewithnestedusertypesviews.RTCollection{Projected: res, View: view} if err := servicemessageusertypewithnestedusertypesviews.ValidateRTCollection(vres); err != nil { return nil, err diff --git a/grpc/codegen/testdata/golden/response_decoder_response-decoder-result-primitive.go.golden b/grpc/codegen/testdata/golden/response_decoder_response-decoder-result-primitive.go.golden index 36ffb88005..faf41f3f9c 100644 --- a/grpc/codegen/testdata/golden/response_decoder_response-decoder-result-primitive.go.golden +++ b/grpc/codegen/testdata/golden/response_decoder_response-decoder-result-primitive.go.golden @@ -5,6 +5,9 @@ func DecodeMethodUnaryRPCNoPayloadResponse(ctx context.Context, v any, hdr, trlr if !ok { return nil, goagrpc.ErrInvalidType("ServiceUnaryRPCNoPayload", "MethodUnaryRPCNoPayload", "*service_unary_rpc_no_payloadpb.MethodUnaryRPCNoPayloadResponse", v) } + if err := ValidateMethodUnaryRPCNoPayloadResponse(message); err != nil { + return nil, err + } res := NewMethodUnaryRPCNoPayloadResult(message) return res, nil } diff --git a/grpc/codegen/testdata/golden/response_decoder_response-decoder-result-with-explicit-view.go.golden b/grpc/codegen/testdata/golden/response_decoder_response-decoder-result-with-explicit-view.go.golden index 6b8a15f20a..362aaa8907 100644 --- a/grpc/codegen/testdata/golden/response_decoder_response-decoder-result-with-explicit-view.go.golden +++ b/grpc/codegen/testdata/golden/response_decoder_response-decoder-result-with-explicit-view.go.golden @@ -2,18 +2,15 @@ // the ServiceMessageResultTypeWithExplicitView // MethodMessageResultTypeWithExplicitView endpoint. func DecodeMethodMessageResultTypeWithExplicitViewResponse(ctx context.Context, v any, hdr, trlr metadata.MD) (any, error) { - var view string - { - if vals := hdr.Get("goa-view"); len(vals) > 0 { - view = vals[0] - } - } message, ok := v.(*service_message_result_type_with_explicit_viewpb.MethodMessageResultTypeWithExplicitViewResponse) if !ok { return nil, goagrpc.ErrInvalidType("ServiceMessageResultTypeWithExplicitView", "MethodMessageResultTypeWithExplicitView", "*service_message_result_type_with_explicit_viewpb.MethodMessageResultTypeWithExplicitViewResponse", v) } + if err := ValidateMethodMessageResultTypeWithExplicitViewResponseTiny(message); err != nil { + return nil, err + } res := NewMethodMessageResultTypeWithExplicitViewResult(message) - vres := &servicemessageresulttypewithexplicitviewviews.RT{Projected: res, View: view} + vres := &servicemessageresulttypewithexplicitviewviews.RT{Projected: res, View: "tiny"} if err := servicemessageresulttypewithexplicitviewviews.ValidateRT(vres); err != nil { return nil, err } diff --git a/grpc/codegen/testdata/golden/response_decoder_response-decoder-result-with-metadata.go.golden b/grpc/codegen/testdata/golden/response_decoder_response-decoder-result-with-metadata.go.golden index ec7d24797c..8b99c522ef 100644 --- a/grpc/codegen/testdata/golden/response_decoder_response-decoder-result-with-metadata.go.golden +++ b/grpc/codegen/testdata/golden/response_decoder_response-decoder-result-with-metadata.go.golden @@ -9,7 +9,7 @@ func DecodeMethodMessageWithMetadataResponse(ctx context.Context, v any, hdr, tr { if vals := hdr.Get("Location"); len(vals) > 0 { - inHeaderRaw = vals[0] + inHeaderRaw := vals[0] v, err2 := strconv.ParseInt(inHeaderRaw, 10, strconv.IntSize) if err2 != nil { @@ -20,7 +20,7 @@ func DecodeMethodMessageWithMetadataResponse(ctx context.Context, v any, hdr, tr } if vals := trlr.Get("InTrailer"); len(vals) > 0 { - inTrailerRaw = vals[0] + inTrailerRaw := vals[0] v, err2 := strconv.ParseBool(inTrailerRaw) if err2 != nil { diff --git a/grpc/codegen/testdata/golden/response_decoder_response-decoder-result-with-validate.go.golden b/grpc/codegen/testdata/golden/response_decoder_response-decoder-result-with-validate.go.golden index f00c4be27a..e2d491dbe2 100644 --- a/grpc/codegen/testdata/golden/response_decoder_response-decoder-result-with-validate.go.golden +++ b/grpc/codegen/testdata/golden/response_decoder_response-decoder-result-with-validate.go.golden @@ -9,7 +9,7 @@ func DecodeMethodMessageWithValidateResponse(ctx context.Context, v any, hdr, tr { if vals := hdr.Get("Location"); len(vals) > 0 { - inHeaderRaw = vals[0] + inHeaderRaw := vals[0] v, err2 := strconv.ParseInt(inHeaderRaw, 10, strconv.IntSize) if err2 != nil { @@ -25,7 +25,7 @@ func DecodeMethodMessageWithValidateResponse(ctx context.Context, v any, hdr, tr } if vals := trlr.Get("InTrailer"); len(vals) > 0 { - inTrailerRaw = vals[0] + inTrailerRaw := vals[0] v, err2 := strconv.ParseBool(inTrailerRaw) if err2 != nil { diff --git a/grpc/codegen/testdata/golden/response_decoder_response-decoder-result-with-views.go.golden b/grpc/codegen/testdata/golden/response_decoder_response-decoder-result-with-views.go.golden index 0f229f21ab..fec0a3a4ed 100644 --- a/grpc/codegen/testdata/golden/response_decoder_response-decoder-result-with-views.go.golden +++ b/grpc/codegen/testdata/golden/response_decoder_response-decoder-result-with-views.go.golden @@ -11,7 +11,19 @@ func DecodeMethodMessageResultTypeWithViewsResponse(ctx context.Context, v any, if !ok { return nil, goagrpc.ErrInvalidType("ServiceMessageResultTypeWithViews", "MethodMessageResultTypeWithViews", "*service_message_result_type_with_viewspb.MethodMessageResultTypeWithViewsResponse", v) } - res := NewMethodMessageResultTypeWithViewsResult(message) + var res *servicemessageresulttypewithviewsviews.RTView + switch view { + case "tiny": + if err := ValidateMethodMessageResultTypeWithViewsResponseTiny(message); err != nil { + return nil, err + } + res = NewMethodMessageResultTypeWithViewsResultTiny(message) + case "default", "": + if err := ValidateMethodMessageResultTypeWithViewsResponse(message); err != nil { + return nil, err + } + res = NewMethodMessageResultTypeWithViewsResult(message) + } vres := &servicemessageresulttypewithviewsviews.RT{Projected: res, View: view} if err := servicemessageresulttypewithviewsviews.ValidateRT(vres); err != nil { return nil, err diff --git a/grpc/codegen/testdata/golden/response_decoder_response-decoder-server-streaming-result-with-views.go.golden b/grpc/codegen/testdata/golden/response_decoder_response-decoder-server-streaming-result-with-views.go.golden index c3409e5199..ce4e1a0e6f 100644 --- a/grpc/codegen/testdata/golden/response_decoder_response-decoder-server-streaming-result-with-views.go.golden +++ b/grpc/codegen/testdata/golden/response_decoder_response-decoder-server-streaming-result-with-views.go.golden @@ -1,15 +1,8 @@ // DecodeMethodServerStreamingUserTypeRPCResponse decodes responses from the // ServiceServerStreamingUserTypeRPC MethodServerStreamingUserTypeRPC endpoint. func DecodeMethodServerStreamingUserTypeRPCResponse(ctx context.Context, v any, hdr, trlr metadata.MD) (any, error) { - var view string - { - if vals := hdr.Get("goa-view"); len(vals) > 0 { - view = vals[0] - } - } return &MethodServerStreamingUserTypeRPCClientStream{ stream: v.(service_server_streaming_user_type_rpcpb.ServiceServerStreamingUserTypeRPC_MethodServerStreamingUserTypeRPCClient), ctx: ctx, - view: view, }, nil } diff --git a/grpc/codegen/testdata/golden/response_encoder_response-encoder-result-collection.go.golden b/grpc/codegen/testdata/golden/response_encoder_response-encoder-result-collection.go.golden index bb2fe25f47..333ac28f59 100644 --- a/grpc/codegen/testdata/golden/response_encoder_response-encoder-result-collection.go.golden +++ b/grpc/codegen/testdata/golden/response_encoder_response-encoder-result-collection.go.golden @@ -7,7 +7,15 @@ func EncodeMethodMessageUserTypeWithNestedUserTypesResponse(ctx context.Context, return nil, goagrpc.ErrInvalidType("ServiceMessageUserTypeWithNestedUserTypes", "MethodMessageUserTypeWithNestedUserTypes", "servicemessageusertypewithnestedusertypesviews.RTCollection", v) } result := vres.Projected + var resp *service_message_user_type_with_nested_user_typespb.RTCollection + switch vres.View { + case "default", "": + resp = NewProtoRTCollection(result) + case "tiny": + resp = NewProtoRTCollectionTiny(result) + default: + return nil, goa.InvalidEnumValueError("view", vres.View, []any{"default", "tiny"}) + } (*hdr).Append("goa-view", vres.View) - resp := NewProtoRTCollection(result) return resp, nil } diff --git a/grpc/codegen/testdata/golden/response_encoder_response-encoder-result-with-explicit-view.go.golden b/grpc/codegen/testdata/golden/response_encoder_response-encoder-result-with-explicit-view.go.golden index 525201a0f1..b8aa816b08 100644 --- a/grpc/codegen/testdata/golden/response_encoder_response-encoder-result-with-explicit-view.go.golden +++ b/grpc/codegen/testdata/golden/response_encoder_response-encoder-result-with-explicit-view.go.golden @@ -7,7 +7,7 @@ func EncodeMethodMessageResultTypeWithExplicitViewResponse(ctx context.Context, return nil, goagrpc.ErrInvalidType("ServiceMessageResultTypeWithExplicitView", "MethodMessageResultTypeWithExplicitView", "*servicemessageresulttypewithexplicitviewviews.RT", v) } result := vres.Projected - (*hdr).Append("goa-view", vres.View) resp := NewProtoMethodMessageResultTypeWithExplicitViewResponse(result) + (*hdr).Append("goa-view", "tiny") return resp, nil } diff --git a/grpc/codegen/testdata/golden/response_encoder_response-encoder-result-with-metadata.go.golden b/grpc/codegen/testdata/golden/response_encoder_response-encoder-result-with-metadata.go.golden index 2b501ff366..febb194a0d 100644 --- a/grpc/codegen/testdata/golden/response_encoder_response-encoder-result-with-metadata.go.golden +++ b/grpc/codegen/testdata/golden/response_encoder_response-encoder-result-with-metadata.go.golden @@ -7,12 +7,14 @@ func EncodeMethodMessageWithMetadataResponse(ctx context.Context, v any, hdr, tr } resp := NewProtoMethodMessageWithMetadataResponse(result) - if res.InHeader != nil { - (*hdr).Append("Location", fmt.Sprintf("%v", *p.InHeader)) + if result.InHeader != nil { + inHeaderWire := *result.InHeader + (*hdr).Append("Location", strconv.Itoa(inHeaderWire)) } - if res.InTrailer != nil { - (*trlr).Append("InTrailer", fmt.Sprintf("%v", *p.InTrailer)) + if result.InTrailer != nil { + inTrailerWire := *result.InTrailer + (*trlr).Append("InTrailer", strconv.FormatBool(inTrailerWire)) } return resp, nil } diff --git a/grpc/codegen/testdata/golden/response_encoder_response-encoder-result-with-validate.go.golden b/grpc/codegen/testdata/golden/response_encoder_response-encoder-result-with-validate.go.golden index 3257742570..a4acf54b13 100644 --- a/grpc/codegen/testdata/golden/response_encoder_response-encoder-result-with-validate.go.golden +++ b/grpc/codegen/testdata/golden/response_encoder_response-encoder-result-with-validate.go.golden @@ -7,12 +7,14 @@ func EncodeMethodMessageWithValidateResponse(ctx context.Context, v any, hdr, tr } resp := NewProtoMethodMessageWithValidateResponse(result) - if res.InHeader != nil { - (*hdr).Append("Location", fmt.Sprintf("%v", *p.InHeader)) + if result.InHeader != nil { + inHeaderWire := *result.InHeader + (*hdr).Append("Location", strconv.Itoa(inHeaderWire)) } - if res.InTrailer != nil { - (*trlr).Append("InTrailer", fmt.Sprintf("%v", *p.InTrailer)) + if result.InTrailer != nil { + inTrailerWire := *result.InTrailer + (*trlr).Append("InTrailer", strconv.FormatBool(inTrailerWire)) } return resp, nil } diff --git a/grpc/codegen/testdata/golden/response_encoder_response-encoder-result-with-views.go.golden b/grpc/codegen/testdata/golden/response_encoder_response-encoder-result-with-views.go.golden index 3699a16bf5..afd26b1453 100644 --- a/grpc/codegen/testdata/golden/response_encoder_response-encoder-result-with-views.go.golden +++ b/grpc/codegen/testdata/golden/response_encoder_response-encoder-result-with-views.go.golden @@ -7,7 +7,15 @@ func EncodeMethodMessageResultTypeWithViewsResponse(ctx context.Context, v any, return nil, goagrpc.ErrInvalidType("ServiceMessageResultTypeWithViews", "MethodMessageResultTypeWithViews", "*servicemessageresulttypewithviewsviews.RT", v) } result := vres.Projected + var resp *service_message_result_type_with_viewspb.MethodMessageResultTypeWithViewsResponse + switch vres.View { + case "tiny": + resp = NewProtoMethodMessageResultTypeWithViewsResponseTiny(result) + case "default", "": + resp = NewProtoMethodMessageResultTypeWithViewsResponse(result) + default: + return nil, goa.InvalidEnumValueError("view", vres.View, []any{"tiny", "default"}) + } (*hdr).Append("goa-view", vres.View) - resp := NewProtoMethodMessageResultTypeWithViewsResponse(result) return resp, nil } diff --git a/grpc/codegen/testdata/golden/server_types_server-alias-validation.go.golden b/grpc/codegen/testdata/golden/server_types_server-alias-validation.go.golden index cb8b274a93..44ce390277 100644 --- a/grpc/codegen/testdata/golden/server_types_server-alias-validation.go.golden +++ b/grpc/codegen/testdata/golden/server_types_server-alias-validation.go.golden @@ -1,14 +1,12 @@ -// NewMethodElemValidationPayload builds the payload of the -// "MethodElemValidation" endpoint of the "ServiceElemValidation" service from -// the gRPC request type. +// NewMethodElemValidationPayload builds serviceelemvalidation.UUID from +// *service_elem_validationpb.UUID. func NewMethodElemValidationPayload(message *service_elem_validationpb.UUID) serviceelemvalidation.UUID { - v := serviceelemvalidation.UUID(message.Field) + v := serviceelemvalidation.UUID(*message.Field) return v } -// NewProtoMethodElemValidationResponse builds the gRPC response type from the -// result of the "MethodElemValidation" endpoint of the "ServiceElemValidation" -// service. +// NewProtoMethodElemValidationResponse builds +// *service_elem_validationpb.MethodElemValidationResponse from metadata values. func NewProtoMethodElemValidationResponse() *service_elem_validationpb.MethodElemValidationResponse { message := &service_elem_validationpb.MethodElemValidationResponse{} return message @@ -16,6 +14,11 @@ func NewProtoMethodElemValidationResponse() *service_elem_validationpb.MethodEle // ValidateUUID runs the validations defined on UUID. func ValidateUUID(message *service_elem_validationpb.UUID) (err error) { - err = goa.MergeErrors(err, goa.ValidateFormat("message.field", message.Field, goa.FormatUUID)) + if message.Field == nil { + err = goa.MergeErrors(err, goa.MissingFieldError("field", "message")) + } + if message.Field != nil { + err = goa.MergeErrors(err, goa.ValidateFormat("message.field", *message.Field, goa.FormatUUID)) + } return } diff --git a/grpc/codegen/testdata/golden/server_types_server-default-fields.go.golden b/grpc/codegen/testdata/golden/server_types_server-default-fields.go.golden index 806756a5d4..b0aa3d164c 100644 --- a/grpc/codegen/testdata/golden/server_types_server-default-fields.go.golden +++ b/grpc/codegen/testdata/golden/server_types_server-default-fields.go.golden @@ -1,12 +1,12 @@ -// NewMethodPayload builds the payload of the "Method" endpoint of the -// "DefaultFields" service from the gRPC request type. +// NewMethodPayload builds *defaultfields.MethodPayload from +// *default_fieldspb.MethodRequest. func NewMethodPayload(message *default_fieldspb.MethodRequest) *defaultfields.MethodPayload { v := &defaultfields.MethodPayload{ - Req: message.Req, + Req: *message.Req, Opt: message.Opt, - Reqs: message.Reqs, + Reqs: *message.Reqs, Opts: message.Opts, - Rat: message.Rat, + Rat: *message.Rat, Flt: message.Flt, } if message.Def0 != nil { @@ -54,9 +54,23 @@ func NewMethodPayload(message *default_fieldspb.MethodRequest) *defaultfields.Me return v } -// NewProtoMethodResponse builds the gRPC response type from the result of the -// "Method" endpoint of the "DefaultFields" service. +// NewProtoMethodResponse builds *default_fieldspb.MethodResponse from metadata +// values. func NewProtoMethodResponse() *default_fieldspb.MethodResponse { message := &default_fieldspb.MethodResponse{} return message } + +// ValidateMethodRequest runs the validations defined on MethodRequest. +func ValidateMethodRequest(message *default_fieldspb.MethodRequest) (err error) { + if message.Req == nil { + err = goa.MergeErrors(err, goa.MissingFieldError("req", "message")) + } + if message.Reqs == nil { + err = goa.MergeErrors(err, goa.MissingFieldError("reqs", "message")) + } + if message.Rat == nil { + err = goa.MergeErrors(err, goa.MissingFieldError("rat", "message")) + } + return +} diff --git a/grpc/codegen/testdata/golden/server_types_server-elem-validation.go.golden b/grpc/codegen/testdata/golden/server_types_server-elem-validation.go.golden index 46fa2111a8..85d890aa27 100644 --- a/grpc/codegen/testdata/golden/server_types_server-elem-validation.go.golden +++ b/grpc/codegen/testdata/golden/server_types_server-elem-validation.go.golden @@ -1,15 +1,17 @@ -// NewMethodElemValidationPayload builds the payload of the -// "MethodElemValidation" endpoint of the "ServiceElemValidation" service from -// the gRPC request type. +// NewMethodElemValidationPayload builds *serviceelemvalidation.PayloadType +// from *service_elem_validationpb.MethodElemValidationRequest. func NewMethodElemValidationPayload(message *service_elem_validationpb.MethodElemValidationRequest) *serviceelemvalidation.PayloadType { v := &serviceelemvalidation.PayloadType{} if message.Foo != nil { v.Foo = make(map[string][]string, len(message.Foo)) for key, val := range message.Foo { tk := key - tv := make([]string, len(val.Field)) - for i, val := range val.Field { - tv[i] = val + var tv []string + if val != nil { + tv = make([]string, len(val.Field)) + for i, val := range val.Field { + tv[i] = val + } } v.Foo[tk] = tv } @@ -17,9 +19,8 @@ func NewMethodElemValidationPayload(message *service_elem_validationpb.MethodEle return v } -// NewProtoMethodElemValidationResponse builds the gRPC response type from the -// result of the "MethodElemValidation" endpoint of the "ServiceElemValidation" -// service. +// NewProtoMethodElemValidationResponse builds +// *service_elem_validationpb.MethodElemValidationResponse from metadata values. func NewProtoMethodElemValidationResponse() *service_elem_validationpb.MethodElemValidationResponse { message := &service_elem_validationpb.MethodElemValidationResponse{} return message @@ -29,22 +30,22 @@ func NewProtoMethodElemValidationResponse() *service_elem_validationpb.MethodEle // MethodElemValidationRequest. func ValidateMethodElemValidationRequest(message *service_elem_validationpb.MethodElemValidationRequest) (err error) { for _, v := range message.Foo { - if v != nil { - if err2 := ValidateArrayOfString(v); err2 != nil { - err = goa.MergeErrors(err, err2) - } + if err2 := validatetest_20_api_ServiceElemValidation_ArrayOfString_At_val(v); err2 != nil { + err = goa.MergeErrors(err, err2) } } return } -// ValidateArrayOfString runs the validations defined on ArrayOfString. -func ValidateArrayOfString(val *service_elem_validationpb.ArrayOfString) (err error) { - if val.Field == nil { - err = goa.MergeErrors(err, goa.MissingFieldError("field", "val")) +// validatetest_20_api_ServiceElemValidation_ArrayOfString_At_val runs the +// validations defined on ArrayOfString. +func validatetest_20_api_ServiceElemValidation_ArrayOfString_At_val(val *service_elem_validationpb.ArrayOfString) (err error) { + var valField []string + if val != nil { + valField = val.Field } - if len(val.Field) < 1 { - err = goa.MergeErrors(err, goa.InvalidLengthError("val.field", val.Field, len(val.Field), 1, true)) + if len(valField) < 1 { + err = goa.MergeErrors(err, goa.InvalidLengthError("val.field", valField, len(valField), 1, true)) } return } diff --git a/grpc/codegen/testdata/golden/server_types_server-payload-with-alias-type.go.golden b/grpc/codegen/testdata/golden/server_types_server-payload-with-alias-type.go.golden index 565b291bff..a34260c79e 100644 --- a/grpc/codegen/testdata/golden/server_types_server-payload-with-alias-type.go.golden +++ b/grpc/codegen/testdata/golden/server_types_server-payload-with-alias-type.go.golden @@ -1,9 +1,9 @@ -// NewMethodMessageUserTypeWithAliasPayload builds the payload of the -// "MethodMessageUserTypeWithAlias" endpoint of the -// "ServiceMessageUserTypeWithAlias" service from the gRPC request type. +// NewMethodMessageUserTypeWithAliasPayload builds +// *servicemessageusertypewithalias.PayloadAliasT from +// *service_message_user_type_with_aliaspb.MethodMessageUserTypeWithAliasRequest. func NewMethodMessageUserTypeWithAliasPayload(message *service_message_user_type_with_aliaspb.MethodMessageUserTypeWithAliasRequest) *servicemessageusertypewithalias.PayloadAliasT { v := &servicemessageusertypewithalias.PayloadAliasT{ - IntAliasField: servicemessageusertypewithalias.IntAlias(message.IntAliasField), + IntAliasField: servicemessageusertypewithalias.IntAlias(*message.IntAliasField), } if message.OptionalIntAliasField != nil { optionalIntAliasField := servicemessageusertypewithalias.IntAlias(*message.OptionalIntAliasField) @@ -12,16 +12,25 @@ func NewMethodMessageUserTypeWithAliasPayload(message *service_message_user_type return v } -// NewProtoMethodMessageUserTypeWithAliasResponse builds the gRPC response type -// from the result of the "MethodMessageUserTypeWithAlias" endpoint of the -// "ServiceMessageUserTypeWithAlias" service. +// NewProtoMethodMessageUserTypeWithAliasResponse builds +// *service_message_user_type_with_aliaspb.MethodMessageUserTypeWithAliasResponse +// from *servicemessageusertypewithalias.PayloadAliasT. func NewProtoMethodMessageUserTypeWithAliasResponse(result *servicemessageusertypewithalias.PayloadAliasT) *service_message_user_type_with_aliaspb.MethodMessageUserTypeWithAliasResponse { - message := &service_message_user_type_with_aliaspb.MethodMessageUserTypeWithAliasResponse{ - IntAliasField: int32(result.IntAliasField), - } + message := &service_message_user_type_with_aliaspb.MethodMessageUserTypeWithAliasResponse{} + intAliasField := int32(result.IntAliasField) + message.IntAliasField = &intAliasField if result.OptionalIntAliasField != nil { optionalIntAliasField := int32(*result.OptionalIntAliasField) message.OptionalIntAliasField = &optionalIntAliasField } return message } + +// ValidateMethodMessageUserTypeWithAliasRequest runs the validations defined +// on MethodMessageUserTypeWithAliasRequest. +func ValidateMethodMessageUserTypeWithAliasRequest(message *service_message_user_type_with_aliaspb.MethodMessageUserTypeWithAliasRequest) (err error) { + if message.IntAliasField == nil { + err = goa.MergeErrors(err, goa.MissingFieldError("IntAliasField", "message")) + } + return +} diff --git a/grpc/codegen/testdata/golden/server_types_server-payload-with-custom-type-package.go.golden b/grpc/codegen/testdata/golden/server_types_server-payload-with-custom-type-package.go.golden index 2ca3f95d14..a6317b5f99 100644 --- a/grpc/codegen/testdata/golden/server_types_server-payload-with-custom-type-package.go.golden +++ b/grpc/codegen/testdata/golden/server_types_server-payload-with-custom-type-package.go.golden @@ -1,6 +1,5 @@ -// NewMethodPayloadWithCustomTypePackagePayload builds the payload of the -// "MethodPayloadWithCustomTypePackage" endpoint of the -// "ServicePayloadWithCustomTypePackage" service from the gRPC request type. +// NewMethodPayloadWithCustomTypePackagePayload builds *types.CustomType from +// *service_payload_with_custom_type_packagepb.MethodPayloadWithCustomTypePackageRequest. func NewMethodPayloadWithCustomTypePackagePayload(message *service_payload_with_custom_type_packagepb.MethodPayloadWithCustomTypePackageRequest) *types.CustomType { v := &types.CustomType{} if message.Field != nil { @@ -10,9 +9,9 @@ func NewMethodPayloadWithCustomTypePackagePayload(message *service_payload_with_ return v } -// NewProtoMethodPayloadWithCustomTypePackageResponse builds the gRPC response -// type from the result of the "MethodPayloadWithCustomTypePackage" endpoint of -// the "ServicePayloadWithCustomTypePackage" service. +// NewProtoMethodPayloadWithCustomTypePackageResponse builds +// *service_payload_with_custom_type_packagepb.MethodPayloadWithCustomTypePackageResponse +// from *types.CustomType. func NewProtoMethodPayloadWithCustomTypePackageResponse(result *types.CustomType) *service_payload_with_custom_type_packagepb.MethodPayloadWithCustomTypePackageResponse { message := &service_payload_with_custom_type_packagepb.MethodPayloadWithCustomTypePackageResponse{} if result.Field != nil { diff --git a/grpc/codegen/testdata/golden/server_types_server-payload-with-duplicate-use.go.golden b/grpc/codegen/testdata/golden/server_types_server-payload-with-duplicate-use.go.golden index 7fc3e8f5eb..b049dede4b 100644 --- a/grpc/codegen/testdata/golden/server_types_server-payload-with-duplicate-use.go.golden +++ b/grpc/codegen/testdata/golden/server_types_server-payload-with-duplicate-use.go.golden @@ -1,31 +1,30 @@ -// NewMethodPayloadDuplicateAPayload builds the payload of the -// "MethodPayloadDuplicateA" endpoint of the "ServicePayloadWithNestedTypes" -// service from the gRPC request type. -func NewMethodPayloadDuplicateAPayload(message *service_payload_with_nested_typespb.DupePayload) servicepayloadwithnestedtypes.DupePayload { - v := servicepayloadwithnestedtypes.DupePayload(message.Field) +// NewDupePayload builds servicepayloadwithnestedtypes.DupePayload from +// *service_payload_with_nested_typespb.DupePayload. +func NewDupePayload(message *service_payload_with_nested_typespb.DupePayload) servicepayloadwithnestedtypes.DupePayload { + v := servicepayloadwithnestedtypes.DupePayload(*message.Field) return v } -// NewProtoMethodPayloadDuplicateAResponse builds the gRPC response type from -// the result of the "MethodPayloadDuplicateA" endpoint of the -// "ServicePayloadWithNestedTypes" service. +// NewProtoMethodPayloadDuplicateAResponse builds +// *service_payload_with_nested_typespb.MethodPayloadDuplicateAResponse from +// metadata values. func NewProtoMethodPayloadDuplicateAResponse() *service_payload_with_nested_typespb.MethodPayloadDuplicateAResponse { message := &service_payload_with_nested_typespb.MethodPayloadDuplicateAResponse{} return message } -// NewMethodPayloadDuplicateBPayload builds the payload of the -// "MethodPayloadDuplicateB" endpoint of the "ServicePayloadWithNestedTypes" -// service from the gRPC request type. -func NewMethodPayloadDuplicateBPayload(message *service_payload_with_nested_typespb.DupePayload) servicepayloadwithnestedtypes.DupePayload { - v := servicepayloadwithnestedtypes.DupePayload(message.Field) - return v -} - -// NewProtoMethodPayloadDuplicateBResponse builds the gRPC response type from -// the result of the "MethodPayloadDuplicateB" endpoint of the -// "ServicePayloadWithNestedTypes" service. +// NewProtoMethodPayloadDuplicateBResponse builds +// *service_payload_with_nested_typespb.MethodPayloadDuplicateBResponse from +// metadata values. func NewProtoMethodPayloadDuplicateBResponse() *service_payload_with_nested_typespb.MethodPayloadDuplicateBResponse { message := &service_payload_with_nested_typespb.MethodPayloadDuplicateBResponse{} return message } + +// ValidateDupePayload runs the validations defined on DupePayload. +func ValidateDupePayload(message *service_payload_with_nested_typespb.DupePayload) (err error) { + if message.Field == nil { + err = goa.MergeErrors(err, goa.MissingFieldError("field", "message")) + } + return +} diff --git a/grpc/codegen/testdata/golden/server_types_server-payload-with-mixed-attributes.go.golden b/grpc/codegen/testdata/golden/server_types_server-payload-with-mixed-attributes.go.golden index 2c4e2f962e..aaae7f3376 100644 --- a/grpc/codegen/testdata/golden/server_types_server-payload-with-mixed-attributes.go.golden +++ b/grpc/codegen/testdata/golden/server_types_server-payload-with-mixed-attributes.go.golden @@ -1,9 +1,9 @@ -// NewUnaryMethodPayload builds the payload of the "UnaryMethod" endpoint of -// the "ServicePayloadWithMixedAttributes" service from the gRPC request type. +// NewUnaryMethodPayload builds *servicepayloadwithmixedattributes.APayload +// from *service_payload_with_mixed_attributespb.UnaryMethodRequest. func NewUnaryMethodPayload(message *service_payload_with_mixed_attributespb.UnaryMethodRequest) *servicepayloadwithmixedattributes.APayload { v := &servicepayloadwithmixedattributes.APayload{ - Required: int(message.Required), - RequiredDefault: int(message.RequiredDefault), + Required: int(*message.Required), + RequiredDefault: int(*message.RequiredDefault), } if message.Optional != nil { optional := int(*message.Optional) @@ -18,26 +18,29 @@ func NewUnaryMethodPayload(message *service_payload_with_mixed_attributespb.Unar return v } -// NewProtoUnaryMethodResponse builds the gRPC response type from the result of -// the "UnaryMethod" endpoint of the "ServicePayloadWithMixedAttributes" -// service. +// NewProtoUnaryMethodResponse builds +// *service_payload_with_mixed_attributespb.UnaryMethodResponse from metadata +// values. func NewProtoUnaryMethodResponse() *service_payload_with_mixed_attributespb.UnaryMethodResponse { message := &service_payload_with_mixed_attributespb.UnaryMethodResponse{} return message } -// NewProtoStreamingMethodResponse builds the gRPC response type from the -// result of the "StreamingMethod" endpoint of the -// "ServicePayloadWithMixedAttributes" service. +// NewProtoStreamingMethodResponse builds +// *service_payload_with_mixed_attributespb.StreamingMethodResponse from +// metadata values. func NewProtoStreamingMethodResponse() *service_payload_with_mixed_attributespb.StreamingMethodResponse { message := &service_payload_with_mixed_attributespb.StreamingMethodResponse{} return message } +// NewStreamingMethodStreamingRequestAPayload builds +// *servicepayloadwithmixedattributes.APayload from +// *service_payload_with_mixed_attributespb.StreamingMethodStreamingRequest. func NewStreamingMethodStreamingRequestAPayload(v *service_payload_with_mixed_attributespb.StreamingMethodStreamingRequest) *servicepayloadwithmixedattributes.APayload { spayload := &servicepayloadwithmixedattributes.APayload{ - Required: int(v.Required), - RequiredDefault: int(v.RequiredDefault), + Required: int(*v.Required), + RequiredDefault: int(*v.RequiredDefault), } if v.Optional != nil { optional := int(*v.Optional) @@ -51,3 +54,27 @@ func NewStreamingMethodStreamingRequestAPayload(v *service_payload_with_mixed_at } return spayload } + +// ValidateUnaryMethodRequest runs the validations defined on +// UnaryMethodRequest. +func ValidateUnaryMethodRequest(message *service_payload_with_mixed_attributespb.UnaryMethodRequest) (err error) { + if message.Required == nil { + err = goa.MergeErrors(err, goa.MissingFieldError("required", "message")) + } + if message.RequiredDefault == nil { + err = goa.MergeErrors(err, goa.MissingFieldError("required_default", "message")) + } + return +} + +// ValidateStreamingMethodStreamingRequest runs the validations defined on +// StreamingMethodStreamingRequest. +func ValidateStreamingMethodStreamingRequest(stream *service_payload_with_mixed_attributespb.StreamingMethodStreamingRequest) (err error) { + if stream.Required == nil { + err = goa.MergeErrors(err, goa.MissingFieldError("required", "stream")) + } + if stream.RequiredDefault == nil { + err = goa.MergeErrors(err, goa.MissingFieldError("required_default", "stream")) + } + return +} diff --git a/grpc/codegen/testdata/golden/server_types_server-payload-with-nested-types.go.golden b/grpc/codegen/testdata/golden/server_types_server-payload-with-nested-types.go.golden index 468b18e174..e9c678c9d4 100644 --- a/grpc/codegen/testdata/golden/server_types_server-payload-with-nested-types.go.golden +++ b/grpc/codegen/testdata/golden/server_types_server-payload-with-nested-types.go.golden @@ -1,71 +1,40 @@ -// NewMethodPayloadWithNestedTypesPayload builds the payload of the -// "MethodPayloadWithNestedTypes" endpoint of the -// "ServicePayloadWithNestedTypes" service from the gRPC request type. +// NewMethodPayloadWithNestedTypesPayload builds +// *servicepayloadwithnestedtypes.MethodPayloadWithNestedTypesPayload from +// *service_payload_with_nested_typespb.MethodPayloadWithNestedTypesRequest. func NewMethodPayloadWithNestedTypesPayload(message *service_payload_with_nested_typespb.MethodPayloadWithNestedTypesRequest) *servicepayloadwithnestedtypes.MethodPayloadWithNestedTypesPayload { v := &servicepayloadwithnestedtypes.MethodPayloadWithNestedTypesPayload{} if message.AParams != nil { - v.AParams = protobufServicePayloadWithNestedTypespbAParamsToServicepayloadwithnestedtypesAParams(message.AParams) + v.AParams = transformProtoAParamsToAParams(message.AParams) } if message.BParams != nil { - v.BParams = protobufServicePayloadWithNestedTypespbBParamsToServicepayloadwithnestedtypesBParams(message.BParams) + v.BParams = transformProtoBParamsToBParams(message.BParams) } return v } -// NewProtoMethodPayloadWithNestedTypesResponse builds the gRPC response type -// from the result of the "MethodPayloadWithNestedTypes" endpoint of the -// "ServicePayloadWithNestedTypes" service. +// NewProtoMethodPayloadWithNestedTypesResponse builds +// *service_payload_with_nested_typespb.MethodPayloadWithNestedTypesResponse +// from metadata values. func NewProtoMethodPayloadWithNestedTypesResponse() *service_payload_with_nested_typespb.MethodPayloadWithNestedTypesResponse { message := &service_payload_with_nested_typespb.MethodPayloadWithNestedTypesResponse{} return message } -// ValidateMethodPayloadWithNestedTypesRequest runs the validations defined on -// MethodPayloadWithNestedTypesRequest. -func ValidateMethodPayloadWithNestedTypesRequest(message *service_payload_with_nested_typespb.MethodPayloadWithNestedTypesRequest) (err error) { - if message.AParams != nil { - if err2 := ValidateAParams(message.AParams); err2 != nil { - err = goa.MergeErrors(err, err2) - } - } - return -} - -// ValidateAParams runs the validations defined on AParams. -func ValidateAParams(aParams *service_payload_with_nested_typespb.AParams) (err error) { - for _, v := range aParams.A { - if v != nil { - if err2 := ValidateArrayOfString(v); err2 != nil { - err = goa.MergeErrors(err, err2) - } - } - } - return -} - -// ValidateArrayOfString runs the validations defined on ArrayOfString. -func ValidateArrayOfString(val *service_payload_with_nested_typespb.ArrayOfString) (err error) { - if val.Field == nil { - err = goa.MergeErrors(err, goa.MissingFieldError("field", "val")) - } - return -} - -// protobufServicePayloadWithNestedTypespbAParamsToServicepayloadwithnestedtypesAParams -// builds a value of type *servicepayloadwithnestedtypes.AParams from a value -// of type *service_payload_with_nested_typespb.AParams. -func protobufServicePayloadWithNestedTypespbAParamsToServicepayloadwithnestedtypesAParams(v *service_payload_with_nested_typespb.AParams) *servicepayloadwithnestedtypes.AParams { - if v == nil { - return nil - } +// transformProtoAParamsToAParams builds a value of type +// *servicepayloadwithnestedtypes.AParams from a value of type +// *service_payload_with_nested_typespb.AParams. +func transformProtoAParamsToAParams(v *service_payload_with_nested_typespb.AParams) *servicepayloadwithnestedtypes.AParams { res := &servicepayloadwithnestedtypes.AParams{} if v.A != nil { res.A = make(map[string][]string, len(v.A)) for key, val := range v.A { tk := key - tv := make([]string, len(val.Field)) - for i, val := range val.Field { - tv[i] = val + var tv []string + if val != nil { + tv = make([]string, len(val.Field)) + for i, val := range val.Field { + tv[i] = val + } } res.A[tk] = tv } @@ -74,13 +43,10 @@ func protobufServicePayloadWithNestedTypespbAParamsToServicepayloadwithnestedtyp return res } -// protobufServicePayloadWithNestedTypespbBParamsToServicepayloadwithnestedtypesBParams -// builds a value of type *servicepayloadwithnestedtypes.BParams from a value -// of type *service_payload_with_nested_typespb.BParams. -func protobufServicePayloadWithNestedTypespbBParamsToServicepayloadwithnestedtypesBParams(v *service_payload_with_nested_typespb.BParams) *servicepayloadwithnestedtypes.BParams { - if v == nil { - return nil - } +// transformProtoBParamsToBParams builds a value of type +// *servicepayloadwithnestedtypes.BParams from a value of type +// *service_payload_with_nested_typespb.BParams. +func transformProtoBParamsToBParams(v *service_payload_with_nested_typespb.BParams) *servicepayloadwithnestedtypes.BParams { res := &servicepayloadwithnestedtypes.BParams{} if v.B != nil { res.B = make(map[string]string, len(v.B)) @@ -93,47 +59,3 @@ func protobufServicePayloadWithNestedTypespbBParamsToServicepayloadwithnestedtyp return res } - -// svcServicepayloadwithnestedtypesAParamsToServicePayloadWithNestedTypespbAParams -// builds a value of type *service_payload_with_nested_typespb.AParams from a -// value of type *servicepayloadwithnestedtypes.AParams. -func svcServicepayloadwithnestedtypesAParamsToServicePayloadWithNestedTypespbAParams(v *servicepayloadwithnestedtypes.AParams) *service_payload_with_nested_typespb.AParams { - if v == nil { - return nil - } - res := &service_payload_with_nested_typespb.AParams{} - if v.A != nil { - res.A = make(map[string]*service_payload_with_nested_typespb.ArrayOfString, len(v.A)) - for key, val := range v.A { - tk := key - tv := &service_payload_with_nested_typespb.ArrayOfString{} - tv.Field = make([]string, len(val)) - for i, val := range val { - tv.Field[i] = val - } - res.A[tk] = tv - } - } - - return res -} - -// svcServicepayloadwithnestedtypesBParamsToServicePayloadWithNestedTypespbBParams -// builds a value of type *service_payload_with_nested_typespb.BParams from a -// value of type *servicepayloadwithnestedtypes.BParams. -func svcServicepayloadwithnestedtypesBParamsToServicePayloadWithNestedTypespbBParams(v *servicepayloadwithnestedtypes.BParams) *service_payload_with_nested_typespb.BParams { - if v == nil { - return nil - } - res := &service_payload_with_nested_typespb.BParams{} - if v.B != nil { - res.B = make(map[string]string, len(v.B)) - for key, val := range v.B { - tk := key - tv := val - res.B[tk] = tv - } - } - - return res -} diff --git a/grpc/codegen/testdata/golden/server_types_server-required-union-validation.go.golden b/grpc/codegen/testdata/golden/server_types_server-required-union-validation.go.golden new file mode 100644 index 0000000000..405de9e42c --- /dev/null +++ b/grpc/codegen/testdata/golden/server_types_server-required-union-validation.go.golden @@ -0,0 +1,70 @@ +// ValidateExchangeRequest runs the validations defined on ExchangeRequest. +func ValidateExchangeRequest(message *union_validationpb.ExchangeRequest) (err error) { + if message.Choice == nil { + err = goa.MergeErrors(err, goa.MissingFieldError("choice", "message")) + } + switch v := message.Choice.(type) { + case *union_validationpb.ExchangeRequest_Number: + if v == nil { + err = goa.MergeErrors(err, goa.MissingFieldError("number", "message.choice")) + break + } + if int(v.Number) < 1 { + err = goa.MergeErrors(err, goa.InvalidRangeError("message.choice.value", int(v.Number), 1, true)) + } + + case *union_validationpb.ExchangeRequest_Detail: + if v == nil { + err = goa.MergeErrors(err, goa.MissingFieldError("detail", "message.choice")) + break + } + if v.Detail == nil { + err = goa.MergeErrors(err, goa.MissingFieldError("detail", "message.choice")) + break + } + if v.Detail != nil { + if err2 := validatetest_20_api_UnionValidation_Detail_At_detail(v.Detail); err2 != nil { + err = goa.MergeErrors(err, err2) + } + } + + case *union_validationpb.ExchangeRequest_Inactive: + if v == nil { + err = goa.MergeErrors(err, goa.MissingFieldError("inactive", "message.choice")) + break + } + if v.Inactive == nil { + err = goa.MergeErrors(err, goa.MissingFieldError("inactive", "message.choice")) + break + } + + case *union_validationpb.ExchangeRequest_Blob: + if v == nil { + err = goa.MergeErrors(err, goa.MissingFieldError("blob", "message.choice")) + break + } + if v.Blob == nil { + err = goa.MergeErrors(err, goa.MissingFieldError("blob", "message.choice")) + break + } + + case *union_validationpb.ExchangeRequest_Token: + if v == nil { + err = goa.MergeErrors(err, goa.MissingFieldError("token", "message.choice")) + break + } + + case *union_validationpb.ExchangeRequest_Metadata: + if v == nil { + err = goa.MergeErrors(err, goa.MissingFieldError("metadata", "message.choice")) + break + } + if v.Metadata == nil { + err = goa.MergeErrors(err, goa.MissingFieldError("metadata", "message.choice")) + break + } + + } + + return +} diff --git a/grpc/codegen/testdata/golden/server_types_server-result-collection.go.golden b/grpc/codegen/testdata/golden/server_types_server-result-collection.go.golden index b6f63e4a56..ace306f00b 100644 --- a/grpc/codegen/testdata/golden/server_types_server-result-collection.go.golden +++ b/grpc/codegen/testdata/golden/server_types_server-result-collection.go.golden @@ -1,21 +1,18 @@ -// NewProtoMethodResultWithCollectionResponse builds the gRPC response type -// from the result of the "MethodResultWithCollection" endpoint of the -// "ServiceResultWithCollection" service. +// NewProtoMethodResultWithCollectionResponse builds +// *service_result_with_collectionpb.MethodResultWithCollectionResponse from +// *serviceresultwithcollection.MethodResultWithCollectionResult. func NewProtoMethodResultWithCollectionResponse(result *serviceresultwithcollection.MethodResultWithCollectionResult) *service_result_with_collectionpb.MethodResultWithCollectionResponse { message := &service_result_with_collectionpb.MethodResultWithCollectionResponse{} if result.Result != nil { - message.Result = svcServiceresultwithcollectionResultTToServiceResultWithCollectionpbResultT(result.Result) + message.Result = transformResultTToProtoResultT(result.Result) } return message } -// svcServiceresultwithcollectionResultTToServiceResultWithCollectionpbResultT -// builds a value of type *service_result_with_collectionpb.ResultT from a -// value of type *serviceresultwithcollection.ResultT. -func svcServiceresultwithcollectionResultTToServiceResultWithCollectionpbResultT(v *serviceresultwithcollection.ResultT) *service_result_with_collectionpb.ResultT { - if v == nil { - return nil - } +// transformResultTToProtoResultT builds a value of type +// *service_result_with_collectionpb.ResultT from a value of type +// *serviceresultwithcollection.ResultT. +func transformResultTToProtoResultT(v *serviceresultwithcollection.ResultT) *service_result_with_collectionpb.ResultT { res := &service_result_with_collectionpb.ResultT{} if v.CollectionField != nil { res.CollectionField = &service_result_with_collectionpb.RTCollection{} @@ -31,25 +28,3 @@ func svcServiceresultwithcollectionResultTToServiceResultWithCollectionpbResultT return res } - -// protobufServiceResultWithCollectionpbResultTToServiceresultwithcollectionResultT -// builds a value of type *serviceresultwithcollection.ResultT from a value of -// type *service_result_with_collectionpb.ResultT. -func protobufServiceResultWithCollectionpbResultTToServiceresultwithcollectionResultT(v *service_result_with_collectionpb.ResultT) *serviceresultwithcollection.ResultT { - if v == nil { - return nil - } - res := &serviceresultwithcollection.ResultT{} - if v.CollectionField != nil { - res.CollectionField = make([]*serviceresultwithcollection.RT, len(v.CollectionField.Field)) - for i, val := range v.CollectionField.Field { - res.CollectionField[i] = &serviceresultwithcollection.RT{} - if val.IntField != nil { - intField := int(*val.IntField) - res.CollectionField[i].IntField = &intField - } - } - } - - return res -} diff --git a/grpc/codegen/testdata/golden/server_types_server-result-field-name-collision.go.golden b/grpc/codegen/testdata/golden/server_types_server-result-field-name-collision.go.golden new file mode 100644 index 0000000000..38cb25e496 --- /dev/null +++ b/grpc/codegen/testdata/golden/server_types_server-result-field-name-collision.go.golden @@ -0,0 +1,8 @@ +// NewProtoShowResponse builds *result_field_name_collisionpb.ShowResponse from +// *resultfieldnamecollision.ToolResult. +func NewProtoShowResponse(result *resultfieldnamecollision.ToolResult) *result_field_name_collisionpb.ShowResponse { + message := &result_field_name_collisionpb.ShowResponse{} + result2 := string(result.Result) + message.Result = &result2 + return message +} diff --git a/grpc/codegen/testdata/golden/server_types_server-result-with-explicit-view.go.golden b/grpc/codegen/testdata/golden/server_types_server-result-with-explicit-view.go.golden new file mode 100644 index 0000000000..6e343c65c0 --- /dev/null +++ b/grpc/codegen/testdata/golden/server_types_server-result-with-explicit-view.go.golden @@ -0,0 +1,9 @@ +// NewProtoMethodMessageResultTypeWithExplicitViewResponse builds +// *service_message_result_type_with_explicit_viewpb.MethodMessageResultTypeWithExplicitViewResponse +// from *servicemessageresulttypewithexplicitviewviews.RTView. +func NewProtoMethodMessageResultTypeWithExplicitViewResponse(result *servicemessageresulttypewithexplicitviewviews.RTView) *service_message_result_type_with_explicit_viewpb.MethodMessageResultTypeWithExplicitViewResponse { + message := &service_message_result_type_with_explicit_viewpb.MethodMessageResultTypeWithExplicitViewResponse{} + intField := int32(*result.IntField) + message.IntField = &intField + return message +} diff --git a/grpc/codegen/testdata/golden/server_types_server-result-with-views.go.golden b/grpc/codegen/testdata/golden/server_types_server-result-with-views.go.golden new file mode 100644 index 0000000000..4b33aaa11e --- /dev/null +++ b/grpc/codegen/testdata/golden/server_types_server-result-with-views.go.golden @@ -0,0 +1,21 @@ +// NewProtoMethodMessageResultTypeWithViewsResponse builds +// *service_message_result_type_with_viewspb.MethodMessageResultTypeWithViewsResponse +// from *servicemessageresulttypewithviewsviews.RTView. +func NewProtoMethodMessageResultTypeWithViewsResponse(result *servicemessageresulttypewithviewsviews.RTView) *service_message_result_type_with_viewspb.MethodMessageResultTypeWithViewsResponse { + message := &service_message_result_type_with_viewspb.MethodMessageResultTypeWithViewsResponse{ + StringField: result.StringField, + } + intField := int32(*result.IntField) + message.IntField = &intField + return message +} + +// NewProtoMethodMessageResultTypeWithViewsResponseTiny builds +// *service_message_result_type_with_viewspb.MethodMessageResultTypeWithViewsResponse +// from *servicemessageresulttypewithviewsviews.RTView. +func NewProtoMethodMessageResultTypeWithViewsResponseTiny(result *servicemessageresulttypewithviewsviews.RTView) *service_message_result_type_with_viewspb.MethodMessageResultTypeWithViewsResponse { + message := &service_message_result_type_with_viewspb.MethodMessageResultTypeWithViewsResponse{} + intField := int32(*result.IntField) + message.IntField = &intField + return message +} diff --git a/grpc/codegen/testdata/golden/server_types_server-streaming-result-with-views.go.golden b/grpc/codegen/testdata/golden/server_types_server-streaming-result-with-views.go.golden new file mode 100644 index 0000000000..a1c151669d --- /dev/null +++ b/grpc/codegen/testdata/golden/server_types_server-streaming-result-with-views.go.golden @@ -0,0 +1,21 @@ +// NewProtoMethodServerStreamingUserTypeRPCResponse builds +// *service_server_streaming_user_type_rpcpb.MethodServerStreamingUserTypeRPCResponse +// from *serviceserverstreamingusertyperpcviews.ResultTypeView. +func NewProtoMethodServerStreamingUserTypeRPCResponse(result *serviceserverstreamingusertyperpcviews.ResultTypeView) *service_server_streaming_user_type_rpcpb.MethodServerStreamingUserTypeRPCResponse { + message := &service_server_streaming_user_type_rpcpb.MethodServerStreamingUserTypeRPCResponse{ + DoubleField: result.DoubleField, + } + intField := int32(*result.IntField) + message.IntField = &intField + return message +} + +// NewProtoMethodServerStreamingUserTypeRPCResponseTiny builds +// *service_server_streaming_user_type_rpcpb.MethodServerStreamingUserTypeRPCResponse +// from *serviceserverstreamingusertyperpcviews.ResultTypeView. +func NewProtoMethodServerStreamingUserTypeRPCResponseTiny(result *serviceserverstreamingusertyperpcviews.ResultTypeView) *service_server_streaming_user_type_rpcpb.MethodServerStreamingUserTypeRPCResponse { + message := &service_server_streaming_user_type_rpcpb.MethodServerStreamingUserTypeRPCResponse{} + intField := int32(*result.IntField) + message.IntField = &intField + return message +} diff --git a/grpc/codegen/testdata/golden/server_types_server-struct-field-name-meta-type.go.golden b/grpc/codegen/testdata/golden/server_types_server-struct-field-name-meta-type.go.golden index c0146338fd..fb61bb1517 100644 --- a/grpc/codegen/testdata/golden/server_types_server-struct-field-name-meta-type.go.golden +++ b/grpc/codegen/testdata/golden/server_types_server-struct-field-name-meta-type.go.golden @@ -1,5 +1,5 @@ -// NewMethodPayload builds the payload of the "Method" endpoint of the -// "UsingMetaTypes" service from the gRPC request type. +// NewMethodPayload builds *usingmetatypes.MethodPayload from +// *using_meta_typespb.MethodRequest. func NewMethodPayload(message *using_meta_typespb.MethodRequest) *usingmetatypes.MethodPayload { v := &usingmetatypes.MethodPayload{} if message.A != nil { @@ -17,8 +17,8 @@ func NewMethodPayload(message *using_meta_typespb.MethodRequest) *usingmetatypes return v } -// NewProtoMethodResponse builds the gRPC response type from the result of the -// "Method" endpoint of the "UsingMetaTypes" service. +// NewProtoMethodResponse builds *using_meta_typespb.MethodResponse from +// *usingmetatypes.MethodResult. func NewProtoMethodResponse(result *usingmetatypes.MethodResult) *using_meta_typespb.MethodResponse { message := &using_meta_typespb.MethodResponse{ A: &result.Foo, @@ -31,11 +31,3 @@ func NewProtoMethodResponse(result *usingmetatypes.MethodResult) *using_meta_typ } return message } - -// ValidateMethodRequest runs the validations defined on MethodRequest. -func ValidateMethodRequest(message *using_meta_typespb.MethodRequest) (err error) { - if message.B == nil { - err = goa.MergeErrors(err, goa.MissingFieldError("b", "message")) - } - return -} diff --git a/grpc/codegen/testdata/golden/server_types_server-struct-meta-type.go.golden b/grpc/codegen/testdata/golden/server_types_server-struct-meta-type.go.golden index d222a7cbf1..b70e2e4a99 100644 --- a/grpc/codegen/testdata/golden/server_types_server-struct-meta-type.go.golden +++ b/grpc/codegen/testdata/golden/server_types_server-struct-meta-type.go.golden @@ -1,5 +1,5 @@ -// NewMethodPayload builds the payload of the "Method" endpoint of the -// "UsingMetaTypes" service from the gRPC request type. +// NewMethodPayload builds *usingmetatypes.MethodPayload from +// *using_meta_typespb.MethodRequest. func NewMethodPayload(message *using_meta_typespb.MethodRequest) *usingmetatypes.MethodPayload { v := &usingmetatypes.MethodPayload{} if message.A != nil { @@ -27,8 +27,8 @@ func NewMethodPayload(message *using_meta_typespb.MethodRequest) *usingmetatypes return v } -// NewProtoMethodResponse builds the gRPC response type from the result of the -// "Method" endpoint of the "UsingMetaTypes" service. +// NewProtoMethodResponse builds *using_meta_typespb.MethodResponse from +// *usingmetatypes.MethodResult. func NewProtoMethodResponse(result *usingmetatypes.MethodResult) *using_meta_typespb.MethodResponse { message := &using_meta_typespb.MethodResponse{} a := int64(result.A) diff --git a/grpc/codegen/testdata/golden/server_types_server-with-errors.go.golden b/grpc/codegen/testdata/golden/server_types_server-with-errors.go.golden index 25c09c5c8b..d65e40197a 100644 --- a/grpc/codegen/testdata/golden/server_types_server-with-errors.go.golden +++ b/grpc/codegen/testdata/golden/server_types_server-with-errors.go.golden @@ -1,48 +1,57 @@ -// NewMethodUnaryRPCWithErrorsPayload builds the payload of the -// "MethodUnaryRPCWithErrors" endpoint of the "ServiceUnaryRPCWithErrors" -// service from the gRPC request type. +// NewMethodUnaryRPCWithErrorsPayload builds string from +// *service_unary_rpc_with_errorspb.MethodUnaryRPCWithErrorsRequest. func NewMethodUnaryRPCWithErrorsPayload(message *service_unary_rpc_with_errorspb.MethodUnaryRPCWithErrorsRequest) string { - v := message.Field + v := *message.Field return v } -// NewProtoMethodUnaryRPCWithErrorsResponse builds the gRPC response type from -// the result of the "MethodUnaryRPCWithErrors" endpoint of the -// "ServiceUnaryRPCWithErrors" service. +// NewProtoMethodUnaryRPCWithErrorsResponse builds +// *service_unary_rpc_with_errorspb.MethodUnaryRPCWithErrorsResponse from +// string. func NewProtoMethodUnaryRPCWithErrorsResponse(result string) *service_unary_rpc_with_errorspb.MethodUnaryRPCWithErrorsResponse { message := &service_unary_rpc_with_errorspb.MethodUnaryRPCWithErrorsResponse{} - message.Field = result + message.Field = new(string) + *message.Field = result return message } -// NewMethodUnaryRPCWithErrorsInternalError builds the gRPC error response type -// from the error of the "MethodUnaryRPCWithErrors" endpoint of the -// "ServiceUnaryRPCWithErrors" service. +// NewMethodUnaryRPCWithErrorsInternalError builds +// *service_unary_rpc_with_errorspb.MethodUnaryRPCWithErrorsInternalError from +// *serviceunaryrpcwitherrors.AnotherError. func NewMethodUnaryRPCWithErrorsInternalError(er *serviceunaryrpcwitherrors.AnotherError) *service_unary_rpc_with_errorspb.MethodUnaryRPCWithErrorsInternalError { message := &service_unary_rpc_with_errorspb.MethodUnaryRPCWithErrorsInternalError{ - Name: er.Name, + Name: &er.Name, Description: er.Description, } return message } -// NewMethodUnaryRPCWithErrorsBadRequestError builds the gRPC error response -// type from the error of the "MethodUnaryRPCWithErrors" endpoint of the -// "ServiceUnaryRPCWithErrors" service. +// NewMethodUnaryRPCWithErrorsBadRequestError builds +// *service_unary_rpc_with_errorspb.MethodUnaryRPCWithErrorsBadRequestError +// from *serviceunaryrpcwitherrors.AnotherError. func NewMethodUnaryRPCWithErrorsBadRequestError(er *serviceunaryrpcwitherrors.AnotherError) *service_unary_rpc_with_errorspb.MethodUnaryRPCWithErrorsBadRequestError { message := &service_unary_rpc_with_errorspb.MethodUnaryRPCWithErrorsBadRequestError{ - Name: er.Name, + Name: &er.Name, Description: er.Description, } return message } -// NewMethodUnaryRPCWithErrorsCustomErrorError builds the gRPC error response -// type from the error of the "MethodUnaryRPCWithErrors" endpoint of the -// "ServiceUnaryRPCWithErrors" service. +// NewMethodUnaryRPCWithErrorsCustomErrorError builds +// *service_unary_rpc_with_errorspb.MethodUnaryRPCWithErrorsCustomErrorError +// from *serviceunaryrpcwitherrors.ErrorType. func NewMethodUnaryRPCWithErrorsCustomErrorError(er *serviceunaryrpcwitherrors.ErrorType) *service_unary_rpc_with_errorspb.MethodUnaryRPCWithErrorsCustomErrorError { message := &service_unary_rpc_with_errorspb.MethodUnaryRPCWithErrorsCustomErrorError{ A: er.A, } return message } + +// ValidateMethodUnaryRPCWithErrorsRequest runs the validations defined on +// MethodUnaryRPCWithErrorsRequest. +func ValidateMethodUnaryRPCWithErrorsRequest(message *service_unary_rpc_with_errorspb.MethodUnaryRPCWithErrorsRequest) (err error) { + if message.Field == nil { + err = goa.MergeErrors(err, goa.MissingFieldError("field", "message")) + } + return +} diff --git a/grpc/codegen/testdata/golden/transform_helper_shared.go.golden b/grpc/codegen/testdata/golden/transform_helper_shared.go.golden new file mode 100644 index 0000000000..9d2a9ff5e5 --- /dev/null +++ b/grpc/codegen/testdata/golden/transform_helper_shared.go.golden @@ -0,0 +1,70 @@ +// NewProtoStoreRequest builds *shared_helperspb.StoreRequest from +// *sharedhelpers.StorePayload. +func NewProtoStoreRequest(payload *sharedhelpers.StorePayload) *shared_helperspb.StoreRequest { + message := &shared_helperspb.StoreRequest{} + if payload.Left != nil { + message.Left = transformSharedChildToProtoSharedChild(payload.Left) + } + if payload.Right != nil { + message.Right = transformSharedChildToProtoSharedChild(payload.Right) + } + return message +} + +// ValidateStoreRequest runs the validations defined on StoreRequest. +func ValidateStoreRequest(message *shared_helperspb.StoreRequest) (err error) { + if message.Left == nil { + err = goa.MergeErrors(err, goa.MissingFieldError("left", "message")) + } + if message.Left != nil { + if err2 := validatetest_20_api_SharedHelpers_SharedChild_At_left(message.Left); err2 != nil { + err = goa.MergeErrors(err, err2) + } + } + if message.Right != nil { + if err2 := validatetest_20_api_SharedHelpers_SharedChild_At_right(message.Right); err2 != nil { + err = goa.MergeErrors(err, err2) + } + } + return +} + +// validatetest_20_api_SharedHelpers_SharedChild_At_left runs the validations +// defined on SharedChild. +func validatetest_20_api_SharedHelpers_SharedChild_At_left(left *shared_helperspb.SharedChild) (err error) { + if left.Value == nil { + err = goa.MergeErrors(err, goa.MissingFieldError("value", "left")) + } + return +} + +// validatetest_20_api_SharedHelpers_SharedChild_At_right runs the validations +// defined on SharedChild. +func validatetest_20_api_SharedHelpers_SharedChild_At_right(right *shared_helperspb.SharedChild) (err error) { + if right.Value == nil { + err = goa.MergeErrors(err, goa.MissingFieldError("value", "right")) + } + return +} + +// transformSharedChildToProtoSharedChild builds a value of type +// *shared_helperspb.SharedChild from a value of type +// *sharedhelpers.SharedChild. +func transformSharedChildToProtoSharedChild(v *sharedhelpers.SharedChild) *shared_helperspb.SharedChild { + res := &shared_helperspb.SharedChild{ + Value: &v.Value, + } + + return res +} + +// transformProtoSharedChildToSharedChild builds a value of type +// *sharedhelpers.SharedChild from a value of type +// *shared_helperspb.SharedChild. +func transformProtoSharedChildToSharedChild(v *shared_helperspb.SharedChild) *sharedhelpers.SharedChild { + res := &sharedhelpers.SharedChild{ + Value: *v.Value, + } + + return res +} diff --git a/grpc/codegen/testdata/golden/transform_helper_shared_methods_client.go.golden b/grpc/codegen/testdata/golden/transform_helper_shared_methods_client.go.golden new file mode 100644 index 0000000000..da7456b7fe --- /dev/null +++ b/grpc/codegen/testdata/golden/transform_helper_shared_methods_client.go.golden @@ -0,0 +1,264 @@ +// NewProtoStoreRequest builds *shared_method_helperspb.StoreRequest from +// *sharedmethodhelpers.StorePayload. +func NewProtoStoreRequest(payload *sharedmethodhelpers.StorePayload) *shared_method_helperspb.StoreRequest { + message := &shared_method_helperspb.StoreRequest{} + if payload.Child != nil { + message.Child = transformSharedMethodChildToProtoSharedMethodChild(payload.Child) + } + return message +} + +// NewProtoReplaceRequest builds *shared_method_helperspb.ReplaceRequest from +// *sharedmethodhelpers.ReplacePayload. +func NewProtoReplaceRequest(payload *sharedmethodhelpers.ReplacePayload) *shared_method_helperspb.ReplaceRequest { + message := &shared_method_helperspb.ReplaceRequest{} + if payload.Child != nil { + message.Child = transformSharedMethodChildToProtoSharedMethodChild(payload.Child) + } + return message +} + +// NewProtoChooseRequest builds *shared_method_helperspb.ChooseRequest from +// *sharedmethodhelpers.ChoosePayload. +func NewProtoChooseRequest(payload *sharedmethodhelpers.ChoosePayload) *shared_method_helperspb.ChooseRequest { + message := &shared_method_helperspb.ChooseRequest{} + if payload.Choice.Kind() != "" { + switch string(payload.Choice.Kind()) { + case "First": + actual, _ := payload.Choice.AsFirst() + message.Choice = &shared_method_helperspb.ChooseRequest_First{First: transformSharedMethodChildToProtoSharedMethodChild(actual)} + case "Second": + actual, _ := payload.Choice.AsSecond() + message.Choice = &shared_method_helperspb.ChooseRequest_Second{Second: transformSharedMethodChildToProtoSharedMethodChild(actual)} + case "Third": + actual, _ := payload.Choice.AsThird() + message.Choice = &shared_method_helperspb.ChooseRequest_Third{Third: transformSharedMethodChildToProtoSharedMethodChild(actual)} + } + } + return message +} + +// NewProtoInspectRequest builds *shared_method_helperspb.InspectRequest from +// *sharedmethodhelpers.InspectPayload. +func NewProtoInspectRequest(payload *sharedmethodhelpers.InspectPayload) *shared_method_helperspb.InspectRequest { + message := &shared_method_helperspb.InspectRequest{} + if payload.Child != nil { + message.Child = transformNearMethodChildToProtoNearMethodChild(payload.Child) + } + return message +} + +// ValidateStoreRequest runs the validations defined on StoreRequest. +func ValidateStoreRequest(message *shared_method_helperspb.StoreRequest) (err error) { + if message.Child == nil { + err = goa.MergeErrors(err, goa.MissingFieldError("child", "message")) + } + if message.Child != nil { + if err2 := validatetest_20_api_SharedMethodHelpers_SharedMethodChild_At_child(message.Child); err2 != nil { + err = goa.MergeErrors(err, err2) + } + } + return +} + +// validatetest_20_api_SharedMethodHelpers_SharedMethodChild_At_child runs the +// validations defined on SharedMethodChild. +func validatetest_20_api_SharedMethodHelpers_SharedMethodChild_At_child(child *shared_method_helperspb.SharedMethodChild) (err error) { + if child.Value == nil { + err = goa.MergeErrors(err, goa.MissingFieldError("value", "child")) + } + if child.Next != nil { + if err2 := validatetest_20_api_SharedMethodHelpers_SharedMethodChild_At_next(child.Next); err2 != nil { + err = goa.MergeErrors(err, err2) + } + } + return +} + +// validatetest_20_api_SharedMethodHelpers_SharedMethodChild_At_next runs the +// validations defined on SharedMethodChild. +func validatetest_20_api_SharedMethodHelpers_SharedMethodChild_At_next(next *shared_method_helperspb.SharedMethodChild) (err error) { + if next.Value == nil { + err = goa.MergeErrors(err, goa.MissingFieldError("value", "next")) + } + if next.Next != nil { + if err2 := validatetest_20_api_SharedMethodHelpers_SharedMethodChild_At_next(next.Next); err2 != nil { + err = goa.MergeErrors(err, err2) + } + } + return +} + +// ValidateReplaceRequest runs the validations defined on ReplaceRequest. +func ValidateReplaceRequest(message *shared_method_helperspb.ReplaceRequest) (err error) { + if message.Child == nil { + err = goa.MergeErrors(err, goa.MissingFieldError("child", "message")) + } + if message.Child != nil { + if err2 := validatetest_20_api_SharedMethodHelpers_SharedMethodChild_At_child(message.Child); err2 != nil { + err = goa.MergeErrors(err, err2) + } + } + return +} + +// ValidateChooseRequest runs the validations defined on ChooseRequest. +func ValidateChooseRequest(message *shared_method_helperspb.ChooseRequest) (err error) { + if message.Choice == nil { + err = goa.MergeErrors(err, goa.MissingFieldError("choice", "message")) + } + switch v := message.Choice.(type) { + case *shared_method_helperspb.ChooseRequest_First: + if v == nil { + err = goa.MergeErrors(err, goa.MissingFieldError("First", "message.choice")) + break + } + if v.First == nil { + err = goa.MergeErrors(err, goa.MissingFieldError("First", "message.choice")) + break + } + if v.First != nil { + if err2 := validatetest_20_api_SharedMethodHelpers_SharedMethodChild_Target_first_Context_First(v.First); err2 != nil { + err = goa.MergeErrors(err, err2) + } + } + + case *shared_method_helperspb.ChooseRequest_Second: + if v == nil { + err = goa.MergeErrors(err, goa.MissingFieldError("Second", "message.choice")) + break + } + if v.Second == nil { + err = goa.MergeErrors(err, goa.MissingFieldError("Second", "message.choice")) + break + } + if v.Second != nil { + if err2 := validatetest_20_api_SharedMethodHelpers_SharedMethodChild_Target_second_Context_Second(v.Second); err2 != nil { + err = goa.MergeErrors(err, err2) + } + } + + case *shared_method_helperspb.ChooseRequest_Third: + if v == nil { + err = goa.MergeErrors(err, goa.MissingFieldError("Third", "message.choice")) + break + } + if v.Third == nil { + err = goa.MergeErrors(err, goa.MissingFieldError("Third", "message.choice")) + break + } + if v.Third != nil { + if err2 := validatetest_20_api_SharedMethodHelpers_SharedMethodChild_Target_third_Context_Third(v.Third); err2 != nil { + err = goa.MergeErrors(err, err2) + } + } + } + + return +} + +// validatetest_20_api_SharedMethodHelpers_SharedMethodChild_Target_first_Context_First +// runs the validations defined on SharedMethodChild. +func validatetest_20_api_SharedMethodHelpers_SharedMethodChild_Target_first_Context_First(first *shared_method_helperspb.SharedMethodChild) (err error) { + if first.Value == nil { + err = goa.MergeErrors(err, goa.MissingFieldError("value", "First")) + } + if first.Next != nil { + if err2 := validatetest_20_api_SharedMethodHelpers_SharedMethodChild_At_next(first.Next); err2 != nil { + err = goa.MergeErrors(err, err2) + } + } + return +} + +// validatetest_20_api_SharedMethodHelpers_SharedMethodChild_Target_second_Context_Second +// runs the validations defined on SharedMethodChild. +func validatetest_20_api_SharedMethodHelpers_SharedMethodChild_Target_second_Context_Second(second *shared_method_helperspb.SharedMethodChild) (err error) { + if second.Value == nil { + err = goa.MergeErrors(err, goa.MissingFieldError("value", "Second")) + } + if second.Next != nil { + if err2 := validatetest_20_api_SharedMethodHelpers_SharedMethodChild_At_next(second.Next); err2 != nil { + err = goa.MergeErrors(err, err2) + } + } + return +} + +// validatetest_20_api_SharedMethodHelpers_SharedMethodChild_Target_third_Context_Third +// runs the validations defined on SharedMethodChild. +func validatetest_20_api_SharedMethodHelpers_SharedMethodChild_Target_third_Context_Third(third *shared_method_helperspb.SharedMethodChild) (err error) { + if third.Value == nil { + err = goa.MergeErrors(err, goa.MissingFieldError("value", "Third")) + } + if third.Next != nil { + if err2 := validatetest_20_api_SharedMethodHelpers_SharedMethodChild_At_next(third.Next); err2 != nil { + err = goa.MergeErrors(err, err2) + } + } + return +} + +// ValidateInspectRequest runs the validations defined on InspectRequest. +func ValidateInspectRequest(message *shared_method_helperspb.InspectRequest) (err error) { + if message.Child == nil { + err = goa.MergeErrors(err, goa.MissingFieldError("child", "message")) + } + return +} + +// transformSharedMethodChildToProtoSharedMethodChild builds a value of type +// *shared_method_helperspb.SharedMethodChild from a value of type +// *sharedmethodhelpers.SharedMethodChild. +func transformSharedMethodChildToProtoSharedMethodChild(v *sharedmethodhelpers.SharedMethodChild) *shared_method_helperspb.SharedMethodChild { + res := &shared_method_helperspb.SharedMethodChild{ + Value: &v.Value, + } + if v.Next != nil { + res.Next = transformSharedMethodChildToProtoSharedMethodChild(v.Next) + } + + return res +} + +// transformProtoSharedMethodChildToSharedMethodChild builds a value of type +// *sharedmethodhelpers.SharedMethodChild from a value of type +// *shared_method_helperspb.SharedMethodChild. +func transformProtoSharedMethodChildToSharedMethodChild(v *shared_method_helperspb.SharedMethodChild) *sharedmethodhelpers.SharedMethodChild { + res := &sharedmethodhelpers.SharedMethodChild{ + Value: *v.Value, + } + if v.Next != nil { + res.Next = transformProtoSharedMethodChildToSharedMethodChild(v.Next) + } + + return res +} + +// transformNearMethodChildToProtoNearMethodChild builds a value of type +// *shared_method_helperspb.NearMethodChild from a value of type +// *sharedmethodhelpers.NearMethodChild. +func transformNearMethodChildToProtoNearMethodChild(v *sharedmethodhelpers.NearMethodChild) *shared_method_helperspb.NearMethodChild { + res := &shared_method_helperspb.NearMethodChild{ + Value: v.Value, + } + if v.Next != nil { + res.Next = transformNearMethodChildToProtoNearMethodChild(v.Next) + } + + return res +} + +// transformProtoNearMethodChildToNearMethodChild builds a value of type +// *sharedmethodhelpers.NearMethodChild from a value of type +// *shared_method_helperspb.NearMethodChild. +func transformProtoNearMethodChildToNearMethodChild(v *shared_method_helperspb.NearMethodChild) *sharedmethodhelpers.NearMethodChild { + res := &sharedmethodhelpers.NearMethodChild{ + Value: v.Value, + } + if v.Next != nil { + res.Next = transformProtoNearMethodChildToNearMethodChild(v.Next) + } + + return res +} diff --git a/grpc/codegen/testdata/golden/transform_helper_shared_methods_server.go.golden b/grpc/codegen/testdata/golden/transform_helper_shared_methods_server.go.golden new file mode 100644 index 0000000000..48182f4d22 --- /dev/null +++ b/grpc/codegen/testdata/golden/transform_helper_shared_methods_server.go.golden @@ -0,0 +1,273 @@ +// NewStorePayload builds *sharedmethodhelpers.StorePayload from +// *shared_method_helperspb.StoreRequest. +func NewStorePayload(message *shared_method_helperspb.StoreRequest) *sharedmethodhelpers.StorePayload { + v := &sharedmethodhelpers.StorePayload{} + if message.Child != nil { + v.Child = transformProtoSharedMethodChildToSharedMethodChild(message.Child) + } + return v +} + +// NewProtoStoreResponse builds *shared_method_helperspb.StoreResponse from +// metadata values. +func NewProtoStoreResponse() *shared_method_helperspb.StoreResponse { + message := &shared_method_helperspb.StoreResponse{} + return message +} + +// NewReplacePayload builds *sharedmethodhelpers.ReplacePayload from +// *shared_method_helperspb.ReplaceRequest. +func NewReplacePayload(message *shared_method_helperspb.ReplaceRequest) *sharedmethodhelpers.ReplacePayload { + v := &sharedmethodhelpers.ReplacePayload{} + if message.Child != nil { + v.Child = transformProtoSharedMethodChildToSharedMethodChild(message.Child) + } + return v +} + +// NewProtoReplaceResponse builds *shared_method_helperspb.ReplaceResponse from +// metadata values. +func NewProtoReplaceResponse() *shared_method_helperspb.ReplaceResponse { + message := &shared_method_helperspb.ReplaceResponse{} + return message +} + +// NewChoosePayload builds *sharedmethodhelpers.ChoosePayload from +// *shared_method_helperspb.ChooseRequest. +func NewChoosePayload(message *shared_method_helperspb.ChooseRequest) *sharedmethodhelpers.ChoosePayload { + v := &sharedmethodhelpers.ChoosePayload{} + if message.Choice != nil { + switch val := message.Choice.(type) { + case *shared_method_helperspb.ChooseRequest_First: + { + u := v.Choice + u.SetFirst(transformProtoSharedMethodChildToSharedMethodChild(val.First)) + v.Choice = u + } + case *shared_method_helperspb.ChooseRequest_Second: + { + u := v.Choice + u.SetSecond(transformProtoSharedMethodChildToSharedMethodChild(val.Second)) + v.Choice = u + } + case *shared_method_helperspb.ChooseRequest_Third: + { + u := v.Choice + u.SetThird(transformProtoSharedMethodChildToSharedMethodChild(val.Third)) + v.Choice = u + } + } + } + return v +} + +// NewProtoChooseResponse builds *shared_method_helperspb.ChooseResponse from +// metadata values. +func NewProtoChooseResponse() *shared_method_helperspb.ChooseResponse { + message := &shared_method_helperspb.ChooseResponse{} + return message +} + +// NewInspectPayload builds *sharedmethodhelpers.InspectPayload from +// *shared_method_helperspb.InspectRequest. +func NewInspectPayload(message *shared_method_helperspb.InspectRequest) *sharedmethodhelpers.InspectPayload { + v := &sharedmethodhelpers.InspectPayload{} + if message.Child != nil { + v.Child = transformProtoNearMethodChildToNearMethodChild(message.Child) + } + return v +} + +// NewProtoInspectResponse builds *shared_method_helperspb.InspectResponse from +// metadata values. +func NewProtoInspectResponse() *shared_method_helperspb.InspectResponse { + message := &shared_method_helperspb.InspectResponse{} + return message +} + +// ValidateStoreRequest runs the validations defined on StoreRequest. +func ValidateStoreRequest(message *shared_method_helperspb.StoreRequest) (err error) { + if message.Child == nil { + err = goa.MergeErrors(err, goa.MissingFieldError("child", "message")) + } + if message.Child != nil { + if err2 := validatetest_20_api_SharedMethodHelpers_SharedMethodChild_At_child(message.Child); err2 != nil { + err = goa.MergeErrors(err, err2) + } + } + return +} + +// validatetest_20_api_SharedMethodHelpers_SharedMethodChild_At_child runs the +// validations defined on SharedMethodChild. +func validatetest_20_api_SharedMethodHelpers_SharedMethodChild_At_child(child *shared_method_helperspb.SharedMethodChild) (err error) { + if child.Value == nil { + err = goa.MergeErrors(err, goa.MissingFieldError("value", "child")) + } + if child.Next != nil { + if err2 := validatetest_20_api_SharedMethodHelpers_SharedMethodChild_At_next(child.Next); err2 != nil { + err = goa.MergeErrors(err, err2) + } + } + return +} + +// validatetest_20_api_SharedMethodHelpers_SharedMethodChild_At_next runs the +// validations defined on SharedMethodChild. +func validatetest_20_api_SharedMethodHelpers_SharedMethodChild_At_next(next *shared_method_helperspb.SharedMethodChild) (err error) { + if next.Value == nil { + err = goa.MergeErrors(err, goa.MissingFieldError("value", "next")) + } + if next.Next != nil { + if err2 := validatetest_20_api_SharedMethodHelpers_SharedMethodChild_At_next(next.Next); err2 != nil { + err = goa.MergeErrors(err, err2) + } + } + return +} + +// ValidateReplaceRequest runs the validations defined on ReplaceRequest. +func ValidateReplaceRequest(message *shared_method_helperspb.ReplaceRequest) (err error) { + if message.Child == nil { + err = goa.MergeErrors(err, goa.MissingFieldError("child", "message")) + } + if message.Child != nil { + if err2 := validatetest_20_api_SharedMethodHelpers_SharedMethodChild_At_child(message.Child); err2 != nil { + err = goa.MergeErrors(err, err2) + } + } + return +} + +// ValidateChooseRequest runs the validations defined on ChooseRequest. +func ValidateChooseRequest(message *shared_method_helperspb.ChooseRequest) (err error) { + if message.Choice == nil { + err = goa.MergeErrors(err, goa.MissingFieldError("choice", "message")) + } + switch v := message.Choice.(type) { + case *shared_method_helperspb.ChooseRequest_First: + if v == nil { + err = goa.MergeErrors(err, goa.MissingFieldError("First", "message.choice")) + break + } + if v.First == nil { + err = goa.MergeErrors(err, goa.MissingFieldError("First", "message.choice")) + break + } + if v.First != nil { + if err2 := validatetest_20_api_SharedMethodHelpers_SharedMethodChild_Target_first_Context_First(v.First); err2 != nil { + err = goa.MergeErrors(err, err2) + } + } + + case *shared_method_helperspb.ChooseRequest_Second: + if v == nil { + err = goa.MergeErrors(err, goa.MissingFieldError("Second", "message.choice")) + break + } + if v.Second == nil { + err = goa.MergeErrors(err, goa.MissingFieldError("Second", "message.choice")) + break + } + if v.Second != nil { + if err2 := validatetest_20_api_SharedMethodHelpers_SharedMethodChild_Target_second_Context_Second(v.Second); err2 != nil { + err = goa.MergeErrors(err, err2) + } + } + + case *shared_method_helperspb.ChooseRequest_Third: + if v == nil { + err = goa.MergeErrors(err, goa.MissingFieldError("Third", "message.choice")) + break + } + if v.Third == nil { + err = goa.MergeErrors(err, goa.MissingFieldError("Third", "message.choice")) + break + } + if v.Third != nil { + if err2 := validatetest_20_api_SharedMethodHelpers_SharedMethodChild_Target_third_Context_Third(v.Third); err2 != nil { + err = goa.MergeErrors(err, err2) + } + } + } + + return +} + +// validatetest_20_api_SharedMethodHelpers_SharedMethodChild_Target_first_Context_First +// runs the validations defined on SharedMethodChild. +func validatetest_20_api_SharedMethodHelpers_SharedMethodChild_Target_first_Context_First(first *shared_method_helperspb.SharedMethodChild) (err error) { + if first.Value == nil { + err = goa.MergeErrors(err, goa.MissingFieldError("value", "First")) + } + if first.Next != nil { + if err2 := validatetest_20_api_SharedMethodHelpers_SharedMethodChild_At_next(first.Next); err2 != nil { + err = goa.MergeErrors(err, err2) + } + } + return +} + +// validatetest_20_api_SharedMethodHelpers_SharedMethodChild_Target_second_Context_Second +// runs the validations defined on SharedMethodChild. +func validatetest_20_api_SharedMethodHelpers_SharedMethodChild_Target_second_Context_Second(second *shared_method_helperspb.SharedMethodChild) (err error) { + if second.Value == nil { + err = goa.MergeErrors(err, goa.MissingFieldError("value", "Second")) + } + if second.Next != nil { + if err2 := validatetest_20_api_SharedMethodHelpers_SharedMethodChild_At_next(second.Next); err2 != nil { + err = goa.MergeErrors(err, err2) + } + } + return +} + +// validatetest_20_api_SharedMethodHelpers_SharedMethodChild_Target_third_Context_Third +// runs the validations defined on SharedMethodChild. +func validatetest_20_api_SharedMethodHelpers_SharedMethodChild_Target_third_Context_Third(third *shared_method_helperspb.SharedMethodChild) (err error) { + if third.Value == nil { + err = goa.MergeErrors(err, goa.MissingFieldError("value", "Third")) + } + if third.Next != nil { + if err2 := validatetest_20_api_SharedMethodHelpers_SharedMethodChild_At_next(third.Next); err2 != nil { + err = goa.MergeErrors(err, err2) + } + } + return +} + +// ValidateInspectRequest runs the validations defined on InspectRequest. +func ValidateInspectRequest(message *shared_method_helperspb.InspectRequest) (err error) { + if message.Child == nil { + err = goa.MergeErrors(err, goa.MissingFieldError("child", "message")) + } + return +} + +// transformProtoSharedMethodChildToSharedMethodChild builds a value of type +// *sharedmethodhelpers.SharedMethodChild from a value of type +// *shared_method_helperspb.SharedMethodChild. +func transformProtoSharedMethodChildToSharedMethodChild(v *shared_method_helperspb.SharedMethodChild) *sharedmethodhelpers.SharedMethodChild { + res := &sharedmethodhelpers.SharedMethodChild{ + Value: *v.Value, + } + if v.Next != nil { + res.Next = transformProtoSharedMethodChildToSharedMethodChild(v.Next) + } + + return res +} + +// transformProtoNearMethodChildToNearMethodChild builds a value of type +// *sharedmethodhelpers.NearMethodChild from a value of type +// *shared_method_helperspb.NearMethodChild. +func transformProtoNearMethodChildToNearMethodChild(v *shared_method_helperspb.NearMethodChild) *sharedmethodhelpers.NearMethodChild { + res := &sharedmethodhelpers.NearMethodChild{ + Value: v.Value, + } + if v.Next != nil { + res.Next = transformProtoNearMethodChildToNearMethodChild(v.Next) + } + + return res +} diff --git a/grpc/codegen/testdata/golden/transform_helper_viewed_service_client.go.golden b/grpc/codegen/testdata/golden/transform_helper_viewed_service_client.go.golden new file mode 100644 index 0000000000..2566e80cca --- /dev/null +++ b/grpc/codegen/testdata/golden/transform_helper_viewed_service_client.go.golden @@ -0,0 +1,117 @@ +// NewProtoViewedRequest builds *view_bindingspb.ViewedRequest from metadata +// values. +func NewProtoViewedRequest() *view_bindingspb.ViewedRequest { + message := &view_bindingspb.ViewedRequest{} + return message +} + +// NewViewedResult builds *viewbindingsviews.ViewedContainerView from +// *view_bindingspb.ViewedResponse. +func NewViewedResult(message *view_bindingspb.ViewedResponse) *viewbindingsviews.ViewedContainerView { + result := &viewbindingsviews.ViewedContainerView{} + if message.Child != nil { + result.Child = transformProtoSharedDeclarationChildToSharedDeclarationChildView(message.Child) + } + return result +} + +// NewProtoPlainRequest builds *view_bindingspb.PlainRequest from +// *viewbindings.PlainPayload. +func NewProtoPlainRequest(payload *viewbindings.PlainPayload) *view_bindingspb.PlainRequest { + message := &view_bindingspb.PlainRequest{} + if payload.Child != nil { + message.Child = transformSharedDeclarationChildToProtoSharedDeclarationChild(payload.Child) + } + return message +} + +// NewPlainResult builds *viewbindings.PlainResult from +// *view_bindingspb.PlainResponse. +func NewPlainResult(message *view_bindingspb.PlainResponse) *viewbindings.PlainResult { + result := &viewbindings.PlainResult{} + if message.Child != nil { + result.Child = transformProtoSharedDeclarationChildToSharedDeclarationChild(message.Child) + } + return result +} + +// ValidateViewedResponse runs the validations defined on ViewedResponse. +func ValidateViewedResponse(message *view_bindingspb.ViewedResponse) (err error) { + if message.Child == nil { + err = goa.MergeErrors(err, goa.MissingFieldError("child", "message")) + } + if message.Child != nil { + if err2 := validatetest_20_api_ViewBindings_SharedDeclarationChild_At_child(message.Child); err2 != nil { + err = goa.MergeErrors(err, err2) + } + } + return +} + +// validatetest_20_api_ViewBindings_SharedDeclarationChild_At_child runs the +// validations defined on SharedDeclarationChild. +func validatetest_20_api_ViewBindings_SharedDeclarationChild_At_child(child *view_bindingspb.SharedDeclarationChild) (err error) { + if child.Value == nil { + err = goa.MergeErrors(err, goa.MissingFieldError("value", "child")) + } + return +} + +// ValidatePlainRequest runs the validations defined on PlainRequest. +func ValidatePlainRequest(message *view_bindingspb.PlainRequest) (err error) { + if message.Child == nil { + err = goa.MergeErrors(err, goa.MissingFieldError("child", "message")) + } + if message.Child != nil { + if err2 := validatetest_20_api_ViewBindings_SharedDeclarationChild_At_child(message.Child); err2 != nil { + err = goa.MergeErrors(err, err2) + } + } + return +} + +// ValidatePlainResponse runs the validations defined on PlainResponse. +func ValidatePlainResponse(message *view_bindingspb.PlainResponse) (err error) { + if message.Child == nil { + err = goa.MergeErrors(err, goa.MissingFieldError("child", "message")) + } + if message.Child != nil { + if err2 := validatetest_20_api_ViewBindings_SharedDeclarationChild_At_child(message.Child); err2 != nil { + err = goa.MergeErrors(err, err2) + } + } + return +} + +// transformProtoSharedDeclarationChildToSharedDeclarationChildView builds a +// value of type *viewbindingsviews.SharedDeclarationChildView from a value of +// type *view_bindingspb.SharedDeclarationChild. +func transformProtoSharedDeclarationChildToSharedDeclarationChildView(v *view_bindingspb.SharedDeclarationChild) *viewbindingsviews.SharedDeclarationChildView { + res := &viewbindingsviews.SharedDeclarationChildView{ + Value: v.Value, + } + + return res +} + +// transformSharedDeclarationChildToProtoSharedDeclarationChild builds a value +// of type *view_bindingspb.SharedDeclarationChild from a value of type +// *viewbindings.SharedDeclarationChild. +func transformSharedDeclarationChildToProtoSharedDeclarationChild(v *viewbindings.SharedDeclarationChild) *view_bindingspb.SharedDeclarationChild { + res := &view_bindingspb.SharedDeclarationChild{ + Value: &v.Value, + } + + return res +} + +// transformProtoSharedDeclarationChildToSharedDeclarationChild builds a value +// of type *viewbindings.SharedDeclarationChild from a value of type +// *view_bindingspb.SharedDeclarationChild. +func transformProtoSharedDeclarationChildToSharedDeclarationChild(v *view_bindingspb.SharedDeclarationChild) *viewbindings.SharedDeclarationChild { + res := &viewbindings.SharedDeclarationChild{ + Value: *v.Value, + } + + return res +} diff --git a/grpc/codegen/testdata/golden/transform_helper_viewed_service_server.go.golden b/grpc/codegen/testdata/golden/transform_helper_viewed_service_server.go.golden new file mode 100644 index 0000000000..cc5b830c62 --- /dev/null +++ b/grpc/codegen/testdata/golden/transform_helper_viewed_service_server.go.golden @@ -0,0 +1,84 @@ +// NewProtoViewedResponse builds *view_bindingspb.ViewedResponse from +// *viewbindingsviews.ViewedContainerView. +func NewProtoViewedResponse(result *viewbindingsviews.ViewedContainerView) *view_bindingspb.ViewedResponse { + message := &view_bindingspb.ViewedResponse{} + if result.Child != nil { + message.Child = transformSharedDeclarationChildViewToProtoSharedDeclarationChild(result.Child) + } + return message +} + +// NewPlainPayload builds *viewbindings.PlainPayload from +// *view_bindingspb.PlainRequest. +func NewPlainPayload(message *view_bindingspb.PlainRequest) *viewbindings.PlainPayload { + v := &viewbindings.PlainPayload{} + if message.Child != nil { + v.Child = transformProtoSharedDeclarationChildToSharedDeclarationChild(message.Child) + } + return v +} + +// NewProtoPlainResponse builds *view_bindingspb.PlainResponse from +// *viewbindings.PlainResult. +func NewProtoPlainResponse(result *viewbindings.PlainResult) *view_bindingspb.PlainResponse { + message := &view_bindingspb.PlainResponse{} + if result.Child != nil { + message.Child = transformSharedDeclarationChildToProtoSharedDeclarationChild(result.Child) + } + return message +} + +// ValidatePlainRequest runs the validations defined on PlainRequest. +func ValidatePlainRequest(message *view_bindingspb.PlainRequest) (err error) { + if message.Child == nil { + err = goa.MergeErrors(err, goa.MissingFieldError("child", "message")) + } + if message.Child != nil { + if err2 := validatetest_20_api_ViewBindings_SharedDeclarationChild_At_child(message.Child); err2 != nil { + err = goa.MergeErrors(err, err2) + } + } + return +} + +// validatetest_20_api_ViewBindings_SharedDeclarationChild_At_child runs the +// validations defined on SharedDeclarationChild. +func validatetest_20_api_ViewBindings_SharedDeclarationChild_At_child(child *view_bindingspb.SharedDeclarationChild) (err error) { + if child.Value == nil { + err = goa.MergeErrors(err, goa.MissingFieldError("value", "child")) + } + return +} + +// transformSharedDeclarationChildViewToProtoSharedDeclarationChild builds a +// value of type *view_bindingspb.SharedDeclarationChild from a value of type +// *viewbindingsviews.SharedDeclarationChildView. +func transformSharedDeclarationChildViewToProtoSharedDeclarationChild(v *viewbindingsviews.SharedDeclarationChildView) *view_bindingspb.SharedDeclarationChild { + res := &view_bindingspb.SharedDeclarationChild{ + Value: v.Value, + } + + return res +} + +// transformProtoSharedDeclarationChildToSharedDeclarationChild builds a value +// of type *viewbindings.SharedDeclarationChild from a value of type +// *view_bindingspb.SharedDeclarationChild. +func transformProtoSharedDeclarationChildToSharedDeclarationChild(v *view_bindingspb.SharedDeclarationChild) *viewbindings.SharedDeclarationChild { + res := &viewbindings.SharedDeclarationChild{ + Value: *v.Value, + } + + return res +} + +// transformSharedDeclarationChildToProtoSharedDeclarationChild builds a value +// of type *view_bindingspb.SharedDeclarationChild from a value of type +// *viewbindings.SharedDeclarationChild. +func transformSharedDeclarationChildToProtoSharedDeclarationChild(v *viewbindings.SharedDeclarationChild) *view_bindingspb.SharedDeclarationChild { + res := &view_bindingspb.SharedDeclarationChild{ + Value: &v.Value, + } + + return res +} diff --git a/grpc/codegen/testdata/golden/transform_helper_wrapped_collection_client.go.golden b/grpc/codegen/testdata/golden/transform_helper_wrapped_collection_client.go.golden new file mode 100644 index 0000000000..2d17f7da7e --- /dev/null +++ b/grpc/codegen/testdata/golden/transform_helper_wrapped_collection_client.go.golden @@ -0,0 +1,67 @@ +// NewProtoListRequest builds *wrapped_collectionpb.ListRequest from metadata +// values. +func NewProtoListRequest() *wrapped_collectionpb.ListRequest { + message := &wrapped_collectionpb.ListRequest{} + return message +} + +// NewListResult builds +// wrappedcollectionviews.WrappedCollectionItemCollectionView from +// *wrapped_collectionpb.WrappedCollectionItemCollection. +func NewListResult(message *wrapped_collectionpb.WrappedCollectionItemCollection) wrappedcollectionviews.WrappedCollectionItemCollectionView { + result := make([]*wrappedcollectionviews.WrappedCollectionItemView, len(message.Field)) + for i, val := range message.Field { + result[i] = &wrappedcollectionviews.WrappedCollectionItemView{} + if val.Child != nil { + result[i].Child = transformProtoWrappedCollectionChildToWrappedCollectionChildView(val.Child) + } + } + return result +} + +// ValidateWrappedCollectionItemCollection runs the validations defined on +// WrappedCollectionItemCollection. +func ValidateWrappedCollectionItemCollection(message *wrapped_collectionpb.WrappedCollectionItemCollection) (err error) { + for _, e := range message.Field { + if e != nil { + if err2 := validatetest_20_api_WrappedCollection_WrappedCollectionItem_At_elem(e); err2 != nil { + err = goa.MergeErrors(err, err2) + } + } + } + return +} + +// validatetest_20_api_WrappedCollection_WrappedCollectionItem_At_elem runs the +// validations defined on WrappedCollectionItem. +func validatetest_20_api_WrappedCollection_WrappedCollectionItem_At_elem(elem *wrapped_collectionpb.WrappedCollectionItem) (err error) { + if elem.Child == nil { + err = goa.MergeErrors(err, goa.MissingFieldError("child", "elem")) + } + if elem.Child != nil { + if err2 := validatetest_20_api_WrappedCollection_WrappedCollectionChild_At_child(elem.Child); err2 != nil { + err = goa.MergeErrors(err, err2) + } + } + return +} + +// validatetest_20_api_WrappedCollection_WrappedCollectionChild_At_child runs +// the validations defined on WrappedCollectionChild. +func validatetest_20_api_WrappedCollection_WrappedCollectionChild_At_child(child *wrapped_collectionpb.WrappedCollectionChild) (err error) { + if child.Value == nil { + err = goa.MergeErrors(err, goa.MissingFieldError("value", "child")) + } + return +} + +// transformProtoWrappedCollectionChildToWrappedCollectionChildView builds a +// value of type *wrappedcollectionviews.WrappedCollectionChildView from a +// value of type *wrapped_collectionpb.WrappedCollectionChild. +func transformProtoWrappedCollectionChildToWrappedCollectionChildView(v *wrapped_collectionpb.WrappedCollectionChild) *wrappedcollectionviews.WrappedCollectionChildView { + res := &wrappedcollectionviews.WrappedCollectionChildView{ + Value: v.Value, + } + + return res +} diff --git a/grpc/codegen/testdata/golden/transform_helper_wrapped_collection_server.go.golden b/grpc/codegen/testdata/golden/transform_helper_wrapped_collection_server.go.golden new file mode 100644 index 0000000000..8d5a096b2c --- /dev/null +++ b/grpc/codegen/testdata/golden/transform_helper_wrapped_collection_server.go.golden @@ -0,0 +1,25 @@ +// NewProtoWrappedCollectionItemCollection builds +// *wrapped_collectionpb.WrappedCollectionItemCollection from +// wrappedcollectionviews.WrappedCollectionItemCollectionView. +func NewProtoWrappedCollectionItemCollection(result wrappedcollectionviews.WrappedCollectionItemCollectionView) *wrapped_collectionpb.WrappedCollectionItemCollection { + message := &wrapped_collectionpb.WrappedCollectionItemCollection{} + message.Field = make([]*wrapped_collectionpb.WrappedCollectionItem, len(result)) + for i, val := range result { + message.Field[i] = &wrapped_collectionpb.WrappedCollectionItem{} + if val.Child != nil { + message.Field[i].Child = transformWrappedCollectionChildViewToProtoWrappedCollectionChild(val.Child) + } + } + return message +} + +// transformWrappedCollectionChildViewToProtoWrappedCollectionChild builds a +// value of type *wrapped_collectionpb.WrappedCollectionChild from a value of +// type *wrappedcollectionviews.WrappedCollectionChildView. +func transformWrappedCollectionChildViewToProtoWrappedCollectionChild(v *wrappedcollectionviews.WrappedCollectionChildView) *wrapped_collectionpb.WrappedCollectionChild { + res := &wrapped_collectionpb.WrappedCollectionChild{ + Value: v.Value, + } + + return res +} diff --git a/grpc/codegen/testdata/golden/viewed_result_dynamic_response_encoder.go.golden b/grpc/codegen/testdata/golden/viewed_result_dynamic_response_encoder.go.golden new file mode 100644 index 0000000000..afd26b1453 --- /dev/null +++ b/grpc/codegen/testdata/golden/viewed_result_dynamic_response_encoder.go.golden @@ -0,0 +1,21 @@ +// EncodeMethodMessageResultTypeWithViewsResponse encodes responses from the +// "ServiceMessageResultTypeWithViews" service +// "MethodMessageResultTypeWithViews" endpoint. +func EncodeMethodMessageResultTypeWithViewsResponse(ctx context.Context, v any, hdr, trlr *metadata.MD) (any, error) { + vres, ok := v.(*servicemessageresulttypewithviewsviews.RT) + if !ok { + return nil, goagrpc.ErrInvalidType("ServiceMessageResultTypeWithViews", "MethodMessageResultTypeWithViews", "*servicemessageresulttypewithviewsviews.RT", v) + } + result := vres.Projected + var resp *service_message_result_type_with_viewspb.MethodMessageResultTypeWithViewsResponse + switch vres.View { + case "tiny": + resp = NewProtoMethodMessageResultTypeWithViewsResponseTiny(result) + case "default", "": + resp = NewProtoMethodMessageResultTypeWithViewsResponse(result) + default: + return nil, goa.InvalidEnumValueError("view", vres.View, []any{"tiny", "default"}) + } + (*hdr).Append("goa-view", vres.View) + return resp, nil +} diff --git a/grpc/codegen/testdata/golden/viewed_result_dynamic_stream_send.go.golden b/grpc/codegen/testdata/golden/viewed_result_dynamic_stream_send.go.golden new file mode 100644 index 0000000000..4ce4cfd685 --- /dev/null +++ b/grpc/codegen/testdata/golden/viewed_result_dynamic_stream_send.go.golden @@ -0,0 +1,36 @@ +// Send streams instances of +// "service_server_streaming_user_type_rpcpb.MethodServerStreamingUserTypeRPCResponse" +// to the "MethodServerStreamingUserTypeRPC" endpoint gRPC stream. +func (s *MethodServerStreamingUserTypeRPCServerStream) Send(res *serviceserverstreamingusertyperpc.ResultType) error { + view := s.view + if view == "" { + view = "default" + } + if s.sentView != "" && view != s.sentView { + return goa.InvalidEnumValueError("view", view, []any{s.sentView}) + } + vres := serviceserverstreamingusertyperpc.NewViewedResultType(res, view) + var v *service_server_streaming_user_type_rpcpb.MethodServerStreamingUserTypeRPCResponse + switch view { + case "tiny": + v = NewProtoMethodServerStreamingUserTypeRPCResponseTiny(vres.Projected) + case "default", "": + v = NewProtoMethodServerStreamingUserTypeRPCResponse(vres.Projected) + default: + return goa.InvalidEnumValueError("view", view, []any{"tiny", "default"}) + } + if s.sentView == "" { + if err := s.stream.SetHeader(metadata.Pairs("goa-view", view)); err != nil { + return err + } + s.sentView = view + } + return s.stream.Send(v) +} + +// SendWithContext streams instances of +// "service_server_streaming_user_type_rpcpb.MethodServerStreamingUserTypeRPCResponse" +// to the "MethodServerStreamingUserTypeRPC" endpoint gRPC stream with context. +func (s *MethodServerStreamingUserTypeRPCServerStream) SendWithContext(ctx context.Context, res *serviceserverstreamingusertyperpc.ResultType) error { + return s.Send(res) +} diff --git a/grpc/codegen/testdata/golden/viewed_result_fixed_response_encoder.go.golden b/grpc/codegen/testdata/golden/viewed_result_fixed_response_encoder.go.golden new file mode 100644 index 0000000000..b8aa816b08 --- /dev/null +++ b/grpc/codegen/testdata/golden/viewed_result_fixed_response_encoder.go.golden @@ -0,0 +1,13 @@ +// EncodeMethodMessageResultTypeWithExplicitViewResponse encodes responses from +// the "ServiceMessageResultTypeWithExplicitView" service +// "MethodMessageResultTypeWithExplicitView" endpoint. +func EncodeMethodMessageResultTypeWithExplicitViewResponse(ctx context.Context, v any, hdr, trlr *metadata.MD) (any, error) { + vres, ok := v.(*servicemessageresulttypewithexplicitviewviews.RT) + if !ok { + return nil, goagrpc.ErrInvalidType("ServiceMessageResultTypeWithExplicitView", "MethodMessageResultTypeWithExplicitView", "*servicemessageresulttypewithexplicitviewviews.RT", v) + } + result := vres.Projected + resp := NewProtoMethodMessageResultTypeWithExplicitViewResponse(result) + (*hdr).Append("goa-view", "tiny") + return resp, nil +} diff --git a/grpc/codegen/testdata/protoc_names.proto b/grpc/codegen/testdata/protoc_names.proto new file mode 100644 index 0000000000..3e4c038432 --- /dev/null +++ b/grpc/codegen/testdata/protoc_names.proto @@ -0,0 +1,78 @@ +// This schema records the Go names produced by Goa's supported protobuf tools. +syntax = "proto3"; + +package goa.names.v1; + +option go_package = "goa.design/goa/v3/grpc/codegen/testdata/protocnames;protocnames"; + +enum http_2_status { + HTTP_2_STATUS_UNSPECIFIED = 0; + HTTP2_OK = 1; + URL_2_READY = 2; +} + +message api2_http_request { + string api_url = 1; + string api2_url = 2; + string url2_id = 3; + string dns_2_server = 4; + string x509_cert = 5; + string type = 6; + string func = 7; + string var = 8; + string range = 9; + string reset = 10; + string string = 11; + string proto_message = 12; + string descriptor = 13; + string marshal = 14; + string unmarshal = 15; + string extension_range_array = 16; + string extension_map = 17; + string get_api_url = 18; + string message_ = 19; + string service_ = 20; + + oneof result_2_kind { + string http_2xx = 21; + nested_api2 api_url_value = 22; + bytes package_ = 23; + } + + message nested_api2 { + enum state_2 { + STATE_2_UNSPECIFIED = 0; + DNS2_READY = 1; + URL_2_READY = 2; + } + + state_2 state = 1; + } +} + +message wrapper_conflict { + message _BranchValue { + } + + string choice_value = 1; + + oneof choiceValue { + _BranchValue branchValue = 2; + string reset = 3; + } +} + +message Explicit_HTTP2_Name { + string Explicit_Field2_Name = 1; +} + +message stream_reply { + http_2_status status = 1; +} + +service api2_http_service { + rpc get_url2(api2_http_request) returns (stream_reply); + rpc watch_dns2(api2_http_request) returns (stream stream_reply); + rpc upload_api2(stream api2_http_request) returns (stream_reply); + rpc sync_x509(stream api2_http_request) returns (stream stream_reply); +} diff --git a/grpc/codegen/testdata/request_encoder_code.go b/grpc/codegen/testdata/request_encoder_code.go deleted file mode 100644 index 1251cba5bc..0000000000 --- a/grpc/codegen/testdata/request_encoder_code.go +++ /dev/null @@ -1,128 +0,0 @@ -package testdata - -const PayloadUserTypeRequestEncoderCode = `// EncodeMethodMessageUserTypeWithNestedUserTypesRequest encodes requests sent -// to ServiceMessageUserTypeWithNestedUserTypes -// MethodMessageUserTypeWithNestedUserTypes endpoint. -func EncodeMethodMessageUserTypeWithNestedUserTypesRequest(ctx context.Context, v any, md *metadata.MD) (any, error) { - payload, ok := v.(*servicemessageusertypewithnestedusertypes.UT) - if !ok { - return nil, goagrpc.ErrInvalidType("ServiceMessageUserTypeWithNestedUserTypes", "MethodMessageUserTypeWithNestedUserTypes", "*servicemessageusertypewithnestedusertypes.UT", v) - } - return NewProtoMethodMessageUserTypeWithNestedUserTypesRequest(payload), nil -} -` - -const PayloadArrayRequestEncoderCode = `// EncodeMethodUnaryRPCNoResultRequest encodes requests sent to -// ServiceUnaryRPCNoResult MethodUnaryRPCNoResult endpoint. -func EncodeMethodUnaryRPCNoResultRequest(ctx context.Context, v any, md *metadata.MD) (any, error) { - payload, ok := v.([]string) - if !ok { - return nil, goagrpc.ErrInvalidType("ServiceUnaryRPCNoResult", "MethodUnaryRPCNoResult", "[]string", v) - } - return NewProtoMethodUnaryRPCNoResultRequest(payload), nil -} -` - -const PayloadMapRequestEncoderCode = `// EncodeMethodMessageMapRequest encodes requests sent to ServiceMessageMap -// MethodMessageMap endpoint. -func EncodeMethodMessageMapRequest(ctx context.Context, v any, md *metadata.MD) (any, error) { - payload, ok := v.(map[int]*servicemessagemap.UT) - if !ok { - return nil, goagrpc.ErrInvalidType("ServiceMessageMap", "MethodMessageMap", "map[int]*servicemessagemap.UT", v) - } - return NewProtoMethodMessageMapRequest(payload), nil -} -` - -const PayloadPrimitiveRequestEncoderCode = `// EncodeMethodServerStreamingRPCRequest encodes requests sent to -// ServiceServerStreamingRPC MethodServerStreamingRPC endpoint. -func EncodeMethodServerStreamingRPCRequest(ctx context.Context, v any, md *metadata.MD) (any, error) { - payload, ok := v.(int) - if !ok { - return nil, goagrpc.ErrInvalidType("ServiceServerStreamingRPC", "MethodServerStreamingRPC", "int", v) - } - return NewProtoMethodServerStreamingRPCRequest(payload), nil -} -` - -const PayloadPrimitiveWithStreamingPayloadRequestEncoderCode = `// EncodeMethodClientStreamingRPCWithPayloadRequest encodes requests sent to -// ServiceClientStreamingRPCWithPayload MethodClientStreamingRPCWithPayload -// endpoint. -func EncodeMethodClientStreamingRPCWithPayloadRequest(ctx context.Context, v any, md *metadata.MD) (any, error) { - payload, ok := v.(int) - if !ok { - return nil, goagrpc.ErrInvalidType("ServiceClientStreamingRPCWithPayload", "MethodClientStreamingRPCWithPayload", "int", v) - } - (*md).Append("goa_payload", fmt.Sprintf("%v", payload)) - return nil, nil -} -` - -const PayloadUserTypeWithStreamingPayloadRequestEncoderCode = `// EncodeMethodBidirectionalStreamingRPCWithPayloadRequest encodes requests -// sent to ServiceBidirectionalStreamingRPCWithPayload -// MethodBidirectionalStreamingRPCWithPayload endpoint. -func EncodeMethodBidirectionalStreamingRPCWithPayloadRequest(ctx context.Context, v any, md *metadata.MD) (any, error) { - payload, ok := v.(*servicebidirectionalstreamingrpcwithpayload.Payload) - if !ok { - return nil, goagrpc.ErrInvalidType("ServiceBidirectionalStreamingRPCWithPayload", "MethodBidirectionalStreamingRPCWithPayload", "*servicebidirectionalstreamingrpcwithpayload.Payload", v) - } - if payload.A != nil { - (*md).Append("a", fmt.Sprintf("%v", *payload.A)) - } - if payload.B != nil { - (*md).Append("b", *payload.B) - } - return nil, nil -} -` - -const PayloadWithMetadataRequestEncoderCode = `// EncodeMethodMessageWithMetadataRequest encodes requests sent to -// ServiceMessageWithMetadata MethodMessageWithMetadata endpoint. -func EncodeMethodMessageWithMetadataRequest(ctx context.Context, v any, md *metadata.MD) (any, error) { - payload, ok := v.(*servicemessagewithmetadata.RequestUT) - if !ok { - return nil, goagrpc.ErrInvalidType("ServiceMessageWithMetadata", "MethodMessageWithMetadata", "*servicemessagewithmetadata.RequestUT", v) - } - if payload.InMetadata != nil { - (*md).Append("Authorization", fmt.Sprintf("%v", *payload.InMetadata)) - } - return NewProtoMethodMessageWithMetadataRequest(payload), nil -} -` - -const PayloadWithValidateRequestEncoderCode = `// EncodeMethodMessageWithValidateRequest encodes requests sent to -// ServiceMessageWithValidate MethodMessageWithValidate endpoint. -func EncodeMethodMessageWithValidateRequest(ctx context.Context, v any, md *metadata.MD) (any, error) { - payload, ok := v.(*servicemessagewithvalidate.RequestUT) - if !ok { - return nil, goagrpc.ErrInvalidType("ServiceMessageWithValidate", "MethodMessageWithValidate", "*servicemessagewithvalidate.RequestUT", v) - } - if payload.InMetadata != nil { - (*md).Append("Authorization", fmt.Sprintf("%v", *payload.InMetadata)) - } - return NewProtoMethodMessageWithValidateRequest(payload), nil -} -` - -const PayloadWithSecurityAttrsRequestEncoderCode = `// EncodeMethodMessageWithSecurityRequest encodes requests sent to -// ServiceMessageWithSecurity MethodMessageWithSecurity endpoint. -func EncodeMethodMessageWithSecurityRequest(ctx context.Context, v any, md *metadata.MD) (any, error) { - payload, ok := v.(*servicemessagewithsecurity.RequestUT) - if !ok { - return nil, goagrpc.ErrInvalidType("ServiceMessageWithSecurity", "MethodMessageWithSecurity", "*servicemessagewithsecurity.RequestUT", v) - } - if payload.Token != nil { - (*md).Append("authorization", *payload.Token) - } - if payload.Key != nil { - (*md).Append("authorization", *payload.Key) - } - if payload.Username != nil { - (*md).Append("username", *payload.Username) - } - if payload.Password != nil { - (*md).Append("password", *payload.Password) - } - return NewProtoMethodMessageWithSecurityRequest(payload), nil -} -` diff --git a/grpc/codegen/testdata/response_encoder_code.go b/grpc/codegen/testdata/response_encoder_code.go deleted file mode 100644 index 770c6fb81c..0000000000 --- a/grpc/codegen/testdata/response_encoder_code.go +++ /dev/null @@ -1,118 +0,0 @@ -package testdata - -const EmptyResultResponseEncoderCode = `// EncodeMethodUnaryRPCNoResultResponse encodes responses from the -// "ServiceUnaryRPCNoResult" service "MethodUnaryRPCNoResult" endpoint. -func EncodeMethodUnaryRPCNoResultResponse(ctx context.Context, v any, hdr, trlr *metadata.MD) (any, error) { - resp := NewProtoMethodUnaryRPCNoResultResponse() - return resp, nil -} -` - -const ResultWithViewsResponseEncoderCode = `// EncodeMethodMessageResultTypeWithViewsResponse encodes responses from the -// "ServiceMessageResultTypeWithViews" service -// "MethodMessageResultTypeWithViews" endpoint. -func EncodeMethodMessageResultTypeWithViewsResponse(ctx context.Context, v any, hdr, trlr *metadata.MD) (any, error) { - vres, ok := v.(*servicemessageresulttypewithviewsviews.RT) - if !ok { - return nil, goagrpc.ErrInvalidType("ServiceMessageResultTypeWithViews", "MethodMessageResultTypeWithViews", "*servicemessageresulttypewithviewsviews.RT", v) - } - result := vres.Projected - (*hdr).Append("goa-view", vres.View) - resp := NewProtoMethodMessageResultTypeWithViewsResponse(result) - return resp, nil -} -` - -const ResultWithExplicitViewResponseEncoderCode = `// EncodeMethodMessageResultTypeWithExplicitViewResponse encodes responses from -// the "ServiceMessageResultTypeWithExplicitView" service -// "MethodMessageResultTypeWithExplicitView" endpoint. -func EncodeMethodMessageResultTypeWithExplicitViewResponse(ctx context.Context, v any, hdr, trlr *metadata.MD) (any, error) { - vres, ok := v.(*servicemessageresulttypewithexplicitviewviews.RT) - if !ok { - return nil, goagrpc.ErrInvalidType("ServiceMessageResultTypeWithExplicitView", "MethodMessageResultTypeWithExplicitView", "*servicemessageresulttypewithexplicitviewviews.RT", v) - } - result := vres.Projected - (*hdr).Append("goa-view", vres.View) - resp := NewProtoMethodMessageResultTypeWithExplicitViewResponse(result) - return resp, nil -} -` - -const ResultArrayResponseEncoderCode = `// EncodeMethodMessageArrayResponse encodes responses from the -// "ServiceMessageArray" service "MethodMessageArray" endpoint. -func EncodeMethodMessageArrayResponse(ctx context.Context, v any, hdr, trlr *metadata.MD) (any, error) { - result, ok := v.([]*servicemessagearray.UT) - if !ok { - return nil, goagrpc.ErrInvalidType("ServiceMessageArray", "MethodMessageArray", "[]*servicemessagearray.UT", v) - } - resp := NewProtoMethodMessageArrayResponse(result) - return resp, nil -} -` - -const ResultPrimitiveResponseEncoderCode = `// EncodeMethodUnaryRPCNoPayloadResponse encodes responses from the -// "ServiceUnaryRPCNoPayload" service "MethodUnaryRPCNoPayload" endpoint. -func EncodeMethodUnaryRPCNoPayloadResponse(ctx context.Context, v any, hdr, trlr *metadata.MD) (any, error) { - result, ok := v.(string) - if !ok { - return nil, goagrpc.ErrInvalidType("ServiceUnaryRPCNoPayload", "MethodUnaryRPCNoPayload", "string", v) - } - resp := NewProtoMethodUnaryRPCNoPayloadResponse(result) - return resp, nil -} -` - -const ResultWithMetadataResponseEncoderCode = `// EncodeMethodMessageWithMetadataResponse encodes responses from the -// "ServiceMessageWithMetadata" service "MethodMessageWithMetadata" endpoint. -func EncodeMethodMessageWithMetadataResponse(ctx context.Context, v any, hdr, trlr *metadata.MD) (any, error) { - result, ok := v.(*servicemessagewithmetadata.ResponseUT) - if !ok { - return nil, goagrpc.ErrInvalidType("ServiceMessageWithMetadata", "MethodMessageWithMetadata", "*servicemessagewithmetadata.ResponseUT", v) - } - resp := NewProtoMethodMessageWithMetadataResponse(result) - - if res.InHeader != nil { - (*hdr).Append("Location", fmt.Sprintf("%v", *p.InHeader)) - } - - if res.InTrailer != nil { - (*trlr).Append("InTrailer", fmt.Sprintf("%v", *p.InTrailer)) - } - return resp, nil -} -` - -const ResultWithValidateResponseEncoderCode = `// EncodeMethodMessageWithValidateResponse encodes responses from the -// "ServiceMessageWithValidate" service "MethodMessageWithValidate" endpoint. -func EncodeMethodMessageWithValidateResponse(ctx context.Context, v any, hdr, trlr *metadata.MD) (any, error) { - result, ok := v.(*servicemessagewithvalidate.ResponseUT) - if !ok { - return nil, goagrpc.ErrInvalidType("ServiceMessageWithValidate", "MethodMessageWithValidate", "*servicemessagewithvalidate.ResponseUT", v) - } - resp := NewProtoMethodMessageWithValidateResponse(result) - - if res.InHeader != nil { - (*hdr).Append("Location", fmt.Sprintf("%v", *p.InHeader)) - } - - if res.InTrailer != nil { - (*trlr).Append("InTrailer", fmt.Sprintf("%v", *p.InTrailer)) - } - return resp, nil -} -` - -const ResultCollectionResponseEncoderCode = `// EncodeMethodMessageUserTypeWithNestedUserTypesResponse encodes responses -// from the "ServiceMessageUserTypeWithNestedUserTypes" service -// "MethodMessageUserTypeWithNestedUserTypes" endpoint. -func EncodeMethodMessageUserTypeWithNestedUserTypesResponse(ctx context.Context, v any, hdr, trlr *metadata.MD) (any, error) { - vres, ok := v.(servicemessageusertypewithnestedusertypesviews.RTCollection) - if !ok { - return nil, goagrpc.ErrInvalidType("ServiceMessageUserTypeWithNestedUserTypes", "MethodMessageUserTypeWithNestedUserTypes", "servicemessageusertypewithnestedusertypesviews.RTCollection", v) - } - result := vres.Projected - (*hdr).Append("goa-view", vres.View) - resp := NewProtoRTCollection(result) - return resp, nil -} -` diff --git a/grpc/codegen/testdata/server-no-server.golden b/grpc/codegen/testdata/server-no-server.golden index 5b125b7f57..0205cc92d3 100644 --- a/grpc/codegen/testdata/server-no-server.golden +++ b/grpc/codegen/testdata/server-no-server.golden @@ -25,12 +25,7 @@ func handleGRPCServer(ctx context.Context, u *url.URL, serviceEndpoints *service // Register the servers. servicepb.RegisterServiceServer(srv, serviceServer) - - for svc, info := range srv.GetServiceInfo() { - for _, m := range info.Methods { - log.Printf(ctx, "serving gRPC method %s", svc+"/"+m.Name) - } - } + log.Printf(ctx, "serving gRPC method %s", "service.Service/Method") // Register the server reflection service on the server. // See https://grpc.github.io/grpc/core/md_doc_server-reflection.html. diff --git a/grpc/codegen/testdata/server-server-hosting-multiple-services.golden b/grpc/codegen/testdata/server-server-hosting-multiple-services.golden index e9311a5676..6082624304 100644 --- a/grpc/codegen/testdata/server-server-hosting-multiple-services.golden +++ b/grpc/codegen/testdata/server-server-hosting-multiple-services.golden @@ -28,12 +28,8 @@ func handleGRPCServer(ctx context.Context, u *url.URL, serviceEndpoints *service // Register the servers. servicepb.RegisterServiceServer(srv, serviceServer) another_servicepb.RegisterAnotherServiceServer(srv, anotherServiceServer) - - for svc, info := range srv.GetServiceInfo() { - for _, m := range info.Methods { - log.Printf(ctx, "serving gRPC method %s", svc+"/"+m.Name) - } - } + log.Printf(ctx, "serving gRPC method %s", "service.Service/Method") + log.Printf(ctx, "serving gRPC method %s", "another_service.AnotherService/Method") // Register the server reflection service on the server. // See https://grpc.github.io/grpc/core/md_doc_server-reflection.html. diff --git a/grpc/codegen/testdata/server-server-hosting-service-subset.golden b/grpc/codegen/testdata/server-server-hosting-service-subset.golden index 5b125b7f57..0205cc92d3 100644 --- a/grpc/codegen/testdata/server-server-hosting-service-subset.golden +++ b/grpc/codegen/testdata/server-server-hosting-service-subset.golden @@ -25,12 +25,7 @@ func handleGRPCServer(ctx context.Context, u *url.URL, serviceEndpoints *service // Register the servers. servicepb.RegisterServiceServer(srv, serviceServer) - - for svc, info := range srv.GetServiceInfo() { - for _, m := range info.Methods { - log.Printf(ctx, "serving gRPC method %s", svc+"/"+m.Name) - } - } + log.Printf(ctx, "serving gRPC method %s", "service.Service/Method") // Register the server reflection service on the server. // See https://grpc.github.io/grpc/core/md_doc_server-reflection.html. diff --git a/grpc/codegen/testdata/streaming_code.go b/grpc/codegen/testdata/streaming_code.go index 2e71644fdb..221517733e 100644 --- a/grpc/codegen/testdata/streaming_code.go +++ b/grpc/codegen/testdata/streaming_code.go @@ -1,3 +1,4 @@ +// This file contains expected gRPC stream code used by generator tests. package testdata var ServerStreamingServerStructCode = `// MethodServerStreamingUserTypeRPCServerStream implements the @@ -12,7 +13,7 @@ var ServerStreamingServerSendCode = `// Send streams instances of // "service_server_streaming_user_type_rpcpb.MethodServerStreamingUserTypeRPCResponse" // to the "MethodServerStreamingUserTypeRPC" endpoint gRPC stream. func (s *MethodServerStreamingUserTypeRPCServerStream) Send(res *serviceserverstreamingusertyperpc.UserType) error { - v := NewProtoUserTypeMethodServerStreamingUserTypeRPCResponse(res) + v := NewProtoMethodServerStreamingUserTypeRPCResponse(res) return s.stream.Send(v) } @@ -69,6 +70,9 @@ var ServerStreamingResultWithViewsServerStructCode = `// MethodServerStreamingUs type MethodServerStreamingUserTypeRPCServerStream struct { stream service_server_streaming_user_type_rpcpb.ServiceServerStreamingUserTypeRPC_MethodServerStreamingUserTypeRPCServer view string + // sentView is the result view named in the response header. Later sends must + // use the same view. + sentView string } ` @@ -76,8 +80,29 @@ var ServerStreamingResultWithViewsServerSendCode = `// Send streams instances of // "service_server_streaming_user_type_rpcpb.MethodServerStreamingUserTypeRPCResponse" // to the "MethodServerStreamingUserTypeRPC" endpoint gRPC stream. func (s *MethodServerStreamingUserTypeRPCServerStream) Send(res *serviceserverstreamingusertyperpc.ResultType) error { - vres := serviceserverstreamingusertyperpc.NewViewedResultType(res, s.view) - v := NewProtoResultTypeViewMethodServerStreamingUserTypeRPCResponse(vres.Projected) + view := s.view + if view == "" { + view = "default" + } + if s.sentView != "" && view != s.sentView { + return goa.InvalidEnumValueError("view", view, []any{s.sentView}) + } + vres := serviceserverstreamingusertyperpc.NewViewedResultType(res, view) + var v *service_server_streaming_user_type_rpcpb.MethodServerStreamingUserTypeRPCResponse + switch view { + case "tiny": + v = NewProtoMethodServerStreamingUserTypeRPCResponseTiny(vres.Projected) + case "default", "": + v = NewProtoMethodServerStreamingUserTypeRPCResponse(vres.Projected) + default: + return goa.InvalidEnumValueError("view", view, []any{"tiny", "default"}) + } + if s.sentView == "" { + if err := s.stream.SetHeader(metadata.Pairs("goa-view", view)); err != nil { + return err + } + s.sentView = view + } return s.stream.Send(v) } @@ -99,9 +124,10 @@ var ServerStreamingResultWithViewsClientStructCode = `// MethodServerStreamingUs // serviceserverstreamingusertyperpc.MethodServerStreamingUserTypeRPCClientStream // interface. type MethodServerStreamingUserTypeRPCClientStream struct { - stream service_server_streaming_user_type_rpcpb.ServiceServerStreamingUserTypeRPC_MethodServerStreamingUserTypeRPCClient - ctx context.Context - view string + stream service_server_streaming_user_type_rpcpb.ServiceServerStreamingUserTypeRPC_MethodServerStreamingUserTypeRPCClient + ctx context.Context + view string + viewSet bool } ` @@ -117,7 +143,31 @@ func (s *MethodServerStreamingUserTypeRPCClientStream) Recv() (*serviceserverstr } return res, err } - proj := NewMethodServerStreamingUserTypeRPCResponseResultTypeView(v) + if !s.viewSet { + hdr, err := s.stream.Header() + if err != nil { + return res, err + } + views := hdr.Get("goa-view") + if len(views) == 0 { + return res, goa.MissingFieldError("goa-view", "metadata") + } + s.view = views[0] + s.viewSet = true + } + var proj *serviceserverstreamingusertyperpcviews.ResultTypeView + switch s.view { + case "tiny": + if err := ValidateMethodServerStreamingUserTypeRPCResponseTiny(v); err != nil { + return res, err + } + proj = NewMethodServerStreamingUserTypeRPCResponseResultTypeViewTiny(v) + case "default", "": + if err := ValidateMethodServerStreamingUserTypeRPCResponse(v); err != nil { + return res, err + } + proj = NewMethodServerStreamingUserTypeRPCResponseResultTypeView(v) + } vres := &serviceserverstreamingusertyperpcviews.ResultType{Projected: proj, View: s.view} if err := serviceserverstreamingusertyperpcviews.ValidateResultType(vres); err != nil { return nil, err @@ -137,6 +187,7 @@ func (s *MethodServerStreamingUserTypeRPCClientStream) RecvWithContext(ctx conte var ServerStreamingResultWithViewsClientSetViewCode = `// SetView sets the view. func (s *MethodServerStreamingUserTypeRPCClientStream) SetView(view string) { s.view = view + s.viewSet = true } ` @@ -146,7 +197,7 @@ var ServerStreamingResultCollectionWithExplicitViewServerSendCode = `// Send str // gRPC stream. func (s *MethodServerStreamingResultTypeCollectionWithExplicitViewServerStream) Send(res serviceserverstreamingresulttypecollectionwithexplicitview.ResultTypeCollection) error { vres := serviceserverstreamingresulttypecollectionwithexplicitview.NewViewedResultTypeCollection(res, "tiny") - v := NewProtoResultTypeCollectionViewResultTypeCollection(vres.Projected) + v := NewProtoResultTypeCollection(vres.Projected) return s.stream.Send(v) } @@ -217,6 +268,9 @@ func (s *MethodServerStreamingRPCClientStream) Recv() (string, error) { } return res, err } + if err = ValidateMethodServerStreamingRPCResponse(v); err != nil { + return res, err + } return NewMethodServerStreamingRPCResponseMethodServerStreamingRPCResponse(v), nil } @@ -383,6 +437,9 @@ func (s *MethodClientStreamingRPCServerStream) Recv() (int, error) { if err != nil { return res, err } + if err = ValidateMethodClientStreamingRPCStreamingRequest(v); err != nil { + return res, err + } return NewMethodClientStreamingRPCStreamingRequestMethodClientStreamingRPCStreamingRequest(v), nil } @@ -430,6 +487,9 @@ func (s *MethodClientStreamingRPCClientStream) CloseAndRecv() (string, error) { } return res, err } + if err = ValidateMethodClientStreamingRPCResponse(v); err != nil { + return res, err + } return NewMethodClientStreamingRPCResponseMethodClientStreamingRPCResponse(v), nil } @@ -465,7 +525,6 @@ var BidirectionalStreamingServerStructCode = `// MethodBidirectionalStreamingRPC // interface. type MethodBidirectionalStreamingRPCServerStream struct { stream service_bidirectional_streaming_rpcpb.ServiceBidirectionalStreamingRPC_MethodBidirectionalStreamingRPCServer - view string } ` @@ -474,7 +533,7 @@ var BidirectionalStreamingServerSendCode = `// Send streams instances of // to the "MethodBidirectionalStreamingRPC" endpoint gRPC stream. func (s *MethodBidirectionalStreamingRPCServerStream) Send(res *servicebidirectionalstreamingrpc.ID) error { vres := servicebidirectionalstreamingrpc.NewViewedID(res, "default") - v := NewProtoIDViewMethodBidirectionalStreamingRPCResponse(vres.Projected) + v := NewProtoMethodBidirectionalStreamingRPCResponse(vres.Projected) return s.stream.Send(v) } @@ -495,6 +554,9 @@ func (s *MethodBidirectionalStreamingRPCServerStream) Recv() (int, error) { if err != nil { return res, err } + if err = ValidateMethodBidirectionalStreamingRPCStreamingRequest(v); err != nil { + return res, err + } return NewMethodBidirectionalStreamingRPCStreamingRequestMethodBidirectionalStreamingRPCStreamingRequest(v), nil } @@ -518,7 +580,6 @@ var BidirectionalStreamingClientStructCode = `// MethodBidirectionalStreamingRPC type MethodBidirectionalStreamingRPCClientStream struct { stream service_bidirectional_streaming_rpcpb.ServiceBidirectionalStreamingRPC_MethodBidirectionalStreamingRPCClient ctx context.Context - view string } ` diff --git a/grpc/codegen/testing.go b/grpc/codegen/testing.go index 103d5c9360..ff5393a9af 100644 --- a/grpc/codegen/testing.go +++ b/grpc/codegen/testing.go @@ -1,3 +1,5 @@ +// This file builds gRPC code-generation analysis in tests using the same +// generation construction, planning, freezing, and rendering as production. package codegen import ( @@ -5,7 +7,9 @@ import ( "testing" "goa.design/goa/v3/codegen" + "goa.design/goa/v3/codegen/example" "goa.design/goa/v3/codegen/service" + "goa.design/goa/v3/eval" "goa.design/goa/v3/expr" ) @@ -17,12 +21,78 @@ func RunGRPCDSL(t *testing.T, dsl func()) *expr.RootExpr { return root } -// CreateGRPCServices creates a new ServicesData instance for testing. The -// root is normalized first like the production Generate flow does before the -// generators read the design. +// CreateGRPCServices creates a new ServicesData instance for testing. +// Generation construction normalizes the root before any planner reads it. func CreateGRPCServices(root *expr.RootExpr) *ServicesData { - codegen.NormalizeRoot(root) - return NewServicesData(service.NewServicesData(root)) + return createServiceServices(root) +} + +// createServiceServices chooses every package name and builds the gRPC service +// data required by transport tests. +func createServiceServices(root *expr.RootExpr) *ServicesData { + return createServiceServicesForPackage(root, "generated.local/gen") +} + +// createServiceServicesForPackage builds test service analysis for the exact +// generated module path whose imports the test renders. +func createServiceServicesForPackage(root *expr.RootExpr, genpkg string) *ServicesData { + generation, err := codegen.NewGeneration(genpkg, []eval.Root{root}) + if err != nil { + panic(err) + } + servicePlan, err := service.NewPlan(root, generation, expr.NewExampleGenerator(root.API.RandomizerFactory)) + if err != nil { + panic(err) + } + grpcPlans, err := NewPlans(generation, PlanInput{Root: root, Service: servicePlan}) + if err != nil { + panic(err) + } + if err := generation.Freeze(); err != nil { + panic(err) + } + if err := servicePlan.Link(); err != nil { + panic(err) + } + if err := grpcPlans[0].Link(); err != nil { + panic(err) + } + return grpcPlans[0].services +} + +// createExamplePlan builds linked gRPC data and copied server data that belong +// to the same service plan. +func createExamplePlan(root *expr.RootExpr, genpkg string) *ExamplePlan { + generation, err := codegen.NewGeneration(genpkg, []eval.Root{root}) + if err != nil { + panic(err) + } + servicePlan, err := service.NewPlan(root, generation, expr.NewExampleGenerator(root.API.RandomizerFactory)) + if err != nil { + panic(err) + } + grpcPlans, err := NewPlans(generation, PlanInput{Root: root, Service: servicePlan}) + if err != nil { + panic(err) + } + examplePlan, err := example.NewPlan(generation, servicePlan) + if err != nil { + panic(err) + } + examples, err := NewExamplePlan(grpcPlans[0], examplePlan) + if err != nil { + panic(err) + } + if err := generation.Freeze(); err != nil { + panic(err) + } + if err := servicePlan.Link(); err != nil { + panic(err) + } + if err := grpcPlans[0].Link(); err != nil { + panic(err) + } + return examples } func sectionCode(t *testing.T, section ...*codegen.SectionTemplate) string { diff --git a/grpc/codegen/transform_helper_test.go b/grpc/codegen/transform_helper_test.go new file mode 100644 index 0000000000..f2e1aea230 --- /dev/null +++ b/grpc/codegen/transform_helper_test.go @@ -0,0 +1,352 @@ +// This file verifies gRPC conversions share strict private functions when the +// generated source and target types are the same. +package codegen + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/require" + + "goa.design/goa/v3/codegen" + "goa.design/goa/v3/codegen/testutil" + "goa.design/goa/v3/dsl" +) + +// TestTransformHelpersShareAcrossDocumentedUses checks that field prose and +// examples do not split one recursive conversion used by several methods and +// union branches. A nearly identical child with different required-field +// pointer rules keeps a separate function and call sites. +func TestTransformHelpersShareAcrossDocumentedUses(t *testing.T) { + root := RunGRPCDSL(t, sharedGRPCTransformHelperMethodsDSL) + services := CreateGRPCServices(root) + service := services.Get("SharedMethodHelpers") + require.NotEmpty(t, service.clientTransformHelpers) + require.NotEmpty(t, service.serverTransformHelpers) + + var clientShared, clientNear *codegen.TransformFunctionData + clientSharedDeclarations := make(map[*codegen.NameDeclaration]struct{}) + for _, helper := range service.clientTransformHelpers { + if !strings.Contains(helper.Declaration.Name(), "ToProto") { + continue + } + switch helper.ParamTypeRef { + case "*sharedmethodhelpers.SharedMethodChild": + clientShared = helper + clientSharedDeclarations[helper.Declaration] = struct{}{} + case "*sharedmethodhelpers.NearMethodChild": + clientNear = helper + } + } + require.NotNil(t, clientShared) + require.NotNil(t, clientNear) + require.Len(t, clientSharedDeclarations, 1) + require.NotSame(t, clientShared.Declaration, clientNear.Declaration) + require.Contains(t, clientShared.Code, clientShared.Declaration.Name()+"(v.Next)") + require.Contains(t, clientNear.Code, clientNear.Declaration.Name()+"(v.Next)") + + var serverShared, serverNear *codegen.TransformFunctionData + serverSharedDeclarations := make(map[*codegen.NameDeclaration]struct{}) + for _, helper := range service.serverTransformHelpers { + switch helper.ParamTypeRef { + case "*shared_method_helperspb.SharedMethodChild": + serverShared = helper + serverSharedDeclarations[helper.Declaration] = struct{}{} + case "*shared_method_helperspb.NearMethodChild": + serverNear = helper + } + } + require.NotNil(t, serverShared) + require.NotNil(t, serverNear) + require.Len(t, serverSharedDeclarations, 1) + require.NotSame(t, serverShared.Declaration, serverNear.Declaration) + require.Contains(t, serverShared.Code, serverShared.Declaration.Name()+"(v.Next)") + require.Contains(t, serverNear.Code, serverNear.Declaration.Name()+"(v.Next)") + + for _, endpoint := range service.Endpoints[:2] { + require.Contains(t, endpoint.Request.ClientConvert.Init.Code, + "message.Child = "+clientShared.Declaration.Name()+"(payload.Child)") + require.Contains(t, endpoint.Request.ServerConvert.Init.Code, + "v.Child = "+serverShared.Declaration.Name()+"(message.Child)") + } + var inspect *EndpointData + for _, endpoint := range service.Endpoints { + if endpoint.Method.Name == "Inspect" { + inspect = endpoint + break + } + } + require.NotNil(t, inspect) + require.Contains(t, inspect.Request.ClientConvert.Init.Code, + "message.Child = "+clientNear.Declaration.Name()+"(payload.Child)") + require.Contains(t, inspect.Request.ServerConvert.Init.Code, + "v.Child = "+serverNear.Declaration.Name()+"(message.Child)") + + client := codegen.SectionsCode(t, clientTypeFiles(services)[0].SectionTemplates[1:]) + server := codegen.SectionsCode(t, serverTypeFiles(services)[0].SectionTemplates[1:]) + testutil.AssertGo(t, "testdata/golden/transform_helper_shared_methods_client.go.golden", client) + testutil.AssertGo(t, "testdata/golden/transform_helper_shared_methods_server.go.golden", server) +} + +// TestTransformHelpersKeepViewedAndServiceDeclarations checks that a projected +// child uses its views package declaration while the original child used by a +// normal method keeps its service package declaration. Their private helpers +// must remain separate in the final client and server packages. +func TestTransformHelpersKeepViewedAndServiceDeclarations(t *testing.T) { + root := RunGRPCDSL(t, viewedAndServiceTransformHelperDSL) + services := CreateGRPCServices(root) + service := services.Get("ViewBindings") + + var clientServiceFrom, clientViewFrom *codegen.TransformFunctionData + for _, helper := range service.clientTransformHelpers { + switch helper.ResultTypeRef { + case "*viewbindings.SharedDeclarationChild": + clientServiceFrom = helper + case "*viewbindingsviews.SharedDeclarationChildView": + clientViewFrom = helper + } + } + require.NotNil(t, clientServiceFrom) + require.NotNil(t, clientViewFrom) + require.NotSame(t, clientServiceFrom.Declaration, clientViewFrom.Declaration) + + var serverServiceTo, serverViewTo *codegen.TransformFunctionData + for _, helper := range service.serverTransformHelpers { + switch helper.ParamTypeRef { + case "*viewbindings.SharedDeclarationChild": + serverServiceTo = helper + case "*viewbindingsviews.SharedDeclarationChildView": + serverViewTo = helper + } + } + require.NotNil(t, serverServiceTo) + require.NotNil(t, serverViewTo) + require.NotSame(t, serverServiceTo.Declaration, serverViewTo.Declaration) + + var viewed, plain *EndpointData + for _, endpoint := range service.Endpoints { + switch endpoint.Method.Name { + case "Viewed": + viewed = endpoint + case "Plain": + plain = endpoint + } + } + require.NotNil(t, viewed) + require.NotNil(t, plain) + require.Contains(t, plain.Response.ClientConvert.Init.Code, clientServiceFrom.Declaration.Name()+"(message.Child)") + require.Contains(t, plain.Response.ServerConvert.Init.Code, serverServiceTo.Declaration.Name()+"(result.Child)") + require.Contains(t, viewed.Response.ClientConvert.Init.Code, clientViewFrom.Declaration.Name()+"(message.Child)") + require.Contains(t, viewed.Response.ServerConvert.Init.Code, serverViewTo.Declaration.Name()+"(result.Child)") + + client := codegen.SectionsCode(t, clientTypeFiles(services)[0].SectionTemplates[1:]) + server := codegen.SectionsCode(t, serverTypeFiles(services)[0].SectionTemplates[1:]) + testutil.AssertGo(t, "testdata/golden/transform_helper_viewed_service_client.go.golden", client) + testutil.AssertGo(t, "testdata/golden/transform_helper_viewed_service_server.go.golden", server) +} + +// TestTransformHelpersFollowCollectionWrapper checks that nested conversions +// use the slice stored in a protobuf response wrapper on both transport sides. +func TestTransformHelpersFollowCollectionWrapper(t *testing.T) { + root := RunGRPCDSL(t, wrappedCollectionTransformHelperDSL) + services := CreateGRPCServices(root) + service := services.Get("WrappedCollection") + require.NotEmpty(t, service.clientTransformHelpers) + require.NotEmpty(t, service.serverTransformHelpers) + + response := service.Endpoints[0].Response + require.Contains(t, response.ServerConvert.Init.Code, "message.Field = make(") + require.Contains(t, response.ClientConvert.Init.Code, "result := make(") + + client := codegen.SectionsCode(t, clientTypeFiles(services)[0].SectionTemplates[1:]) + server := codegen.SectionsCode(t, serverTypeFiles(services)[0].SectionTemplates[1:]) + testutil.AssertGo(t, "testdata/golden/transform_helper_wrapped_collection_client.go.golden", client) + testutil.AssertGo(t, "testdata/golden/transform_helper_wrapped_collection_server.go.golden", server) +} + +// TestTransformHelpersShareRequiredAndOptionalCalls checks that a required +// field and an optional field in one conversion plan share one function. The +// optional call remains inside its nil check. +func TestTransformHelpersShareRequiredAndOptionalCalls(t *testing.T) { + root := RunGRPCDSL(t, sharedGRPCTransformHelperDSL) + services := CreateGRPCServices(root) + service := services.Get("SharedHelpers") + require.Len(t, service.clientTransformHelpers, 2) + require.Len(t, service.serverTransformHelpers, 1) + var clientHelper *codegen.TransformFunctionData + for _, helper := range service.clientTransformHelpers { + if strings.HasPrefix(helper.ParamTypeRef, "*sharedhelpers.") { + clientHelper = helper + } + } + require.NotNil(t, clientHelper) + serverHelper := service.serverTransformHelpers[0] + require.NotContains(t, clientHelper.Code, "if v == nil") + require.NotContains(t, serverHelper.Code, "if v == nil") + + request := service.Endpoints[0].Request + clientCode := request.ClientConvert.Init.Code + require.Contains(t, clientCode, "message.Left = "+clientHelper.Declaration.Name()+"(payload.Left)") + require.Contains(t, clientCode, "if payload.Right != nil {") + require.Contains(t, clientCode, "message.Right = "+clientHelper.Declaration.Name()+"(payload.Right)") + serverCode := request.ServerConvert.Init.Code + require.Contains(t, serverCode, "v.Left = "+serverHelper.Declaration.Name()+"(message.Left)") + require.Contains(t, serverCode, "if message.Right != nil {") + require.Contains(t, serverCode, "v.Right = "+serverHelper.Declaration.Name()+"(message.Right)") + + for _, side := range []validateKind{validateClient, validateServer} { + validations := make(map[string]*protobufValidationRecord) + for _, validation := range service.protobuf.validators { + if validation.side == side { + validations[validation.source.path] = validation + } + } + rootValidation := validations[""] + leftValidation := validations["left"] + rightValidation := validations["right"] + require.NotNil(t, rootValidation) + require.NotNil(t, leftValidation) + require.NotNil(t, rightValidation) + require.Equal(t, "ValidateStoreRequest", rootValidation.declaration.Name()) + require.Equal(t, "validatetest_20_api_SharedHelpers_SharedChild_At_left", leftValidation.declaration.Name()) + require.Equal(t, "validatetest_20_api_SharedHelpers_SharedChild_At_right", rightValidation.declaration.Name()) + require.Contains(t, leftValidation.data.Def, `MissingFieldError("value", "left")`) + require.Contains(t, rightValidation.data.Def, `MissingFieldError("value", "right")`) + require.Contains(t, rootValidation.data.Def, "validatetest_20_api_SharedHelpers_SharedChild_At_left(message.Left)") + require.Contains(t, rootValidation.data.Def, "validatetest_20_api_SharedHelpers_SharedChild_At_right(message.Right)") + } + + client := codegen.SectionsCode(t, clientTypeFiles(services)[0].SectionTemplates[1:]) + testutil.AssertGo(t, "testdata/golden/transform_helper_shared.go.golden", client) +} + +// sharedGRPCTransformHelperDSL creates one request with required and optional +// fields that use the same named type. +func sharedGRPCTransformHelperDSL() { + child := dsl.Type("SharedChild", func() { + dsl.Field(1, "value", dsl.String) + dsl.Required("value") + }) + dsl.Service("SharedHelpers", func() { + dsl.Method("Store", func() { + dsl.Payload(func() { + dsl.Field(1, "left", child) + dsl.Field(2, "right", child) + dsl.Required("left") + }) + dsl.GRPC(func() {}) + }) + }) +} + +// sharedGRPCTransformHelperMethodsDSL uses one recursive child under different +// method and union field prose. It also defines a separate recursive child +// whose value field is optional instead of required. +func sharedGRPCTransformHelperMethodsDSL() { + child := dsl.Type("SharedMethodChild", func() { + dsl.Field(1, "value", dsl.String) + dsl.Field(2, "next", "SharedMethodChild") + dsl.Required("value") + }) + near := dsl.Type("NearMethodChild", func() { + dsl.Field(1, "value", dsl.String) + dsl.Field(2, "next", "NearMethodChild") + }) + dsl.Service("SharedMethodHelpers", func() { + for _, method := range []string{"Store", "Replace"} { + dsl.Method(method, func() { + dsl.Payload(func() { + dsl.Field(1, "child", child, method+" uses the shared child.", func() { + dsl.Example(map[string]any{"value": method}) + }) + dsl.Required("child") + }) + dsl.GRPC(func() {}) + }) + } + dsl.Method("Choose", func() { + dsl.Payload(func() { + dsl.OneOf("choice", func() { + dsl.TypeName("DescribedChoice") + for index, branch := range []string{"First", "Second", "Third"} { + dsl.Field(index+1, branch, child, branch+" documented branch.", func() { + dsl.Example(map[string]any{"value": branch}) + }) + } + }) + dsl.Required("choice") + }) + dsl.GRPC(func() {}) + }) + dsl.Method("Inspect", func() { + dsl.Payload(func() { + dsl.Field(1, "child", near) + dsl.Required("child") + }) + dsl.GRPC(func() {}) + }) + }) +} + +// viewedAndServiceTransformHelperDSL uses one named child through both a +// projected result view and an ordinary method result. +func viewedAndServiceTransformHelperDSL() { + child := dsl.Type("SharedDeclarationChild", func() { + dsl.Field(1, "value", dsl.String) + dsl.Required("value") + }) + viewedResult := dsl.ResultType("application/vnd.view-bindings", func() { + dsl.TypeName("ViewedContainer") + dsl.Field(1, "child", child) + dsl.Required("child") + dsl.View("default", func() { + dsl.Attribute("child") + }) + }) + dsl.Service("ViewBindings", func() { + dsl.Method("Viewed", func() { + dsl.Result(viewedResult) + dsl.GRPC(func() {}) + }) + dsl.Method("Plain", func() { + dsl.Payload(func() { + dsl.Field(1, "child", child) + dsl.Required("child") + }) + dsl.Result(func() { + dsl.Field(1, "child", child) + dsl.Required("child") + }) + dsl.GRPC(func() {}) + }) + }) +} + +// wrappedCollectionTransformHelperDSL returns a viewed slice whose item uses a +// second viewed type, so gRPC needs a nested conversion inside its Field slice. +func wrappedCollectionTransformHelperDSL() { + child := dsl.ResultType("application/vnd.wrapped-collection-child", func() { + dsl.TypeName("WrappedCollectionChild") + dsl.Field(1, "value", dsl.String) + dsl.Required("value") + dsl.View("default", func() { + dsl.Attribute("value") + }) + }) + item := dsl.ResultType("application/vnd.wrapped-collection-item", func() { + dsl.TypeName("WrappedCollectionItem") + dsl.Field(1, "child", child) + dsl.Required("child") + dsl.View("default", func() { + dsl.Attribute("child") + }) + }) + dsl.Service("WrappedCollection", func() { + dsl.Method("List", func() { + dsl.Result(dsl.CollectionOf(item), func() { + dsl.View("default") + }) + dsl.GRPC(func() {}) + }) + }) +} diff --git a/grpc/codegen/types.go b/grpc/codegen/types.go index f5849111b6..1e1fc9bd22 100644 --- a/grpc/codegen/types.go +++ b/grpc/codegen/types.go @@ -1,29 +1,27 @@ +// This file renders gRPC client and server conversion types per service and +// attaches imports to the exact side-specific file that uses them. package codegen import ( - "path" "path/filepath" "goa.design/goa/v3/codegen" - "goa.design/goa/v3/expr" ) -// ServerTypeFiles returns the server types files containing all the server -// interfaces and types needed to implement gRPC server. -func ServerTypeFiles(genpkg string, services *ServicesData) []*codegen.File { - fw := make([]*codegen.File, len(services.Root.API.GRPC.Services)) - for i, svc := range services.Root.API.GRPC.Services { - fw[i] = typesFile(genpkg, svc, services, true) +// serverTypeFiles returns the planned conversion types used by gRPC servers. +func serverTypeFiles(services *ServicesData) []*codegen.File { + fw := make([]*codegen.File, len(services.servicePlans)) + for i, servicePlan := range services.servicePlans { + fw[i] = addEndpointImports(typesFile(servicePlan, services, true), services) } return fw } -// ClientTypeFiles returns the client types files containing all the client -// interfaces and types needed to implement gRPC client. -func ClientTypeFiles(genpkg string, services *ServicesData) []*codegen.File { - fw := make([]*codegen.File, len(services.Root.API.GRPC.Services)) - for i, svc := range services.Root.API.GRPC.Services { - fw[i] = typesFile(genpkg, svc, services, false) +// clientTypeFiles returns the planned conversion types used by gRPC clients. +func clientTypeFiles(services *ServicesData) []*codegen.File { + fw := make([]*codegen.File, len(services.servicePlans)) + for i, servicePlan := range services.servicePlans { + fw[i] = addEndpointImports(typesFile(servicePlan, services, false), services) } return fw } @@ -31,22 +29,23 @@ func ClientTypeFiles(genpkg string, services *ServicesData) []*codegen.File { // typesFile returns the file defining the gRPC types for the given service. // svr indicates whether the file is generated for the server (true) or the // client (false) package. -func typesFile(genpkg string, svc *expr.GRPCServiceExpr, services *ServicesData, svr bool) *codegen.File { +func typesFile(servicePlan *grpcServicePlan, services *ServicesData, svr bool) *codegen.File { + svc := servicePlan.expression var ( initData []*InitData sd = services.Get(svc.Name()) ) { - seen := make(map[string]struct{}) + seen := make(map[*codegen.NameDeclaration]struct{}) collect := func(c *ConvertData) { if c == nil || c.Init == nil { return } - if _, ok := seen[c.Init.Name]; ok { + if _, ok := seen[c.Init.Declaration]; ok { return } - seen[c.Init.Name] = struct{}{} + seen[c.Init.Declaration] = struct{}{} initData = append(initData, c.Init) } for _, a := range svc.GRPCEndpoints { @@ -57,8 +56,14 @@ func typesFile(genpkg string, svc *expr.GRPCServiceExpr, services *ServicesData, collect(ed.Request.LegacyDecode.ServerConvert) } collect(ed.Response.ServerConvert) + for _, conversion := range ed.Response.ServerConverts { + collect(conversion.Convert) + } if ed.ServerStream != nil { collect(ed.ServerStream.SendConvert) + for _, conversion := range ed.ServerStream.SendConverts { + collect(conversion.Convert) + } collect(ed.ServerStream.RecvConvert) } for _, e := range ed.Errors { @@ -67,8 +72,14 @@ func typesFile(genpkg string, svc *expr.GRPCServiceExpr, services *ServicesData, } else { collect(ed.Request.ClientConvert) collect(ed.Response.ClientConvert) + for _, conversion := range ed.Response.ClientConverts { + collect(conversion.Convert) + } if ed.ClientStream != nil { collect(ed.ClientStream.RecvConvert) + for _, conversion := range ed.ClientStream.RecvConverts { + collect(conversion.Convert) + } collect(ed.ClientStream.SendConvert) } for _, e := range ed.Errors { @@ -92,29 +103,12 @@ func typesFile(genpkg string, svc *expr.GRPCServiceExpr, services *ServicesData, { svcName := sd.Service.PathName fpath = filepath.Join(codegen.Gendir, "grpc", svcName, side, "types.go") - imports := []*codegen.ImportSpec{ - {Path: "unicode/utf8"}, - codegen.GoaImport(""), - {Path: path.Join(genpkg, svcName), Name: sd.Service.PkgName}, - {Path: path.Join(genpkg, svcName, "views"), Name: sd.Service.ViewsPkg}, - {Path: path.Join(genpkg, "grpc", svcName, pbPkgName), Name: sd.PkgName}, - } - // Add imports if Any type is used - if usesAnyType(svc.GRPCEndpoints, true) { - imports = append(imports, &codegen.ImportSpec{Path: "fmt"}) - imports = append(imports, &codegen.ImportSpec{Path: "google.golang.org/protobuf/types/known/structpb", Name: "structpb"}) - } - imports = append(imports, sd.Service.ProtoImports...) - sections = []*codegen.SectionTemplate{codegen.Header(svc.Name()+" gRPC "+side+" types", side, imports)} + sections = []*codegen.SectionTemplate{codegen.Header(svc.Name()+" gRPC "+side+" types", side, nil)} for _, init := range initData { sections = append(sections, &codegen.SectionTemplate{ Name: side + "-type-init", Source: grpcTemplates.Read(grpcTypeInitT), Data: init, - FuncMap: map[string]any{ - "isAlias": expr.IsAlias, - "fullName": fullTypeName, - }, }) } for _, data := range sd.validations { @@ -127,7 +121,11 @@ func typesFile(genpkg string, svc *expr.GRPCServiceExpr, services *ServicesData, Data: data, }) } - for _, h := range sd.transformHelpers { + helpers := sd.clientTransformHelpers + if svr { + helpers = sd.serverTransformHelpers + } + for _, h := range helpers { sections = append(sections, &codegen.SectionTemplate{ Name: side + "-transform-helper", Source: grpcTemplates.Read(grpcTransformHelperT), @@ -137,12 +135,3 @@ func typesFile(genpkg string, svc *expr.GRPCServiceExpr, services *ServicesData, } return &codegen.File{Path: fpath, SectionTemplates: sections} } - -// fullTypeName returns the name of the given type qualified with the name of -// its package when the type is defined in an explicit user type location. -func fullTypeName(dt expr.DataType) string { - if loc := codegen.UserTypeLocation(dt); loc != nil { - return loc.PackageName() + "." + dt.Name() - } - return dt.Name() -} diff --git a/grpc/codegen/view_specialization_test.go b/grpc/codegen/view_specialization_test.go new file mode 100644 index 0000000000..7e5ff11a64 --- /dev/null +++ b/grpc/codegen/view_specialization_test.go @@ -0,0 +1,272 @@ +// This file checks that generated gRPC code keeps design-selected views in +// source and reads transport metadata only when the caller selects the view. +package codegen + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "goa.design/goa/v3/codegen" + "goa.design/goa/v3/codegen/testutil" + d "goa.design/goa/v3/dsl" + "goa.design/goa/v3/expr" + "goa.design/goa/v3/grpc/codegen/testdata" +) + +func TestUnaryViewedResultSpecialization(t *testing.T) { + t.Run("missing or conflicting headers keep design selected view", func(t *testing.T) { + root := RunGRPCDSL(t, testdata.MessageResultTypeWithExplicitViewDSL) + services := CreateGRPCServices(root) + clientFiles := clientFiles(services) + serverFiles := serverFiles(services) + require.Len(t, clientFiles, 2) + require.Len(t, serverFiles, 2) + + decoder := codegen.SectionsCode(t, clientFiles[1].Section("response-decoder")) + assert.NotContains(t, decoder, `hdr.Get("goa-view")`) + assert.Contains(t, decoder, `View: "tiny"`) + assert.NotContains(t, decoder, "switch") + encoder := codegen.SectionsCode(t, serverFiles[1].Section("response-encoder")) + assert.Contains(t, encoder, `Append("goa-view", "tiny")`) + assert.NotContains(t, encoder, `Append("goa-view", vres.View)`) + testutil.AssertGo(t, "testdata/golden/viewed_result_fixed_response_encoder.go.golden", encoder) + response := services.Get("ServiceMessageResultTypeWithExplicitView").Endpoints[0].Response + require.Len(t, response.ClientConverts, 1) + require.Equal(t, "tiny", response.ClientConverts[0].View) + require.Same(t, response.ClientConverts[0].Convert, response.ClientConvert) + }) + + t.Run("caller selected view", func(t *testing.T) { + root := RunGRPCDSL(t, testdata.MessageResultTypeWithViewsDSL) + services := CreateGRPCServices(root) + clientFiles := clientFiles(services) + serverFiles := serverFiles(services) + require.Len(t, clientFiles, 2) + require.Len(t, serverFiles, 2) + + decoder := codegen.SectionsCode(t, clientFiles[1].Section("response-decoder")) + assert.Contains(t, decoder, `hdr.Get("goa-view")`) + assert.Contains(t, decoder, "View: view") + assert.Contains(t, decoder, "switch view") + assert.Contains(t, decoder, "NewMethodMessageResultTypeWithViewsResultTiny(message)") + assert.Contains(t, decoder, "NewMethodMessageResultTypeWithViewsResult(message)") + encoder := codegen.SectionsCode(t, serverFiles[1].Section("response-encoder")) + assert.Contains(t, encoder, `Append("goa-view", vres.View)`) + assert.Contains(t, encoder, `return nil, goa.InvalidEnumValueError("view", vres.View`) + testutil.AssertGo(t, "testdata/golden/viewed_result_dynamic_response_encoder.go.golden", encoder) + response := services.Get("ServiceMessageResultTypeWithViews").Endpoints[0].Response + require.Len(t, response.ServerConverts, 2) + require.Equal(t, "default", response.ServerConverts[1].View) + require.Same(t, response.ServerConverts[1].Convert, response.ServerConvert) + require.Len(t, response.ClientConverts, 2) + require.Equal(t, "default", response.ClientConverts[1].View) + require.Same(t, response.ClientConverts[1].Convert, response.ClientConvert) + tinyValidation := response.ClientConverts[0].Convert.Validation + fullValidation := response.ClientConverts[1].Convert.Validation + require.NotNil(t, tinyValidation) + require.NotNil(t, fullValidation) + require.NotSame(t, tinyValidation, fullValidation) + require.Equal(t, "ValidateMethodMessageResultTypeWithViewsResponseTiny", tinyValidation.Declaration.Name()) + require.Equal(t, "ValidateMethodMessageResultTypeWithViewsResponse", fullValidation.Declaration.Name()) + assert.Contains(t, tinyValidation.Def, `MissingFieldError("IntField", "message")`) + assert.NotContains(t, tinyValidation.Def, `MissingFieldError("StringField", "message")`) + assert.Contains(t, fullValidation.Def, `MissingFieldError("StringField", "message")`) + require.Less(t, + strings.Index(decoder, tinyValidation.Declaration.Name()+"(message)"), + strings.Index(decoder, "NewMethodMessageResultTypeWithViewsResultTiny(message)"), + ) + require.Less(t, + strings.Index(decoder, fullValidation.Declaration.Name()+"(message)"), + strings.Index(decoder, "NewMethodMessageResultTypeWithViewsResult(message)"), + ) + }) +} + +func TestStreamingViewedResultSpecialization(t *testing.T) { + t.Run("design selected view", func(t *testing.T) { + root := RunGRPCDSL(t, testdata.ServerStreamingResultCollectionWithExplicitViewDSL) + services := CreateGRPCServices(root) + serverFiles := serverFiles(services) + clientFiles := clientFiles(services) + require.Len(t, serverFiles, 2) + require.Len(t, clientFiles, 2) + + serverStruct := codegen.SectionsCode(t, serverFiles[0].Section("server-stream-struct-type")) + clientStruct := codegen.SectionsCode(t, clientFiles[0].Section("client-stream-struct-type")) + assert.NotContains(t, serverStruct, "\n\tview") + assert.NotContains(t, clientStruct, "\n\tview") + stream := services.Get("ServiceServerStreamingResultTypeCollectionWithExplicitView").Endpoints[0].ClientStream + require.Len(t, stream.RecvConverts, 1) + require.Equal(t, "tiny", stream.RecvConverts[0].View) + require.Same(t, stream.RecvConverts[0].Convert, stream.RecvConvert) + }) + + t.Run("caller selected view", func(t *testing.T) { + root := RunGRPCDSL(t, testdata.ServerStreamingResultWithViewsDSL) + services := CreateGRPCServices(root) + serverFiles := serverFiles(services) + clientFiles := clientFiles(services) + require.Len(t, serverFiles, 2) + require.Len(t, clientFiles, 2) + + serverStruct := codegen.SectionsCode(t, serverFiles[0].Section("server-stream-struct-type")) + clientStruct := codegen.SectionsCode(t, clientFiles[0].Section("client-stream-struct-type")) + assert.Contains(t, serverStruct, "\n\tview") + assert.Contains(t, clientStruct, "\n\tview") + assert.Contains(t, serverStruct, "sentView string") + assert.Contains(t, clientStruct, "viewSet bool") + send := codegen.SectionsCode(t, serverFiles[0].Section("server-stream-send")) + assert.Contains(t, send, `if view == "" {`) + assert.Contains(t, send, `view = "default"`) + assert.Contains(t, send, `if s.sentView != "" && view != s.sentView`) + assert.Contains(t, send, `SetHeader(metadata.Pairs("goa-view", view))`) + assert.Contains(t, send, `return goa.InvalidEnumValueError("view", view`) + require.Less(t, + strings.Index(send, `InvalidEnumValueError("view", view`), + strings.Index(send, `SetHeader(metadata.Pairs("goa-view", view))`), + ) + testutil.AssertGo(t, "testdata/golden/viewed_result_dynamic_stream_send.go.golden", send) + recv := codegen.SectionsCode(t, clientFiles[0].Section("client-stream-recv")) + assert.Contains(t, recv, `s.stream.Header()`) + assert.Contains(t, recv, `goa.MissingFieldError("goa-view", "metadata")`) + assert.Contains(t, recv, "switch s.view") + assert.Contains(t, recv, "NewMethodServerStreamingUserTypeRPCResponseResultTypeViewTiny(v)") + assert.Contains(t, recv, "NewMethodServerStreamingUserTypeRPCResponseResultTypeView(v)") + stream := services.Get("ServiceServerStreamingUserTypeRPC").Endpoints[0].ServerStream + require.Len(t, stream.SendConverts, 2) + require.Equal(t, "default", stream.SendConverts[1].View) + require.Same(t, stream.SendConverts[1].Convert, stream.SendConvert) + clientStream := services.Get("ServiceServerStreamingUserTypeRPC").Endpoints[0].ClientStream + require.Len(t, clientStream.RecvConverts, 2) + require.Equal(t, "default", clientStream.RecvConverts[1].View) + require.Same(t, clientStream.RecvConverts[1].Convert, clientStream.RecvConvert) + tinyValidation := clientStream.RecvConverts[0].Convert.Validation + fullValidation := clientStream.RecvConverts[1].Convert.Validation + require.NotNil(t, tinyValidation) + require.NotNil(t, fullValidation) + require.NotSame(t, tinyValidation, fullValidation) + require.Equal(t, "ValidateMethodServerStreamingUserTypeRPCResponseTiny", tinyValidation.Declaration.Name()) + require.Equal(t, "ValidateMethodServerStreamingUserTypeRPCResponse", fullValidation.Declaration.Name()) + assert.Contains(t, tinyValidation.Def, `MissingFieldError("IntField", "message")`) + assert.NotContains(t, tinyValidation.Def, `MissingFieldError("DoubleField", "message")`) + assert.Contains(t, fullValidation.Def, `MissingFieldError("DoubleField", "message")`) + require.Less(t, + strings.Index(recv, tinyValidation.Declaration.Name()+"(v)"), + strings.Index(recv, "NewMethodServerStreamingUserTypeRPCResponseResultTypeViewTiny(v)"), + ) + require.Less(t, + strings.Index(recv, fullValidation.Declaration.Name()+"(v)"), + strings.Index(recv, "NewMethodServerStreamingUserTypeRPCResponseResultTypeView(v)"), + ) + }) +} + +// TestViewedResponseValidationNamesIgnoreViewOrder checks that each generated +// name describes its selected view regardless of DSL declaration order. +func TestViewedResponseValidationNamesIgnoreViewOrder(t *testing.T) { + namesFor := func(views []string) map[string]string { + root := RunGRPCDSL(t, func() { + result := d.ResultType("application/vnd.validation-order", func() { + d.TypeName("ValidationOrder") + d.Attributes(func() { + d.Field(1, "first", d.String) + d.Field(2, "second", d.String) + d.Required("first", "second") + }) + for _, view := range views { + d.View(view, func() { + if view == "tiny" { + d.Attribute("first") + } else { + d.Attribute("second") + } + }) + } + }) + d.Service("ValidationOrderService", func() { + d.Method("Show", func() { + d.Result(result) + d.GRPC(func() {}) + }) + }) + }) + response := CreateGRPCServices(root).Get("ValidationOrderService").Endpoints[0].Response + names := make(map[string]string, len(response.ClientConverts)) + for _, conversion := range response.ClientConverts { + names[conversion.View] = conversion.Convert.Validation.Declaration.Name() + } + return names + } + + forward := namesFor([]string{"tiny", "compact"}) + reverse := namesFor([]string{"compact", "tiny"}) + require.Equal(t, forward, reverse) + require.Equal(t, "ValidateShowResponseTiny", forward["tiny"]) + require.Equal(t, "ValidateShowResponseCompact", forward["compact"]) + require.Equal(t, "ValidateShowResponse", forward[expr.DefaultView]) +} + +// TestGRPCProtobufValidationForRecursiveView checks that selected and omitted +// recursive fields are each visited once while their validation rules are kept +// or removed. +func TestGRPCProtobufValidationForRecursiveView(t *testing.T) { + responseType := &expr.UserTypeExpr{TypeName: "Response", UID: "response"} + responseValue := &expr.AttributeExpr{Type: expr.String} + responseChild := &expr.AttributeExpr{Type: responseType} + responseType.AttributeExpr = &expr.AttributeExpr{ + Type: &expr.Object{ + {Name: "value", Attribute: responseValue}, + {Name: "child", Attribute: responseChild}, + }, + Validation: &expr.ValidationExpr{Required: []string{"value", "child"}}, + } + selectedType := &expr.UserTypeExpr{TypeName: "Selected", UID: "selected"} + selectedChild := &expr.AttributeExpr{Type: selectedType} + selectedType.AttributeExpr = &expr.AttributeExpr{ + Type: &expr.Object{{Name: "child", Attribute: selectedChild}}, + Validation: &expr.ValidationExpr{Required: []string{"child"}}, + } + + validation := grpcProtobufValidationForView( + &expr.AttributeExpr{Type: responseType}, + &expr.AttributeExpr{Type: selectedType}, + ) + validationObject := validation.Type.(expr.UserType).Attribute() + require.Equal(t, []string{"child"}, validationObject.Validation.Required) + require.Nil(t, expr.AsObject(validationObject.Type).Attribute("value").Validation) +} + +// TestGRPCProtobufValidationForSharedViewField checks that omitting one field +// does not remove checks from a selected sibling with the same nested type. +func TestGRPCProtobufValidationForSharedViewField(t *testing.T) { + nested := &expr.UserTypeExpr{ + TypeName: "Nested", + UID: "nested", + AttributeExpr: &expr.AttributeExpr{ + Type: &expr.Object{{Name: "value", Attribute: &expr.AttributeExpr{Type: expr.String}}}, + Validation: &expr.ValidationExpr{Required: []string{"value"}}, + }, + } + response := &expr.AttributeExpr{ + Type: &expr.Object{ + {Name: "selected", Attribute: &expr.AttributeExpr{Type: nested}}, + {Name: "omitted", Attribute: &expr.AttributeExpr{Type: nested}}, + }, + Validation: &expr.ValidationExpr{Required: []string{"selected", "omitted"}}, + } + selected := &expr.AttributeExpr{ + Type: &expr.Object{{Name: "selected", Attribute: &expr.AttributeExpr{Type: nested}}}, + Validation: &expr.ValidationExpr{Required: []string{"selected"}}, + } + + validation := grpcProtobufValidationForView(response, selected) + fields := expr.AsObject(validation.Type) + selectedType := fields.Attribute("selected").Type.(expr.UserType) + omittedType := fields.Attribute("omitted").Type.(expr.UserType) + require.NotNil(t, selectedType.Attribute().Validation) + require.Equal(t, []string{"value"}, selectedType.Attribute().Validation.Required) + require.Nil(t, omittedType.Attribute().Validation) +} diff --git a/grpc/error_test.go b/grpc/error_test.go index 5e8b1482fe..36a2e873f7 100644 --- a/grpc/error_test.go +++ b/grpc/error_test.go @@ -4,15 +4,20 @@ import ( "context" "errors" "fmt" + "net" "testing" "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" statuspb "google.golang.org/genproto/googleapis/rpc/status" + "google.golang.org/grpc" "google.golang.org/grpc/codes" + "google.golang.org/grpc/credentials/insecure" "google.golang.org/grpc/status" + "google.golang.org/grpc/test/bufconn" "google.golang.org/protobuf/types/known/anypb" + "google.golang.org/protobuf/types/known/emptypb" goapb "goa.design/goa/v3/grpc/pb" goa "goa.design/goa/v3/pkg" @@ -94,6 +99,48 @@ func TestEncodeErrorStatusCodes(t *testing.T) { } } +func TestInvalidLengthErrorFitsGRPCHeaders(t *testing.T) { + t.Parallel() + + listener := bufconn.Listen(1 << 20) + server := grpc.NewServer(grpc.UnknownServiceHandler(func(any, grpc.ServerStream) error { + return EncodeError(goa.InvalidLengthError("payload", make([]byte, 1<<20), 1<<20, 1024, false)) + })) + serveErr := make(chan error, 1) + go func() { + serveErr <- server.Serve(listener) + }() + conn, err := grpc.NewClient( + "passthrough:///bounded-validation-error", + grpc.WithContextDialer(func(context.Context, string) (net.Conn, error) { + return listener.Dial() + }), + grpc.WithTransportCredentials(insecure.NewCredentials()), + grpc.WithMaxHeaderListSize(8*1024), + ) + require.NoError(t, err) + t.Cleanup(func() { + require.NoError(t, conn.Close()) + server.Stop() + serveResult := <-serveErr + if serveResult != nil { + require.ErrorIs(t, serveResult, grpc.ErrServerStopped) + } + }) + + err = conn.Invoke( + context.Background(), + "/test.Validation/Check", + &emptypb.Empty{}, + &emptypb.Empty{}, + ) + require.Equal(t, codes.InvalidArgument, status.Code(err)) + response, ok := DecodeError(err).(*goapb.ErrorResponse) + require.True(t, ok) + require.Equal(t, goa.InvalidLength, response.Name) + require.Equal(t, "length of payload must be at most 1024 but got 1048576", response.Msg) +} + func TestNewTransportError(t *testing.T) { cases := []struct { name string diff --git a/http/client.go b/http/client.go index 7b447cb156..e117a12908 100644 --- a/http/client.go +++ b/http/client.go @@ -180,7 +180,7 @@ func ErrInvalidURL(svc, m, u string, err error) error { return &ClientError{Name: "invalid_url", Message: msg, Service: svc, Method: m, Err: err} } -// ErrDecodingError is the error returned when the decoder fails to decode the +// ErrDecodingError reports a failure while reading, decoding, or closing a // response body. func ErrDecodingError(svc, m string, err error) error { msg := fmt.Sprintf("failed to decode response body: %s", err) diff --git a/http/codegen/client.go b/http/codegen/client.go index f4efb304e0..95b95e8679 100644 --- a/http/codegen/client.go +++ b/http/codegen/client.go @@ -1,3 +1,5 @@ +// This file renders HTTP client calls and codecs per service; each file owns +// the imports required by the service methods it contains. package codegen import ( @@ -9,62 +11,36 @@ import ( "goa.design/goa/v3/expr" ) -// ClientFiles returns the generated HTTP client files. -func ClientFiles(genpkg string, data *ServicesData) []*codegen.File { +// clientFiles builds the HTTP client files read by Plan.Link. +func clientFiles(data *ServicesData) []*codegen.File { files := make([]*codegen.File, 0, len(data.Expressions.Services)*3) // preallocate for client files for _, svc := range data.Expressions.Services { - files = append(files, clientFile(genpkg, svc, data)) - if f := WebsocketClientFile(genpkg, svc, data); f != nil { + files = append(files, clientFile(svc, data)) + if f := websocketClientFile(svc, data); f != nil { files = append(files, f) } - if f := sseClientFile(genpkg, svc, data); f != nil { + if f := sseClientFile(svc, data); f != nil { files = append(files, f) } } for _, svc := range data.Expressions.Services { - if f := ClientEncodeDecodeFile(genpkg, svc, data); f != nil { + if f := clientEncodeDecodeFile(svc, data); f != nil { files = append(files, f) } } return files } -// ClientEncodeDecodeFile returns the file containing the HTTP client encoding +// clientEncodeDecodeFile returns the file containing the HTTP client encoding // and decoding logic. -func ClientEncodeDecodeFile(genpkg string, svc *expr.HTTPServiceExpr, services *ServicesData) *codegen.File { +func clientEncodeDecodeFile(svc *expr.HTTPServiceExpr, services *ServicesData) *codegen.File { data := services.Get(svc.Name()) svcName := data.Service.PathName path := filepath.Join(codegen.Gendir, services.dir(), svcName, "client", "encode_decode.go") + outputPackage := generatedFileOutputPackage(services, path) + data = serviceDataForOutput(data, services, outputPackage) title := fmt.Sprintf("%s %s client encoders and decoders", svc.Name(), services.label()) - imports := []*codegen.ImportSpec{ - {Path: "bytes"}, - {Path: "context"}, - {Path: "encoding/json"}, - {Path: "fmt"}, - {Path: "io"}, - {Path: "mime/multipart"}, - {Path: "net/http"}, - {Path: "net/url"}, - {Path: "os"}, - {Path: "strconv"}, - {Path: "strings"}, - {Path: "unicode/utf8"}, - codegen.GoaImport(""), - codegen.GoaNamedImport("http", "goahttp"), - {Path: genpkg + "/" + svcName, Name: data.Service.PkgName}, - {Path: genpkg + "/" + svcName + "/" + "views", Name: data.Service.ViewsPkg}, - } - for _, e := range data.Endpoints { - if e.IsJSONRPC { - // JSON-RPC request encoders build the JSON-RPC envelope. - imports = append(imports, - &codegen.ImportSpec{Path: "github.com/google/uuid"}, - codegen.GoaImport("jsonrpc"), - ) - break - } - } - sections := []*codegen.SectionTemplate{codegen.Header(title, "client", imports)} + sections := []*codegen.SectionTemplate{plannedFileHeader(title, "client", path, services)} for _, e := range data.Endpoints { sections = append(sections, &codegen.SectionTemplate{ @@ -72,15 +48,15 @@ func ClientEncodeDecodeFile(genpkg string, svc *expr.HTTPServiceExpr, services * Source: httpTemplates.Read(requestBuilderT), Data: e, }) - if e.RequestEncoder != "" && (e.Payload.Ref != "" || e.IsJSONRPC) { + if e.RequestEncoderDeclaration != nil && (e.Payload.Ref != "" || e.IsJSONRPC) { sections = append(sections, &codegen.SectionTemplate{ Name: "request-encoder", - Source: httpTemplates.Read(requestEncoderT, clientTypeConversionP, clientMapConversionP, jsonrpcRequestEnvelopeP), + Source: httpTemplates.Read(requestEncoderT, clientTypeExpressionP, clientTypeConversionP, clientMapConversionP, jsonrpcRequestEnvelopeP), FuncMap: map[string]any{ "typeConversionData": typeConversionData, "mapConversionData": mapConversionData, "goTypeRef": func(dt expr.DataType) string { - return services.ServicesData.Get(svc.Name()).Scope.GoTypeRef(&expr.AttributeExpr{Type: dt}) + return data.Scope.GoTypeRef(&expr.AttributeExpr{Type: dt}) }, "isBearer": isBearer, "aliasedType": fieldType, @@ -94,7 +70,6 @@ func ClientEncodeDecodeFile(genpkg string, svc *expr.HTTPServiceExpr, services * } return dt }, - "requestStructPkg": requestStructPkg, }, Data: e, }) @@ -112,9 +87,10 @@ func ClientEncodeDecodeFile(genpkg string, svc *expr.HTTPServiceExpr, services * Data: e, FuncMap: map[string]any{ "goTypeRef": func(dt expr.DataType) string { - return services.ServicesData.Get(svc.Name()).Scope.GoTypeRef(&expr.AttributeExpr{Type: dt}) + return data.Scope.GoTypeRef(&expr.AttributeExpr{Type: dt}) }, - "buildResponseData": buildResponseData, + "buildResponseData": buildResponseData, + "buildViewedResponseData": buildViewedResponseData, }, }) if e.Method.SkipRequestBodyEncodeDecode { @@ -122,9 +98,6 @@ func ClientEncodeDecodeFile(genpkg string, svc *expr.HTTPServiceExpr, services * Name: "build-stream-request", Source: httpTemplates.Read(buildStreamRequestT), Data: e, - FuncMap: map[string]any{ - "requestStructPkg": requestStructPkg, - }, }) } } @@ -139,28 +112,16 @@ func ClientEncodeDecodeFile(genpkg string, svc *expr.HTTPServiceExpr, services * return &codegen.File{Path: path, SectionTemplates: sections} } -// clientFile returns the client HTTP transport file -func clientFile(genpkg string, svc *expr.HTTPServiceExpr, services *ServicesData) *codegen.File { +// clientFile returns the client HTTP transport file. +func clientFile(svc *expr.HTTPServiceExpr, services *ServicesData) *codegen.File { data := services.Get(svc.Name()) svcName := data.Service.PathName path := filepath.Join(codegen.Gendir, "http", svcName, "client", "client.go") + outputPackage := generatedFileOutputPackage(services, path) + data = serviceDataForOutput(data, services, outputPackage) title := fmt.Sprintf("%s client HTTP transport", svc.Name()) sections := []*codegen.SectionTemplate{ - codegen.Header(title, "client", []*codegen.ImportSpec{ - {Path: "context"}, - {Path: "fmt"}, - {Path: "io"}, - {Path: "mime/multipart"}, - {Path: "net/http"}, - {Path: "strconv"}, - {Path: "strings"}, - {Path: "time"}, - {Path: "github.com/gorilla/websocket"}, - codegen.GoaImport(""), - codegen.GoaNamedImport("http", "goahttp"), - {Path: genpkg + "/" + svcName, Name: data.Service.PkgName}, - {Path: genpkg + "/" + svcName + "/" + "views", Name: data.Service.ViewsPkg}, - }), + plannedFileHeader(title, "client", path, services), } sections = append(sections, &codegen.SectionTemplate{ Name: "client-struct", @@ -211,7 +172,7 @@ func clientFile(genpkg string, svc *expr.HTTPServiceExpr, services *ServicesData FuncMap: map[string]any{ "isWebSocketEndpoint": IsWebSocketEndpoint, "isSSEEndpoint": IsSSEEndpoint, - "responseStructPkg": responseStructPkg, + "isServerStreamKind": isServerStreamKind, }, }) } @@ -262,6 +223,19 @@ func buildResponseData(data *ResponseData, serviceName string, method *service.M } } +// buildViewedResponseData selects the body type planned for one response view +// while retaining the response's headers and cookies. +func buildViewedResponseData( + data *ResponseData, + representation *ViewedRepresentationData, + serviceName string, + method *service.MethodData, +) map[string]any { + selected := *data + selected.ClientBody = representation.ClientBody + return buildResponseData(&selected, serviceName, method) +} + func fieldType(ft expr.DataType) expr.DataType { ut, isut := ft.(expr.UserType) if isut { @@ -282,17 +256,3 @@ func isBearer(schemes []*service.SchemeData) bool { } return false } - -func requestStructPkg(m *service.MethodData, def string) string { - if m.PayloadLoc != nil { - return m.PayloadLoc.PackageName() - } - return def -} - -func responseStructPkg(m *service.MethodData, def string) string { - if m.ResultLoc != nil { - return m.ResultLoc.PackageName() - } - return def -} diff --git a/http/codegen/client_body_types_test.go b/http/codegen/client_body_types_test.go index 5dc8ec2660..f1a023f411 100644 --- a/http/codegen/client_body_types_test.go +++ b/http/codegen/client_body_types_test.go @@ -8,13 +8,12 @@ import ( "goa.design/goa/v3/codegen" "goa.design/goa/v3/codegen/testutil" + "goa.design/goa/v3/dsl" "goa.design/goa/v3/expr" "goa.design/goa/v3/http/codegen/testdata" ) func TestBodyTypeDecl(t *testing.T) { - const genpkg = "gen" - cases := []struct { Name string DSL func() @@ -25,8 +24,8 @@ func TestBodyTypeDecl(t *testing.T) { for _, c := range cases { t.Run(c.Name, func(t *testing.T) { root := expr.RunDSL(t, c.DSL) - services := CreateHTTPServices(root) - fs := typesFile(genpkg, root.API.HTTP.Services[0], false, services) + plan := linkedHTTPPlanForRoot(t, root) + fs := plan.ClientTypeFiles()[0] section := fs.SectionTemplates[1] code := codegen.SectionCode(t, section) testutil.AssertGo(t, "testdata/golden/client_body_type_decl_"+c.Name+".go.golden", code) @@ -35,7 +34,6 @@ func TestBodyTypeDecl(t *testing.T) { } func TestBodyTypeInit(t *testing.T) { - const genpkg = "gen" cases := []struct { Name string DSL func() @@ -48,6 +46,7 @@ func TestBodyTypeInit(t *testing.T) { {"result-body-user-required", testdata.ResultBodyUserRequiredDSL, 3}, {"result-body-inline-object", testdata.ResultBodyInlineObjectDSL, 2}, {"result-explicit-body-primitive", testdata.ExplicitBodyPrimitiveResultMultipleViewsDSL, 1}, + {"result-explicit-body-primitive-default-view", testdata.ExplicitBodyPrimitiveResultDefaultViewDSL, 1}, {"result-explicit-body-user-type", testdata.ExplicitBodyUserResultMultipleViewsDSL, 3}, {"result-explicit-body-object", testdata.ExplicitBodyUserResultObjectDSL, 3}, {"result-explicit-body-object-views", testdata.ExplicitBodyUserResultObjectMultipleViewDSL, 3}, @@ -56,8 +55,8 @@ func TestBodyTypeInit(t *testing.T) { for _, c := range cases { t.Run(c.Name, func(t *testing.T) { root := expr.RunDSL(t, c.DSL) - services := CreateHTTPServices(root) - fs := typesFile(genpkg, root.API.HTTP.Services[0], false, services) + plan := linkedHTTPPlanForRoot(t, root) + fs := plan.ClientTypeFiles()[0] section := fs.SectionTemplates[c.SectionIndex] code := codegen.SectionCode(t, section) testutil.AssertGo(t, "testdata/golden/client_body_type_init_"+c.Name+".go.golden", code) @@ -65,8 +64,41 @@ func TestBodyTypeInit(t *testing.T) { } } +// TestRequiredViewedPrimitiveBodyConstructorUsesProjectedPointer verifies that +// a required JSON string still enters a pointer field in the decoded view so a +// missing value can be reported. +func TestRequiredViewedPrimitiveBodyConstructorUsesProjectedPointer(t *testing.T) { + root := expr.RunDSL(t, func() { + result := dsl.ResultType("application/vnd.required-viewed-primitive", func() { + dsl.TypeName("RequiredViewedPrimitive") + dsl.Attribute("value", dsl.String) + dsl.Required("value") + dsl.View("default", func() { dsl.Attribute("value") }) + dsl.View("summary", func() { dsl.Attribute("value") }) + }) + dsl.Service("Values", func() { + dsl.Method("Fetch", func() { + dsl.Result(result) + dsl.HTTP(func() { + dsl.GET("/values") + dsl.Response(dsl.StatusOK, func() { dsl.Body("value") }) + }) + }) + }) + }) + plan := linkedHTTPPlanForRoot(t, root) + file := plan.ClientTypeFiles()[0] + var generated bytes.Buffer + for _, section := range file.SectionTemplates[1:] { + require.NoError(t, section.Write(&generated)) + } + definition := codegen.FormatTestCode(t, "package client\n"+generated.String()) + + require.Contains(t, definition, `func NewFetchRequiredViewedPrimitiveOK(body string) *valuesviews.RequiredViewedPrimitiveView`) + require.Contains(t, definition, "Value: &v,") +} + func TestClientTypes(t *testing.T) { - const genpkg = "gen" cases := []struct { Name string DSL func() @@ -76,11 +108,13 @@ func TestClientTypes(t *testing.T) { {"client-multiple-methods-with-array-type-payloads", testdata.MultipleMethodsWithArrayTypePayloadsDSL}, {"client-payload-extend-validate", testdata.PayloadExtendedValidateDSL}, {"client-result-type-validate", testdata.ResultTypeValidateDSL}, + {"client-status-tag-required", testdata.ResultStatusTagRequiredDSL}, {"client-with-result-collection", testdata.ResultWithResultCollectionDSL}, {"client-with-result-view", testdata.ResultWithResultViewDSL}, {"client-empty-error-response-body", testdata.EmptyErrorResponseBodyDSL}, {"client-with-error-custom-pkg", testdata.WithErrorCustomPkgDSL}, {"client-body-custom-name", testdata.PayloadBodyCustomNameDSL}, + {"client-required-primitive-arrays", testdata.RequiredPrimitiveArrayDSL}, {"client-path-custom-name", testdata.PayloadPathCustomNameDSL}, {"client-query-custom-name", testdata.PayloadQueryCustomNameDSL}, {"client-header-custom-name", testdata.PayloadHeaderCustomNameDSL}, @@ -90,8 +124,8 @@ func TestClientTypes(t *testing.T) { for _, c := range cases { t.Run(c.Name, func(t *testing.T) { root := expr.RunDSL(t, c.DSL) - services := CreateHTTPServices(root) - fs := typesFile(genpkg, root.API.HTTP.Services[0], false, services) + plan := linkedHTTPPlanForRoot(t, root) + fs := plan.ClientTypeFiles()[0] var buf bytes.Buffer for _, s := range fs.SectionTemplates[1:] { require.NoError(t, s.Write(&buf)) @@ -103,7 +137,6 @@ func TestClientTypes(t *testing.T) { } func TestClientTypeFiles(t *testing.T) { - const genpkg = "gen" cases := []struct { Name string DSL func() @@ -113,8 +146,8 @@ func TestClientTypeFiles(t *testing.T) { for _, c := range cases { t.Run(c.Name, func(t *testing.T) { root := expr.RunDSL(t, c.DSL) - services := CreateHTTPServices(root) - fw := ClientTypeFiles(genpkg, services) + plan := linkedHTTPPlanForRoot(t, root) + fw := plan.ClientTypeFiles() for i, fs := range fw { var buf bytes.Buffer for _, s := range fs.SectionTemplates[1:] { diff --git a/http/codegen/client_cli.go b/http/codegen/client_cli.go index 3a0a108594..f1e0f2545e 100644 --- a/http/codegen/client_cli.go +++ b/http/codegen/client_cli.go @@ -1,3 +1,5 @@ +// This file renders HTTP client command parsers and per-service payload +// builders, including imports for relocated payload types used by each builder. package codegen import ( @@ -12,6 +14,7 @@ import ( // commandData wraps the common CommandData and adds HTTP-specific fields. type commandData struct { *cli.CommandData + serviceName string // Subcommands is the list of endpoint commands. Subcommands []*subcommandData // NeedDialer if true initializes the websocket dialer. @@ -20,28 +23,55 @@ type commandData struct { // streaming endpoints are configured with a goahttp.ConnConfigureFunc // instead of a client package ConnConfigurer. JSONRPC bool + // ClientInit is the client constructor called by ParseEndpoint. + ClientInit *codegen.NameDeclaration + // Configurer is the WebSocket configuration type accepted by ParseEndpoint. + Configurer *codegen.NameDeclaration + // ConfigurerLocal is the exact ParseEndpoint parameter that receives the + // WebSocket or JSON-RPC connection configuration. + ConfigurerLocal *cli.ParserLocalData } -// commandData wraps the common SubcommandData and adds HTTP-specific fields. +// subcommandData wraps the common SubcommandData and adds HTTP-specific fields. type subcommandData struct { *cli.SubcommandData - // MultipartFuncName is the name of the function used to render a multipart - // request encoder. + methodName string + // MultipartFuncDeclaration supplies the multipart request encoder type name. + MultipartFuncDeclaration *codegen.NameDeclaration + // MultipartFuncName is the final multipart request encoder name kept for + // existing plugin templates. + // + // Deprecated: Use MultipartFuncDeclaration.Name() after planning. MultipartFuncName string - // MultipartFuncName is the name of the variable used to render a multipart - // request encoder. + // MultipartVarName is the variable that holds the multipart request encoder. MultipartVarName string + // MultipartLocal is the exact ParseEndpoint parameter that receives the + // multipart request encoder. + MultipartLocal *cli.ParserLocalData // StreamFlag is the flag used to identify the file to be streamed when // the endpoint uses SkipRequestBodyEncodeDecode. StreamFlag *cli.FlagData - // BuildStreamPayload is the name of the generated function that builds the - // request data structure that wraps the payload and the file stream for - // endpoints that use SkipRequestBodyEncodeDecode. + // StreamPointerVar is the exact parser variable passed to the stream payload builder. + StreamPointerVar string + // BuildStreamPayloadDeclaration is the generated function that builds the + // request containing the payload and file stream. + BuildStreamPayloadDeclaration *codegen.NameDeclaration + // BuildStreamPayload is the final stream payload helper name kept for + // existing plugin templates. + // + // Deprecated: Use BuildStreamPayloadDeclaration.Name() after planning. BuildStreamPayload string } -// ClientCLIFiles returns the client HTTP CLI support file. +// ClientCLIFiles returns the client HTTP CLI support files. genpkg must match +// the package used to create data. func ClientCLIFiles(genpkg string, data *ServicesData) []*codegen.File { + requireGeneratedPackage(genpkg, data) + return clientCLIFiles(data) +} + +// clientCLIFiles builds the client command file read by Plan.Link. +func clientCLIFiles(data *ServicesData) []*codegen.File { if len(data.Expressions.Services) == 0 { return nil } @@ -54,8 +84,11 @@ func ClientCLIFiles(genpkg string, data *ServicesData) []*codegen.File { if len(sd.Endpoints) > 0 { command := &commandData{ CommandData: cli.BuildCommandData(sd.Service), + serviceName: sd.Service.Name, NeedDialer: HasWebSocket(sd), JSONRPC: sd.Endpoints[0].IsJSONRPC, + ClientInit: sd.ClientInitDeclaration, + Configurer: sd.ClientConnConfigurerDeclaration, } for _, e := range sd.Endpoints { @@ -72,6 +105,9 @@ func ClientCLIFiles(genpkg string, data *ServicesData) []*codegen.File { } files := make([]*codegen.File, 0, len(data.Root.API.Servers)*2) // preallocate for CLI files for _, svr := range data.Root.API.Servers { + if data.cliParsers[svr.Name] == nil { + continue + } var svrData []*commandData for _, name := range svr.Services { for i, svc := range svcs { @@ -80,105 +116,153 @@ func ClientCLIFiles(genpkg string, data *ServicesData) []*codegen.File { } } } - files = append(files, endpointParser(genpkg, data.Root, svr, svrData, data)) + files = append(files, endpointParser(svr, svrData, data)) } for i, svc := range svcs { - files = append(files, payloadBuilders(genpkg, svc, cmds[i].CommandData, data)) + files = append(files, payloadBuilders(svc, cmds[i].CommandData, data)) } return files } func buildSubcommandData(sd *ServiceData, e *EndpointData) *subcommandData { flags, buildFunction := buildFlags(sd, e) + if buildFunction != nil { + buildFunction.Name = e.CLIPayloadDeclaration.Name() + } sub := &subcommandData{ SubcommandData: cli.BuildSubcommandData(sd.Service, e.Method, buildFunction, flags), + methodName: e.Method.Name, } if e.MultipartRequestEncoder != nil { sub.MultipartVarName = e.MultipartRequestEncoder.VarName - sub.MultipartFuncName = e.MultipartRequestEncoder.FuncName + sub.MultipartFuncDeclaration = e.MultipartRequestEncoder.FuncDeclaration + sub.MultipartFuncName = e.MultipartRequestEncoder.FuncDeclaration.Name() } if e.Method.SkipRequestBodyEncodeDecode { - sub.StreamFlag = streamFlag(sd.Service.Name, e.Method.Name) - sub.BuildStreamPayload = e.BuildStreamPayload + sub.StreamFlag = flags[len(flags)-1] + sub.BuildStreamPayloadDeclaration = e.BuildStreamPayloadDeclaration + sub.BuildStreamPayload = e.BuildStreamPayloadDeclaration.Name() } return sub } // endpointParser returns the file that implements the command line parser that // builds the client endpoint and payload necessary to perform a request. -func endpointParser(genpkg string, root *expr.RootExpr, svr *expr.ServerExpr, data []*commandData, services *ServicesData) *codegen.File { +func endpointParser(svr *expr.ServerExpr, data []*commandData, services *ServicesData) *codegen.File { + genpkg := services.GenPkg() pkg := codegen.SnakeCase(codegen.Goify(svr.Name, true)) path := filepath.Join(codegen.Gendir, services.dir(), "cli", pkg, "cli.go") + outputPackage := generatedFileOutputPackage(services, path) title := fmt.Sprintf("%s %s client CLI support package", svr.Name, services.label()) - specs := []*codegen.ImportSpec{ - {Path: "encoding/json"}, - {Path: "flag"}, - {Path: "fmt"}, - {Path: "net/http"}, - {Path: "os"}, - {Path: "strconv"}, - {Path: "unicode/utf8"}, - codegen.GoaImport(""), - codegen.GoaNamedImport("http", "goahttp"), + specs := services.fileImports[filepathKey(path)] + + parser := services.cliParsers[svr.Name] + if parser == nil { + panic(fmt.Sprintf("HTTP CLI parser names are missing for server %q", svr.Name)) } - for _, sv := range svr.Services { - svc := root.Service(sv) - sd := services.Get(svc.Name) - if sd == nil { - continue + plannedData := make([]*commandData, len(data)) + cliData := make([]*cli.CommandData, len(data)) + var parserLocals []*cli.ParserLocalData + for i, command := range data { + commandNames := parser.Commands[command.serviceName] + if commandNames == nil { + panic(fmt.Sprintf("HTTP CLI command names are missing for service %q", command.serviceName)) + } + commandCopy := *command + commonCommand := *command.CommandData + clientImport := services.PackageImport( + outputPackage, + genpkg+"/"+services.dir()+"/"+services.Get(command.serviceName).Service.PathName+"/client", + ) + commonCommand.PkgName = clientImport.Name + if commonCommand.Interceptors != nil { + interceptors := *commonCommand.Interceptors + interceptors.PkgName = services.ServiceImport(outputPackage, command.serviceName).Name + commonCommand.Interceptors = &interceptors + } + commonCommand.UsageDeclaration = commandNames.Usage + commandCopy.CommandData = &commonCommand + if commandCopy.NeedDialer { + suffix := "Configurer" + use := "websocket configurer" + if commandCopy.JSONRPC { + suffix = "ConfigFn" + use = "JSON-RPC connection configurer" + } + commandCopy.ConfigurerLocal = &cli.ParserLocalData{ + ServiceName: command.serviceName, + Use: use, + PreferredName: commonCommand.VarName + suffix, + } + parserLocals = append(parserLocals, commandCopy.ConfigurerLocal) } - specs = append(specs, &codegen.ImportSpec{ - Path: genpkg + "/" + services.dir() + "/" + sd.Service.PathName + "/client", - Name: sd.Service.PkgName + "c", - }) - // Add interceptors import if service has client interceptors - if len(sd.Service.ClientInterceptors) > 0 { - specs = append(specs, &codegen.ImportSpec{ - Path: genpkg + "/" + sd.Service.PathName, - Name: sd.Service.PkgName, - }) + commandCopy.Subcommands = make([]*subcommandData, len(command.Subcommands)) + commonCommand.Subcommands = make([]*cli.SubcommandData, len(command.Subcommands)) + for j, subcommand := range command.Subcommands { + usage := commandNames.Methods[subcommand.methodName] + if usage == nil { + panic(fmt.Sprintf("HTTP CLI method help name is missing for %q.%q", command.serviceName, subcommand.methodName)) + } + subcommandCopy := *subcommand + commonSubcommand := *subcommand.SubcommandData + if commonSubcommand.Interceptors != nil { + interceptors := *commonSubcommand.Interceptors + interceptors.PkgName = services.ServiceImport(outputPackage, command.serviceName).Name + commonSubcommand.Interceptors = &interceptors + } + commonSubcommand.UsageDeclaration = usage + subcommandCopy.SubcommandData = &commonSubcommand + if subcommandCopy.MultipartVarName != "" { + subcommandCopy.MultipartLocal = &cli.ParserLocalData{ + ServiceName: command.serviceName, + MethodName: subcommand.methodName, + Use: "multipart request encoder", + PreferredName: subcommandCopy.MultipartVarName, + } + parserLocals = append(parserLocals, subcommandCopy.MultipartLocal) + } + commandCopy.Subcommands[j] = &subcommandCopy + commonCommand.Subcommands[j] = &commonSubcommand } + plannedData[i] = &commandCopy + cliData[i] = &commonCommand } - - cliData := make([]*cli.CommandData, len(data)) - for i, cmd := range data { - cliData[i] = cmd.CommandData + parser.PlanVariables(cliData, parserLocals) + for _, command := range plannedData { + for _, subcommand := range command.Subcommands { + if subcommand.StreamFlag != nil { + subcommand.StreamPointerVar = subcommand.StreamFlag.PointerVar + } + } } parseSection := &codegen.SectionTemplate{ Name: "parse-endpoint", Source: httpTemplates.Read(parseEndpointT), Data: struct { - FlagsCode string - Commands []*commandData + Declaration *codegen.NameDeclaration + FlagsCode string + Commands []*commandData + Variables *cli.ParserVariablesData }{ - cli.FlagsCode(cliData), - data, + parser.Declarations.ParseEndpoint, + parser.FlagsCode(cliData), + plannedData, + parser.Variables, }, FuncMap: map[string]any{"streamingCmdExists": streamingCmdExists}, } - return cli.EndpointParserFile(path, title, specs, cliData, parseSection) + return parser.EndpointParserFile(path, title, specs, cliData, parseSection) } // payloadBuilders returns the file that contains the payload constructors that // use flag values as arguments. -func payloadBuilders(genpkg string, svc *expr.HTTPServiceExpr, data *cli.CommandData, services *ServicesData) *codegen.File { +func payloadBuilders(svc *expr.HTTPServiceExpr, data *cli.CommandData, services *ServicesData) *codegen.File { sd := services.Get(svc.Name()) path := filepath.Join(codegen.Gendir, services.dir(), sd.Service.PathName, "client", "cli.go") title := fmt.Sprintf("%s %s client CLI support package", svc.Name(), services.label()) - specs := []*codegen.ImportSpec{ - {Path: "encoding/json"}, - {Path: "fmt"}, - {Path: "net/http"}, - {Path: "os"}, - {Path: "strconv"}, - {Path: "unicode/utf8"}, - codegen.GoaImport(""), - codegen.GoaNamedImport("http", "goahttp"), - {Path: genpkg + "/" + sd.Service.PathName, Name: sd.Service.PkgName}, - } - return cli.PayloadBuildersFile(path, title, specs, data) + return cli.PayloadBuildersFile(path, title, services.fileImports[filepathKey(path)], data) } // buildFlags builds the flag data and build function for an endpoint. @@ -195,7 +279,7 @@ func buildFlags(svc *ServiceData, e *EndpointData) ([]*cli.FlagData, *cli.BuildF args = append(args, e.Payload.Request.PayloadInit.CLIArgs...) flags, buildFunction = makeFlags(e, args, e.Payload.Request.PayloadType) } else if e.Payload.Ref != "" { - flags = append(flags, cli.NewFlagData(svcn, en, "p", e.Method.PayloadRef, e.Method.PayloadDesc, true, e.Method.PayloadEx, e.Method.PayloadDefault)) + flags = append(flags, cli.NewFlagDataForPlan(svcn, en, "p", e.Payload.CLIPlan, e.Method.PayloadDesc, true, e.Method.PayloadEx, e.Method.PayloadDefault)) } if e.Method.SkipRequestBodyEncodeDecode { flags = append(flags, streamFlag(svcn, en)) @@ -215,18 +299,20 @@ func makeFlags(e *EndpointData, args []*InitArgData, payload expr.DataType) ([]* FieldName: arg.FieldName, FieldPointer: arg.FieldPointer, FieldType: arg.FieldType, + FieldTypeRef: arg.ServiceTypeRef, Type: arg.Type, } fargs[i] = &cli.FlagArgData{ Name: arg.VarName, TypeName: arg.TypeName, + Plan: arg.CLIPlan, TypeRef: arg.TypeRef, + Pointer: arg.Pointer, FieldName: arg.FieldName, Description: arg.Description, Required: arg.Required, Example: arg.Example, DefaultValue: arg.DefaultValue, - Validate: arg.Validate, OmitField: arg.FieldName == "" && arg.VarName != "body", } } @@ -235,6 +321,8 @@ func makeFlags(e *EndpointData, args []*InitArgData, payload expr.DataType) ([]* Code: e.Payload.Request.PayloadInit.ClientCode, ReturnTypeAttribute: e.Payload.Request.PayloadInit.ReturnTypeAttribute, ReturnTypeAttributePointer: e.Payload.Request.PayloadInit.ReturnIsPrimitivePointer, + ReturnTypeAttributeUnion: e.Payload.Request.PayloadInit.ReturnIsUnion, + OptionalBody: e.Payload.Request.PayloadInit.ClientBodyOptional, ReturnIsStruct: e.Payload.Request.PayloadInit.ReturnIsStruct, ReturnTypeName: e.Payload.Request.PayloadInit.ReturnTypeName, ReturnTypePkg: e.Payload.Request.PayloadInit.ReturnTypePkg, @@ -247,7 +335,8 @@ func makeFlags(e *EndpointData, args []*InitArgData, payload expr.DataType) ([]* // streamFlag returns the flag used to specify the upload file for endpoints // that use SkipRequestBodyEncodeDecode. func streamFlag(svcn, en string) *cli.FlagData { - return cli.NewFlagData(svcn, en, "stream", "string", "path to file containing the streamed request body", true, "goa.png", nil) + plan := cli.NewFlagPlan(&expr.AttributeExpr{Type: expr.String}, "string", "string", nil) + return cli.NewFlagDataForPlan(svcn, en, "stream", plan, "path to file containing the streamed request body", true, "goa.png", nil) } // streamingCmdExists returns true if at least one command in the list of commands diff --git a/http/codegen/client_cli_test.go b/http/codegen/client_cli_test.go index a9aebff5f0..db381e6616 100644 --- a/http/codegen/client_cli_test.go +++ b/http/codegen/client_cli_test.go @@ -1,12 +1,18 @@ +// This file verifies HTTP client CLI generation consumes stable, non-empty +// examples for body, parameter, header, cookie, array, and map flags. package codegen import ( "testing" - "goa.design/goa/v3/codegen/testutil" - "goa.design/goa/v3/expr" + "github.com/stretchr/testify/require" "goa.design/goa/v3/codegen" + "goa.design/goa/v3/codegen/cli" + ctestdata "goa.design/goa/v3/codegen/example/testdata" + "goa.design/goa/v3/codegen/testutil" + "goa.design/goa/v3/dsl" + "goa.design/goa/v3/expr" "goa.design/goa/v3/http/codegen/testdata" ) @@ -31,7 +37,9 @@ func TestClientCLIFiles(t *testing.T) { {"string-build", testdata.PayloadQueryStringDSL, 1, 1}, {"string-required-build", testdata.PayloadQueryStringValidateDSL, 1, 1}, {"string-default-build", testdata.PayloadQueryStringDefaultDSL, 1, 1}, + {"path-string-default-build", testdata.PayloadPathStringDefaultDSL, 1, 1}, {"body-query-path-object-build", testdata.PayloadBodyQueryPathObjectDSL, 1, 1}, + {"body-nested-user-build", testdata.PayloadBodyNestedUserDSL, 1, 1}, {"param-validation-build", testdata.ParamValidateDSL, 1, 1}, {"payload-primitive-type", testdata.PayloadBodyPrimitiveBoolValidateDSL, 0, 3}, {"payload-array-primitive-type", testdata.PayloadBodyPrimitiveArrayStringValidateDSL, 0, 3}, @@ -53,11 +61,176 @@ func TestClientCLIFiles(t *testing.T) { for _, c := range cases { t.Run(c.Name, func(t *testing.T) { root := expr.RunDSL(t, c.DSL) - services := CreateHTTPServices(root) - fs := ClientCLIFiles("", services) + plan := linkedHTTPPlanForRoot(t, root) + fs := plan.ClientCLIFiles() sections := fs[c.FileIndex].SectionTemplates code := codegen.SectionCode(t, sections[c.SectionIndex]) testutil.AssertGo(t, "testdata/golden/client_cli_"+c.Name+".go.golden", code) }) } } + +func TestClientCLIFilesOmitFilesOnlyServer(t *testing.T) { + root := codegen.RunDSL(t, ctestdata.ServerHostingServiceWithFileServerDSL) + plan := linkedHTTPPlanForRoot(t, root) + + require.NotEmpty(t, plan.ServerFiles()) + require.Empty(t, plan.ClientCLIFiles()) +} + +// TestClientCLIFlagPresenceGolden shows how generated HTTP commands preserve +// explicit empty values while applying every authored zero-valued default. +func TestClientCLIFlagPresenceGolden(t *testing.T) { + root := expr.RunDSL(t, func() { + dsl.Service("FlagPresence", func() { + dsl.Method("check", func() { + dsl.Payload(func() { + dsl.Field(1, "required", dsl.String) + dsl.Field(2, "optional", dsl.String) + dsl.Field(3, "empty", dsl.String, func() { + dsl.Default("") + }) + dsl.Field(4, "disabled", dsl.Boolean, func() { + dsl.Default(false) + }) + dsl.Field(5, "zero", dsl.Int, func() { + dsl.Default(0) + }) + dsl.Required("required") + }) + dsl.HTTP(func() { + dsl.GET("/") + dsl.Param("required") + dsl.Param("optional") + dsl.Param("empty") + dsl.Param("disabled") + dsl.Param("zero") + }) + }) + }) + }) + files := linkedHTTPPlanForRoot(t, root).ClientCLIFiles() + require.Len(t, files, 2) + + parser := codegen.SectionsCode(t, files[0].Section("parse-endpoint")) + testutil.AssertGo(t, "testdata/golden/client_cli_flag-presence-parse.go.golden", parser) + builder := codegen.SectionsCode(t, files[1].Section("cli-build-payload")) + testutil.AssertGo(t, "testdata/golden/client_cli_flag-presence-build.go.golden", builder) +} + +// TestJSONRPCRequestIDCLIFlagPresence checks that a defaulted request ID uses +// an ordinary string flag while an optional ID keeps omission distinct from an +// explicitly empty string. +func TestJSONRPCRequestIDCLIFlagPresence(t *testing.T) { + tests := []struct { + name string + defaultID bool + wantPresence bool + wantParamType string + }{ + {"defaulted", true, false, "string"}, + {"optional", false, true, "*string"}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + root := expr.RunDSL(t, func() { + dsl.Service("Requests", func() { + dsl.JSONRPC(func() { + dsl.POST("/rpc") + }) + dsl.Method("send", func() { + dsl.Payload(func() { + if test.defaultID { + dsl.ID("id", dsl.String, func() { + dsl.Default("default-id") + }) + return + } + dsl.ID("id", dsl.String) + }) + dsl.JSONRPC(func() {}) + }) + }) + }) + plan, generation, servicePlan := plannedHTTPPlan(t, root, true) + parser := plan.cliParsers[root.API.Servers[0].Name] + require.NotNil(t, parser) + require.Equal(t, test.wantPresence, parser.Declarations.PresenceFlagType != nil) + + require.NoError(t, generation.Freeze()) + require.NoError(t, servicePlan.Link()) + require.NoError(t, plan.Link()) + + service := plan.services.Get("Requests") + require.NotNil(t, service) + command := buildSubcommandData(service, service.Endpoint("send")) + require.Len(t, command.Flags, 1) + require.Equal(t, test.defaultID, command.Flags[0].HasDefault) + require.Equal(t, test.wantPresence, command.Flags[0].TracksPresence) + require.NotNil(t, command.BuildFunction) + require.Equal(t, []string{test.wantParamType}, command.BuildFunction.FormalParamTypes) + }) + } +} + +// TestClientCLIBuildNameMatchesDeclaration verifies released plugins can read +// the final payload builder name without choosing that name themselves. +func TestClientCLIBuildNameMatchesDeclaration(t *testing.T) { + root := expr.RunDSL(t, testdata.MultiSimpleDSL) + plan := linkedHTTPPlanForRoot(t, root) + files := plan.ClientCLIFiles() + require.Greater(t, len(files), 1) + + build, ok := files[1].SectionTemplates[1].Data.(*cli.BuildFunctionData) + require.True(t, ok) + endpoint := plan.services.Get("ServiceMultiSimple1").Endpoint("MethodMultiSimplePayload") + require.Equal(t, endpoint.CLIPayloadDeclaration.Name(), build.Name) +} + +// TestClientCLITransportNamesMatchDeclarations checks the released multipart +// and stream helper names exposed to plugin templates. +func TestClientCLITransportNamesMatchDeclarations(t *testing.T) { + plan := linkedHTTPPlanForRoot(t, releasedHTTPNamesRoot(t)) + service := plan.services.Get("Names") + + multipart := buildSubcommandData(service, service.Endpoint("Multipart")) + require.NotNil(t, multipart.MultipartFuncDeclaration) + require.Equal(t, multipart.MultipartFuncDeclaration.Name(), multipart.MultipartFuncName) + + stream := buildSubcommandData(service, service.Endpoint("Raw")) + require.NotNil(t, stream.BuildStreamPayloadDeclaration) + require.Equal(t, stream.BuildStreamPayloadDeclaration.Name(), stream.BuildStreamPayload) +} + +func TestEmptyBodyCLIUsesPayloadFieldExample(t *testing.T) { + root := expr.RunDSL(t, testdata.PayloadBodyPrimitiveFieldEmptyDSL) + plan := linkedHTTPPlanForRoot(t, root) + endpoint := plan.services.Get("ServiceBodyPrimitiveArrayUser").Endpoints[0] + require.NotNil(t, endpoint.Payload.Request.PayloadInit) + require.Len(t, endpoint.Payload.Request.PayloadInit.ClientArgs, 1) + example := endpoint.Payload.Request.PayloadInit.ClientArgs[0].Example + + require.IsType(t, []string{}, example) + require.NotEmpty(t, example) +} + +// TestClientCLINestedBodyWithoutValidationEmitsNoChecks verifies that a body +// with no top-level checks does not add validation to the payload builder. +func TestClientCLINestedBodyWithoutValidationEmitsNoChecks(t *testing.T) { + root := expr.RunDSL(t, testdata.PayloadBodyUserInnerDSL) + plan := linkedHTTPPlanForRoot(t, root) + files := plan.ClientCLIFiles() + require.NotEmpty(t, files) + service := plan.services.Get("ServiceBodyUserInner") + endpoint := service.Endpoints[0] + require.NotNil(t, endpoint.Payload.Request.PayloadInit) + require.Len(t, endpoint.Payload.Request.PayloadInit.ClientArgs, 1) + arg := endpoint.Payload.Request.PayloadInit.ClientArgs[0] + require.Empty(t, arg.Validate) + require.NotNil(t, arg.CLIPlan) + _, builder := buildFlags(service, endpoint) + require.NotNil(t, builder) + require.Len(t, builder.Fields, 1) + require.NotContains(t, builder.Fields[0].Init, "goa.") +} diff --git a/http/codegen/client_decode_test.go b/http/codegen/client_decode_test.go index b66a8b7d9a..1d6fae48bb 100644 --- a/http/codegen/client_decode_test.go +++ b/http/codegen/client_decode_test.go @@ -24,6 +24,7 @@ func TestClientDecode(t *testing.T) { {"explicit-body-result-multiple-views", testdata.ExplicitBodyUserResultMultipleViewsDSL}, {"explicit-body-result-collection", testdata.ExplicitBodyResultCollectionDSL}, {"tag-result-multiple-views", testdata.ResultMultipleViewsTagDSL}, + {"status-tag-required", testdata.ResultStatusTagRequiredDSL}, {"empty-server-response-with-tags", testdata.EmptyServerResponseWithTagsDSL}, {"header-string-implicit", testdata.ResultHeaderStringImplicitDSL}, {"header-string-array", testdata.ResultHeaderStringArrayDSL}, @@ -34,16 +35,24 @@ func TestClientDecode(t *testing.T) { {"with-headers-dsl-viewed-result", testdata.WithHeadersBlockViewedResultDSL}, {"validate-error-response-type", testdata.ValidateErrorResponseTypeDSL}, {"empty-error-response-body", testdata.EmptyErrorResponseBodyDSL}, + {"required-primitive-arrays", testdata.RequiredPrimitiveArrayDSL}, + {"skip-response-body-encode-decode", testdata.ServerSkipResponseBodyEncodeDecodeDSL}, } for _, c := range cases { t.Run(c.Name, func(t *testing.T) { root := expr.RunDSL(t, c.DSL) - services := CreateHTTPServices(root) - fs := ClientFiles("", services) + plan := linkedHTTPPlanForRoot(t, root) + fs := plan.ClientFiles() require.Len(t, fs, 2) sections := fs[1].SectionTemplates - require.Greater(t, len(sections), 2) - code := codegen.SectionCode(t, sections[2]) + var section *codegen.SectionTemplate + for _, s := range sections { + if s.Name == "response-decoder" { + section = s + } + } + require.NotNil(t, section) + code := codegen.SectionCode(t, section) testutil.AssertGo(t, "testdata/golden/client_decode_"+c.Name+".go.golden", code) }) } diff --git a/http/codegen/client_encode_test.go b/http/codegen/client_encode_test.go index 3a2c43a908..c0b6bd5d1e 100644 --- a/http/codegen/client_encode_test.go +++ b/http/codegen/client_encode_test.go @@ -121,6 +121,7 @@ func TestClientEncode(t *testing.T) { {"body-string", testdata.PayloadBodyStringDSL}, {"body-string-validate", testdata.PayloadBodyStringValidateDSL}, {"body-user", testdata.PayloadBodyUserDSL}, + {"body-user-nested", testdata.PayloadBodyNestedUserDSL}, {"body-user-validate", testdata.PayloadBodyUserValidateDSL}, {"body-array-string", testdata.PayloadBodyArrayStringDSL}, {"body-array-string-validate", testdata.PayloadBodyArrayStringValidateDSL}, @@ -177,12 +178,13 @@ func TestClientEncode(t *testing.T) { {"query-custom-name", testdata.PayloadQueryCustomNameDSL}, {"header-custom-name", testdata.PayloadHeaderCustomNameDSL}, {"cookie-custom-name", testdata.PayloadCookieCustomNameDSL}, + {"skip-request-body-header", testdata.SkipRequestBodyEncodeDecodeHeaderDSL}, } for _, c := range cases { t.Run(c.Name, func(t *testing.T) { root := expr.RunDSL(t, c.DSL) - services := CreateHTTPServices(root) - fs := ClientFiles("", services) + plan := linkedHTTPPlanForRoot(t, root) + fs := plan.ClientFiles() require.Len(t, fs, 2) sections := fs[1].SectionTemplates require.Greater(t, len(sections), 2) @@ -192,21 +194,42 @@ func TestClientEncode(t *testing.T) { } } +// TestSkipRequestBodyEncoderSelection verifies raw body access keeps encoders +// needed for headers while omitting an encoder that would do no work. +func TestSkipRequestBodyEncoderSelection(t *testing.T) { + t.Run("mapped header", func(t *testing.T) { + root := expr.RunDSL(t, testdata.SkipRequestBodyEncodeDecodeHeaderDSL) + plan := linkedHTTPPlanForRoot(t, root) + service, ok := plan.Service(root.API.HTTP.Service("SkipRequestBodyEncodeDecodeHeader")) + require.True(t, ok) + require.NotNil(t, service.Endpoints[0].RequestEncoderDeclaration) + }) + t.Run("raw body only", func(t *testing.T) { + root := expr.RunDSL(t, testdata.SkipRequestBodyEncodeDecodeDSL) + plan := linkedHTTPPlanForRoot(t, root) + service, ok := plan.Service(root.API.HTTP.Service("SkipRequestBodyEncodeDecode")) + require.True(t, ok) + require.Nil(t, service.Endpoints[0].RequestEncoderDeclaration) + }) +} + func TestClientBuildRequest(t *testing.T) { cases := []struct { Name string DSL func() }{ {"path-string", testdata.PayloadPathStringDSL}, + {"path-int-alias", testdata.PathIntAliasDSL}, {"path-string-required", testdata.PayloadPathStringValidateDSL}, {"path-string-default", testdata.PayloadPathStringDefaultDSL}, + {"path-primitive-string", testdata.PayloadPathPrimitiveStringFormatIPValidateDSL}, {"path-object", testdata.PayloadPathObjectDSL}, } for _, c := range cases { t.Run(c.Name, func(t *testing.T) { root := expr.RunDSL(t, c.DSL) - services := CreateHTTPServices(root) - fs := ClientFiles("", services) + plan := linkedHTTPPlanForRoot(t, root) + fs := plan.ClientFiles() require.Len(t, fs, 2) sections := fs[1].SectionTemplates require.Greater(t, len(sections), 2) diff --git a/http/codegen/client_init_test.go b/http/codegen/client_init_test.go index d19ce7e272..dbeaed1b4c 100644 --- a/http/codegen/client_init_test.go +++ b/http/codegen/client_init_test.go @@ -25,8 +25,8 @@ func TestClientInit(t *testing.T) { for _, c := range cases { t.Run(c.Name, func(t *testing.T) { root := expr.RunDSL(t, c.DSL) - services := CreateHTTPServices(root) - fs := ClientFiles("", services) + plan := linkedHTTPPlanForRoot(t, root) + fs := plan.ClientFiles() require.Len(t, fs, c.FileCount) sections := fs[0].SectionTemplates require.Greater(t, len(sections), c.SectionNum) diff --git a/http/codegen/client_primitive_path_runtime_test.go b/http/codegen/client_primitive_path_runtime_test.go new file mode 100644 index 0000000000..3f3a3b8819 --- /dev/null +++ b/http/codegen/client_primitive_path_runtime_test.go @@ -0,0 +1,119 @@ +// This file renders an HTTP client for a primitive path payload. The generated +// test checks that the request builder validates and copies the payload into the +// URL instead of silently using the primitive type's zero value. +package codegen + +import ( + "context" + "fmt" + "os" + "os/exec" + "path/filepath" + "slices" + "testing" + "time" + + "github.com/stretchr/testify/require" + + goacodegen "goa.design/goa/v3/codegen" + "goa.design/goa/v3/codegen/service" + "goa.design/goa/v3/dsl" + "goa.design/goa/v3/eval" + "goa.design/goa/v3/expr" +) + +// TestGeneratedPrimitivePathRequestBuilder checks the public behavior of a +// generated request builder for both a valid string and a value of the wrong +// type. +func TestGeneratedPrimitivePathRequestBuilder(t *testing.T) { + root := expr.RunDSL(t, primitivePathDSL) + generation, err := goacodegen.NewGeneration("generated.local/gen", []eval.Root{root}) + require.NoError(t, err) + servicePlan, err := service.NewPlan(root, generation, expr.NewExampleGenerator(root.API.RandomizerFactory)) + require.NoError(t, err) + httpPlans, err := NewPlans(generation, PlanInput{Root: root, Service: servicePlan}) + require.NoError(t, err) + require.NoError(t, generation.Freeze()) + require.NoError(t, servicePlan.Link()) + require.NoError(t, httpPlans[0].Link()) + + serviceFiles, err := service.Files(servicePlan) + require.NoError(t, err) + files := slices.Clone(serviceFiles) + files = append(files, httpPlans[0].ClientFiles()...) + files = append(files, httpPlans[0].ClientTypeFiles()...) + files = append(files, httpPlans[0].PathFiles()...) + runPrimitivePathRuntimeTest(t, files) +} + +// primitivePathDSL defines a string payload used directly as a path value. +func primitivePathDSL() { + dsl.Service("primitive_path", func() { + dsl.Method("download", func() { + dsl.Payload(dsl.String) + dsl.HTTP(func() { + dsl.GET("/files/{filename}") + dsl.Param("filename") + }) + }) + }) +} + +// runPrimitivePathRuntimeTest writes the generated client and runs its test in +// an isolated module that uses this Goa checkout. +func runPrimitivePathRuntimeTest(t *testing.T, files []*goacodegen.File) { + t.Helper() + directory := t.TempDir() + repository, err := filepath.Abs(filepath.Join("..", "..")) + require.NoError(t, err) + module := fmt.Sprintf( + "module generated.local\n\ngo 1.24\n\nrequire goa.design/goa/v3 v3.0.0\n\nreplace goa.design/goa/v3 => %s\n", + filepath.ToSlash(repository), + ) + require.NoError(t, os.WriteFile(filepath.Join(directory, "go.mod"), []byte(module), 0o600)) + for _, file := range files { + _, err := file.Render(directory) + require.NoError(t, err) + } + testPath := filepath.Join(directory, "gen", "http", "primitive_path", "client", "primitive_path_test.go") + require.NoError(t, os.WriteFile(testPath, []byte(generatedPrimitivePathTest), 0o600)) + + ctx, cancel := context.WithTimeout(context.Background(), time.Minute) + defer cancel() + command := exec.CommandContext(ctx, "go", "test", "-mod=mod", "./gen/http/primitive_path/client") + command.Dir = directory + command.Env = append(os.Environ(), "GOWORK=off") + output, err := command.CombinedOutput() + require.NoError(t, err, "run generated primitive path test:\n%s", output) +} + +const generatedPrimitivePathTest = `package client + +import ( + "context" + "errors" + "testing" + + goahttp "goa.design/goa/v3/http" +) + +func TestPrimitivePathRequest(t *testing.T) { + client := NewClient("https", "example.com", nil, nil, nil, false) + request, err := client.BuildDownloadRequest(context.Background(), "report.csv") + if err != nil { + t.Fatal(err) + } + if got, want := request.URL.Path, "/files/report.csv"; got != want { + t.Fatalf("path = %q, want %q", got, want) + } + + _, err = client.BuildDownloadRequest(context.Background(), 42) + var clientError *goahttp.ClientError + if !errors.As(err, &clientError) { + t.Fatalf("error = %v, want *goahttp.ClientError", err) + } + if got, want := clientError.Name, "invalid_type"; got != want { + t.Fatalf("error name = %q, want %q", got, want) + } +} +` diff --git a/http/codegen/client_query_float_runtime_test.go b/http/codegen/client_query_float_runtime_test.go new file mode 100644 index 0000000000..37b05c2649 --- /dev/null +++ b/http/codegen/client_query_float_runtime_test.go @@ -0,0 +1,143 @@ +// This file runs generated HTTP client query encoders and checks the exact +// values that a server receives after URL parsing. +package codegen + +import ( + "context" + "os" + "os/exec" + "path/filepath" + "slices" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/require" + + "goa.design/goa/v3/codegen" + "goa.design/goa/v3/codegen/service" + "goa.design/goa/v3/dsl" + "goa.design/goa/v3/eval" + "goa.design/goa/v3/expr" +) + +// TestGeneratedClientFormatsFloatQueriesCompactly catches fixed-point query +// formatting that expands values which have a shorter exponent form. +func TestGeneratedClientFormatsFloatQueriesCompactly(t *testing.T) { + root := expr.RunDSL(t, func() { + dsl.Service("float_query", func() { + dsl.Method("format", func() { + dsl.Payload(func() { + dsl.Attribute("scalar32", dsl.Float32) + dsl.Attribute("scalar64", dsl.Float64) + dsl.Attribute("repeated32", dsl.ArrayOf(dsl.Float32)) + dsl.Attribute("repeated64", dsl.ArrayOf(dsl.Float64)) + dsl.Required("scalar32", "scalar64", "repeated32", "repeated64") + }) + dsl.HTTP(func() { + dsl.GET("/") + dsl.Param("scalar32") + dsl.Param("scalar64") + dsl.Param("repeated32") + dsl.Param("repeated64") + }) + }) + }) + }) + + generation, err := codegen.NewGeneration("generated.local/gen", []eval.Root{root}) + require.NoError(t, err) + servicePlan, err := service.NewPlan(root, generation, expr.NewExampleGenerator(root.API.RandomizerFactory)) + require.NoError(t, err) + httpPlans, err := NewPlans(generation, PlanInput{Root: root, Service: servicePlan}) + require.NoError(t, err) + require.NoError(t, generation.Freeze()) + require.NoError(t, servicePlan.Link()) + require.NoError(t, httpPlans[0].Link()) + + serviceFiles, err := service.Files(servicePlan) + require.NoError(t, err) + files := slices.Clone(serviceFiles) + files = append(files, httpPlans[0].ClientFiles()...) + files = append(files, httpPlans[0].ClientTypeFiles()...) + files = append(files, httpPlans[0].PathFiles()...) + runGeneratedFloatQueryTest(t, files) +} + +// runGeneratedFloatQueryTest writes the generated packages and a test in the +// generated client package, then runs that test in an isolated module. +func runGeneratedFloatQueryTest(t *testing.T, files []*codegen.File) { + t.Helper() + directory := t.TempDir() + goaRoot := floatQueryModuleDirectory(t) + module := "module generated.local\n\ngo 1.24\n\n" + + "require goa.design/goa/v3 v3.0.0\n\n" + + "replace goa.design/goa/v3 => " + filepath.ToSlash(goaRoot) + "\n" + require.NoError(t, os.WriteFile(filepath.Join(directory, "go.mod"), []byte(module), 0o600)) + for _, file := range files { + _, err := file.Render(directory) + require.NoError(t, err) + } + + testPath := filepath.Join(directory, "gen", "http", "float_query", "client", "float_query_test.go") + require.NoError(t, os.WriteFile(testPath, []byte(generatedFloatQueryTest), 0o600)) + + ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) + defer cancel() + command := exec.CommandContext(ctx, "go", "test", "-mod=mod", "./gen/http/float_query/client") + command.Dir = directory + command.Env = append(os.Environ(), "GOWORK=off") + output, err := command.CombinedOutput() + require.NoError(t, err, "run generated float query test:\n%s", output) +} + +// floatQueryModuleDirectory returns this Goa checkout so the temporary module +// tests the generated code against the same runtime as the generator. +func floatQueryModuleDirectory(t *testing.T) string { + t.Helper() + command := exec.Command("go", "list", "-m", "-f", "{{.Dir}}", "goa.design/goa/v3") + output, err := command.CombinedOutput() + require.NoError(t, err, "resolve Goa module:\n%s", output) + directory := strings.TrimSpace(string(output)) + require.NotEmpty(t, directory) + return directory +} + +const generatedFloatQueryTest = `package client + +import ( + "net/http" + "testing" + + genfloatquery "generated.local/gen/float_query" +) + +func TestFloatQueryValues(t *testing.T) { + payload := &genfloatquery.FormatPayload{ + Scalar32: 12.5, + Scalar64: 1e100, + Repeated32: []float32{1e20, 0.25}, + Repeated64: []float64{1e100, 0.25}, + } + request, err := http.NewRequest(http.MethodGet, "http://example.com", nil) + if err != nil { + t.Fatal(err) + } + if err := EncodeFormatRequest(nil)(request, payload); err != nil { + t.Fatal(err) + } + query := request.URL.Query() + if got := query.Get("scalar32"); got != "12.5" { + t.Fatalf("scalar32 query = %q, want %q", got, "12.5") + } + if got := query.Get("scalar64"); got != "1e+100" { + t.Fatalf("scalar64 query = %q, want %q", got, "1e+100") + } + if got := query["repeated32"]; len(got) != 2 || got[0] != "1e+20" || got[1] != "0.25" { + t.Fatalf("repeated32 query = %#v, want %#v", got, []string{"1e+20", "0.25"}) + } + if got := query["repeated64"]; len(got) != 2 || got[0] != "1e+100" || got[1] != "0.25" { + t.Fatalf("repeated64 query = %#v, want %#v", got, []string{"1e+100", "0.25"}) + } +} +` diff --git a/http/codegen/client_response_body_runtime_test.go b/http/codegen/client_response_body_runtime_test.go new file mode 100644 index 0000000000..3ba775c94b --- /dev/null +++ b/http/codegen/client_response_body_runtime_test.go @@ -0,0 +1,352 @@ +// This file renders an HTTP client into a temporary module and calls its +// generated endpoints and response decoders. The response bodies can fail +// while reading or closing so the tests can check every returned error. +package codegen + +import ( + "context" + "os" + "os/exec" + "path/filepath" + "slices" + "testing" + "time" + + "github.com/stretchr/testify/require" + + "goa.design/goa/v3/codegen" + "goa.design/goa/v3/codegen/service" + "goa.design/goa/v3/codegen/testutil" + "goa.design/goa/v3/dsl" + "goa.design/goa/v3/eval" + "goa.design/goa/v3/expr" +) + +// TestGeneratedClientResponseBodyLifecycle checks that generated decoders +// close bodies they consume, preserve bodies requested by callers, and return +// every read, decode, and close error. +func TestGeneratedClientResponseBodyLifecycle(t *testing.T) { + root := expr.RunDSL(t, responseBodyLifecycleDSL) + generation, err := codegen.NewGeneration("generated.local/gen", []eval.Root{root}) + require.NoError(t, err) + servicePlan, err := service.NewPlan(root, generation, expr.NewExampleGenerator(root.API.RandomizerFactory)) + require.NoError(t, err) + httpPlans, err := NewPlans(generation, PlanInput{Root: root, Service: servicePlan}) + require.NoError(t, err) + require.NoError(t, generation.Freeze()) + require.NoError(t, servicePlan.Link()) + require.NoError(t, httpPlans[0].Link()) + + clientFiles := httpPlans[0].ClientFiles() + endpointCode := codegen.SectionsCode(t, clientFiles[0].Section("client-endpoint-init")) + testutil.AssertGo(t, "testdata/golden/client_endpoint_response_body_lifecycle.go.golden", endpointCode) + + serviceFiles, err := service.Files(servicePlan) + require.NoError(t, err) + files := slices.Clone(serviceFiles) + files = append(files, clientFiles...) + files = append(files, httpPlans[0].ClientTypeFiles()...) + files = append(files, httpPlans[0].PathFiles()...) + runGeneratedResponseBodyLifecycleTest(t, files) +} + +// responseBodyLifecycleDSL defines an ordinary response, a response whose +// bytes are returned to the caller, and a server-sent event stream. +func responseBodyLifecycleDSL() { + dsl.Service("body_lifecycle", func() { + dsl.Method("read", func() { + dsl.Result(func() { + dsl.Attribute("value", dsl.String) + dsl.Required("value") + }) + dsl.HTTP(func() { + dsl.GET("/read") + }) + }) + dsl.Method("raw", func() { + dsl.Error("bad", func() { + dsl.Attribute("message", dsl.String) + dsl.Required("message") + }) + dsl.HTTP(func() { + dsl.GET("/raw") + dsl.SkipResponseBodyEncodeDecode() + dsl.Response(dsl.StatusOK) + dsl.Response("bad", dsl.StatusBadRequest) + }) + }) + dsl.Method("watch", func() { + dsl.StreamingResult(dsl.String) + dsl.HTTP(func() { + dsl.GET("/watch") + dsl.ServerSentEvents() + }) + }) + }) +} + +// runGeneratedResponseBodyLifecycleTest writes generated code and its runtime +// test into an isolated module, then runs only the generated client package. +func runGeneratedResponseBodyLifecycleTest(t *testing.T, files []*codegen.File) { + t.Helper() + directory := t.TempDir() + repository, err := filepath.Abs(filepath.Join("..", "..")) + require.NoError(t, err) + module := "module generated.local\n\ngo 1.25\n\n" + + "require goa.design/goa/v3 v3.0.0\n\n" + + "replace goa.design/goa/v3 => " + filepath.ToSlash(repository) + "\n" + require.NoError(t, os.WriteFile(filepath.Join(directory, "go.mod"), []byte(module), 0o600)) + for _, file := range files { + _, err := file.Render(directory) + require.NoError(t, err) + } + + testPath := filepath.Join(directory, "gen", "http", "body_lifecycle", "client", "response_body_test.go") + require.NoError(t, os.WriteFile(testPath, []byte(generatedResponseBodyLifecycleTest), 0o600)) + + ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) + defer cancel() + command := exec.CommandContext(ctx, "go", "test", "-mod=mod", "./gen/http/body_lifecycle/client") + command.Dir = directory + command.Env = append(os.Environ(), "GOWORK=off") + output, err := command.CombinedOutput() + require.NoError(t, err, "run generated response body test:\n%s", output) +} + +const generatedResponseBodyLifecycleTest = `package client + +import ( + "context" + "errors" + "fmt" + "io" + "net/http" + "strings" + "testing" + + "github.com/stretchr/testify/require" + + goahttp "goa.design/goa/v3/http" +) + +type doerFunc func(*http.Request) (*http.Response, error) + +func (doer doerFunc) Do(request *http.Request) (*http.Response, error) { + return doer(request) +} + +type controlledBody struct { + reader io.Reader + readErr error + closeErr error + closeCalls int +} + +func (body *controlledBody) Read(buffer []byte) (int, error) { + if body.readErr != nil { + return 0, body.readErr + } + return body.reader.Read(buffer) +} + +func (body *controlledBody) Close() error { + body.closeCalls++ + return body.closeErr +} + +func TestRestoreBodyReturnsReadFailureAndClosesOriginal(t *testing.T) { + readErr := errors.New("read failed") + body := &controlledBody{reader: strings.NewReader("ignored"), readErr: readErr} + response := response(http.StatusOK, body) + + _, err := DecodeReadResponse(goahttp.ResponseDecoder, true)(response) + + assertDecodingError(t, err) + require.ErrorIs(t, err, readErr) + require.Equal(t, 1, body.closeCalls) +} + +func TestDecoderReturnsCloseFailureAfterSuccess(t *testing.T) { + closeErr := errors.New("close failed") + body := &controlledBody{reader: strings.NewReader(` + "`" + `{"value":"ready"}` + "`" + `), closeErr: closeErr} + response := response(http.StatusOK, body) + + result, err := DecodeReadResponse(goahttp.ResponseDecoder, false)(response) + + require.NotNil(t, result) + assertDecodingError(t, err) + require.ErrorIs(t, err, closeErr) + require.Equal(t, 1, body.closeCalls) +} + +func TestDecoderReturnsDecodeAndCloseFailures(t *testing.T) { + decodeErr := errors.New("decode failed") + closeErr := errors.New("close failed") + body := &controlledBody{reader: strings.NewReader("ignored"), closeErr: closeErr} + response := response(http.StatusOK, body) + decoder := func(*http.Response) goahttp.Decoder { + return goahttp.EncodingFunc(func(any) error { + return decodeErr + }) + } + + _, err := DecodeReadResponse(decoder, false)(response) + + assertDecodingError(t, err) + require.ErrorIs(t, err, decodeErr) + require.ErrorIs(t, err, closeErr) + require.Equal(t, 1, body.closeCalls) +} + +func TestRestoreBodyLeavesReadableCopyAndClosesOriginal(t *testing.T) { + const encoded = ` + "`" + `{"value":"ready"}` + "`" + ` + body := &controlledBody{reader: strings.NewReader(encoded)} + response := response(http.StatusOK, body) + + result, err := DecodeReadResponse(goahttp.ResponseDecoder, true)(response) + + require.NoError(t, err) + require.NotNil(t, result) + require.Equal(t, 1, body.closeCalls) + restored, err := io.ReadAll(response.Body) + require.NoError(t, err) + require.Equal(t, encoded, string(restored)) +} + +func TestUnexpectedStatusReturnsReadFailure(t *testing.T) { + readErr := errors.New("read failed") + body := &controlledBody{reader: strings.NewReader("ignored"), readErr: readErr} + response := response(http.StatusTeapot, body) + + _, err := DecodeReadResponse(goahttp.ResponseDecoder, false)(response) + + assertDecodingError(t, err) + require.ErrorIs(t, err, readErr) + require.Equal(t, 1, body.closeCalls) +} + +func TestRawBodyRemainsCallerOwned(t *testing.T) { + for _, restoreBody := range []bool{false, true} { + t.Run(fmt.Sprintf("restoreBody=%t", restoreBody), func(t *testing.T) { + body := &controlledBody{reader: strings.NewReader("raw bytes")} + response := response(http.StatusOK, body) + + _, err := DecodeRawResponse(goahttp.ResponseDecoder, restoreBody)(response) + + require.NoError(t, err) + require.Same(t, body, response.Body) + require.Zero(t, body.closeCalls) + content, err := io.ReadAll(response.Body) + require.NoError(t, err) + require.Equal(t, "raw bytes", string(content)) + }) + } +} + +func TestStreamContentTypeReturnsCloseFailure(t *testing.T) { + closeErr := errors.New("close failed") + body := &controlledBody{reader: strings.NewReader("ignored"), closeErr: closeErr} + doer := doerFunc(func(*http.Request) (*http.Response, error) { + response := response(http.StatusOK, body) + response.Header.Set("Content-Type", "application/json") + return response, nil + }) + client := NewClient("http", "example.test", doer, goahttp.RequestEncoder, goahttp.ResponseDecoder, false) + + _, err := client.Watch()(context.Background(), nil) + + require.ErrorContains(t, err, "unexpected content type") + require.ErrorIs(t, err, closeErr) + assertDecodingError(t, err) + require.Equal(t, 1, body.closeCalls) +} + +func TestStreamContentTypeRemainsPlainWhenCloseSucceeds(t *testing.T) { + body := &controlledBody{reader: strings.NewReader("ignored")} + doer := doerFunc(func(*http.Request) (*http.Response, error) { + response := response(http.StatusOK, body) + response.Header.Set("Content-Type", "application/json") + return response, nil + }) + client := NewClient("http", "example.test", doer, goahttp.RequestEncoder, goahttp.ResponseDecoder, false) + + _, err := client.Watch()(context.Background(), nil) + + require.EqualError(t, err, "unexpected content type: application/json (expected text/event-stream)") + var clientErr *goahttp.ClientError + require.NotErrorAs(t, err, &clientErr) + require.Equal(t, 1, body.closeCalls) +} + +func TestStreamContentTypeIsExact(t *testing.T) { + tests := []struct { + name string + contentType string + valid bool + }{ + {name: "event stream", contentType: "text/event-stream", valid: true}, + {name: "event stream parameters", contentType: "text/event-stream; charset=utf-8", valid: true}, + {name: "missing"}, + {name: "malformed", contentType: "text/event-stream; charset=\""}, + {name: "prefix lookalike", contentType: "text/event-streaming"}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + body := &controlledBody{reader: strings.NewReader("ignored")} + doer := doerFunc(func(*http.Request) (*http.Response, error) { + response := response(http.StatusOK, body) + if test.contentType != "" { + response.Header.Set("Content-Type", test.contentType) + } + return response, nil + }) + client := NewClient("http", "example.test", doer, goahttp.RequestEncoder, goahttp.ResponseDecoder, false) + + result, err := client.Watch()(context.Background(), nil) + if test.valid { + require.NoError(t, err) + require.NotNil(t, result) + require.NoError(t, result.(interface{ Close() error }).Close()) + require.Equal(t, 1, body.closeCalls) + return + } + require.EqualError(t, err, "unexpected content type: "+test.contentType+" (expected text/event-stream)") + require.Nil(t, result) + require.Equal(t, 1, body.closeCalls) + }) + } +} + +func TestRawEndpointReturnsDecoderAndCloseFailures(t *testing.T) { + decodeErr := errors.New("decode failed") + closeErr := errors.New("close failed") + body := &controlledBody{reader: strings.NewReader("ignored"), closeErr: closeErr} + doer := doerFunc(func(*http.Request) (*http.Response, error) { + return response(http.StatusBadRequest, body), nil + }) + decoder := func(*http.Response) goahttp.Decoder { + return goahttp.EncodingFunc(func(any) error { + return decodeErr + }) + } + client := NewClient("http", "example.test", doer, goahttp.RequestEncoder, decoder, false) + + _, err := client.Raw()(context.Background(), nil) + + require.ErrorIs(t, err, decodeErr) + require.ErrorIs(t, err, closeErr) + assertDecodingError(t, err) + require.Equal(t, 1, body.closeCalls) +} + +func response(status int, body io.ReadCloser) *http.Response { + return &http.Response{StatusCode: status, Header: make(http.Header), Body: body} +} + +func assertDecodingError(t *testing.T, err error) { + t.Helper() + var clientErr *goahttp.ClientError + require.ErrorAs(t, err, &clientErr) + require.Equal(t, "decoding_error", clientErr.Name) +} +` diff --git a/http/codegen/clone.go b/http/codegen/clone.go new file mode 100644 index 0000000000..06846a127a --- /dev/null +++ b/http/codegen/clone.go @@ -0,0 +1,129 @@ +// This file copies the values used to write generated files. A caller may +// change the copy without changing the HTTP files saved for the same service. +package codegen + +import ( + "fmt" + "reflect" + + "goa.design/goa/v3/codegen" +) + +var immutableRenderPointers = map[reflect.Type]struct{}{ + reflect.TypeFor[*codegen.NameDeclaration](): {}, + reflect.TypeFor[*codegen.TypeDeclaration](): {}, + reflect.TypeFor[*codegen.UnionDeclaration](): {}, + reflect.TypeFor[*codegen.UnionBranchDeclaration](): {}, + reflect.TypeFor[*codegen.Location](): {}, + reflect.TypeFor[*codegen.GoTypePlan](): {}, + reflect.TypeFor[*wireTypeRecord](): {}, + reflect.TypeFor[*wireUnionRecord](): {}, +} + +// cloneRenderData copies maps, slices, pointers, and values stored in an +// interface. Generated name and type records are shared because they cannot be +// changed after their names are assigned. +func cloneRenderData(data any) any { + if data == nil { + return nil + } + return cloneRenderValue(reflect.ValueOf(data), make(map[clonePointer]reflect.Value)).Interface() +} + +type clonePointer struct { + typeOf reflect.Type + pointer uintptr +} + +// cloneRenderValue remembers pointers it has already copied. This preserves +// repeated references and lets it copy values that refer back to themselves. +func cloneRenderValue(source reflect.Value, seen map[clonePointer]reflect.Value) reflect.Value { + if !source.IsValid() { + return source + } + if source.Type() == reflect.TypeFor[TypeData]() { + data := source.Interface().(TypeData) + copy := data + copy.Init = copyInitData(data.Init) + copy.Example = cloneRenderData(data.Example) + return reflect.ValueOf(copy) + } + switch source.Kind() { + case reflect.Bool, reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, + reflect.Int64, reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, + reflect.Uint64, reflect.Uintptr, reflect.Float32, reflect.Float64, + reflect.Complex64, reflect.Complex128, reflect.String: + return source + case reflect.Func: + panic(fmt.Sprintf("HTTP template data contains function value of type %s", source.Type())) + case reflect.Interface: + if source.IsNil() { + return reflect.Zero(source.Type()) + } + copy := reflect.New(source.Type()).Elem() + copy.Set(cloneRenderValue(source.Elem(), seen)) + return copy + case reflect.Pointer: + if source.IsNil() { + return reflect.Zero(source.Type()) + } + if _, ok := immutableRenderPointers[source.Type()]; ok { + return source + } + key := clonePointer{source.Type(), uintptr(source.UnsafePointer())} + if copy, ok := seen[key]; ok { + return copy + } + copy := reflect.New(source.Type().Elem()) + seen[key] = copy + copy.Elem().Set(cloneRenderValue(source.Elem(), seen)) + return copy + case reflect.Slice: + if source.IsNil() { + return reflect.Zero(source.Type()) + } + key := clonePointer{source.Type(), source.Pointer()} + if copy, ok := seen[key]; ok { + return copy + } + copy := reflect.MakeSlice(source.Type(), source.Len(), source.Len()) + seen[key] = copy + for index := 0; index < source.Len(); index++ { + copy.Index(index).Set(cloneRenderValue(source.Index(index), seen)) + } + return copy + case reflect.Map: + if source.IsNil() { + return reflect.Zero(source.Type()) + } + key := clonePointer{source.Type(), uintptr(source.UnsafePointer())} + if copy, ok := seen[key]; ok { + return copy + } + copy := reflect.MakeMapWithSize(source.Type(), source.Len()) + seen[key] = copy + iterator := source.MapRange() + for iterator.Next() { + copy.SetMapIndex(cloneRenderValue(iterator.Key(), seen), cloneRenderValue(iterator.Value(), seen)) + } + return copy + case reflect.Struct: + copy := reflect.New(source.Type()).Elem() + for index := 0; index < source.NumField(); index++ { + field := source.Type().Field(index) + if field.PkgPath != "" { + panic(fmt.Sprintf("HTTP template data contains private field %s.%s", source.Type(), field.Name)) + } + copy.Field(index).Set(cloneRenderValue(source.Field(index), seen)) + } + return copy + case reflect.Array: + copy := reflect.New(source.Type()).Elem() + for index := 0; index < source.Len(); index++ { + copy.Index(index).Set(cloneRenderValue(source.Index(index), seen)) + } + return copy + default: + panic(fmt.Sprintf("HTTP template data contains unsupported %s value", source.Kind())) + } +} diff --git a/http/codegen/compatibility.go b/http/codegen/compatibility.go new file mode 100644 index 0000000000..7f26895443 --- /dev/null +++ b/http/codegen/compatibility.go @@ -0,0 +1,72 @@ +// This file keeps released HTTP generator entry points available to plugins +// while all rendering uses the one transport plan retained by Goa. +package codegen + +import ( + "fmt" + + "goa.design/goa/v3/codegen" + "goa.design/goa/v3/expr" +) + +// ClientFiles returns the planned client files. genpkg must match the package +// used to create data. +func ClientFiles(genpkg string, data *ServicesData) []*codegen.File { + requireGeneratedPackage(genpkg, data) + return clientFiles(data) +} + +// ServerFiles returns the planned server files. genpkg must match the package +// used to create data. +func ServerFiles(genpkg string, data *ServicesData) []*codegen.File { + requireGeneratedPackage(genpkg, data) + return serverFiles(data) +} + +// ServerTypeFiles returns the planned server type files. genpkg must match the +// package used to create data. +func ServerTypeFiles(genpkg string, data *ServicesData) []*codegen.File { + requireGeneratedPackage(genpkg, data) + return serverTypeFiles(data) +} + +// ClientTypeFiles returns the planned client type files. genpkg must match the +// package used to create data. +func ClientTypeFiles(genpkg string, data *ServicesData) []*codegen.File { + requireGeneratedPackage(genpkg, data) + return clientTypeFiles(data) +} + +// PathFiles returns the planned request path files. +func PathFiles(data *ServicesData) []*codegen.File { + return pathFiles(data) +} + +// ClientEncodeDecodeFile returns the planned client encoder and decoder file +// for service. genpkg must match the package used to create data. +func ClientEncodeDecodeFile(genpkg string, service *expr.HTTPServiceExpr, data *ServicesData) *codegen.File { + requireGeneratedPackage(genpkg, data) + return clientEncodeDecodeFile(service, data) +} + +// ServerEncodeDecodeFile returns the planned server encoder and decoder file +// for service. genpkg must match the package used to create data. +func ServerEncodeDecodeFile(genpkg string, service *expr.HTTPServiceExpr, data *ServicesData) *codegen.File { + requireGeneratedPackage(genpkg, data) + return serverEncodeDecodeFile(service, data) +} + +// WebsocketClientFile returns the planned WebSocket client file for service. +// genpkg must match the package used to create data. +func WebsocketClientFile(genpkg string, service *expr.HTTPServiceExpr, data *ServicesData) *codegen.File { + requireGeneratedPackage(genpkg, data) + return websocketClientFile(service, data) +} + +// requireGeneratedPackage rejects a package argument that does not describe +// the HTTP data supplied by the same generation run. +func requireGeneratedPackage(genpkg string, data *ServicesData) { + if genpkg != data.GenPkg() { + panic(fmt.Sprintf("HTTP generation package %q does not match planned package %q", genpkg, data.GenPkg())) + } +} diff --git a/http/codegen/cookie_security_test.go b/http/codegen/cookie_security_test.go index 91f75fd194..91ec5906eb 100644 --- a/http/codegen/cookie_security_test.go +++ b/http/codegen/cookie_security_test.go @@ -1,3 +1,5 @@ +// This file renders HTTP security designs through both OpenAPI versions and +// verifies cookie API-key placement with run-owned example generation enabled. package codegen import ( @@ -36,7 +38,6 @@ func TestCookieAPIKeySecurity(t *testing.T) { t.Run("openapi uses cookie security scheme", func(t *testing.T) { root := expr.RunDSL(t, cookieAPIKeySecurityDSL) - openapi.Definitions = make(map[string]*openapi.Schema) v2Files, err := openapiv2.Files(root, openapi.DefaultPath20) require.NoError(t, err) @@ -55,7 +56,6 @@ func TestCookieAPIKeySecurity(t *testing.T) { require.Contains(t, (*swagger.Paths["/auth/profile"].Get.Security)[0], name) } - openapi.Definitions = make(map[string]*openapi.Schema) v3JSON := renderOpenAPIJSON(t, openapiv3.Files(root, openapi.Version30, openapi.DefaultPath30)) loader := openapi3.NewLoader() doc, err := loader.LoadFromData(v3JSON) @@ -76,9 +76,9 @@ func TestCookieAPIKeySecurity(t *testing.T) { t.Run("http codegen does not duplicate cookie-backed auth fields", func(t *testing.T) { root := expr.RunDSL(t, cookieAPIKeySecurityDSL) - services := CreateHTTPServices(root) + plan := linkedHTTPPlanForRoot(t, root) - serverTypes := typesFile("gen", root.API.HTTP.Services[0], true, services) + serverTypes := plan.ServerTypeFiles()[0] var serverTypesBuf bytes.Buffer for _, section := range serverTypes.SectionTemplates[1:] { require.NoError(t, section.Write(&serverTypesBuf)) @@ -88,7 +88,7 @@ func TestCookieAPIKeySecurity(t *testing.T) { require.NotContains(t, serverTypesCode, "browserSession *string, browserSession *string") require.NotContains(t, serverTypesCode, "browserSession string, browserSession string") - serverFiles := ServerFiles("", services) + serverFiles := plan.ServerFiles() require.Len(t, serverFiles, 2) serverDecode := codegen.SectionCode(t, serverFiles[1].SectionTemplates[2]) require.Contains(t, serverDecode, `r.Cookie("__Host-ak_session")`) @@ -96,7 +96,7 @@ func TestCookieAPIKeySecurity(t *testing.T) { require.NotContains(t, serverDecode, "browserSession *string, browserSession *string") require.NotContains(t, serverDecode, "browserSession string, browserSession string") - clientFiles := ClientFiles("", services) + clientFiles := plan.ClientFiles() require.Len(t, clientFiles, 2) clientEncode := codegen.SectionCode(t, clientFiles[1].SectionTemplates[2]) require.Contains(t, clientEncode, `req.AddCookie(&http.Cookie{`) diff --git a/http/codegen/default_value.go b/http/codegen/default_value.go new file mode 100644 index 0000000000..1180168d41 --- /dev/null +++ b/http/codegen/default_value.go @@ -0,0 +1,195 @@ +// This file translates authored defaults into the exact values accepted by +// generated HTTP command-line flags. Service defaults remain in service code; +// this file only owns HTTP field names and JSON encoding choices. +package codegen + +import ( + "encoding/base64" + "fmt" + "reflect" + "strings" + + "goa.design/goa/v3/codegen" + "goa.design/goa/v3/expr" +) + +// requestElementDefault returns the default used by one generated request +// field. Object payloads own field defaults; complete primitive and collection +// payloads carry the default on the transport attribute itself. +func requestElementDefault(payload *expr.AttributeExpr, name string, attribute *expr.AttributeExpr) any { + if expr.IsObject(payload.Type) { + return payload.GetDefault(name) + } + return attribute.DefaultValue +} + +// clientBodyDefault translates an authored default into the value that the +// generated HTTP command parser serializes. A top-level byte slice is entered +// as plain flag text. Byte slices inside JSON values remain byte slices so the +// standard JSON encoder writes their base64 representation. +func clientBodyDefault(attribute *expr.AttributeExpr, value any) any { + if value == nil { + return nil + } + return projectHTTPDefault(attribute, reflect.ValueOf(value), true) +} + +// projectHTTPDefault translates one designed value into its HTTP wire shape. +// The complete design is available here, so generated programs only parse the +// already specialized flag default. +func projectHTTPDefault(attribute *expr.AttributeExpr, value reflect.Value, topLevel bool) any { + value = concreteDefaultValue(value) + switch { + case expr.IsPrimitive(attribute.Type): + if attribute.Type.Kind() != expr.BytesKind { + return value.Interface() + } + bytes := defaultBytes(value) + if topLevel { + return string(bytes) + } + return bytes + case expr.IsArray(attribute.Type): + array := expr.AsArray(attribute.Type) + items := make([]any, value.Len()) + for index := range items { + items[index] = projectHTTPDefault(array.ElemType, value.Index(index), false) + } + return items + case expr.IsMap(attribute.Type): + mapped := expr.AsMap(attribute.Type) + items := make(map[string]any, value.Len()) + iterator := value.MapRange() + for iterator.Next() { + key := httpDefaultMapKey(mapped.KeyType, iterator.Key()) + items[key] = projectHTTPDefault(mapped.ElemType, iterator.Value(), false) + } + return items + case expr.IsObject(attribute.Type): + mapped := expr.NewMappedAttributeExpr(attribute) + items := make(map[string]any) + for _, field := range *expr.AsObject(mapped.Type) { + fieldValue, ok := defaultObjectField(value, field.Name, field.Attribute) + if !ok { + continue + } + name, include := httpDefaultFieldName(field.Attribute, mapped.ElemName(field.Name)) + if include { + items[name] = projectHTTPDefault(field.Attribute, fieldValue, false) + } + } + return items + case expr.IsUnion(attribute.Type): + return projectHTTPUnionDefault(expr.AsUnion(attribute.Type), value) + default: + panic(fmt.Sprintf("cannot translate HTTP default for %s", attribute.Type.Name())) // bug + } +} + +// projectHTTPUnionDefault keeps the union keys defined by the DSL and +// translates the selected branch value recursively. +func projectHTTPUnionDefault(union *expr.Union, value reflect.Value) any { + typeValue, ok := defaultObjectField(value, union.GetTypeKey(), nil) + if !ok { + panic(fmt.Sprintf("union default is missing %q", union.GetTypeKey())) // bug + } + branchName := concreteDefaultValue(typeValue).String() + var branch *expr.AttributeExpr + for _, candidate := range union.Values { + if candidate.Name == branchName { + branch = candidate.Attribute + break + } + } + if branch == nil { + panic(fmt.Sprintf("union default selects unknown branch %q", branchName)) // bug + } + branchValue, ok := defaultObjectField(value, union.GetValueKey(), branch) + if !ok { + panic(fmt.Sprintf("union default is missing %q", union.GetValueKey())) // bug + } + return map[string]any{ + union.GetTypeKey(): branchName, + union.GetValueKey(): projectHTTPDefault(branch, branchValue, false), + } +} + +// httpDefaultMapKey returns the JSON object key written for one designed map +// key. JSON represents byte keys with the same base64 text used for byte +// values and represents every other allowed primitive key as text. +func httpDefaultMapKey(attribute *expr.AttributeExpr, value reflect.Value) string { + value = concreteDefaultValue(value) + if attribute.Type.Kind() == expr.BytesKind { + return base64.StdEncoding.EncodeToString(defaultBytes(value)) + } + return fmt.Sprint(value.Interface()) +} + +// httpDefaultFieldName applies the same name precedence as the generated JSON +// struct tag: a complete json tag, then a json name override, then the name +// mapped by the HTTP body design. +func httpDefaultFieldName(attribute *expr.AttributeExpr, mappedName string) (string, bool) { + for _, key := range []string{"struct:tag:json", "struct:tag:json:name"} { + values := attribute.Meta[key] + if len(values) == 0 { + continue + } + name := strings.SplitN(strings.Join(values, ","), ",", 2)[0] + if name == "-" { + return "", false + } + if name != "" { + return name, true + } + return mappedName, true + } + return mappedName, true +} + +// defaultBytes accepts both forms allowed by authored defaults and returns the +// bytes that the HTTP flag or JSON encoder must receive. +func defaultBytes(value reflect.Value) []byte { + switch value.Kind() { + case reflect.String: + return []byte(value.String()) + case reflect.Slice: + return value.Bytes() + default: + panic(fmt.Sprintf("byte default has Go type %s", value.Type())) // bug + } +} + +// concreteDefaultValue removes interface and pointer wrappers from an authored +// default before the design determines its HTTP representation. +func concreteDefaultValue(value reflect.Value) reflect.Value { + for value.Kind() == reflect.Interface || value.Kind() == reflect.Pointer { + value = value.Elem() + } + return value +} + +// defaultObjectField returns one authored object field from either a map or a +// Go struct. Missing map entries remain absent from the projected JSON object. +func defaultObjectField(value reflect.Value, name string, attribute *expr.AttributeExpr) (reflect.Value, bool) { + value = concreteDefaultValue(value) + switch value.Kind() { + case reflect.Map: + iterator := value.MapRange() + for iterator.Next() { + key := concreteDefaultValue(iterator.Key()) + if key.Kind() == reflect.String && key.String() == name { + return iterator.Value(), true + } + } + return reflect.Value{}, false + case reflect.Struct: + fieldName := codegen.Goify(name, true) + if attribute != nil { + fieldName = codegen.GoifyAtt(attribute, name, true) + } + field := value.FieldByName(fieldName) + return field, field.IsValid() + default: + panic(fmt.Sprintf("object default has Go type %s", value.Type())) // bug + } +} diff --git a/http/codegen/default_value_test.go b/http/codegen/default_value_test.go new file mode 100644 index 0000000000..8a9fcc9bbb --- /dev/null +++ b/http/codegen/default_value_test.go @@ -0,0 +1,118 @@ +// This file checks that authored defaults become the exact flag text and JSON +// shape accepted by generated HTTP clients. +package codegen + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/require" + + "goa.design/goa/v3/dsl" + "goa.design/goa/v3/expr" +) + +func TestClientBodyDefaultUsesHTTPFieldNamesAndJSONBytes(t *testing.T) { + object := expr.Object{ + &expr.NamedAttributeExpr{ + Name: "full:mapped_full", + Attribute: &expr.AttributeExpr{ + Type: expr.String, + Meta: expr.MetaExpr{ + "struct:tag:json": {"full_name,omitempty"}, + "struct:tag:json:name": {"ignored_name"}, + }, + }, + }, + &expr.NamedAttributeExpr{ + Name: "bytes:mapped_bytes", + Attribute: &expr.AttributeExpr{ + Type: expr.Bytes, + Meta: expr.MetaExpr{"struct:tag:json:name": {"encoded_bytes"}}, + }, + }, + &expr.NamedAttributeExpr{ + Name: "plain:mapped_plain", + Attribute: &expr.AttributeExpr{Type: expr.String}, + }, + &expr.NamedAttributeExpr{ + Name: "skip:mapped_skip", + Attribute: &expr.AttributeExpr{ + Type: expr.String, + Meta: expr.MetaExpr{"struct:tag:json": {"-"}}, + }, + }, + } + attribute := &expr.AttributeExpr{Type: &object} + got := clientBodyDefault(attribute, map[string]any{ + "full": "first", + "bytes": "plain bytes", + "plain": "third", + "skip": "hidden", + }) + + encoded, err := json.Marshal(got) + require.NoError(t, err) + require.JSONEq(t, `{"full_name":"first","encoded_bytes":"cGxhaW4gYnl0ZXM=","mapped_plain":"third"}`, string(encoded)) +} + +func TestClientBodyDefaultUsesRawTextForTopLevelBytes(t *testing.T) { + attribute := &expr.AttributeExpr{Type: expr.Bytes} + for _, test := range []struct { + name string + value any + }{ + {name: "authored string", value: "plain bytes"}, + {name: "authored byte slice", value: []byte("plain bytes")}, + } { + t.Run(test.name, func(t *testing.T) { + require.Equal(t, "plain bytes", clientBodyDefault(attribute, test.value)) + }) + } +} + +func TestClientBodyDefaultIsUsedOnlyByClientCLIPlanning(t *testing.T) { + root := expr.RunDSL(t, func() { + details := dsl.Type("DefaultDetails", func() { + dsl.Attribute("content", dsl.Bytes, func() { + dsl.Meta("struct:tag:json:name", "encoded_content") + }) + }) + dsl.Service("defaults", func() { + dsl.Method("object", func() { + dsl.Payload(func() { + dsl.Attribute("value", details, func() { + dsl.Default(map[string]any{"content": "plain bytes"}) + }) + }) + dsl.HTTP(func() { + dsl.POST("/object") + dsl.Body("value") + }) + }) + dsl.Method("bytes", func() { + dsl.Payload(func() { + dsl.Attribute("value", dsl.Bytes, func() { + dsl.Default("plain bytes") + }) + }) + dsl.HTTP(func() { + dsl.POST("/bytes") + dsl.Body("value") + }) + }) + }) + }) + + service := linkedHTTPPlanForRoot(t, root).services.Get("defaults") + object := service.Endpoint("object").Payload.Request.PayloadInit + require.Equal(t, map[string]any{"encoded_content": []byte("plain bytes")}, object.ClientArgs[0].DefaultValue) + require.Equal(t, map[string]any{"content": "plain bytes"}, object.ServerArgs[0].DefaultValue) + objectFlags, _ := buildFlags(service, service.Endpoint("object")) + require.Equal(t, `{"encoded_content":"cGxhaW4gYnl0ZXM="}`, objectFlags[0].DefaultValue) + bytes := service.Endpoint("bytes").Payload.Request.PayloadInit + require.Equal(t, "plain bytes", bytes.ClientArgs[0].DefaultValue) + require.Equal(t, "plain bytes", bytes.ServerArgs[0].DefaultValue) + byteFlags, _ := buildFlags(service, service.Endpoint("bytes")) + require.Equal(t, "plain bytes", byteFlags[0].DefaultValue) +} diff --git a/http/codegen/error_body_description_test.go b/http/codegen/error_body_description_test.go new file mode 100644 index 0000000000..8b63bce767 --- /dev/null +++ b/http/codegen/error_body_description_test.go @@ -0,0 +1,44 @@ +// This file verifies generated HTTP error body comments name the service +// errors that use each body type. +package codegen + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/require" + + "goa.design/goa/v3/codegen" + "goa.design/goa/v3/expr" + "goa.design/goa/v3/http/codegen/testdata" +) + +func TestErrorBodyDescriptionNamesSingleError(t *testing.T) { + root := expr.RunDSL(t, testdata.WithErrorCustomPkgDSL) + plan := linkedHTTPPlanForRoot(t, root) + want := "MethodWithErrorCustomPkgErrorNameResponseBody is the type of the\n" + + "// \"ServiceWithErrorCustomPkg\" service \"MethodWithErrorCustomPkg\" endpoint HTTP\n" + + "// response body for the \"error_name\" error." + + for _, file := range []struct { + name string + sections string + }{ + {name: "client", sections: renderHTTPSections(t, plan.ClientTypeFiles()[0])}, + {name: "server", sections: renderHTTPSections(t, plan.ServerTypeFiles()[0])}, + } { + t.Run(file.name, func(t *testing.T) { + require.Contains(t, file.sections, want) + }) + } +} + +// renderHTTPSections writes all generated sections after the file header. +func renderHTTPSections(t *testing.T, file *codegen.File) string { + t.Helper() + var rendered strings.Builder + for _, section := range file.SectionTemplates[1:] { + require.NoError(t, section.Write(&rendered)) + } + return rendered.String() +} diff --git a/http/codegen/example_cli.go b/http/codegen/example_cli.go index 3d0ba64ec2..08d6005390 100644 --- a/http/codegen/example_cli.go +++ b/http/codegen/example_cli.go @@ -1,73 +1,74 @@ +// This file writes runnable HTTP and JSON-RPC command-line examples with the +// package names already chosen for this generation. package codegen import ( - "os" + "path" "path/filepath" "goa.design/goa/v3/codegen" "goa.design/goa/v3/codegen/example" + "goa.design/goa/v3/codegen/service" "goa.design/goa/v3/expr" ) -// ExampleCLIFiles returns an example client tool implementation for the -// transport described by services for each server expression. -func ExampleCLIFiles(genpkg string, services *ServicesData) []*codegen.File { +// exampleCLIFiles returns an example command-line client for the HTTP services +// on each configured server. +func exampleCLIFiles(root *example.Root, services *ServicesData) []*codegen.File { var files []*codegen.File - for _, svr := range services.Root.API.Servers { - if f := ExampleCLI(genpkg, svr, services); f != nil { + for _, server := range root.Servers { + if f := exampleCLI(server, services); f != nil { files = append(files, f) } } return files } -// ExampleCLI returns an example client tool implementation for the transport -// described by services and the given server expression. -func ExampleCLI(genpkg string, svr *expr.ServerExpr, services *ServicesData) *codegen.File { - svrdata := example.Servers.Get(svr, services.Root) - path := filepath.Join("cmd", svrdata.Dir+"-cli", services.dir()+".go") - if _, err := os.Stat(path); !os.IsNotExist(err) { - return nil // file already exists, skip it. - } +// exampleCLI returns an example command-line client for the HTTP services on +// the given server. +func exampleCLI(server *example.Data, services *ServicesData) *codegen.File { + genpkg := services.GenPkg() + outputPath := filepath.Join("cmd", server.Dir+"-cli", services.dir()+".go") + outputPackage := path.Join(path.Dir(genpkg), "cmd", server.Dir+"-cli") funcSuffix := "HTTP" if services.jsonrpc { funcSuffix = "JSONRPC" } - rootPath := example.RootPath(genpkg) - specs := []*codegen.ImportSpec{ - {Path: "context"}, - {Path: "encoding/json"}, - {Path: "flag"}, - {Path: "fmt"}, - {Path: "net/http"}, - {Path: "net/url"}, - {Path: "os"}, - {Path: "strings"}, - {Path: "time"}, - {Path: "github.com/gorilla/websocket"}, - codegen.GoaImport(""), - codegen.GoaNamedImport("http", "goahttp"), - {Path: genpkg + "/" + services.dir() + "/cli/" + svrdata.Dir, Name: "cli"}, - } - importScope := codegen.NewNameScope() - for _, svc := range services.Root.Services { - data := services.ServicesData.Get(svc.Name) - specs = append(specs, &codegen.ImportSpec{Path: genpkg + "/" + data.PkgName}) - importScope.Unique(data.PkgName) + rootPath := path.Dir(genpkg) + parser := services.cliParsers[server.Name] + if parser == nil { + return nil } - interceptorsPkg := importScope.Unique("interceptors", "ex") - specs = append(specs, &codegen.ImportSpec{Path: rootPath + "/interceptors", Name: interceptorsPkg}) - apiPkg := example.APIPkg(services.Root, importScope) - specs = append(specs, &codegen.ImportSpec{Path: rootPath, Name: apiPkg}) - var svcData []*ServiceData - for _, svc := range svr.Services { - if data := services.Get(svc); data != nil { - svcData = append(svcData, data) + hasClientInterceptors := false + hasMultipart := false + for _, name := range server.Services { + data := services.Get(name) + if data == nil { + continue + } + if len(data.Endpoints) == 0 { + continue } + copy := exampleServiceDataForOutput(data, services, outputPackage) + svcData = append(svcData, copy) + hasClientInterceptors = hasClientInterceptors || len(data.Service.ClientInterceptors) > 0 + for _, endpoint := range data.Endpoints { + hasMultipart = hasMultipart || endpoint.MultipartRequestDecoder != nil + } + } + cliImport := services.PackageImport(outputPackage, path.Join(genpkg, services.dir(), "cli", server.Dir)) + var interceptorsPkg string + if hasClientInterceptors { + interceptorImport := services.PackageImport(outputPackage, rootPath+"/interceptors") + interceptorsPkg = interceptorImport.Name + } + var apiPkg string + if hasMultipart { + apiPkg = services.PackageImport(outputPackage, rootPath).Name } sections := []*codegen.SectionTemplate{ - codegen.Header("", "main", specs), + plannedFileHeader("", "main", outputPath, services), { Name: "cli-http-start", Source: httpTemplates.Read(cliStartT), @@ -91,12 +92,22 @@ func ExampleCLI(genpkg string, svr *expr.ServerExpr, services *ServicesData) *co Name: "cli-http-end", Source: httpTemplates.Read(cliEndT), Data: map[string]any{ - "Services": svcData, - "APIPkg": apiPkg, + "Services": svcData, + "APIPkg": apiPkg, + "CLIPkg": cliImport.Name, + "Parser": parser.Declarations, + "Transport": services.label(), }, FuncMap: map[string]any{ - "needDialer": NeedDialer, - "hasWebSocket": HasWebSocket, + "hasAnyInputStreams": cliHasAnyInputStreams, + "hasInputStreams": cliHasInputStreams, + "hasRunnable": cliHasRunnableCommands, + "hasRunnableService": cliHasRunnableService, + "needDialer": NeedDialer, + "hasWebSocket": HasWebSocket, + "kebab": codegen.KebabCase, + "streamsInput": cliStreamsInput, + "streamsOutput": cliStreamsOutput, }, }, { @@ -104,12 +115,70 @@ func ExampleCLI(genpkg string, svr *expr.ServerExpr, services *ServicesData) *co Source: httpTemplates.Read(cliUsageT), Data: map[string]any{ "VarPrefix": services.dir(), + "CLIPkg": cliImport.Name, + "Parser": parser.Declarations, }, }, } return &codegen.File{ - Path: path, + Path: outputPath, SectionTemplates: sections, SkipExist: true, } } + +// cliStreamsInput reports whether an example command would need to send more +// payload values after the endpoint call starts. +func cliStreamsInput(method *service.MethodData) bool { + return method.StreamKind == expr.ClientStreamKind || method.StreamKind == expr.BidirectionalStreamKind +} + +// cliStreamsOutput reports whether an example command receives a sequence of +// results from the server. +func cliStreamsOutput(method *service.MethodData) bool { + return method.StreamKind == expr.ServerStreamKind +} + +// cliHasInputStreams reports whether a service has commands that the example +// client must reject before parsing an endpoint. +func cliHasInputStreams(data *ServiceData) bool { + for _, endpoint := range data.Endpoints { + if cliStreamsInput(endpoint.Method) { + return true + } + } + return false +} + +// cliHasAnyInputStreams reports whether any service has a command that the +// example client must reject before parsing an endpoint. +func cliHasAnyInputStreams(services []*ServiceData) bool { + for _, data := range services { + if cliHasInputStreams(data) { + return true + } + } + return false +} + +// cliHasRunnableCommands reports whether the example client can invoke at +// least one generated endpoint. +func cliHasRunnableCommands(services []*ServiceData) bool { + for _, data := range services { + if cliHasRunnableService(data) { + return true + } + } + return false +} + +// cliHasRunnableService reports whether the example client can invoke at +// least one endpoint in the service. +func cliHasRunnableService(data *ServiceData) bool { + for _, endpoint := range data.Endpoints { + if !cliStreamsInput(endpoint.Method) { + return true + } + } + return false +} diff --git a/http/codegen/example_cli_test.go b/http/codegen/example_cli_test.go index 032358dfd8..15010ecc98 100644 --- a/http/codegen/example_cli_test.go +++ b/http/codegen/example_cli_test.go @@ -1,16 +1,16 @@ +// This file verifies generated HTTP command-line client examples. package codegen import ( "bytes" + "path" "path/filepath" "testing" "github.com/stretchr/testify/require" "goa.design/goa/v3/codegen" - "goa.design/goa/v3/codegen/example" ctestdata "goa.design/goa/v3/codegen/example/testdata" - "goa.design/goa/v3/codegen/service" "goa.design/goa/v3/codegen/testutil" "goa.design/goa/v3/http/codegen/testdata" ) @@ -25,14 +25,14 @@ func TestExampleCLIFiles(t *testing.T) { {"server-hosting-multiple-services", ctestdata.ServerHostingMultipleServicesDSL}, {"streaming", testdata.StreamingResultDSL}, {"streaming-multiple-services", testdata.StreamingMultipleServicesDSL}, + {"streaming-input-only", testdata.StreamingPayloadDSL}, + {"mixed-results", testdata.MixedResultsDSL}, } for _, c := range cases { t.Run(c.Name, func(t *testing.T) { - // reset global variable - example.Servers = make(example.ServersData) root := codegen.RunDSL(t, c.DSL) - httpServices := NewServicesData(service.NewServicesData(root), root.API.HTTP) - fs := ExampleCLIFiles("", httpServices) + examples := linkedHTTPExamplePlanForRoot(t, root) + fs := examples.CLIFiles() require.Len(t, fs, 1) require.Greater(t, len(fs[0].SectionTemplates), 0) var buf bytes.Buffer @@ -45,3 +45,46 @@ func TestExampleCLIFiles(t *testing.T) { }) } } + +func TestExampleCLIFilesOmitFilesOnlyServer(t *testing.T) { + root := codegen.RunDSL(t, ctestdata.ServerHostingServiceWithFileServerDSL) + examples := linkedHTTPExamplePlanForRoot(t, root) + + require.NotEmpty(t, examples.ServerFiles()) + require.Empty(t, examples.CLIFiles()) +} + +func TestExampleCLIUsesServicePathsForCommands(t *testing.T) { + root := codegen.RunDSL(t, collidingServiceNamesDSL) + examples := linkedHTTPExamplePlanForRoot(t, root) + files := examples.CLIFiles() + require.Len(t, files, 1) + + var output bytes.Buffer + for _, section := range files[0].SectionTemplates { + require.NoError(t, section.Write(&output)) + } + first := examples.transport.services.Get("read_value").Service + second := examples.transport.services.Get("read-value").Service + firstCommand := codegen.KebabCase(first.PathName) + secondCommand := codegen.KebabCase(second.PathName) + require.NotEqual(t, firstCommand, secondCommand) + require.Contains(t, output.String(), `case "`+firstCommand+`":`) + require.Contains(t, output.String(), `case "`+secondCommand+`":`) +} + +// TestExampleCLIImportsOnlyConfiguredServices verifies that an example client +// does not reserve packages for services its server does not expose or for +// streaming code it does not write. +func TestExampleCLIImportsOnlyConfiguredServices(t *testing.T) { + root := codegen.RunDSL(t, ctestdata.ServerHostingServiceSubsetDSL) + examples := linkedHTTPExamplePlanForRoot(t, root) + files := examples.CLIFiles() + require.Len(t, files, 1) + + imports := importPaths(files[0].SectionTemplates[0].Data.(map[string]any)["Imports"].([]*codegen.ImportSpec)) + ignored := examples.transport.services.Get("IgnoredService").Service + require.NotContains(t, imports, path.Join(examples.transport.services.GenPkg(), ignored.PathName)) + require.NotContains(t, imports, "strings") + require.NotContains(t, imports, "github.com/gorilla/websocket") +} diff --git a/http/codegen/example_server.go b/http/codegen/example_server.go index 6fb3144c79..bff19dc821 100644 --- a/http/codegen/example_server.go +++ b/http/codegen/example_server.go @@ -1,85 +1,89 @@ +// This file writes runnable HTTP servers and file-upload helpers with the +// package names already chosen for this generation. package codegen import ( - "os" + "maps" "path" "path/filepath" - "strings" "goa.design/goa/v3/codegen" "goa.design/goa/v3/codegen/example" "goa.design/goa/v3/expr" ) -// ExampleServerFiles returns an example http service implementation. -func ExampleServerFiles(genpkg string, data *ServicesData) []*codegen.File { +type ( + // exampleServerArgumentData contains one typed parameter accepted by a + // generated transport helper. + exampleServerArgumentData struct { + // Name is the parameter name used inside the helper. + Name string + // PkgName is the generated service package name. + PkgName string + // TypeName is the generated service or endpoint type name. + TypeName string + // Pointer is true when TypeName is an endpoint collection pointer. + Pointer bool + } + + // exampleMultipartDecoderData describes the HTTP request body filled by one + // starter multipart decoder. + exampleMultipartDecoderData struct { + *MultipartData + // BodyType is the request body type as seen from the starter service + // package. + BodyType string + } +) + +// exampleServerFiles builds each runnable HTTP server from copied server data. +func exampleServerFiles(root *example.Root, data *ServicesData) []*codegen.File { var fw []*codegen.File - for _, svr := range data.Root.API.Servers { - if m := ExampleServer(genpkg, data.Root, svr, data); m != nil { + for _, server := range root.Servers { + if m := exampleServer(server, data); m != nil { fw = append(fw, m) } } for _, svc := range data.Expressions.Services { - if f := dummyMultipartFile(genpkg, data.Root, svc, data); f != nil { + if f := dummyMultipartFile(svc, data); f != nil { fw = append(fw, f) } } return fw } -// ExampleServer returns an example HTTP server implementation. -func ExampleServer(genpkg string, root *expr.RootExpr, svr *expr.ServerExpr, services *ServicesData) *codegen.File { - svrdata := example.Servers.Get(svr, root) - fpath := filepath.Join("cmd", svrdata.Dir, "http.go") - specs := make([]*codegen.ImportSpec, 0, 12+2*len(root.API.HTTP.Services)) - baseSpecs := []*codegen.ImportSpec{ - {Path: "context"}, - {Path: "net/http"}, - {Path: "net/url"}, - {Path: "os"}, - {Path: "sync"}, - {Path: "time"}, - codegen.GoaNamedImport("http", "goahttp"), - {Path: "goa.design/clue/debug"}, - {Path: "goa.design/clue/log"}, - codegen.GoaImport("middleware"), - {Path: "github.com/gorilla/websocket"}, - } - specs = append(specs, baseSpecs...) - - scope := codegen.NewNameScope() - for _, svc := range root.API.HTTP.Services { - sd := services.Get(svc.Name()) - svcName := sd.Service.PathName - specs = append(specs, - &codegen.ImportSpec{ - Path: path.Join(genpkg, "http", svcName, "server"), - Name: scope.Unique(sd.Service.PkgName + "svr"), - }, - &codegen.ImportSpec{ - Path: path.Join(genpkg, svcName), - Name: scope.Unique(sd.Service.PkgName), - }) - } - - rootPath := example.RootPath(genpkg) - apiPkg := scope.Unique(strings.ToLower(codegen.Goify(services.Root.API.Name, false) + "api")) - specs = append(specs, &codegen.ImportSpec{Path: rootPath, Name: apiPkg}) - +// exampleServer returns an example HTTP server implementation. +func exampleServer(server *example.Data, services *ServicesData) *codegen.File { + genpkg := services.GenPkg() + fpath := filepath.Join("cmd", server.Dir, "http.go") + outputPackage := path.Join(path.Dir(genpkg), "cmd", server.Dir) + rootPath := path.Dir(genpkg) + var apiPkg string var svcdata []*ServiceData - for _, svc := range svr.Services { + for _, svc := range server.Services { if data := services.Get(svc); data != nil { - svcdata = append(svcdata, data) + copy := exampleServiceDataForOutput(data, services, outputPackage) + copy.ServerPkgName = services.PackageImport( + outputPackage, + path.Join(genpkg, "http", data.Service.PathName, "server"), + ).Name + svcdata = append(svcdata, copy) + for _, endpoint := range data.Endpoints { + if endpoint.MultipartRequestDecoder != nil { + apiPkg = services.PackageImport(outputPackage, rootPath).Name + } + } } } sections := []*codegen.SectionTemplate{ - codegen.Header("", "main", specs), + plannedFileHeader("", "main", fpath, services), { Name: "server-http-start", Source: httpTemplates.Read(serverStartT), Data: map[string]any{ - "Services": svcdata, + "Services": svcdata, + "HandlerArgs": exampleServerArguments(server, svcdata, nil), // JSONRPCServices must always be set (typed nil when // absent) so the template functions receive a valid // []*ServiceData value. The JSON-RPC generator @@ -126,48 +130,217 @@ func ExampleServer(genpkg string, root *expr.RootExpr, svr *expr.ServerExpr, ser return &codegen.File{Path: fpath, SectionTemplates: sections, SkipExist: true} } -// dummyMultipartFile returns a dummy implementation of the multipart decoders -// and encoders. -func dummyMultipartFile(genpkg string, root *expr.RootExpr, svc *expr.HTTPServiceExpr, services *ServicesData) *codegen.File { - mpath := "multipart.go" - if _, err := os.Stat(mpath); !os.IsNotExist(err) { - return nil // file already exists, skip it. +// combinedExampleServerFiles builds runnable server files that mount both the +// JSON-RPC services and the ordinary HTTP services from one design. The caller +// may edit every returned file without changing either input plan. +func combinedExampleServerFiles(root *example.Root, jsonrpc, application *ServicesData) []*codegen.File { + files := make([]*codegen.File, 0, len(root.Servers)) + for _, server := range root.Servers { + file := combinedExampleServer(server, jsonrpc, application) + if file != nil { + files = append(files, file) + } } - var ( - sections []*codegen.SectionTemplate - mustGen bool + if application != nil { + for _, service := range application.Expressions.Services { + if file := dummyMultipartFile(service, application); file != nil { + files = append(files, cloneGeneratedFile(file)) + } + } + } + return files +} - scope = codegen.NewNameScope() - ) - // determine the unique API package name different from the service names - for _, httpSvc := range root.API.HTTP.Services { - s := services.Get(httpSvc.Name()) - if s == nil { - panic("unknown http service, " + httpSvc.Name()) // bug +// combinedExampleServer builds one main-package file for a configured server. +// It reads service membership from server and writes separate HTTP and +// JSON-RPC lists because the code that writes main initializes them differently. +func combinedExampleServer(server *example.Data, jsonrpc, application *ServicesData) *codegen.File { + outputPackage := path.Join(path.Dir(jsonrpc.GenPkg()), "cmd", server.Dir) + var ordinaryServices []*ServiceData + var apiPkg string + if application != nil { + for _, name := range server.Services { + data := application.Get(name) + if data == nil { + continue + } + copy := exampleServiceDataForOutput(data, application, outputPackage) + copy.ServerPkgName = application.PackageImport( + outputPackage, + path.Join(application.GenPkg(), "http", data.Service.PathName, "server"), + ).Name + ordinaryServices = append(ordinaryServices, copy) + for _, endpoint := range data.Endpoints { + if endpoint.MultipartRequestDecoder != nil { + apiPkg = jsonrpc.PackageImport(outputPackage, path.Dir(jsonrpc.GenPkg())).Name + } + } } - if s.Service == nil { - panic("unknown service, " + httpSvc.Name()) // bug + } + var jsonrpcServices []*ServiceData + for _, name := range server.Services { + data := jsonrpc.Get(name) + if data == nil { + continue } - scope.Unique(s.Service.PkgName) + copy := exampleServiceDataForOutput(data, jsonrpc, outputPackage) + copy.ServerPkgName = jsonrpc.PackageImport( + outputPackage, + path.Join(jsonrpc.GenPkg(), "jsonrpc", data.Service.PathName, "server"), + ).Name + jsonrpcServices = append(jsonrpcServices, copy) + } + if len(ordinaryServices) == 0 && len(jsonrpcServices) == 0 { + return nil + } + data := map[string]any{ + "Services": ordinaryServices, + "JSONRPCServices": jsonrpcServices, + "HandlerArgs": exampleServerArguments(server, ordinaryServices, jsonrpcServices), } + filePath := filepath.Join("cmd", server.Dir, "http.go") + sections := []*codegen.SectionTemplate{ + plannedFileHeader("", "main", filePath, jsonrpc), + {Name: "server-http-start", Source: httpTemplates.Read(serverStartT), Data: data}, + {Name: "server-http-encoding", Source: httpTemplates.Read(serverEncodingT)}, + {Name: "server-http-mux", Source: httpTemplates.Read(serverMuxT)}, + { + Name: "server-http-init", + Source: httpTemplates.Read(serverConfigureT), + Data: map[string]any{ + "Services": ordinaryServices, + "JSONRPCServices": jsonrpcServices, + "APIPkg": apiPkg, + }, + FuncMap: map[string]any{"needDialer": NeedDialer, "hasWebSocket": HasWebSocket}, + }, + {Name: "server-http-middleware", Source: httpTemplates.Read(serverMiddlewareT)}, + {Name: "server-http-end", Source: httpTemplates.Read(serverEndT), Data: data}, + {Name: "server-http-errorhandler", Source: httpTemplates.Read(serverErrorHandlerT)}, + } + return &codegen.File{ + Path: filePath, + SectionTemplates: sections, + SkipExist: true, + } +} + +// exampleServerArguments adds generated Go names and types to the ordered +// service values copied by the shared example plan. +func exampleServerArguments( + server *example.Data, + ordinary, jsonrpc []*ServiceData, +) []*exampleServerArgumentData { + services := make(map[string]*ServiceData, len(ordinary)+len(jsonrpc)) + for _, service := range ordinary { + services[service.Service.Name] = service + } + for _, service := range jsonrpc { + services[service.Service.Name] = service + } + planned := server.HandlerArgs(example.TransportHTTP) + arguments := make([]*exampleServerArgumentData, len(planned)) + for index, argument := range planned { + service := services[argument.Service].Service + data := &exampleServerArgumentData{ + PkgName: service.PkgName, + } + if argument.Endpoint { + data.Name = service.VarName + "Endpoints" + data.TypeName = service.EndpointsDeclaration.Name() + data.Pointer = true + } else { + data.Name = service.VarName + "Svc" + data.TypeName = service.ServiceDeclaration.Name() + } + arguments[index] = data + } + return arguments +} + +// cloneGeneratedFile copies a generated file and its section records so the +// caller may change the copy without changing the source plan. +func cloneGeneratedFile(source *codegen.File) *codegen.File { + if source == nil { + return nil + } + clone := *source + clone.SectionTemplates = make([]*codegen.SectionTemplate, len(source.SectionTemplates)) + for index, section := range source.SectionTemplates { + sectionClone := *section + sectionClone.FuncMap = maps.Clone(section.FuncMap) + sectionClone.Data = cloneRenderData(section.Data) + clone.SectionTemplates[index] = §ionClone + } + return &clone +} + +// cloneJSONRPCCodecFile copies an encoder and decoder file and replaces each +// HTTP endpoint value with the smaller value read by JSON-RPC code. +func cloneJSONRPCCodecFile(source *codegen.File) *codegen.File { + if source == nil { + return nil + } + clone := *source + clone.SectionTemplates = make([]*codegen.SectionTemplate, len(source.SectionTemplates)) + for index, section := range source.SectionTemplates { + sectionCopy := *section + sectionCopy.FuncMap = maps.Clone(section.FuncMap) + if endpoint, ok := section.Data.(*EndpointData); ok { + switch section.Name { + case "response-decoder": + data := copyJSONRPCEndpoint(endpoint) + sectionCopy.Data = &data + case "request-builder", "request-encoder", "request-decoder": + sectionCopy.Data = copyJSONRPCRequestCodec(endpoint) + default: + panic("JSON-RPC codec contains an unsupported endpoint section " + section.Name) + } + } else if helper, ok := section.Data.(*codegen.TransformFunctionData); ok { + if section.Name != "client-transform-helper" && section.Name != "server-transform-helper" { + panic("JSON-RPC codec contains a transform helper in unsupported section " + section.Name) + } + sectionCopy.Data = copyJSONRPCTransformFunction(helper) + } else { + sectionCopy.Data = cloneRenderData(section.Data) + } + clone.SectionTemplates[index] = §ionCopy + } + return &clone +} + +// dummyMultipartFile returns a dummy implementation of the multipart decoders +// and encoders. +func dummyMultipartFile(svc *expr.HTTPServiceExpr, services *ServicesData) *codegen.File { + genpkg := services.GenPkg() + mpath := "multipart.go" + outputPackage := path.Dir(genpkg) + var ( + sections []*codegen.SectionTemplate + decoderData = make(map[*MultipartData]*exampleMultipartDecoderData) + mustGen bool + ) { - specs := make([]*codegen.ImportSpec, 0, 2) - specs = append(specs, &codegen.ImportSpec{Path: "mime/multipart"}) data := services.Get(svc.Name()) - specs = append(specs, &codegen.ImportSpec{ - Path: path.Join(genpkg, data.Service.PathName), - Name: scope.Unique(data.Service.PkgName, "svc"), - }) + for _, endpoint := range data.Endpoints { + if endpoint.MultipartRequestDecoder == nil { + continue + } + decoderData[endpoint.MultipartRequestDecoder] = &exampleMultipartDecoderData{ + MultipartData: endpoint.MultipartRequestDecoder, + BodyType: exampleMultipartBodyType(svc, endpoint, services, outputPackage), + } + } - apiPkg := example.APIPkg(root, scope) - sections = []*codegen.SectionTemplate{codegen.Header("", apiPkg, specs)} + apiPkg := examplePackageImportName(services.Root) + sections = []*codegen.SectionTemplate{plannedFileHeader("", apiPkg, mpath, services)} for _, e := range data.Endpoints { if e.MultipartRequestDecoder != nil { mustGen = true sections = append(sections, &codegen.SectionTemplate{ Name: "dummy-multipart-request-decoder", Source: httpTemplates.Read(dummyMultipartRequestDecoderT), - Data: e.MultipartRequestDecoder, + Data: decoderData[e.MultipartRequestDecoder], }) } if e.MultipartRequestEncoder != nil { @@ -189,3 +362,31 @@ func dummyMultipartFile(genpkg string, root *expr.RootExpr, svc *expr.HTTPServic SkipExist: true, } } + +// exampleMultipartBodyType returns the request body type visible from the +// starter service package. +func exampleMultipartBodyType(svc *expr.HTTPServiceExpr, endpoint *EndpointData, services *ServicesData, outputPackage string) string { + serverBody := endpoint.Payload.Request.ServerBody + service := services.Get(svc.Name()) + serverPath := path.Join(services.GenPkg(), "http", service.Service.PathName, "server") + serverImport := services.PackageImport(outputPackage, serverPath) + if serverBody.Declaration != nil { + return serverImport.Name + "." + serverBody.Declaration.Name() + } + body := serverBody.attribute + usesServerType := false + collectUserTypes(body.Type, func(expr.UserType) { + usesServerType = true + }) + if usesServerType { + resolver := &wireAttributeScope{ + catalog: service.serverWireTypes, + base: codegen.NewAttributeScope(service.serverWireTypes.scope), + pkg: serverImport.Name, + policy: jsonBodyPolicy(true, true, true, ""), + exactOccurrence: true, + } + return resolver.Name(body, serverImport.Name, true, false) + } + return serverBody.VarName +} diff --git a/http/codegen/example_server_test.go b/http/codegen/example_server_test.go index d28d4d73f5..fb5bbeeecc 100644 --- a/http/codegen/example_server_test.go +++ b/http/codegen/example_server_test.go @@ -1,7 +1,9 @@ +// This file verifies generated HTTP server examples. package codegen import ( "bytes" + "path" "path/filepath" "testing" @@ -9,10 +11,9 @@ import ( "github.com/stretchr/testify/require" "goa.design/goa/v3/codegen" - "goa.design/goa/v3/codegen/example" ctestdata "goa.design/goa/v3/codegen/example/testdata" - "goa.design/goa/v3/codegen/service" "goa.design/goa/v3/codegen/testutil" + dsl "goa.design/goa/v3/dsl" "goa.design/goa/v3/http/codegen/testdata" ) @@ -31,12 +32,10 @@ func TestExampleServerFiles(t *testing.T) { } for _, c := range cases { t.Run(c.Name, func(t *testing.T) { - // reset global variable - example.Servers = make(example.ServersData) root := codegen.RunDSL(t, c.DSL) require.Len(t, root.Services, 3) - httpServices := NewServicesData(service.NewServicesData(root), root.API.HTTP) - fs := ExampleServerFiles("", httpServices) + examples := linkedHTTPExamplePlanForRoot(t, root) + fs := examples.ServerFiles() require.Len(t, fs, 2) for i, f := range fs { if i < len(fs)-1 { @@ -55,6 +54,38 @@ func TestExampleServerFiles(t *testing.T) { } }) + t.Run("multipart code check", func(t *testing.T) { + cases := []struct { + Name string + DSL func() + }{ + {"object", testdata.PayloadMultipartValidationDSL}, + {"array", testdata.PayloadMultipartArrayTypeDSL}, + {"map", testdata.PayloadMultipartMapTypeDSL}, + } + for _, c := range cases { + t.Run(c.Name, func(t *testing.T) { + root := codegen.RunDSL(t, c.DSL) + examples := linkedHTTPExamplePlanForRoot(t, root) + var multipartFile *codegen.File + for _, file := range examples.ServerFiles() { + if file.Path == "multipart.go" { + multipartFile = file + break + } + } + require.NotNil(t, multipartFile) + var buf bytes.Buffer + for _, section := range multipartFile.SectionTemplates { + require.NoError(t, section.Write(&buf)) + } + code := codegen.FormatTestCode(t, buf.String()) + golden := filepath.Join("testdata", "golden", "server-multipart-"+c.Name+".golden") + testutil.CompareOrUpdateGolden(t, code, golden) + }) + } + }) + t.Run("code check", func(t *testing.T) { cases := []struct { Name string @@ -64,15 +95,14 @@ func TestExampleServerFiles(t *testing.T) { {"server-hosting-service-with-file-server", ctestdata.ServerHostingServiceWithFileServerDSL}, {"server-hosting-service-subset", ctestdata.ServerHostingServiceSubsetDSL}, {"server-hosting-multiple-services", ctestdata.ServerHostingMultipleServicesDSL}, + {"colliding-service-names", collidingServiceNamesDSL}, {"streaming", testdata.StreamingMultipleServicesDSL}, } for _, c := range cases { t.Run(c.Name, func(t *testing.T) { - // reset global variable - example.Servers = make(example.ServersData) root := codegen.RunDSL(t, c.DSL) - httpServices := NewServicesData(service.NewServicesData(root), root.API.HTTP) - fs := ExampleServerFiles("", httpServices) + examples := linkedHTTPExamplePlanForRoot(t, root) + fs := examples.ServerFiles() require.Len(t, fs, 1) require.Greater(t, len(fs[0].SectionTemplates), 0) var buf bytes.Buffer @@ -86,3 +116,59 @@ func TestExampleServerFiles(t *testing.T) { } }) } + +func TestExampleServerUsesServicePathsForLocalNames(t *testing.T) { + root := codegen.RunDSL(t, collidingServiceNamesDSL) + examples := linkedHTTPExamplePlanForRoot(t, root) + files := examples.ServerFiles() + require.Len(t, files, 1) + + var output bytes.Buffer + for _, section := range files[0].SectionTemplates { + require.NoError(t, section.Write(&output)) + } + first := examples.transport.services.Get("read_value").Service + second := examples.transport.services.Get("read-value").Service + firstBase := codegen.Goify(first.PathName, false) + secondBase := codegen.Goify(second.PathName, false) + require.NotEqual(t, firstBase, secondBase) + require.Contains(t, output.String(), firstBase+"Endpoints") + require.Contains(t, output.String(), secondBase+"Endpoints") + require.Contains(t, output.String(), firstBase+"Server") + require.Contains(t, output.String(), secondBase+"Server") +} + +// TestExampleServerImportsOnlyConfiguredServices verifies that an example +// server does not reserve packages for services it does not expose or for a +// WebSocket configurer it does not write. +func TestExampleServerImportsOnlyConfiguredServices(t *testing.T) { + root := codegen.RunDSL(t, ctestdata.ServerHostingServiceSubsetDSL) + examples := linkedHTTPExamplePlanForRoot(t, root) + files := examples.ServerFiles() + require.Len(t, files, 1) + + imports := importPaths(files[0].SectionTemplates[0].Data.(map[string]any)["Imports"].([]*codegen.ImportSpec)) + ignored := examples.transport.services.Get("IgnoredService").Service + require.NotContains(t, imports, path.Join(examples.transport.services.GenPkg(), ignored.PathName)) + require.NotContains(t, imports, "github.com/gorilla/websocket") +} + +// collidingServiceNamesDSL defines two services whose names become the same Go +// name. Their generated package paths remain distinct. +func collidingServiceNamesDSL() { + dsl.API("collision", func() { + dsl.Server("collision", func() { + dsl.Services("read_value", "read-value") + }) + }) + dsl.Service("read_value", func() { + dsl.Method("read", func() { + dsl.HTTP(func() { dsl.GET("/underscore") }) + }) + }) + dsl.Service("read-value", func() { + dsl.Method("read", func() { + dsl.HTTP(func() { dsl.GET("/dash") }) + }) + }) +} diff --git a/http/codegen/handler_test.go b/http/codegen/handler_test.go index d61cc85664..f7d79025e9 100644 --- a/http/codegen/handler_test.go +++ b/http/codegen/handler_test.go @@ -14,7 +14,6 @@ import ( ) func TestHandlerInit(t *testing.T) { - const genpkg = "gen" cases := []struct { Name string DSL func() @@ -31,8 +30,8 @@ func TestHandlerInit(t *testing.T) { for _, c := range cases { t.Run(c.Name, func(t *testing.T) { root := expr.RunDSL(t, c.DSL) - services := CreateHTTPServices(root) - fs := ServerFiles(genpkg, services) + plan := linkedHTTPPlanForRoot(t, root) + fs := plan.ServerFiles() sections := codegentest.Sections(fs, "server.go", "server-handler-init") require.Greater(t, len(sections), 0) code := codegen.SectionCode(t, sections[0]) diff --git a/http/codegen/idempotency_test.go b/http/codegen/idempotency_test.go index e859cb2e86..17b2335d75 100644 --- a/http/codegen/idempotency_test.go +++ b/http/codegen/idempotency_test.go @@ -1,3 +1,5 @@ +// This file verifies repeated HTTP generation produces the same Go names and +// does not keep changeable values from an earlier run. package codegen import ( @@ -37,8 +39,8 @@ func TestIdempotentHTTPEndpointCodegen(t *testing.T) { }) }) }) - services := CreateHTTPServices(root) - clientFiles := ClientFiles("", services) + plan := linkedHTTPPlanForRoot(t, root) + clientFiles := plan.ClientFiles() require.NotEmpty(t, clientFiles) clientCode := codegen.SectionsCode(t, clientFiles[0].Section("client-endpoint-init")) @@ -50,8 +52,8 @@ func TestIdempotentHTTPEndpointCodegen(t *testing.T) { // TestFileGenerationIdempotent builds the HTTP services data once and renders // the complete generated file set twice, asserting that both renders produce // byte-identical outputs. This guards against file generators mutating shared -// analysis state (e.g. the ServerTypeNames/ClientTypeNames dedup sets or the -// PathInit argument data) in ways that change subsequent renders. +// analysis state (for example package declaration catalogs or PathInit +// argument data) in ways that change subsequent renders. func TestFileGenerationIdempotent(t *testing.T) { cases := []struct { Name string @@ -64,14 +66,14 @@ func TestFileGenerationIdempotent(t *testing.T) { for _, c := range cases { t.Run(c.Name, func(t *testing.T) { root := expr.RunDSL(t, c.DSL) - services := CreateHTTPServices(root) + plan := linkedHTTPPlanForRoot(t, root) render := func(dir string) { - files := PathFiles(services) - files = append(files, ServerFiles("gen", services)...) - files = append(files, ClientFiles("gen", services)...) - files = append(files, ServerTypeFiles("gen", services)...) - files = append(files, ClientTypeFiles("gen", services)...) + files := plan.PathFiles() + files = append(files, plan.ServerFiles()...) + files = append(files, plan.ClientFiles()...) + files = append(files, plan.ServerTypeFiles()...) + files = append(files, plan.ClientTypeFiles()...) require.NotEmpty(t, files) for _, f := range files { _, err := f.Render(dir) diff --git a/http/codegen/jsonrpc_data.go b/http/codegen/jsonrpc_data.go new file mode 100644 index 0000000000..cfbc1bb999 --- /dev/null +++ b/http/codegen/jsonrpc_data.go @@ -0,0 +1,507 @@ +// This file copies the HTTP values used to write JSON-RPC files. Changing a +// copy cannot change the HTTP values saved for the same service. +package codegen + +import ( + "goa.design/goa/v3/codegen" + "goa.design/goa/v3/codegen/service" + "goa.design/goa/v3/expr" +) + +type ( + // jsonRPCRequestIDPolicy records the request-ID choice made from the + // validated endpoint expression before generated packages reserve imports. + jsonRPCRequestIDPolicy struct { + attribute *expr.NamedAttributeExpr + defaultValue any + required bool + pointer bool + } + + // jsonRPCRequestCodecData contains the values used to write JSON-RPC request + // builders, encoders, and decoders. + jsonRPCRequestCodecData struct { + *JSONRPCEndpointSnapshot + BasicScheme *service.SchemeData + HeaderSchemes service.SchemesData + MultipartRequestEncoder any + MultipartRequestDecoder any + } + + // jsonRPCTransformFunctionData contains the five values used to write one + // generated conversion function. + jsonRPCTransformFunctionData struct { + Declaration *codegen.NameDeclaration + Name string + ParamTypeRef string + ResultTypeRef string + Code string + } +) + +// copyJSONRPCEndpoint returns the values read by JSON-RPC files for endpoint. +// Changing the returned value cannot change endpoint. +func copyJSONRPCEndpoint(endpoint *EndpointData) JSONRPCEndpointSnapshot { + requestEncoder := "" + if endpoint.RequestEncoderDeclaration != nil { + requestEncoder = endpoint.RequestEncoderDeclaration.Name() + } + requestDecoder := "" + if endpoint.RequestDecoderDeclaration != nil { + requestDecoder = endpoint.RequestDecoderDeclaration.Name() + } + result := JSONRPCEndpointSnapshot{ + IsJSONRPC: endpoint.IsJSONRPC, + IsJSONRPCNotification: endpoint.IsJSONRPCNotification, + JSONRPCRequestID: copyJSONRPCRequestID(endpoint.JSONRPCRequestID), + Method: copyJSONRPCMethod(endpoint.Method), + ServiceName: endpoint.ServiceName, + ServicePkgName: endpoint.ServicePkgName, + Payload: copyJSONRPCPayload(endpoint.Payload), + Result: copyJSONRPCResult(endpoint.Result), + Errors: copyJSONRPCErrors(endpoint.Errors), + Routes: copyJSONRPCRoutes(endpoint.Routes), + RequestInit: copyInitData(endpoint.RequestInit), + EndpointInit: endpoint.EndpointInit, + HandlerInit: endpoint.HandlerInitDeclaration.Name(), + HandlerInitDeclaration: endpoint.HandlerInitDeclaration, + ClientStruct: endpoint.ClientStructDeclaration.Name(), + ClientStructDeclaration: endpoint.ClientStructDeclaration, + RequestEncoder: requestEncoder, + RequestEncoderDeclaration: endpoint.RequestEncoderDeclaration, + RequestDecoder: requestDecoder, + RequestDecoderDeclaration: endpoint.RequestDecoderDeclaration, + ResponseDecoder: endpoint.ResponseDecoderDeclaration.Name(), + ResponseDecoderDeclaration: endpoint.ResponseDecoderDeclaration, + SSE: copyJSONRPCSSE(endpoint.SSE), + } + return result +} + +// copyJSONRPCRequestID returns a detached request-ID plan. +func copyJSONRPCRequestID(id *JSONRPCRequestIDData) *JSONRPCRequestIDData { + if id == nil { + return nil + } + copy := *id + return © +} + +// jsonRPCRequestIDPolicyFor returns nil for notifications and otherwise +// records the direct payload field that supplies the ID, when one exists. +func jsonRPCRequestIDPolicyFor(endpoint *expr.HTTPEndpointExpr) *jsonRPCRequestIDPolicy { + if !endpoint.IsJSONRPC() || endpoint.IsJSONRPCNotification() { + return nil + } + policy := &jsonRPCRequestIDPolicy{} + object := expr.AsObject(endpoint.MethodExpr.Payload.Type) + if object == nil { + return policy + } + for _, field := range *object { + if _, ok := field.Attribute.Meta["jsonrpc:id"]; ok { + policy.attribute = field + policy.defaultValue = endpoint.MethodExpr.Payload.GetDefault(field.Name) + policy.required = endpoint.MethodExpr.Payload.IsRequired(field.Name) + policy.pointer = endpoint.MethodExpr.Payload.IsPrimitivePointer(field.Name, true) + return policy + } + } + return policy +} + +// generates reports whether the client must create an ID for every call or +// when an optional payload ID is absent. +func (p *jsonRPCRequestIDPolicy) generates() bool { + return p != nil && (p.attribute == nil || p.pointer) +} + +// copyJSONRPCRequestCodec returns the values used to write one JSON-RPC request. +// JSON-RPC request bodies cannot use multipart encoding. +func copyJSONRPCRequestCodec(endpoint *EndpointData) *jsonRPCRequestCodecData { + if endpoint.MultipartRequestEncoder != nil || endpoint.MultipartRequestDecoder != nil { + panic("JSON-RPC request codec cannot use multipart encoding") + } + data := copyJSONRPCEndpoint(endpoint) + return &jsonRPCRequestCodecData{ + JSONRPCEndpointSnapshot: &data, + BasicScheme: copyJSONRPCScheme(endpoint.BasicScheme), + HeaderSchemes: copyJSONRPCSchemes(endpoint.HeaderSchemes), + } +} + +// copyJSONRPCTransformFunction returns the values written into one generated +// conversion function. +func copyJSONRPCTransformFunction(helper *codegen.TransformFunctionData) *jsonRPCTransformFunctionData { + return &jsonRPCTransformFunctionData{ + Declaration: helper.Declaration, + Name: helper.Name, + ParamTypeRef: helper.ParamTypeRef, + ResultTypeRef: helper.ResultTypeRef, + Code: helper.Code, + } +} + +// copyJSONRPCSchemes returns new security records for a generated request. +func copyJSONRPCSchemes(schemes service.SchemesData) service.SchemesData { + result := make(service.SchemesData, len(schemes)) + for index, scheme := range schemes { + result[index] = copyJSONRPCScheme(scheme) + } + return result +} + +// copyJSONRPCScheme returns a security record that callers may change independently. +func copyJSONRPCScheme(scheme *service.SchemeData) *service.SchemeData { + if scheme == nil { + return nil + } + copy := *scheme + copy.Scopes = append([]string(nil), scheme.Scopes...) + copy.Flows = make([]*expr.FlowExpr, len(scheme.Flows)) + for index, flow := range scheme.Flows { + flowCopy := *flow + copy.Flows[index] = &flowCopy + } + return © +} + +// copyJSONRPCMethod returns the method names and stream methods used by JSON-RPC files. +func copyJSONRPCMethod(method *service.MethodData) JSONRPCMethodData { + result := JSONRPCMethodData{ + Name: method.Name, + VarName: method.VarName, + Result: method.Result, + HasMixedResults: method.HasMixedResults, + Idempotent: method.Idempotent, + ServerStream: copyJSONRPCStream(method.ServerStream), + ClientStream: copyJSONRPCStream(method.ClientStream), + StreamKind: method.StreamKind, + SkipRequestBodyEncodeDecode: method.SkipRequestBodyEncodeDecode, + RequestStruct: method.RequestStruct, + } + result.Errors = make([]JSONRPCMethodErrorData, len(method.Errors)) + for index, serviceError := range method.Errors { + result.Errors[index] = JSONRPCMethodErrorData{ + ErrName: serviceError.ErrName, + Temporary: serviceError.Temporary, + } + } + if method.ViewedResult != nil { + viewed := copyJSONRPCViewedResult(method.ViewedResult) + result.ViewedResult = &JSONRPCMethodViewedResultData{ + JSONRPCViewedResultData: viewed, + ViewName: method.ViewedResult.ViewName, + } + } + return result +} + +// copyJSONRPCViewedResult returns the names used to check and convert a viewed +// result in JSON-RPC files. +func copyJSONRPCViewedResult(viewed *service.ViewedResultTypeData) JSONRPCViewedResultData { + return JSONRPCViewedResultData{ + FullRef: viewed.FullRef, + VarName: viewed.VarName, + ViewsPkg: viewed.ViewsPkg, + Validate: viewed.Validate.Declaration, + ResultInit: viewed.ResultInit.Declaration, + Init: viewed.Init.Declaration, + IsCollection: viewed.IsCollection, + } +} + +// copyJSONRPCStream returns the service stream method names read by JSON-RPC files. +func copyJSONRPCStream(stream *service.StreamData) *JSONRPCStreamData { + if stream == nil { + return nil + } + return &JSONRPCStreamData{ + Interface: stream.Interface, + VarName: stream.VarName, + SendName: stream.SendName, + SendDesc: stream.SendDesc, + SendWithContextName: stream.SendWithContextName, + SendWithContextDesc: stream.SendWithContextDesc, + SendTypeName: stream.SendTypeName, + SendTypeRef: stream.SendTypeRef, + RecvName: stream.RecvName, + RecvDesc: stream.RecvDesc, + RecvWithContextName: stream.RecvWithContextName, + RecvWithContextDesc: stream.RecvWithContextDesc, + RecvTypeName: stream.RecvTypeName, + RecvTypeRef: stream.RecvTypeRef, + EndpointStruct: stream.EndpointStruct, + Kind: stream.Kind, + } +} + +// copyJSONRPCPayload returns the request values read by JSON-RPC files. +func copyJSONRPCPayload(payload *PayloadData) *JSONRPCPayloadData { + if payload == nil { + return nil + } + result := &JSONRPCPayloadData{ + Ref: payload.Ref, + IDAttribute: payload.IDAttribute, + IDAttributeTypeRef: payload.IDAttributeTypeRef, + IDAttributeRequired: payload.IDAttributeRequired, + DecoderReturnValue: payload.DecoderReturnValue, + } + result.JSONRPCRequestID = copyJSONRPCRequestID(payload.JSONRPCRequestID) + if payload.Request != nil { + request := payload.Request + result.Request = &JSONRPCRequestData{ + ClientBody: copyJSONRPCBody(request.ClientBody), + ServerBody: copyJSONRPCBody(request.ServerBody), + PayloadInit: copyInitData(request.PayloadInit), + Headers: copyJSONRPCHeaders(request.Headers), + Cookies: copyJSONRPCCookies(request.Cookies), + PayloadAttr: request.PayloadAttr, + MustHaveBody: request.MustHaveBody, + OptionalBody: request.OptionalBody, + BodyIsUnion: request.BodyIsUnion, + BodyFieldPointer: request.BodyFieldPointer, + BodyFieldCanBeAbsent: request.BodyFieldCanBeAbsent, + MustValidate: request.MustValidate, + Params: copyJSONRPCParams(request.JSONRPCParams), + } + } + return result +} + +// copyJSONRPCResult returns the response values read by JSON-RPC files. +func copyJSONRPCResult(result *ResultData) *JSONRPCResultData { + if result == nil { + return nil + } + copy := &JSONRPCResultData{ + Ref: result.Ref, + View: result.View, + Responses: make([]JSONRPCResponseData, len(result.Responses)), + } + for index, response := range result.Responses { + copy.Responses[index] = copyJSONRPCResponse(response) + } + return copy +} + +// copyJSONRPCResponse returns the body, headers, cookies, and constructor for one response. +func copyJSONRPCResponse(response *ResponseData) JSONRPCResponseData { + serverBodies := make([]JSONRPCBodyData, len(response.ServerBody)) + for index, body := range response.ServerBody { + serverBodies[index] = *copyJSONRPCBody(body) + } + return JSONRPCResponseData{ + StatusCode: response.StatusCode, + Code: response.Code, + Headers: copyJSONRPCHeaders(response.Headers), + Cookies: copyJSONRPCCookies(response.Cookies), + ServerBody: serverBodies, + ClientBody: copyJSONRPCBody(response.ClientBody), + ResultInit: copyInitData(response.ResultInit), + ResultAttr: response.ResultAttr, + MustValidate: response.MustValidate, + } +} + +// copyJSONRPCErrors returns the designed error responses read by JSON-RPC files. +func copyJSONRPCErrors(groups []*ErrorGroupData) []JSONRPCErrorGroupData { + result := make([]JSONRPCErrorGroupData, len(groups)) + for groupIndex, group := range groups { + errors := make([]JSONRPCErrorData, len(group.Errors)) + for errorIndex, serviceError := range group.Errors { + errors[errorIndex] = JSONRPCErrorData{ + Name: serviceError.Name, + Ref: serviceError.Ref, + Response: copyJSONRPCResponse(serviceError.Response), + } + } + result[groupIndex] = JSONRPCErrorGroupData{StatusCode: group.StatusCode, Errors: errors} + } + return result +} + +// copyJSONRPCRoutes returns the HTTP verbs and paths accepted by a JSON-RPC server. +func copyJSONRPCRoutes(routes []*RouteData) []JSONRPCRouteData { + result := make([]JSONRPCRouteData, len(routes)) + for index, route := range routes { + result[index] = JSONRPCRouteData{Verb: route.Verb, Path: route.Path} + } + return result +} + +// copyJSONRPCSSE returns the event-stream fields read by JSON-RPC files. +func copyJSONRPCSSE(stream *SSEData) *JSONRPCSSEData { + if stream == nil { + return nil + } + return &JSONRPCSSEData{ + StructDeclaration: stream.StructDeclaration, + ClientInterfaceDeclaration: stream.ClientInterfaceDeclaration, + ClientStructDeclaration: stream.ClientStructDeclaration, + ClientInitDeclaration: stream.ClientInitDeclaration, + EventTypeRef: stream.EventTypeRef, + EventTypeName: stream.EventTypeName, + EventIsStruct: stream.EventIsStruct, + DataField: stream.DataField, + Data: stream.Data, + IDField: stream.IDField, + ID: copySSEValue(stream.ID), + ClientIDPointer: stream.ClientIDPointer, + EventField: stream.EventField, + Event: copySSEValue(stream.Event), + ClientEventPointer: stream.ClientEventPointer, + RetryField: stream.RetryField, + Retry: copySSEValue(stream.Retry), + HasResponseBody: stream.HasResponseBody, + Response: copyJSONRPCResponsePtr(stream.Response), + RequestIDField: stream.RequestIDField, + RequestIDPointer: stream.RequestIDPointer, + Params: copyJSONRPCParams(stream.JSONRPCParams), + ClientEventCode: stream.ClientEventCode, + } +} + +// copySSEValue returns a detached SSE value plan. +func copySSEValue(value *SSEValueData) *SSEValueData { + if value == nil { + return nil + } + copy := *value + return © +} + +// copyJSONRPCParams returns a detached parameter-shape plan. +func copyJSONRPCParams(params *JSONRPCParamsData) *JSONRPCParamsData { + if params == nil { + return nil + } + copy := *params + return © +} + +// copyJSONRPCResponsePtr returns an independent copy of response. +func copyJSONRPCResponsePtr(response *ResponseData) *JSONRPCResponseData { + if response == nil { + return nil + } + copy := copyJSONRPCResponse(response) + return © +} + +// copyJSONRPCBody returns the generated body names and the code that converts +// the body value. +func copyJSONRPCBody(body *TypeData) *JSONRPCBodyData { + if body == nil { + return nil + } + return &JSONRPCBodyData{ + Declaration: body.Declaration, + VarName: body.VarName, + Ref: body.Ref, + ValidateRef: body.ValidateRef, + ValidatorDeclaration: body.ValidatorDeclaration, + ValidationTarget: body.ValidationTarget, + Init: copyInitData(body.Init), + } +} + +// copyInitData returns conversion arguments that callers may change without +// changing the source values. +func copyInitData(init *InitData) *InitData { + if init == nil { + return nil + } + copy := *init + copy.ServerArgs = copyInitArgs(init.ServerArgs) + copy.ClientArgs = copyInitArgs(init.ClientArgs) + copy.CLIArgs = copyInitArgs(init.CLIArgs) + return © +} + +// copyInitArgs returns conversion arguments with new attribute records. +func copyInitArgs(args []*InitArgData) []*InitArgData { + result := make([]*InitArgData, len(args)) + for index, arg := range args { + copy := *arg + if arg.AttributeData != nil { + attribute := *arg.AttributeData + attribute.Type = copyDataType(attribute.Type) + attribute.FieldType = copyDataType(attribute.FieldType) + attribute.DefaultValue = cloneRenderData(attribute.DefaultValue) + attribute.Example = cloneRenderData(attribute.Example) + copy.AttributeData = &attribute + } + result[index] = © + } + return result +} + +// copyDataType returns a new Goa type graph, or nil when no type was supplied. +func copyDataType(dataType expr.DataType) expr.DataType { + if dataType == nil { + return nil + } + return expr.Dup(dataType) +} + +// copyJSONRPCHeaders returns header values that do not share default data with source. +func copyJSONRPCHeaders(source []*HeaderData) []JSONRPCHeaderData { + result := make([]JSONRPCHeaderData, len(source)) + for index, header := range source { + result[index] = JSONRPCHeaderData{ + JSONRPCElementData: copyJSONRPCElement(header.Element), + CanonicalName: header.CanonicalName, + PreserveEmpty: header.PreserveEmpty, + } + } + return result +} + +// copyJSONRPCCookies returns cookie values that do not share default data with source. +func copyJSONRPCCookies(source []*CookieData) []JSONRPCCookieData { + result := make([]JSONRPCCookieData, len(source)) + for index, cookie := range source { + result[index] = JSONRPCCookieData{ + JSONRPCElementData: copyJSONRPCElement(cookie.Element), + MaxAge: cookie.MaxAge, + Path: cookie.Path, + Domain: cookie.Domain, + Secure: cookie.Secure, + HTTPOnly: cookie.HTTPOnly, + SameSite: cookie.SameSite, + } + } + return result +} + +// copyJSONRPCElement returns the fields used to decode one header or cookie. +func copyJSONRPCElement(element *Element) JSONRPCElementData { + dataType := element.Type + result := JSONRPCElementData{ + Name: element.Name, + VarName: element.VarName, + TypeName: dataType.Name(), + Type: copyDataType(element.Type), + FieldType: copyDataType(element.FieldType), + ElemTypeRef: element.ElemTypeRef, + TypeRef: element.TypeRef, + ValueTypeRef: element.ValueTypeRef, + Pointer: element.Pointer, + FieldName: element.FieldName, + FieldPointer: element.FieldPointer, + IsAliased: expr.IsAlias(element.FieldType), + Required: element.Required, + DefaultValue: cloneRenderData(element.DefaultValue), + HasDefault: element.HasDefault, + Validate: element.Validate, + HTTPName: element.HTTPName, + StringSlice: element.StringSlice, + Slice: element.Slice, + } + if array := expr.AsArray(dataType); array != nil { + result.ElemTypeName = array.ElemType.Type.Name() + } + return result +} diff --git a/http/codegen/multipart_test.go b/http/codegen/multipart_test.go index 518e54fd2c..9ade50f6aa 100644 --- a/http/codegen/multipart_test.go +++ b/http/codegen/multipart_test.go @@ -13,21 +13,21 @@ import ( ) func TestServerMultipartFuncType(t *testing.T) { - const genpkg = "gen" cases := []struct { Name string DSL func() }{ {"multipart-body-primitive", testdata.PayloadMultipartPrimitiveDSL}, {"multipart-body-user-type", testdata.PayloadMultipartUserTypeDSL}, + {"multipart-body-validation", testdata.PayloadMultipartValidationDSL}, {"multipart-body-array-type", testdata.PayloadMultipartArrayTypeDSL}, {"multipart-body-map-type", testdata.PayloadMultipartMapTypeDSL}, } for _, c := range cases { t.Run(c.Name, func(t *testing.T) { root := expr.RunDSL(t, c.DSL) - services := CreateHTTPServices(root) - fs := ServerFiles(genpkg, services) + plan := linkedHTTPPlanForRoot(t, root) + fs := plan.ServerFiles() require.Len(t, fs, 2) sections := fs[0].SectionTemplates require.Greater(t, len(sections), 5) @@ -38,7 +38,6 @@ func TestServerMultipartFuncType(t *testing.T) { } func TestClientMultipartFuncType(t *testing.T) { - const genpkg = "gen" cases := []struct { Name string DSL func() @@ -51,8 +50,8 @@ func TestClientMultipartFuncType(t *testing.T) { for _, c := range cases { t.Run(c.Name, func(t *testing.T) { root := expr.RunDSL(t, c.DSL) - services := CreateHTTPServices(root) - fs := ClientFiles(genpkg, services) + plan := linkedHTTPPlanForRoot(t, root) + fs := plan.ClientFiles() require.Len(t, fs, 2) sections := fs[0].SectionTemplates require.Greater(t, len(sections), 4) @@ -63,13 +62,13 @@ func TestClientMultipartFuncType(t *testing.T) { } func TestServerMultipartNewFunc(t *testing.T) { - const genpkg = "gen" cases := []struct { Name string DSL func() }{ {"server-multipart-body-primitive", testdata.PayloadMultipartPrimitiveDSL}, {"server-multipart-body-user-type", testdata.PayloadMultipartUserTypeDSL}, + {"server-multipart-body-validation", testdata.PayloadMultipartValidationDSL}, {"server-multipart-body-array-type", testdata.PayloadMultipartArrayTypeDSL}, {"server-multipart-body-map-type", testdata.PayloadMultipartMapTypeDSL}, {"server-multipart-with-param", testdata.PayloadMultipartWithParamDSL}, @@ -78,8 +77,8 @@ func TestServerMultipartNewFunc(t *testing.T) { for _, c := range cases { t.Run(c.Name, func(t *testing.T) { root := expr.RunDSL(t, c.DSL) - services := CreateHTTPServices(root) - fs := ServerFiles(genpkg, services) + plan := linkedHTTPPlanForRoot(t, root) + fs := plan.ServerFiles() require.Len(t, fs, 2) sections := fs[1].SectionTemplates require.Greater(t, len(sections), 3) @@ -90,7 +89,6 @@ func TestServerMultipartNewFunc(t *testing.T) { } func TestClientMultipartNewFunc(t *testing.T) { - const genpkg = "gen" cases := []struct { Name string DSL func() @@ -105,8 +103,8 @@ func TestClientMultipartNewFunc(t *testing.T) { for _, c := range cases { t.Run(c.Name, func(t *testing.T) { root := expr.RunDSL(t, c.DSL) - services := CreateHTTPServices(root) - fs := ClientFiles(genpkg, services) + plan := linkedHTTPPlanForRoot(t, root) + fs := plan.ClientFiles() require.Len(t, fs, 2) sections := fs[1].SectionTemplates require.Greater(t, len(sections), 3) diff --git a/http/codegen/oneof_http_codegen_test.go b/http/codegen/oneof_http_codegen_test.go index 365b45ca6e..8ff858576c 100644 --- a/http/codegen/oneof_http_codegen_test.go +++ b/http/codegen/oneof_http_codegen_test.go @@ -20,7 +20,7 @@ func TestClientCLIInlinesOneOfRequestValidation(t *testing.T) { require.Contains(t, code, "BuildMethodBodyUnionUserValidatePayload") require.Contains(t, code, "if body.A == nil") - require.Contains(t, code, "marshalUnionUserValidateRequestBodyTo") + require.Contains(t, code, "marshalUnionUserValidateRequestBodyToServicebodyunionuservalidateUnionUserValidate") require.NotContains(t, code, "ValidateMethodBodyUnionUserValidateRequestBody") } @@ -66,8 +66,8 @@ func renderClientCLISectionCode(t *testing.T, dsl func(), fileIndex, sectionInde t.Helper() root := expr.RunDSL(t, dsl) - services := CreateHTTPServices(root) - fs := ClientCLIFiles("", services) + plan := linkedHTTPPlanForRoot(t, root) + fs := plan.ClientCLIFiles() return codegen.SectionCode(t, fs[fileIndex].SectionTemplates[sectionIndex]) } @@ -76,11 +76,9 @@ func renderClientCLISectionCode(t *testing.T, dsl func(), fileIndex, sectionInde func renderClientTypesCode(t *testing.T, dsl func()) string { t.Helper() - const genpkg = "gen" - root := expr.RunDSL(t, dsl) - services := CreateHTTPServices(root) - fs := typesFile(genpkg, root.API.HTTP.Services[0], false, services) + plan := linkedHTTPPlanForRoot(t, root) + fs := plan.ClientTypeFiles()[0] var buf bytes.Buffer for _, s := range fs.SectionTemplates[1:] { @@ -96,8 +94,8 @@ func renderClientDecodeCode(t *testing.T, dsl func()) string { t.Helper() root := expr.RunDSL(t, dsl) - services := CreateHTTPServices(root) - fs := ClientFiles("", services) + plan := linkedHTTPPlanForRoot(t, root) + fs := plan.ClientFiles() require.Len(t, fs, 2) sections := fs[1].SectionTemplates diff --git a/http/codegen/openapi.go b/http/codegen/openapi.go index af0f42fb5d..db7055ec47 100644 --- a/http/codegen/openapi.go +++ b/http/codegen/openapi.go @@ -1,6 +1,13 @@ +// This file reads one HTTP design and builds the requested OpenAPI files. +// The plan builds every file immediately and returns those files later without +// reading the design again. package codegen import ( + "fmt" + "path" + "strings" + "goa.design/goa/v3/codegen" "goa.design/goa/v3/expr" "goa.design/goa/v3/http/codegen/openapi" @@ -8,33 +15,140 @@ import ( openapiv3 "goa.design/goa/v3/http/codegen/openapi/v3" ) -// OpenAPIFiles returns the files for the OpenAPI specs of the given HTTP API. +type ( + // OpenAPIPlan stores the OpenAPI files built from one HTTP design. + OpenAPIPlan struct { + files []*codegen.File + } +) + +// NewOpenAPIPlan builds the OpenAPI files for root. Later calls to Files +// return these same files without reading root again. // The "openapi:versions" API meta selects the generated specification // versions and the "openapi:path:" API meta overrides their output // paths, see openapi.Specs. -func OpenAPIFiles(root *expr.RootExpr) ([]*codegen.File, error) { - // Only create a OpenAPI specification if there are HTTP services. - if len(root.API.HTTP.Services) == 0 { - return nil, nil - } +func NewOpenAPIPlan(root *expr.RootExpr, generator *expr.ExampleGenerator) (*OpenAPIPlan, error) { + return NewOpenAPIPlanWithValues(root, generator, openapi.Values{}) +} +// NewOpenAPIPlanWithValues builds OpenAPI files using values in place of +// matching titles, descriptions, and examples from the evaluated design. +func NewOpenAPIPlanWithValues(root *expr.RootExpr, generator *expr.ExampleGenerator, values openapi.Values) (*OpenAPIPlan, error) { specs, err := openapi.Specs(root.API.Meta) if err != nil { return nil, err } + return NewOpenAPIPlanFromSpecs(root, generator, specs, values) +} + +// NewOpenAPIPlanFromSpecs builds the exact OpenAPI versions and paths in specs, +// using values in place of matching design text and examples. Paths are +// relative to the gen directory and omit the JSON or YAML extension because +// Goa writes both formats. +func NewOpenAPIPlanFromSpecs(root *expr.RootExpr, generator *expr.ExampleGenerator, specs []openapi.Spec, values openapi.Values) (*OpenAPIPlan, error) { + if err := validateOpenAPISpecs(specs); err != nil { + return nil, err + } + // Only create a OpenAPI specification if there are HTTP services. + if len(root.API.HTTP.Services) == 0 { + return &OpenAPIPlan{}, nil + } + var files []*codegen.File for _, spec := range specs { - var fs []*codegen.File + specGenerator := generator + if examplesDisabled(root.API.Meta) { + specGenerator = &expr.ExampleGenerator{} + } + var ( + fs []*codegen.File + err error + ) switch spec.Version { case openapi.Version20: - fs, err = openapiv2.Files(root, spec.Path) + fs, err = openapiv2.FilesWithValues(root, spec.Path, specGenerator, values) if err != nil { return nil, err } default: // Version30, Version32 - fs = openapiv3.Files(root, spec.Version, spec.Path) + fs = openapiv3.FilesWithValues(root, spec.Version, spec.Path, specGenerator, values) } files = append(files, fs...) } - return files, nil + return &OpenAPIPlan{files: files}, nil +} + +// Files returns the OpenAPI files built when the plan was created. +func (p *OpenAPIPlan) Files() []*codegen.File { + return p.files +} + +// examplesDisabled reports whether API metadata suppresses examples from +// generated OpenAPI documents. +func examplesDisabled(meta expr.MetaExpr) bool { + value, ok := meta.Last("openapi:example") + if !ok { + value, ok = meta.Last("swagger:example") + } + return ok && value == "false" +} + +// validateOpenAPISpecs checks the complete version and path list before any +// file is built. +func validateOpenAPISpecs(specs []openapi.Spec) error { + versions := make(map[openapi.Version]struct{}, len(specs)) + paths := make(map[string]openapi.Spec, len(specs)) + for _, spec := range specs { + switch spec.Version { + case openapi.Version20, openapi.Version30, openapi.Version32: + default: + return fmt.Errorf("unsupported OpenAPI version %q", spec.Version) + } + if _, ok := versions[spec.Version]; ok { + return fmt.Errorf("OpenAPI version %q appears more than once", spec.Version) + } + versions[spec.Version] = struct{}{} + if err := validateOpenAPIPath(spec.Path); err != nil { + return fmt.Errorf("invalid OpenAPI %s path %q: %w", spec.Version, spec.Path, err) + } + for existingPath, existing := range paths { + if existingPath == spec.Path { + return fmt.Errorf("OpenAPI versions %s and %s use the same output path %q", existing.Version, spec.Version, spec.Path) + } + if strings.EqualFold(existingPath, spec.Path) { + return fmt.Errorf( + "OpenAPI paths %q and %q collide on a case-insensitive filesystem", + existingPath, + spec.Path, + ) + } + } + paths[spec.Path] = spec + } + return nil +} + +// validateOpenAPIPath checks one extension-less path relative to gen. +func validateOpenAPIPath(outputPath string) error { + if outputPath == "" { + return fmt.Errorf("path cannot be empty") + } + if strings.Contains(outputPath, "\\") { + return fmt.Errorf("path cannot contain a backslash") + } + if strings.HasPrefix(outputPath, "/") { + return fmt.Errorf("path must be relative to the gen directory") + } + cleaned := path.Clean(outputPath) + if cleaned == "." || cleaned == ".." || strings.HasPrefix(cleaned, "../") { + return fmt.Errorf("path must not escape the gen directory") + } + if cleaned != outputPath { + return fmt.Errorf("path must be clean; use %q", cleaned) + } + switch path.Ext(outputPath) { + case ".json", ".yaml", ".yml": + return fmt.Errorf("path must not include an extension") + } + return nil } diff --git a/http/codegen/openapi/docs.go b/http/codegen/openapi/docs.go index a6d63045c2..d5d44e679f 100644 --- a/http/codegen/openapi/docs.go +++ b/http/codegen/openapi/docs.go @@ -1,3 +1,5 @@ +// This file converts Goa documentation links into OpenAPI values while +// allowing one specification build to replace their displayed description. package openapi import "goa.design/goa/v3/expr" @@ -12,11 +14,23 @@ type ExternalDocs struct { // DocsFromExpr builds a ExternalDocs from the Goa docs expression. func DocsFromExpr(docs *expr.DocsExpr, meta expr.MetaExpr) *ExternalDocs { + return docsFromExpr(docs, meta, Values{}) +} + +// DocsFromExprWithValues builds ExternalDocs and uses values for its +// description when one is present for docs. +func DocsFromExprWithValues(docs *expr.DocsExpr, meta expr.MetaExpr, values Values) *ExternalDocs { + return docsFromExpr(docs, meta, values) +} + +// docsFromExpr is the one implementation used by ordinary and customized +// OpenAPI builds. +func docsFromExpr(docs *expr.DocsExpr, meta expr.MetaExpr, values Values) *ExternalDocs { if docs == nil { return nil } return &ExternalDocs{ - Description: docs.Description, + Description: values.Description(docs, docs.Description), URL: docs.URL, Extensions: ExtensionsFromExpr(meta), } diff --git a/http/codegen/openapi/error_example.go b/http/codegen/openapi/error_example.go new file mode 100644 index 0000000000..2ca50d95ce --- /dev/null +++ b/http/codegen/openapi/error_example.go @@ -0,0 +1,51 @@ +// This file derives HTTP response details shared by the OpenAPI 2 and OpenAPI +// 3 generators. The shared Error schema stays reusable while each response +// shows the exact flags generated by its service constructor. +package openapi + +import "goa.design/goa/v3/expr" + +// ResponseContentType returns the content type used for one HTTP response. A +// response setting wins, followed by its result type and application/json. +func ResponseContentType(response *expr.HTTPResponseExpr) string { + if response.ContentType != "" { + return response.ContentType + } + if result, ok := response.Body.Type.(*expr.ResultTypeExpr); ok && result.ContentType != "" { + return result.ContentType + } + return "application/json" +} + +// ErrorResponseExample returns the generated body example for one use of +// Goa's built-in error result. It returns false when the error uses another +// type, supplies an authored example, or suppresses generated examples. +func ErrorResponseExample(errorExpression *expr.ErrorExpr, body *expr.AttributeExpr, generator *expr.ExampleGenerator, values Values) (any, bool) { + if !expr.IsErrorResult(errorExpression.Type) || len(values.Examples(body, body.ExtractUserExamples())) > 0 { + return nil, false + } + example := ProjectExample(body, values.Example(body, generator)) + if example == nil { + return nil, false + } + object, ok := example.(map[string]any) + if !ok { + return example, true + } + setExampleField(object, "name", errorExpression.Name) + _, temporary := errorExpression.Meta["goa:error:temporary"] + _, timeout := errorExpression.Meta["goa:error:timeout"] + _, fault := errorExpression.Meta["goa:error:fault"] + setExampleField(object, "temporary", temporary) + setExampleField(object, "timeout", timeout) + setExampleField(object, "fault", fault) + return object, true +} + +// setExampleField changes a field only when that field is part of the HTTP +// response body. Fields mapped to headers or cookies are not added back. +func setExampleField(example map[string]any, name string, value any) { + if _, ok := example[name]; ok { + example[name] = value + } +} diff --git a/http/codegen/openapi/files.go b/http/codegen/openapi/files.go index a6ec41edb3..f56065368f 100644 --- a/http/codegen/openapi/files.go +++ b/http/codegen/openapi/files.go @@ -1,8 +1,12 @@ +// This file writes OpenAPI documents as JSON and YAML code generation files. package openapi import ( "encoding/json" "path/filepath" + "regexp" + "strconv" + "strings" "text/template" "gopkg.in/yaml.v3" @@ -11,6 +15,8 @@ import ( "goa.design/goa/v3/expr" ) +var yamlDatePrefix = regexp.MustCompile(`^[0-9]{4}-[0-9]{2}-[0-9]{2}(?:$|[Tt ].*)`) + // Files returns the codegen files rendering the given OpenAPI document in // JSON and YAML at gen/.json and gen/.yaml. section names the // generated section templates and meta provides the JSON formatting options @@ -66,5 +72,29 @@ func toYAML(d any) string { if err != nil { panic("openapi: " + err.Error()) // bug } - return string(b) + return quoteDateShapedYAMLStrings(string(b)) +} + +// quoteDateShapedYAMLStrings keeps YAML readers from treating string examples +// as timestamps. Some readers reject date-shaped strings with invalid months +// or days before they can see that the OpenAPI schema declares a string. +func quoteDateShapedYAMLStrings(source string) string { + lines := strings.Split(source, "\n") + for index, line := range lines { + valueStart := strings.LastIndex(line, ": ") + if valueStart >= 0 { + valueStart += 2 + } else { + trimmed := strings.TrimLeft(line, " ") + if !strings.HasPrefix(trimmed, "- ") { + continue + } + valueStart = len(line) - len(trimmed) + 2 + } + value := line[valueStart:] + if yamlDatePrefix.MatchString(value) { + lines[index] = line[:valueStart] + strconv.Quote(value) + } + } + return strings.Join(lines, "\n") } diff --git a/http/codegen/openapi/files_test.go b/http/codegen/openapi/files_test.go new file mode 100644 index 0000000000..2863ab4fce --- /dev/null +++ b/http/codegen/openapi/files_test.go @@ -0,0 +1,18 @@ +// This file verifies the final JSON and YAML text written for OpenAPI files. +package openapi + +import ( + "testing" + + "github.com/stretchr/testify/require" + "gopkg.in/yaml.v3" +) + +func TestToYAMLQuotesDateShapedStrings(t *testing.T) { + encoded := toYAML(map[string]any{"example": "3117-95-58"}) + require.Contains(t, encoded, `example: "3117-95-58"`) + + var decoded map[string]any + require.NoError(t, yaml.Unmarshal([]byte(encoded), &decoded)) + require.Equal(t, "3117-95-58", decoded["example"]) +} diff --git a/http/codegen/openapi/json_schema.go b/http/codegen/openapi/json_schema.go index 287dbda967..9f3e627fd0 100644 --- a/http/codegen/openapi/json_schema.go +++ b/http/codegen/openapi/json_schema.go @@ -1,12 +1,13 @@ +// This file defines the JSON schema values shared by the OpenAPI generators. +// It also converts Goa examples into the fields visible in those schemas. package openapi import ( + "encoding/base64" "encoding/json" - "fmt" "reflect" "strconv" - "goa.design/goa/v3/codegen" "goa.design/goa/v3/expr" ) @@ -108,26 +109,6 @@ const ( // SchemaRef is the JSON Schema draft 2020-12 meta-schema identifier. const SchemaRef = "https://json-schema.org/draft/2020-12/schema" -var ( - // Definitions contains the generated JSON schema definitions - Definitions map[string]*Schema - - // definitionNames records the definition name assigned to result type - // expressions returned as-is by expr.Project (the design expression when - // it is already projected onto the requested view). The historical - // implementation renamed those expressions in place which made later - // references resolve to the first assigned name; the registry preserves - // that behavior while keeping the design expression tree read-only for - // the generators. Entries are keyed by expression instance so stale - // entries from previous generations cannot collide with new designs. - definitionNames = make(map[*expr.ResultTypeExpr]string) -) - -// Initialize the global variables -func init() { - Definitions = make(map[string]*Schema) -} - // NewSchema instantiates a new JSON schema. func NewSchema() *Schema { js := Schema{ @@ -146,313 +127,6 @@ func (s *Schema) JSON() ([]byte, error) { return json.Marshal(s) } -// APISchema produces the API JSON hyper schema. -func APISchema(api *expr.APIExpr, r *expr.RootExpr) *Schema { - for _, res := range r.API.HTTP.Services { - GenerateServiceDefinition(api, res) - } - href := string(api.Servers[0].Hosts[0].URIs[0]) - links := []*Link{ - { - Href: href, - Rel: "self", - }, - { - Href: "/schema", - Method: "GET", - Rel: "self", - TargetSchema: &Schema{ - Schema: SchemaRef, - AdditionalProperties: true, - }, - }, - } - s := Schema{ - ID: fmt.Sprintf("%s/schema", href), - Title: api.Title, - Description: api.Description, - Type: Object, - Defs: Definitions, - Properties: propertiesFromDefs(Definitions, "#/$defs/"), - Links: links, - } - return &s -} - -// GenerateServiceDefinition produces the JSON schema corresponding to the given -// service. It stores the results in Definitions. -func GenerateServiceDefinition(api *expr.APIExpr, res *expr.HTTPServiceExpr) { - s := NewSchema() - s.Description = res.Description() - s.Type = Object - s.Title = res.Name() - Definitions[res.Name()] = s - for _, a := range res.HTTPEndpoints { - var requestSchema *Schema - if a.MethodExpr.Payload.Type != expr.Empty { - requestSchema = AttributeTypeSchema(api, a.MethodExpr.Payload) - requestSchema.Description = a.Name() + " payload" - } - var targetSchema *Schema - var identifier string - for _, resp := range a.Responses { - dt := resp.Body.Type - if mt := dt.(*expr.ResultTypeExpr); mt != nil { - if identifier == "" { - identifier = mt.Identifier - } else { - identifier = "" - } - switch { - case targetSchema == nil: - targetSchema = TypeSchemaWithPrefix(api, mt, a.Name()) - case targetSchema.AnyOf == nil: - firstSchema := targetSchema - targetSchema = NewSchema() - targetSchema.AnyOf = []*Schema{firstSchema, TypeSchemaWithPrefix(api, mt, a.Name())} - default: - targetSchema.AnyOf = append(targetSchema.AnyOf, TypeSchemaWithPrefix(api, mt, a.Name())) - } - } - } - for i, r := range a.Routes { - for j, href := range toSchemaHrefs(r) { - link := Link{ - Title: a.Name(), - Rel: a.Name(), - Href: href, - Method: r.Method, - Schema: requestSchema, - TargetSchema: targetSchema, - ResultType: identifier, - } - if i == 0 && j == 0 { - if ca := a.Service.CanonicalEndpoint(); ca != nil { - if ca.Name() == a.Name() { - link.Rel = "self" - } - } - } - s.Links = append(s.Links, &link) - } - } - } -} - -// ResultTypeRef produces the JSON reference to the media type definition with -// the given view. -func ResultTypeRef(api *expr.APIExpr, mt *expr.ResultTypeExpr, view string) string { - return ResultTypeRefWithPrefix(api, mt, view, "") -} - -// ResultTypeRefWithPrefix produces the JSON reference to the media type definition with -// the given view and adds the provided prefix to the type name -func ResultTypeRefWithPrefix(api *expr.APIExpr, mt *expr.ResultTypeExpr, view, prefix string) string { - projected, err := expr.Project(mt, view) - if err != nil { - panic(fmt.Sprintf("failed to project media type %#v: %s", mt.Identifier, err)) // bug - } - var metaName string - if n, ok := mt.Meta["openapi:typename"]; ok { - metaName = codegen.Goify(n[0], true) - } - name := projected.TypeName - if metaName != "" { - name = metaName - } - if assigned, ok := definitionNames[projected]; ok { - // expr.Project returned the design expression itself and a - // definition name was already assigned to it: keep referencing it. - name = assigned - } else { - if _, ok := Definitions[name]; !ok { - name = codegen.Goify(prefix, true) + codegen.Goify(name, true) - if metaName != "" { - name = metaName - } - } - if projected == mt { - // expr.Project returns its input when the result type is - // already projected onto the requested view. Record the - // assigned name instead of renaming the design expression in - // place: the design tree is read-only for the generators. - definitionNames[projected] = name - } - } - if _, ok := Definitions[name]; !ok { - GenerateResultTypeDefinition(api, renamedResultType(projected, name), expr.DefaultView) - } - return fmt.Sprintf("#/$defs/%s", name) -} - -// TypeRef produces the JSON reference to the type definition. -func TypeRef(api *expr.APIExpr, ut *expr.UserTypeExpr) string { - return TypeRefWithPrefix(api, ut, "") -} - -// TypeRefWithPrefix produces the JSON reference to the type definition and adds the provided prefix -// to the type name -func TypeRefWithPrefix(api *expr.APIExpr, ut *expr.UserTypeExpr, prefix string) string { - typeName := ut.TypeName - if prefix != "" { - typeName = codegen.Goify(prefix, true) + codegen.Goify(ut.TypeName, true) - } - if n, ok := ut.Meta["openapi:typename"]; ok { - typeName = codegen.Goify(n[0], true) - } - if _, ok := Definitions[typeName]; !ok { - GenerateTypeDefinitionWithName(api, ut, typeName) - } - return fmt.Sprintf("#/$defs/%s", typeName) -} - -// GenerateResultTypeDefinition produces the JSON schema corresponding to the -// given media type and given view. -func GenerateResultTypeDefinition(api *expr.APIExpr, mt *expr.ResultTypeExpr, view string) { - if _, ok := Definitions[mt.TypeName]; ok { - return - } - s := NewSchema() - s.Title = fmt.Sprintf("Mediatype identifier: %s", mt.Identifier) - Definitions[mt.TypeName] = s - buildResultTypeSchema(api, mt, view, s) -} - -// GenerateTypeDefinition produces the JSON schema corresponding to the given -// type. -func GenerateTypeDefinition(api *expr.APIExpr, ut *expr.UserTypeExpr) { - GenerateTypeDefinitionWithName(api, ut, ut.TypeName) -} - -// GenerateTypeDefinitionWithName produces the JSON schema corresponding to the given -// type with provided type name. -func GenerateTypeDefinitionWithName(api *expr.APIExpr, ut *expr.UserTypeExpr, typeName string) { - if _, ok := Definitions[typeName]; ok { - return - } - s := NewSchema() - - s.Title = typeName - Definitions[typeName] = s - buildAttributeSchema(api, s, ut.AttributeExpr, api.ExampleGenerator.Rebased(ut.ID())) -} - -// TypeSchema produces the JSON schema corresponding to the given data type. -func TypeSchema(api *expr.APIExpr, t expr.DataType) *Schema { - return TypeSchemaWithPrefix(api, t, "") -} - -// TypeSchemaWithPrefix produces the JSON schema corresponding to the given data type -// and adds the provided prefix to the type name -func TypeSchemaWithPrefix(api *expr.APIExpr, t expr.DataType, prefix string) *Schema { - return typeSchemaWithGen(api, t, prefix, api.ExampleGenerator) -} - -// typeSchemaWithGen builds the JSON schema for t drawing example values from -// gen. Child schemas derive their example streams from their position (object -// property name, array index, map entry, union member) so every example in -// the schema is anchored to the design element it illustrates. -func typeSchemaWithGen(api *expr.APIExpr, t expr.DataType, prefix string, gen *expr.ExampleGenerator) *Schema { - s := NewSchema() - switch actual := t.(type) { - case expr.Primitive: - s.Type = Type(actual.Name()) - switch actual.Kind() { - case expr.AnyKind: - // A schema without a type matches any data type. - // See https://swagger.io/docs/specification/data-models/data-types/#any. - s.Type = Type("") - case expr.IntKind, expr.Int64Kind, - expr.UIntKind, expr.UInt64Kind: - // Use int64 format for IntKind and UIntKind because the OpenAPI - // generator produced int32 by default. - s.Type = Type("integer") - s.Format = "int64" - case expr.Int32Kind, expr.UInt32Kind: - s.Type = Type("integer") - s.Format = "int32" - case expr.Float32Kind: - s.Type = Type("number") - s.Format = "float" - case expr.Float64Kind: - s.Type = Type("number") - s.Format = "double" - case expr.BytesKind: - s.Type = Type("string") - s.Format = "byte" - } - case *expr.Array: - s.Type = Array - s.Items = NewSchema() - buildAttributeSchema(api, s.Items, actual.ElemType, gen.Derived("0")) - case *expr.Object: - s.Type = Object - for _, nat := range *actual { - if !MustGenerate(nat.Attribute.Meta) { - continue - } - prop := NewSchema() - buildAttributeSchema(api, prop, nat.Attribute, gen.Derived(nat.Name)) - s.Properties[nat.Name] = prop - } - case *expr.Map: - s.Type = Object - if actual.KeyType.Type == expr.String && actual.ElemType.Type != expr.Any { - // Use free-form objects when elements are of type "Any" - additionalProperties := NewSchema() - s.AdditionalProperties = buildAttributeSchema(api, additionalProperties, actual.ElemType, gen.Derived("val0")) - } else { - s.AdditionalProperties = true - } - case *expr.Union: - // Each branch owns both its discriminator literal and value schema so - // clients cannot combine one branch tag with another branch value. - typeKey := actual.GetTypeKey() - valueKey := actual.GetValueKey() - - s.Type = Object - for _, val := range actual.Values { - valueSchema := typeSchemaWithGen(api, val.Attribute.Type, prefix, gen.Derived(val.Name)) - initAttributeValidation(valueSchema, val.Attribute) - s.AnyOf = append(s.AnyOf, &Schema{ - Type: Object, - Properties: map[string]*Schema{ - typeKey: { - Type: String, - Enum: []any{val.Name}, - }, - valueKey: valueSchema, - }, - Required: []string{typeKey, valueKey}, - }) - } - case *expr.UserTypeExpr: - if expr.IsAlias(actual) { - s = typeSchemaWithGen(api, actual.Attribute().Type, prefix, gen.Rebased(actual.ID())) - initAttributeValidation(s, actual.Attribute()) - break - } - s.Ref = TypeRefWithPrefix(api, actual, prefix) - case *expr.ResultTypeExpr: - // Use "default" view by default - s.Ref = ResultTypeRefWithPrefix(api, actual, expr.DefaultView, prefix) - } - return s -} - -// AttributeTypeSchema produces the JSON schema corresponding to the given attribute. -func AttributeTypeSchema(api *expr.APIExpr, at *expr.AttributeExpr) *Schema { - return AttributeTypeSchemaWithPrefix(api, at, "") -} - -// AttributeTypeSchemaWithPrefix produces the JSON schema corresponding to the given attribute -// and adds the provided prefix to the type name -func AttributeTypeSchemaWithPrefix(api *expr.APIExpr, at *expr.AttributeExpr, prefix string) *Schema { - s := TypeSchemaWithPrefix(api, at.Type, prefix) - initAttributeValidation(s, at) - return s -} - // ToString returns the string representation of the given type. func ToString(val any) string { switch actual := val.(type) { @@ -514,38 +188,75 @@ func (s *Schema) MarshalYAML() (any, error) { return MarshalYAML((*_Schema)(s), s.Extensions) } -// Dup creates a shallow clone of the given schema. +// Dup returns an independent copy of the schema. Callers may change any nested +// schema, collection, example, default, or extension without changing s. func (s *Schema) Dup() *Schema { js := Schema{ - ID: s.ID, - Description: s.Description, - Schema: s.Schema, - Type: s.Type, - DefaultValue: s.DefaultValue, - Title: s.Title, - Media: s.Media, - ReadOnly: s.ReadOnly, - PathStart: s.PathStart, - Links: s.Links, - Ref: s.Ref, - Enum: s.Enum, - Format: s.Format, - Pattern: s.Pattern, - Minimum: s.Minimum, - Maximum: s.Maximum, - MinLength: s.MinLength, - MaxLength: s.MaxLength, - MinItems: s.MinItems, - MaxItems: s.MaxItems, - Required: s.Required, - AdditionalProperties: s.AdditionalProperties, - ContentMediaType: s.ContentMediaType, + Schema: s.Schema, + ID: s.ID, + Title: s.Title, + Type: s.Type, + Description: s.Description, + DefaultValue: duplicateJSONValue(s.DefaultValue), + Example: duplicateJSONValue(s.Example), + ReadOnly: s.ReadOnly, + PathStart: s.PathStart, + Ref: s.Ref, + Format: s.Format, + Pattern: s.Pattern, + ExclusiveMinimum: duplicatePointer(s.ExclusiveMinimum), + Minimum: duplicatePointer(s.Minimum), + ExclusiveMaximum: duplicatePointer(s.ExclusiveMaximum), + Maximum: duplicatePointer(s.Maximum), + MinLength: duplicatePointer(s.MinLength), + MaxLength: duplicatePointer(s.MaxLength), + MinItems: duplicatePointer(s.MinItems), + MaxItems: duplicatePointer(s.MaxItems), + Required: append([]string(nil), s.Required...), + ContentMediaType: s.ContentMediaType, + } + if s.Media != nil { + media := *s.Media + js.Media = &media + } + if s.Links != nil { + js.Links = make([]*Link, len(s.Links)) + for index, link := range s.Links { + copy := *link + if link.Schema != nil { + copy.Schema = link.Schema.Dup() + } + if link.TargetSchema != nil { + copy.TargetSchema = link.TargetSchema.Dup() + } + js.Links[index] = © + } + } + if s.Enum != nil { + js.Enum = make([]any, len(s.Enum)) + for index, value := range s.Enum { + js.Enum[index] = duplicateJSONValue(value) + } + } + if additional, ok := s.AdditionalProperties.(*Schema); ok { + js.AdditionalProperties = additional.Dup() + } else { + js.AdditionalProperties = duplicateJSONValue(s.AdditionalProperties) + } + if s.Extensions != nil { + js.Extensions = make(map[string]any, len(s.Extensions)) + for name, value := range s.Extensions { + js.Extensions[name] = duplicateJSONValue(value) + } } if s.ContentSchema != nil { js.ContentSchema = s.ContentSchema.Dup() } - for n, p := range s.Properties { - js.Properties[n] = p.Dup() + if s.Properties != nil { + js.Properties = make(map[string]*Schema, len(s.Properties)) + for name, property := range s.Properties { + js.Properties[name] = property.Dup() + } } if s.Items != nil { js.Items = s.Items.Dup() @@ -556,138 +267,13 @@ func (s *Schema) Dup() *Schema { js.AnyOf[i] = branch.Dup() } } - for n, d := range s.Defs { - js.Defs[n] = d.Dup() - } - return &js -} - -// buildAttributeSchema initializes the given JSON schema that corresponds to -// the given attribute, drawing example values from gen. -func buildAttributeSchema(api *expr.APIExpr, s *Schema, at *expr.AttributeExpr, gen *expr.ExampleGenerator) *Schema { - s.Merge(typeSchemaWithGen(api, at.Type, "", gen)) - if s.Ref != "" { - // Ref is exclusive with other fields - return s - } - s.DefaultValue = ToStringMap(at.DefaultValue) - if at.Description != "" { - s.Description = at.Description - } - s.Example = ProjectExample(at, at.Example(gen)) - s.Extensions = ExtensionsFromExpr(at.Meta) - if ap := AdditionalPropertiesFromExpr(at.Meta); ap != nil { - s.AdditionalProperties = ap - } - initAttributeValidation(s, at) - - return s -} - -// initAttributeValidation initializes validation rules for an attribute. -func initAttributeValidation(s *Schema, at *expr.AttributeExpr) { - val := at.Validation - if val == nil { - return - } - s.Enum = val.Values - if val.Format != "" { - s.Format = string(val.Format) - } - s.Pattern = val.Pattern - if val.ExclusiveMinimum != nil { - s.ExclusiveMinimum = val.ExclusiveMinimum - } - if val.Minimum != nil { - s.Minimum = val.Minimum - } - if val.ExclusiveMaximum != nil { - s.ExclusiveMaximum = val.ExclusiveMaximum - } - if val.Maximum != nil { - s.Maximum = val.Maximum - } - if val.MinLength != nil { - if _, ok := at.Type.(*expr.Array); ok { - s.MinItems = val.MinLength - } else { - s.MinLength = val.MinLength - } - } - if val.MaxLength != nil { - if _, ok := at.Type.(*expr.Array); ok { - s.MaxItems = val.MaxLength - } else { - s.MaxLength = val.MaxLength - } - } - for _, v := range val.Required { - if a := at.Find(v); a != nil { - if !MustGenerate(a.Meta) { - continue - } - } - s.Required = append(s.Required, v) - } -} - -// renamedResultType returns rt carrying the given type name. When the name -// already matches it returns rt unchanged, otherwise it returns a shallow -// copy sharing the attribute, views and identifier so the schema definition -// is registered under the assigned name without renaming the (possibly design -// owned) expression in place. -func renamedResultType(rt *expr.ResultTypeExpr, name string) *expr.ResultTypeExpr { - if rt.TypeName == name { - return rt - } - ut := *rt.UserTypeExpr - ut.TypeName = name - dup := *rt - dup.UserTypeExpr = &ut - return &dup -} - -// toSchemaHrefs produces hrefs that replace the path wildcards with JSON -// schema references when appropriate. -func toSchemaHrefs(r *expr.RouteExpr) []string { - paths := r.FullPaths() - res := make([]string, len(paths)) - for i, path := range paths { - params := expr.ExtractHTTPWildcards(path) - args := make([]any, len(params)) - for j, p := range params { - args[j] = fmt.Sprintf("/{%s}", p) - } - tmpl := expr.HTTPWildcardRegex.ReplaceAllLiteralString(path, "%s") - res[i] = fmt.Sprintf(tmpl, args...) - } - return res -} - -// propertiesFromDefs creates a Properties map referencing the given definitions -// under the given path. -func propertiesFromDefs(definitions map[string]*Schema, path string) map[string]*Schema { - res := make(map[string]*Schema, len(definitions)) - for n := range definitions { - if n == "identity" { - continue + if s.Defs != nil { + js.Defs = make(map[string]*Schema, len(s.Defs)) + for name, definition := range s.Defs { + js.Defs[name] = definition.Dup() } - s := NewSchema() - s.Ref = path + n - res[n] = s } - return res -} - -// buildResultTypeSchema initializes s as the JSON schema representing mt for the -// given view. -func buildResultTypeSchema(api *expr.APIExpr, mt *expr.ResultTypeExpr, view string, s *Schema) { - s.Media = &Media{Type: mt.Identifier} - projected, err := expr.Project(mt, view) - if err != nil { - panic(fmt.Sprintf("failed to project media type %#v: %s", mt.Identifier, err)) // bug - } - buildAttributeSchema(api, s, projected.AttributeExpr, api.ExampleGenerator.Rebased(projected.ID())) + return &js } // MustGenerate returns true if the meta indicates that a OpenAPI specification should be @@ -714,6 +300,11 @@ func AdditionalPropertiesFromExpr(meta expr.MetaExpr) any { func projectExample(t expr.DataType, val any) any { switch actual := t.(type) { + case expr.Primitive: + if actual.Kind() == expr.BytesKind { + return base64.StdEncoding.EncodeToString(reflect.ValueOf(val).Bytes()) + } + return ToStringMap(val) case *expr.UserTypeExpr: return ProjectExample(actual.Attribute(), val) case *expr.ResultTypeExpr: @@ -729,6 +320,75 @@ func projectExample(t expr.DataType, val any) any { } } +// duplicateJSONValue copies the maps, slices, arrays, pointers, and interface +// values accepted by JSON fields while preserving their concrete Go types. +func duplicateJSONValue(value any) any { + if value == nil { + return nil + } + return duplicateJSONReflectValue(reflect.ValueOf(value)).Interface() +} + +// duplicateJSONReflectValue recursively copies one reflected JSON value. +func duplicateJSONReflectValue(value reflect.Value) reflect.Value { + switch value.Kind() { + case reflect.Interface: + if value.IsNil() { + return reflect.Zero(value.Type()) + } + copy := duplicateJSONReflectValue(value.Elem()) + result := reflect.New(value.Type()).Elem() + result.Set(copy) + return result + case reflect.Pointer: + if value.IsNil() { + return reflect.Zero(value.Type()) + } + copy := reflect.New(value.Type().Elem()) + copy.Elem().Set(duplicateJSONReflectValue(value.Elem())) + return copy + case reflect.Map: + if value.IsNil() { + return reflect.Zero(value.Type()) + } + copy := reflect.MakeMapWithSize(value.Type(), value.Len()) + iterator := value.MapRange() + for iterator.Next() { + copy.SetMapIndex( + duplicateJSONReflectValue(iterator.Key()), + duplicateJSONReflectValue(iterator.Value()), + ) + } + return copy + case reflect.Slice: + if value.IsNil() { + return reflect.Zero(value.Type()) + } + copy := reflect.MakeSlice(value.Type(), value.Len(), value.Cap()) + for index := range value.Len() { + copy.Index(index).Set(duplicateJSONReflectValue(value.Index(index))) + } + return copy + case reflect.Array: + copy := reflect.New(value.Type()).Elem() + for index := range value.Len() { + copy.Index(index).Set(duplicateJSONReflectValue(value.Index(index))) + } + return copy + default: + return value + } +} + +// duplicatePointer copies one scalar schema limit while preserving nil. +func duplicatePointer[T any](value *T) *T { + if value == nil { + return nil + } + copy := *value + return © +} + func projectObjectExample(obj *expr.Object, val any) any { values, ok := exampleMap(val) if !ok { diff --git a/http/codegen/openapi/json_schema_dup_test.go b/http/codegen/openapi/json_schema_dup_test.go new file mode 100644 index 0000000000..f7841bbb06 --- /dev/null +++ b/http/codegen/openapi/json_schema_dup_test.go @@ -0,0 +1,105 @@ +// This file verifies that copied schemas share no mutable values with their +// source. Plugins may safely edit a copy without changing Goa's planned schema. +package openapi + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestSchemaDupCopiesEveryMutableField(t *testing.T) { + exclusiveMinimum := 1.0 + minimum := 2.0 + exclusiveMaximum := 9.0 + maximum := 10.0 + minLength := 1 + maxLength := 8 + minItems := 2 + maxItems := 4 + original := &Schema{ + Schema: "schema", + ID: "id", + Title: "title", + Type: Object, + Items: &Schema{Title: "items"}, + Properties: map[string]*Schema{"property": {Title: "property"}}, + Defs: map[string]*Schema{"definition": {Title: "definition"}}, + Description: "description", + DefaultValue: map[string][]string{"values": {"default"}}, + Example: []map[string]any{{"value": "example"}}, + Media: &Media{BinaryEncoding: "binary", Type: "media"}, + ReadOnly: true, + PathStart: "/path", + Links: []*Link{{Title: "link", Schema: &Schema{Title: "link schema"}, TargetSchema: &Schema{Title: "target schema"}}}, + Ref: "#/$defs/reference", + Enum: []any{[]string{"enum"}}, + Format: "format", + Pattern: "pattern", + ExclusiveMinimum: &exclusiveMinimum, + Minimum: &minimum, + ExclusiveMaximum: &exclusiveMaximum, + Maximum: &maximum, + MinLength: &minLength, + MaxLength: &maxLength, + MinItems: &minItems, + MaxItems: &maxItems, + Required: []string{"required"}, + AdditionalProperties: &Schema{Title: "additional properties"}, + ContentMediaType: "application/json", + ContentSchema: &Schema{Title: "content schema"}, + AnyOf: []*Schema{{Title: "union branch"}}, + Extensions: map[string]any{"x-values": []string{"extension"}}, + } + + duplicate := original.Dup() + require.Equal(t, original, duplicate) + + duplicate.Items.Title = "changed" + duplicate.Properties["property"].Title = "changed" + duplicate.Defs["definition"].Title = "changed" + duplicate.DefaultValue.(map[string][]string)["values"][0] = "changed" + duplicate.Example.([]map[string]any)[0]["value"] = "changed" + duplicate.Media.Type = "changed" + duplicate.Links[0].Title = "changed" + duplicate.Links[0].Schema.Title = "changed" + duplicate.Links[0].TargetSchema.Title = "changed" + duplicate.Enum[0].([]string)[0] = "changed" + *duplicate.ExclusiveMinimum = 3 + *duplicate.Minimum = 4 + *duplicate.ExclusiveMaximum = 7 + *duplicate.Maximum = 8 + *duplicate.MinLength = 2 + *duplicate.MaxLength = 7 + *duplicate.MinItems = 1 + *duplicate.MaxItems = 3 + duplicate.Required[0] = "changed" + duplicate.AdditionalProperties.(*Schema).Title = "changed" + duplicate.ContentSchema.Title = "changed" + duplicate.AnyOf[0].Title = "changed" + duplicate.Extensions["x-values"].([]string)[0] = "changed" + + require.Equal(t, "items", original.Items.Title) + require.Equal(t, "property", original.Properties["property"].Title) + require.Equal(t, "definition", original.Defs["definition"].Title) + require.Equal(t, "default", original.DefaultValue.(map[string][]string)["values"][0]) + require.Equal(t, "example", original.Example.([]map[string]any)[0]["value"]) + require.Equal(t, "media", original.Media.Type) + require.Equal(t, "link", original.Links[0].Title) + require.Equal(t, "link schema", original.Links[0].Schema.Title) + require.Equal(t, "target schema", original.Links[0].TargetSchema.Title) + require.Equal(t, "enum", original.Enum[0].([]string)[0]) + require.Equal(t, 1.0, *original.ExclusiveMinimum) + require.Equal(t, 2.0, *original.Minimum) + require.Equal(t, 9.0, *original.ExclusiveMaximum) + require.Equal(t, 10.0, *original.Maximum) + require.Equal(t, 1, *original.MinLength) + require.Equal(t, 8, *original.MaxLength) + require.Equal(t, 2, *original.MinItems) + require.Equal(t, 4, *original.MaxItems) + require.Equal(t, "required", original.Required[0]) + require.Equal(t, "additional properties", original.AdditionalProperties.(*Schema).Title) + require.Equal(t, "content schema", original.ContentSchema.Title) + require.Equal(t, "union branch", original.AnyOf[0].Title) + require.Equal(t, "extension", original.Extensions["x-values"].([]string)[0]) +} diff --git a/http/codegen/openapi/json_schema_union_test.go b/http/codegen/openapi/json_schema_union_test.go deleted file mode 100644 index cecfd4b46a..0000000000 --- a/http/codegen/openapi/json_schema_union_test.go +++ /dev/null @@ -1,41 +0,0 @@ -package openapi - -import ( - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - - "goa.design/goa/v3/expr" -) - -func TestAttributeTypeSchemaCorrelatesUnionDiscriminatorAndValue(t *testing.T) { - schema := AttributeTypeSchema(&expr.APIExpr{ExampleGenerator: expr.NewRandom("test")}, unionAttribute()) - - require.Len(t, schema.AnyOf, 2) - assertUnionSchemaBranch(t, schema.AnyOf[0], "text", Type(String)) - assertUnionSchemaBranch(t, schema.AnyOf[1], "count", Type(Integer)) - assert.Empty(t, schema.Properties) -} - -func assertUnionSchemaBranch(t *testing.T, branch *Schema, tag string, valueType Type) { - t.Helper() - assert.Equal(t, Type(Object), branch.Type) - assert.Equal(t, []string{"type", "value"}, branch.Required) - require.Contains(t, branch.Properties, "type") - assert.Equal(t, []any{tag}, branch.Properties["type"].Enum) - require.Contains(t, branch.Properties, "value") - assert.Equal(t, valueType, branch.Properties["value"].Type) -} - -func unionAttribute() *expr.AttributeExpr { - return &expr.AttributeExpr{ - Type: &expr.Union{ - TypeName: "outcome", - Values: []*expr.NamedAttributeExpr{ - {Name: "text", Attribute: &expr.AttributeExpr{Type: expr.String}}, - {Name: "count", Attribute: &expr.AttributeExpr{Type: expr.Int}}, - }, - }, - } -} diff --git a/http/codegen/openapi/marshal.go b/http/codegen/openapi/marshal.go index aa65703ca3..7e2728eb8e 100644 --- a/http/codegen/openapi/marshal.go +++ b/http/codegen/openapi/marshal.go @@ -1,6 +1,7 @@ package openapi import ( + "bytes" "encoding/json" "maps" @@ -17,13 +18,14 @@ func MarshalJSON(v any, extensions map[string]any) ([]byte, error) { if len(extensions) == 0 { return marshaled, nil } - var unmarshaled any - if err := json.Unmarshal(marshaled, &unmarshaled); err != nil { + decoder := json.NewDecoder(bytes.NewReader(marshaled)) + decoder.UseNumber() + var unmarshaled map[string]any + if err := decoder.Decode(&unmarshaled); err != nil { return nil, err } - asserted := unmarshaled.(map[string]any) - maps.Copy(asserted, extensions) - merged, err := json.Marshal(asserted) + maps.Copy(unmarshaled, extensions) + merged, err := json.Marshal(unmarshaled) if err != nil { return nil, err } diff --git a/http/codegen/openapi/marshal_test.go b/http/codegen/openapi/marshal_test.go new file mode 100644 index 0000000000..edcea7cdd4 --- /dev/null +++ b/http/codegen/openapi/marshal_test.go @@ -0,0 +1,30 @@ +// This file verifies that adding OpenAPI extensions does not change values in +// the object being encoded. +package openapi + +import ( + "bytes" + "encoding/json" + "testing" + + "github.com/stretchr/testify/require" +) + +type ( + marshalExample struct { + Value int64 `json:"value"` + } +) + +func TestMarshalJSONPreservesLargeIntegers(t *testing.T) { + const value int64 = 9007199254740993 + + encoded, err := MarshalJSON(marshalExample{Value: value}, map[string]any{"x-test": true}) + require.NoError(t, err) + + decoder := json.NewDecoder(bytes.NewReader(encoded)) + decoder.UseNumber() + var decoded map[string]any + require.NoError(t, decoder.Decode(&decoded)) + require.Equal(t, json.Number("9007199254740993"), decoded["value"]) +} diff --git a/http/codegen/openapi/response_projection.go b/http/codegen/openapi/response_projection.go new file mode 100644 index 0000000000..c31da2031c --- /dev/null +++ b/http/codegen/openapi/response_projection.go @@ -0,0 +1,47 @@ +// This file selects a response view on a private result type copy and keeps +// the component names produced by released Goa versions. +package openapi + +import ( + "fmt" + + "goa.design/goa/v3/codegen" + "goa.design/goa/v3/expr" +) + +// ResponseProjection contains one detached response result and the types whose +// released names should be preferred when they each describe one schema. +type ResponseProjection struct { + Result *expr.ResultTypeExpr + Preferred []expr.UserType +} + +// ProjectResponseResult selects view without changing the design. Collection +// result names keep the Response suffix before the view name. +func ProjectResponseResult(result *expr.ResultTypeExpr, view string) ResponseProjection { + projected, err := expr.Project(result, view) + if err != nil { + panic(fmt.Sprintf("failed to project result type %q to view %q: %s", result.Identifier, view, err)) + } + copy := expr.DupAtt(&expr.AttributeExpr{Type: projected}).Type.(*expr.ResultTypeExpr) + originalArray := expr.AsArray(result.Type) + projectedArray := expr.AsArray(copy.Type) + if originalArray == nil || projectedArray == nil { + return ResponseProjection{Result: copy} + } + originalElement, originalNamed := originalArray.ElemType.Type.(*expr.ResultTypeExpr) + projectedElement, projectedNamed := projectedArray.ElemType.Type.(*expr.ResultTypeExpr) + if !originalNamed || !projectedNamed { + return ResponseProjection{Result: copy} + } + name := codegen.Goify(originalElement.Name(), true) + "Response" + if view != "" && view != expr.DefaultView { + name += codegen.Goify(view, true) + } + projectedElement.Rename(name) + copy.Rename(name + "Collection") + return ResponseProjection{ + Result: copy, + Preferred: []expr.UserType{copy, projectedElement}, + } +} diff --git a/http/codegen/openapi/v2/build_isolation_test.go b/http/codegen/openapi/v2/build_isolation_test.go new file mode 100644 index 0000000000..4387d59ebb --- /dev/null +++ b/http/codegen/openapi/v2/build_isolation_test.go @@ -0,0 +1,125 @@ +// This file checks that Swagger builds do not share or change each other's schemas. +package openapiv2_test + +import ( + "encoding/json" + "sync" + "testing" + + "github.com/stretchr/testify/require" + + "goa.design/goa/v3/dsl" + "goa.design/goa/v3/expr" + "goa.design/goa/v3/http/codegen/openapi" + openapiv2 "goa.design/goa/v3/http/codegen/openapi/v2" +) + +func TestBuildsKeepDefinitionsSeparate(t *testing.T) { + firstRoot := expr.RunDSL(t, schemaBuildDSL("first")) + secondRoot := expr.RunDSL(t, schemaBuildDSL("second")) + + first, err := openapiv2.NewV2( + firstRoot, + firstRoot.API.Servers[0].Hosts[0], + ) + require.NoError(t, err) + firstJSON, err := json.Marshal(first) + require.NoError(t, err) + + second, err := openapiv2.NewV2( + secondRoot, + secondRoot.API.Servers[0].Hosts[0], + ) + require.NoError(t, err) + + firstDefinition := definitionWithProperty(first.Definitions, "first") + require.NotNil(t, firstDefinition, "definitions: %#v", first.Definitions) + require.Contains(t, firstDefinition.Properties, "first") + require.NotContains(t, firstDefinition.Properties, "second") + secondDefinition := definitionWithProperty(second.Definitions, "second") + require.NotNil(t, secondDefinition, "definitions: %#v", second.Definitions) + require.Contains(t, secondDefinition.Properties, "second") + require.NotContains(t, secondDefinition.Properties, "first") + + firstJSONAfterSecondBuild, err := json.Marshal(first) + require.NoError(t, err) + require.Equal(t, firstJSON, firstJSONAfterSecondBuild) +} + +func TestBuildsAreSafeToRunTogether(t *testing.T) { + firstRoot := expr.RunDSL(t, schemaBuildDSL("first")) + secondRoot := expr.RunDSL(t, schemaBuildDSL("second")) + + type result struct { + spec *openapiv2.V2 + err error + } + start := make(chan struct{}) + results := make(chan result, 2) + var ready sync.WaitGroup + ready.Add(2) + build := func(root *expr.RootExpr) { + ready.Done() + <-start + spec, err := openapiv2.NewV2( + root, + root.API.Servers[0].Hosts[0], + ) + results <- result{spec: spec, err: err} + } + go build(firstRoot) + go build(secondRoot) + ready.Wait() + close(start) + + properties := make(map[string]int) + for range 2 { + built := <-results + require.NoError(t, built.err) + var found []string + for _, definition := range built.spec.Definitions { + for property := range definition.Properties { + if property == "first" || property == "second" { + found = append(found, property) + } + } + } + require.Len(t, found, 1) + properties[found[0]]++ + } + require.Equal(t, map[string]int{"first": 1, "second": 1}, properties) +} + +// definitionWithProperty finds the returned schema that contains property. +func definitionWithProperty(definitions map[string]*openapi.Schema, property string) *openapi.Schema { + for _, definition := range definitions { + if _, ok := definition.Properties[property]; ok { + return definition + } + } + return nil +} + +// schemaBuildDSL returns an API whose Shared result contains only field. +func schemaBuildDSL(field string) func() { + return func() { + shared := dsl.Type("Shared", func() { + dsl.Attribute(field, dsl.String) + }) + dsl.API("test", func() { + dsl.Server("test", func() { + dsl.Host("localhost", func() { + dsl.URI("https://goa.design") + }) + }) + }) + dsl.Service("testService", func() { + dsl.Method("show", func() { + dsl.Result(shared) + dsl.HTTP(func() { + dsl.GET("/") + }) + }) + }) + } +} diff --git a/http/codegen/openapi/v2/builder.go b/http/codegen/openapi/v2/builder.go index 91955703ec..e4aeaee384 100644 --- a/http/codegen/openapi/v2/builder.go +++ b/http/codegen/openapi/v2/builder.go @@ -1,3 +1,5 @@ +// This file builds Swagger 2.0 operations and schemas from HTTP endpoints. It +// uses the request or response being described to choose each example value. package openapiv2 import ( @@ -17,6 +19,21 @@ import ( // NewV2 returns the OpenAPI v2 specification for the given API. func NewV2(root *expr.RootExpr, h *expr.HostExpr) (*V2, error) { + if root == nil { + return nil, nil + } + return NewV2WithValues( + root, + h, + expr.NewExampleGenerator(root.API.RandomizerFactory), + openapi.Values{}, + ) +} + +// NewV2WithValues returns the OpenAPI v2 specification using values in place +// of matching titles, descriptions, and examples from the evaluated design. +// The generator supplies examples for attributes that have no matching value. +func NewV2WithValues(root *expr.RootExpr, h *expr.HostExpr, generator *expr.ExampleGenerator, values openapi.Values) (*V2, error) { if root == nil { return nil, nil } @@ -31,12 +48,23 @@ func NewV2(root *expr.RootExpr, h *expr.HostExpr) (*V2, error) { if !openapi.MustGenerate(root.API.Servers[0].Meta) || !openapi.MustGenerate(h.Meta) { host = "" } + schemas := newSchemaBuilder(values) + var contact *expr.ContactExpr + if root.API.Contact != nil { + contactCopy := *root.API.Contact + contact = &contactCopy + } + var license *expr.LicenseExpr + if root.API.License != nil { + licenseCopy := *root.API.License + license = &licenseCopy + } basePath := root.API.HTTP.Path if hasAbsoluteRoutes(root) { basePath = "" } - params := paramsFromExpr(nil, root.API.HTTP.Params, basePath) + params := paramsFromExpr(nil, root.API.HTTP.Params, basePath, values) var paramMap map[string]*Parameter if len(params) > 0 { paramMap = make(map[string]*Parameter, len(params)) @@ -47,23 +75,23 @@ func NewV2(root *expr.RootExpr, h *expr.HostExpr) (*V2, error) { s := &V2{ Swagger: "2.0", Info: &Info{ - Title: root.API.Title, - Description: root.API.Description, + Title: values.Title(root.API, root.API.Title), + Description: values.Description(root.API, root.API.Description), TermsOfService: root.API.TermsOfService, - Contact: root.API.Contact, - License: root.API.License, + Contact: contact, + License: license, Version: root.API.Version, Extensions: openapi.ExtensionsFromExpr(root.API.Meta), }, Host: host, BasePath: basePath, Paths: make(map[string]any), - Consumes: root.API.HTTP.Consumes, - Produces: root.API.HTTP.Produces, + Consumes: slices.Clone(root.API.HTTP.Consumes), + Produces: slices.Clone(root.API.HTTP.Produces), Parameters: paramMap, Tags: tags, - SecurityDefinitions: securitySpecFromExpr(root), - ExternalDocs: openapi.DocsFromExpr(root.API.Docs, root.API.Meta), + SecurityDefinitions: securitySpecFromExpr(root, values), + ExternalDocs: openapi.DocsFromExprWithValues(root.API.Docs, root.API.Meta, values), } for _, res := range root.API.HTTP.Services { if !openapi.MustGenerate(res.Meta) || !openapi.MustGenerate(res.ServiceExpr.Meta) { @@ -74,24 +102,23 @@ func NewV2(root *expr.RootExpr, h *expr.HostExpr) (*V2, error) { if !openapi.MustGenerate(fs.Meta) || !openapi.MustGenerate(fs.Service.Meta) { continue } - buildPathFromFileServer(s, root, fs) + buildPathFromFileServer(s, root, fs, schemas, generator, values) } for _, a := range res.HTTPEndpoints { if !openapi.MustGenerate(a.Meta) || !openapi.MustGenerate(a.MethodExpr.Meta) { continue } for _, route := range a.Routes { - buildPathFromExpr(s, root, h, route, basePath) + buildPathFromExpr(s, root, h, route, basePath, schemas, generator, values) } } } - if len(openapi.Definitions) > 0 { - s.Definitions = make(map[string]*openapi.Schema) - for n, d := range openapi.Definitions { - // sad but swagger doesn't support these + if len(schemas.definitions) > 0 { + s.Definitions = schemas.definitions + for _, d := range schemas.definitions { + // Swagger 2.0 does not support media metadata or schema links. d.Media = nil d.Links = nil - s.Definitions[n] = d } } // Convert OpenAPI 3.0 references (#/$defs/) to Swagger 2.0 format (#/definitions/) @@ -140,14 +167,20 @@ func addScopeDescription(scopes []*expr.ScopeExpr, sd *SecurityDefinition) { // securitySpecFromExpr generates the OpenAPI security definitions from the // security design. -func securitySpecFromExpr(root *expr.RootExpr) map[string]*SecurityDefinition { +func securitySpecFromExpr(root *expr.RootExpr, values openapi.Values) map[string]*SecurityDefinition { sds := make(map[string]*SecurityDefinition) for _, svc := range root.API.HTTP.Services { + if !openapi.MustGenerate(svc.Meta) || !openapi.MustGenerate(svc.ServiceExpr.Meta) { + continue + } for _, e := range svc.HTTPEndpoints { + if !openapi.MustGenerate(e.Meta) || !openapi.MustGenerate(e.MethodExpr.Meta) { + continue + } for _, req := range e.Requirements { for _, s := range req.Schemes { sd := SecurityDefinition{ - Description: s.Description, + Description: values.Description(s.AuthoredScheme(), s.Description), Extensions: openapi.ExtensionsFromExpr(s.Meta), } @@ -270,7 +303,7 @@ func summaryFromMeta(name string, meta expr.MetaExpr) string { return name } -func paramsFromExpr(endpoint *expr.HTTPEndpointExpr, params *expr.MappedAttributeExpr, path string) []*Parameter { +func paramsFromExpr(endpoint *expr.HTTPEndpointExpr, params *expr.MappedAttributeExpr, path string, values openapi.Values) []*Parameter { if params == nil { return nil } @@ -287,14 +320,14 @@ func paramsFromExpr(endpoint *expr.HTTPEndpointExpr, params *expr.MappedAttribut if endpoint != nil && in != "path" && openapiinternal.IsSecurityParameter(endpoint, in, pn) { return nil } - param := paramFor(at, pn, in, required) + param := paramFor(at, pn, in, required, values) res = append(res, param) return nil }) return res } -func paramsFromHeaders(endpoint *expr.HTTPEndpointExpr) []*Parameter { +func paramsFromHeaders(endpoint *expr.HTTPEndpointExpr, values openapi.Values) []*Parameter { var params []*Parameter expr.WalkMappedAttr(endpoint.Headers, func(name, elem string, att *expr.AttributeExpr) error { // nolint: errcheck @@ -302,21 +335,21 @@ func paramsFromHeaders(endpoint *expr.HTTPEndpointExpr) []*Parameter { return nil } required := endpoint.Headers.IsRequiredNoDefault(name) - params = append(params, paramFor(att, elem, "header", required)) + params = append(params, paramFor(att, elem, "header", required, values)) return nil }) return params } -func paramFor(at *expr.AttributeExpr, name, in string, required bool) *Parameter { +func paramFor(at *expr.AttributeExpr, name, in string, required bool, values openapi.Values) *Parameter { alias := at at = resolvedAliasAttribute(at) p := &Parameter{ In: in, Name: name, Default: openapi.ToStringMap(at.DefaultValue), - Description: at.Description, + Description: values.Description(alias.AuthoredAttribute(), at.Description), Required: required, } p.Type, p.Format = openAPITypeFormat(at) @@ -342,7 +375,7 @@ func itemsFromExpr(at *expr.AttributeExpr) *Items { return items } -func responseSpecFromExpr(_ *V2, root *expr.RootExpr, r *expr.HTTPResponseExpr, typeNamePrefix string) *Response { +func responseSpecFromExpr(_ *V2, root *expr.RootExpr, r *expr.HTTPResponseExpr, typeNamePrefix string, schemas *schemaBuilder, generator *expr.ExampleGenerator, fallbackDescription string, values openapi.Values) *Response { var schema *openapi.Schema if mt, ok := r.Body.Type.(*expr.ResultTypeExpr); ok { view := expr.DefaultView @@ -350,15 +383,19 @@ func responseSpecFromExpr(_ *V2, root *expr.RootExpr, r *expr.HTTPResponseExpr, view = v } schema = openapi.NewSchema() - schema.Ref = openapi.ResultTypeRefWithPrefix(root.API, mt, view, typeNamePrefix) + projection := openapi.ProjectResponseResult(mt, view) + schema.Ref = schemas.projectedResultTypeRefWithPrefix(root.API, mt, projection.Result, typeNamePrefix, generator) } else if r.Body.Type != expr.Empty { - schema = openapi.AttributeTypeSchemaWithPrefix(root.API, r.Body, typeNamePrefix) + schema = schemas.attributeTypeSchemaWithPrefix(root.API, r.Body, typeNamePrefix, generator) } if schema != nil { schema.Extensions = openapi.ExtensionsFromExpr(r.Meta) } - headers := headersFromExpr(r.Headers) - desc := r.Description + headers := headersFromExpr(r.Headers, values) + desc := values.Description(r, r.Description) + if desc == "" { + desc = fallbackDescription + } if desc == "" { desc = fmt.Sprintf("%s response.", http.StatusText(r.StatusCode)) } @@ -370,7 +407,7 @@ func responseSpecFromExpr(_ *V2, root *expr.RootExpr, r *expr.HTTPResponseExpr, } } -func headersFromExpr(headers *expr.MappedAttributeExpr) map[string]*Header { +func headersFromExpr(headers *expr.MappedAttributeExpr, values openapi.Values) map[string]*Header { if headers == nil { return nil } @@ -379,7 +416,7 @@ func headersFromExpr(headers *expr.MappedAttributeExpr) map[string]*Header { headerType, headerFormat := openAPITypeFormat(at) header := &Header{ Default: at.DefaultValue, - Description: at.Description, + Description: values.Description(at.AuthoredAttribute(), at.Description), Type: headerType, Format: headerFormat, } @@ -435,7 +472,7 @@ func initAttributeValidations(at *expr.AttributeExpr, def any) { initValidations(at, def) } -func buildPathFromFileServer(s *V2, root *expr.RootExpr, fs *expr.HTTPFileServerExpr) { +func buildPathFromFileServer(s *V2, root *expr.RootExpr, fs *expr.HTTPFileServerExpr, schemas *schemaBuilder, generator *expr.ExampleGenerator, values openapi.Values) { for _, path := range fs.RequestPaths { wcs := expr.ExtractHTTPWildcards(path) var param []*Parameter @@ -456,7 +493,8 @@ func buildPathFromFileServer(s *V2, root *expr.RootExpr, fs *expr.HTTPFileServer }, } if len(wcs) > 0 { - schema := openapi.TypeSchema(root.API, expr.ErrorResult) + errgen := generator.At(expr.UserTypeExampleIdentity(expr.ErrorResult)) + schema := schemas.typeSchema(root.API, expr.ErrorResult, errgen) responses["404"] = &Response{Description: "File not found", Schema: schema} } @@ -477,9 +515,9 @@ func buildPathFromFileServer(s *V2, root *expr.RootExpr, fs *expr.HTTPFileServer } operation := &Operation{ - Description: fs.Description, + Description: values.Description(fs, fs.Description), Summary: summaryFromMeta(fmt.Sprintf("Download %s", fs.FilePath), fs.Meta), - ExternalDocs: openapi.DocsFromExpr(fs.Docs, fs.Meta), + ExternalDocs: openapi.DocsFromExprWithValues(fs.Docs, fs.Meta, values), OperationID: operationID, Parameters: param, Responses: responses, @@ -503,7 +541,7 @@ func buildPathFromFileServer(s *V2, root *expr.RootExpr, fs *expr.HTTPFileServer } } -func buildPathFromExpr(s *V2, root *expr.RootExpr, h *expr.HostExpr, route *expr.RouteExpr, basePath string) { +func buildPathFromExpr(s *V2, root *expr.RootExpr, h *expr.HostExpr, route *expr.RouteExpr, basePath string, schemas *schemaBuilder, generator *expr.ExampleGenerator, values openapi.Values) { endpoint := route.Endpoint tagNames := openapi.TagNamesFromExpr(endpoint.Meta) @@ -515,12 +553,13 @@ func buildPathFromExpr(s *V2, root *expr.RootExpr, h *expr.HostExpr, route *expr // Remove any wildcards that is defined in path as a workaround to // https://github.com/OAI/OpenAPI-Specification/issues/291 key = expr.HTTPWildcardRegex.ReplaceAllString(key, "/{$1}") - params := paramsFromExpr(endpoint, endpoint.Params, key) - params = append(params, paramsFromHeaders(endpoint)...) + params := paramsFromExpr(endpoint, endpoint.Params, key, values) + params = append(params, paramsFromHeaders(endpoint, values)...) var produces []string responses := make(map[string]*Response, len(endpoint.Responses)) for _, r := range endpoint.Responses { + responseGenerator := generator.At(expr.ResponseBodyExampleIdentity(endpoint, r)) if endpoint.UsesWebSocket() { // A WebSocket endpoint allows at most one successful response // definition. So it is okay to change the first successful @@ -530,7 +569,7 @@ func buildPathFromExpr(s *V2, root *expr.RootExpr, h *expr.HostExpr, route *expr r.StatusCode = expr.StatusSwitchingProtocols } } - resp := responseSpecFromExpr(s, root, r, endpoint.Service.Name()) + resp := responseSpecFromExpr(s, root, r, endpoint.Service.Name(), schemas, responseGenerator, "", values) responses[strconv.Itoa(r.StatusCode)] = resp if r.ContentType != "" { foundCT := slices.Contains(produces, r.ContentType) @@ -540,7 +579,13 @@ func buildPathFromExpr(s *V2, root *expr.RootExpr, h *expr.HostExpr, route *expr } } for _, er := range endpoint.HTTPErrors { - resp := responseSpecFromExpr(s, root, er.Response, endpoint.Service.Name()) + responseGenerator := generator.At(expr.ErrorResponseBodyExampleIdentity(endpoint, er)) + errorDescription := values.Description(er.ErrorExpr, er.Description) + resp := responseSpecFromExpr(s, root, er.Response, endpoint.Service.Name(), schemas, responseGenerator, errorDescription, values) + resp.Description = er.Name + ": " + resp.Description + if example, ok := openapi.ErrorResponseExample(er.ErrorExpr, er.Response.Body, responseGenerator, values); ok { + resp.Examples = map[string]any{openapi.ResponseContentType(er.Response): example} + } responses[strconv.Itoa(er.Response.StatusCode)] = resp } @@ -557,9 +602,14 @@ func buildPathFromExpr(s *V2, root *expr.RootExpr, h *expr.HostExpr, route *expr pp := &Parameter{ Name: endpoint.Body.Type.Name(), In: in, - Description: endpoint.Body.Description, + Description: values.Description(endpoint.Body.AuthoredAttribute(), endpoint.Body.Description), Required: true, - Schema: openapi.AttributeTypeSchemaWithPrefix(root.API, endpoint.Body, codegen.Goify(endpoint.Service.Name(), true)), + Schema: schemas.attributeTypeSchemaWithPrefix( + root.API, + endpoint.Body, + codegen.Goify(endpoint.Service.Name(), true), + generator.At(expr.RequestBodyExampleIdentity(endpoint)), + ), } params = append(params, pp) } @@ -599,7 +649,7 @@ func buildPathFromExpr(s *V2, root *expr.RootExpr, h *expr.HostExpr, route *expr } } - description := endpoint.Description() + description := values.Description(endpoint.MethodExpr, endpoint.Description()) var requirements SecurityRequirements if len(endpoint.Requirements) > 0 { @@ -608,7 +658,7 @@ func buildPathFromExpr(s *V2, root *expr.RootExpr, h *expr.HostExpr, route *expr for i, req := range endpoint.Requirements { requirement := make(map[string][]string) for _, s := range req.Schemes { - requirement[s.Hash()] = nil + requirement[s.Hash()] = make([]string, 0) switch s.Kind { case expr.OAuth2Kind: if len(req.Scopes) > 0 { @@ -638,7 +688,7 @@ func buildPathFromExpr(s *V2, root *expr.RootExpr, h *expr.HostExpr, route *expr Tags: tagNames, Description: description, Summary: summaryFromExpr(endpoint.Name()+" "+endpoint.Service.Name(), endpoint, root.API.Meta), - ExternalDocs: openapi.DocsFromExpr(endpoint.MethodExpr.Docs, endpoint.MethodExpr.Meta), + ExternalDocs: openapi.DocsFromExprWithValues(endpoint.MethodExpr.Docs, endpoint.MethodExpr.Meta, values), OperationID: operationID, Parameters: params, Consumes: consumes, diff --git a/http/codegen/openapi/v2/builder_test.go b/http/codegen/openapi/v2/builder_test.go index ad5114df41..123cef057f 100644 --- a/http/codegen/openapi/v2/builder_test.go +++ b/http/codegen/openapi/v2/builder_test.go @@ -1,3 +1,5 @@ +// This file verifies OpenAPI v2 construction from evaluated HTTP endpoint +// designs, including request and response example ownership. package openapiv2 import ( @@ -8,9 +10,36 @@ import ( "goa.design/goa/v3/codegen" dsl "goa.design/goa/v3/dsl" "goa.design/goa/v3/expr" + "goa.design/goa/v3/http/codegen/openapi" "gopkg.in/yaml.v3" ) +func TestNewV2WithValues(t *testing.T) { + root := codegen.RunDSL(t, localizedValuesDSL) + service := root.Service("messages") + method := service.Method("show") + values := (openapi.Values{}). + WithTitle(root.API, "Localized API"). + WithDescription(root.API, "Localized API description"). + WithDescription(service, "Localized service description"). + WithDescription(method, "Localized method description") + + spec, err := NewV2WithValues( + root, + root.API.Servers[0].Hosts[0], + expr.NewExampleGenerator(root.API.RandomizerFactory), + values, + ) + require.NoError(t, err) + require.Equal(t, "Localized API", spec.Info.Title) + require.Equal(t, "Localized API description", spec.Info.Description) + operation := spec.Paths["/messages"].(*Path).Get + require.Equal(t, "Localized method description", operation.Description) + require.Contains(t, operation.Tags, "messages") + require.Equal(t, "Original API", root.API.Title) + require.Equal(t, "Original method description", method.Description) +} + func TestBuildPathFromFileServer(t *testing.T) { cases := []struct { path string @@ -36,7 +65,7 @@ func TestBuildPathFromFileServer(t *testing.T) { } root := &expr.RootExpr{ API: &expr.APIExpr{ - ExampleGenerator: expr.NewRandom("test"), + RandomizerFactory: expr.NewFakerRandomizerFactory("test"), }, } fs := &expr.HTTPFileServerExpr{ @@ -47,7 +76,7 @@ func TestBuildPathFromFileServer(t *testing.T) { }, RequestPaths: []string{tc.path}, } - buildPathFromFileServer(s, root, fs) + buildPathFromFileServer(s, root, fs, newSchemaBuilder(openapi.Values{}), expr.NewExampleGenerator(root.API.RandomizerFactory), openapi.Values{}) for actual := range s.Paths { if actual != tc.expected { t.Errorf("got %#v, expected %#v", actual, tc.expected) @@ -193,6 +222,19 @@ func TestOperationSecurityMarshal(t *testing.T) { } } +func TestSecurityDefinitionsIncludeVisibleOperationsOnly(t *testing.T) { + root := codegen.RunDSL(t, visibleSecuritySchemesDSL) + spec, err := NewV2(root, root.API.Servers[0].Hosts[0]) + require.NoError(t, err) + + visible := root.Service("visible").Method("read").Requirements[0].Schemes[0].Hash() + hiddenMethod := root.Service("mixed").Method("hidden").Requirements[0].Schemes[0].Hash() + hiddenService := root.Service("hidden").Method("read").Requirements[0].Schemes[0].Hash() + require.Contains(t, spec.SecurityDefinitions, visible) + require.NotContains(t, spec.SecurityDefinitions, hiddenMethod) + require.NotContains(t, spec.SecurityDefinitions, hiddenService) +} + var noSecurityOverridesAPISecurityDSL = func() { var JWTAuth = dsl.JWTSecurity("jwt") @@ -219,6 +261,66 @@ var noSecurityOverridesAPISecurityDSL = func() { }) } +var localizedValuesDSL = func() { + dsl.API("messages", func() { + dsl.Title("Original API") + dsl.Description("Original API description") + }) + dsl.Service("messages", func() { + dsl.Description("Original service description") + dsl.Method("show", func() { + dsl.Description("Original method description") + dsl.HTTP(func() { + dsl.GET("/messages") + }) + }) + }) +} + +var visibleSecuritySchemesDSL = func() { + var ( + VisibleAuth = dsl.JWTSecurity("visible_auth") + HiddenMethodAuth = dsl.JWTSecurity("hidden_method_auth") + HiddenServiceAuth = dsl.JWTSecurity("hidden_service_auth") + ) + + dsl.Service("visible", func() { + dsl.Method("read", func() { + dsl.Security(VisibleAuth) + dsl.Payload(func() { + dsl.Token("token", dsl.String) + }) + dsl.HTTP(func() { + dsl.GET("/visible") + }) + }) + }) + dsl.Service("mixed", func() { + dsl.Method("hidden", func() { + dsl.Meta("openapi:generate", "false") + dsl.Security(HiddenMethodAuth) + dsl.Payload(func() { + dsl.Token("token", dsl.String) + }) + dsl.HTTP(func() { + dsl.GET("/hidden-method") + }) + }) + }) + dsl.Service("hidden", func() { + dsl.Meta("openapi:generate", "false") + dsl.Method("read", func() { + dsl.Security(HiddenServiceAuth) + dsl.Payload(func() { + dsl.Token("token", dsl.String) + }) + dsl.HTTP(func() { + dsl.GET("/hidden-service") + }) + }) + }) +} + var noSecurityOverridesServiceSecurityDSL = func() { var JWTAuth = dsl.JWTSecurity("jwt") @@ -322,13 +424,17 @@ func TestBuildPathFromExpr(t *testing.T) { Meta: expr.MetaExpr{}, }, } + route.Endpoint.MethodExpr.Name = "method" + route.Endpoint.Service.ServiceExpr.Name = "service" + route.Endpoint.MethodExpr.Service = route.Endpoint.Service.ServiceExpr if tc.deprecated { route.Endpoint.Meta["openapi:deprecated"] = []string{"true"} } basePath := "/" - buildPathFromExpr(s, root, h, route, basePath) + generator := expr.NewExampleGenerator(expr.NewFakerRandomizerFactory("test")) + buildPathFromExpr(s, root, h, route, basePath, newSchemaBuilder(openapi.Values{}), generator, openapi.Values{}) for _, path := range s.Paths { actual := path.(*Path).Post if len(actual.Consumes) != len(tc.expected.Consumes) { diff --git a/http/codegen/openapi/v2/description_ownership_test.go b/http/codegen/openapi/v2/description_ownership_test.go new file mode 100644 index 0000000000..c10e3028ec --- /dev/null +++ b/http/codegen/openapi/v2/description_ownership_test.go @@ -0,0 +1,52 @@ +// This file verifies that shared OpenAPI v2 definitions use the named Goa +// type description instead of text from one response that uses the type. +package openapiv2 + +import ( + "testing" + + "github.com/stretchr/testify/require" + + "goa.design/goa/v3/codegen" + "goa.design/goa/v3/expr" + "goa.design/goa/v3/http/codegen/openapi" + "goa.design/goa/v3/http/codegen/testdata" +) + +func TestSharedErrorDefinitionDescription(t *testing.T) { + cases := []struct { + name string + dsl func() + description string + }{ + {"method order", testdata.SharedErrorDescriptionDSL, "Shared error value"}, + {"reversed method order", testdata.ReversedSharedErrorDescriptionDSL, "Shared error value"}, + {"undescribed type", testdata.UndescribedSharedErrorDSL, ""}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + root := codegen.RunDSL(t, tc.dsl) + spec, err := NewV2( + root, + root.API.Servers[0].Hosts[0], + ) + require.NoError(t, err) + require.Equal(t, tc.description, spec.Definitions["SharedError"].Description) + }) + } +} + +func TestSharedErrorDefinitionLocalizedDescription(t *testing.T) { + root := codegen.RunDSL(t, testdata.SharedErrorDescriptionDSL) + sharedError := root.UserType("SharedError") + values := (openapi.Values{}).WithDescription(sharedError.Attribute(), "Localized shared error") + + spec, err := NewV2WithValues( + root, + root.API.Servers[0].Hosts[0], + expr.NewExampleGenerator(root.API.RandomizerFactory), + values, + ) + require.NoError(t, err) + require.Equal(t, "Localized shared error", spec.Definitions["SharedError"].Description) +} diff --git a/http/codegen/openapi/v2/files.go b/http/codegen/openapi/v2/files.go index a542fd9e49..7c43d2174a 100644 --- a/http/codegen/openapi/v2/files.go +++ b/http/codegen/openapi/v2/files.go @@ -1,3 +1,5 @@ +// This file builds Swagger 2.0 JSON and YAML files from one HTTP design. Each +// example comes from the request or response described in the file. package openapiv2 import ( @@ -10,7 +12,19 @@ import ( // path is the output path of the files relative to the gen directory, without // extension. func Files(root *expr.RootExpr, path string) ([]*codegen.File, error) { - spec, err := NewV2(root, root.API.Servers[0].Hosts[0]) + return FilesWithValues( + root, + path, + expr.NewExampleGenerator(root.API.RandomizerFactory), + openapi.Values{}, + ) +} + +// FilesWithValues returns Swagger 2.0 files using values in place of matching +// titles, descriptions, and examples from the evaluated design. The generator +// supplies examples for attributes that have no matching value. +func FilesWithValues(root *expr.RootExpr, path string, generator *expr.ExampleGenerator, values openapi.Values) ([]*codegen.File, error) { + spec, err := NewV2WithValues(root, root.API.Servers[0].Hosts[0], generator, values) if err != nil { return nil, err } diff --git a/http/codegen/openapi/v2/files_test.go b/http/codegen/openapi/v2/files_test.go index 0846eaac00..52bb5247a5 100644 --- a/http/codegen/openapi/v2/files_test.go +++ b/http/codegen/openapi/v2/files_test.go @@ -1,3 +1,5 @@ +// This file renders complete Swagger 2.0 documents from prepared HTTP designs +// and compares the JSON and YAML output produced with run-owned example state. package openapiv2_test import ( @@ -33,6 +35,7 @@ func TestSections(t *testing.T) { {"multiple-services", testdata.MultipleServicesDSL}, {"multiple-views", testdata.MultipleViewsDSL}, {"explicit-view", testdata.ExplicitViewDSL}, + {"released-response-collection-names", testdata.ReleasedResponseCollectionNamesDSL}, {"security", testdata.SecurityDSL}, {"server-host-with-variables", testdata.ServerHostWithVariablesDSL}, {"with-spaces", testdata.WithSpacesDSL}, @@ -52,11 +55,11 @@ func TestSections(t *testing.T) { {"additional-properties-type", testdata.AdditionalPropertiesTypeDSL}, {"additional-properties-payload-result", testdata.AdditionalPropertiesPayloadResultDSL}, {"additional-properties-embedded-payload-result", testdata.AdditionalPropertiesPayloadResultDSL}, + {"error-examples", testdata.ErrorExamplesDSL}, + {"shared-error-description", testdata.SharedErrorDescriptionDSL}, } for _, c := range cases { t.Run(c.Name, func(t *testing.T) { - // Reset global variables - openapi.Definitions = make(map[string]*openapi.Schema) root := expr.RunDSL(t, c.DSL) oFiles, err := openapiv2.Files(root, openapi.DefaultPath20) if err != nil { @@ -112,8 +115,6 @@ func TestValidations(t *testing.T) { } for _, c := range cases { t.Run(c.Name, func(t *testing.T) { - // Reset global variables - openapi.Definitions = make(map[string]*openapi.Schema) root := expr.RunDSL(t, c.DSL) oFiles, err := openapiv2.Files(root, openapi.DefaultPath20) require.NoError(t, err, "OpenAPI failed") @@ -156,8 +157,6 @@ func TestExtensions(t *testing.T) { } for _, c := range cases { t.Run(c.Name, func(t *testing.T) { - // Reset global variables - openapi.Definitions = make(map[string]*openapi.Schema) root := expr.RunDSL(t, c.DSL) oFiles, err := openapiv2.Files(root, openapi.DefaultPath20) require.NoError(t, err, "OpenAPI failed") @@ -189,9 +188,6 @@ func TestExtensions(t *testing.T) { } func TestNamedPrimitiveParamsAndHeadersUseOpenAPIBaseTypes(t *testing.T) { - // Reset global variables - openapi.Definitions = make(map[string]*openapi.Schema) - root := expr.RunDSL(t, func() { var UUID = dsl.Type("UUID", dsl.String, func() { dsl.Format(dsl.FormatUUID) diff --git a/http/codegen/openapi/v2/json_schema.go b/http/codegen/openapi/v2/json_schema.go new file mode 100644 index 0000000000..8d16cb89a6 --- /dev/null +++ b/http/codegen/openapi/v2/json_schema.go @@ -0,0 +1,310 @@ +// This file builds the JSON schemas placed in one Swagger 2.0 document. Each +// build keeps its definitions and assigned type names in its own builder. +package openapiv2 + +import ( + "fmt" + + "goa.design/goa/v3/codegen" + "goa.design/goa/v3/expr" + "goa.design/goa/v3/http/codegen/openapi" +) + +type ( + // schemaBuilder builds every schema used by one Swagger document. + schemaBuilder struct { + definitions map[string]*openapi.Schema + definitionNames map[*expr.ResultTypeExpr]string + values openapi.Values + } +) + +// newSchemaBuilder starts a schema build with no definitions or assigned names. +func newSchemaBuilder(values openapi.Values) *schemaBuilder { + return &schemaBuilder{ + definitions: make(map[string]*openapi.Schema), + definitionNames: make(map[*expr.ResultTypeExpr]string), + values: values, + } +} + +// BuildAttributeSchema returns the JSON schema for at. The returned schema +// includes every named definition referenced by at. +func BuildAttributeSchema(api *expr.APIExpr, at *expr.AttributeExpr, generator *expr.ExampleGenerator) *openapi.Schema { + builder := newSchemaBuilder(openapi.Values{}) + schema := builder.attributeTypeSchemaWithPrefix(api, at, "", generator) + if len(builder.definitions) > 0 { + schema.Defs = builder.definitions + } + return schema +} + +// resultTypeRefWithPrefix returns a reference to the requested result view. It +// adds the definition to this builder the first time the result is used. +func (b *schemaBuilder) resultTypeRefWithPrefix(api *expr.APIExpr, mt *expr.ResultTypeExpr, view, prefix string, gen *expr.ExampleGenerator) string { + projected, err := expr.Project(mt, view) + if err != nil { + panic(fmt.Sprintf("failed to project media type %#v: %s", mt.Identifier, err)) // bug + } + return b.projectedResultTypeRefWithPrefix(api, mt, projected, prefix, gen) +} + +// projectedResultTypeRefWithPrefix adds a result type that was already +// projected for one HTTP response. +func (b *schemaBuilder) projectedResultTypeRefWithPrefix(api *expr.APIExpr, source, projected *expr.ResultTypeExpr, prefix string, gen *expr.ExampleGenerator) string { + var metaName string + if n, ok := source.Meta["openapi:typename"]; ok { + metaName = codegen.Goify(n[0], true) + } + name := projected.TypeName + if metaName != "" { + name = metaName + } + if assigned, ok := b.definitionNames[projected]; ok { + // expr.Project can return the original result type. Reuse the name chosen + // when this build first saw that result. + name = assigned + } else { + if _, ok := b.definitions[name]; !ok { + name = codegen.Goify(prefix, true) + codegen.Goify(name, true) + if metaName != "" { + name = metaName + } + } + if projected == source { + // Keep the chosen name here instead of changing the design result. + b.definitionNames[projected] = name + } + } + if _, ok := b.definitions[name]; !ok { + b.generateResultTypeDefinition(api, renamedResultType(projected, name), expr.DefaultView, gen) + } + return fmt.Sprintf("#/$defs/%s", name) +} + +// typeRefWithPrefix returns a reference to a user type. It adds the definition +// to this builder the first time the type is used. +func (b *schemaBuilder) typeRefWithPrefix(api *expr.APIExpr, ut *expr.UserTypeExpr, prefix string, gen *expr.ExampleGenerator) string { + typeName := ut.TypeName + if prefix != "" { + typeName = codegen.Goify(prefix, true) + codegen.Goify(ut.TypeName, true) + } + if n, ok := ut.Meta["openapi:typename"]; ok { + typeName = codegen.Goify(n[0], true) + } + if _, ok := b.definitions[typeName]; !ok { + b.generateTypeDefinitionWithName(api, ut, typeName, gen) + } + return fmt.Sprintf("#/$defs/%s", typeName) +} + +// generateResultTypeDefinition adds the requested result view unless this +// build already has a definition with the same name. +func (b *schemaBuilder) generateResultTypeDefinition(api *expr.APIExpr, mt *expr.ResultTypeExpr, view string, gen *expr.ExampleGenerator) { + if _, ok := b.definitions[mt.TypeName]; ok { + return + } + schema := openapi.NewSchema() + schema.Title = fmt.Sprintf("Mediatype identifier: %s", mt.Identifier) + b.definitions[mt.TypeName] = schema + b.buildResultTypeSchema(api, mt, view, schema, gen) +} + +// generateTypeDefinitionWithName adds the user type under typeName unless this +// build already has a definition with that name. +func (b *schemaBuilder) generateTypeDefinitionWithName(api *expr.APIExpr, ut *expr.UserTypeExpr, typeName string, gen *expr.ExampleGenerator) { + if _, ok := b.definitions[typeName]; ok { + return + } + schema := openapi.NewSchema() + schema.Title = typeName + b.definitions[typeName] = schema + b.buildAttributeSchema(api, schema, ut.AttributeExpr, gen.At(expr.UserTypeExampleIdentity(ut))) +} + +// typeSchema builds a schema for t and adds any named definitions it uses to +// this builder. +func (b *schemaBuilder) typeSchema(api *expr.APIExpr, t expr.DataType, gen *expr.ExampleGenerator) *openapi.Schema { + return b.typeSchemaWithPrefix(api, t, "", gen) +} + +// typeSchemaWithPrefix builds a schema for t and adds prefix to new named +// definitions created while walking the type. +func (b *schemaBuilder) typeSchemaWithPrefix(api *expr.APIExpr, t expr.DataType, prefix string, gen *expr.ExampleGenerator) *openapi.Schema { + schema := openapi.NewSchema() + switch actual := t.(type) { + case expr.Primitive: + schema.Type = openapi.Type(actual.Name()) + switch actual.Kind() { + case expr.AnyKind: + // Leaving the type empty allows every JSON value. + schema.Type = openapi.Type("") + case expr.IntKind, expr.Int64Kind, + expr.UIntKind, expr.UInt64Kind: + schema.Type = openapi.Integer + schema.Format = "int64" + case expr.Int32Kind, expr.UInt32Kind: + schema.Type = openapi.Integer + schema.Format = "int32" + case expr.Float32Kind: + schema.Type = openapi.Number + schema.Format = "float" + case expr.Float64Kind: + schema.Type = openapi.Number + schema.Format = "double" + case expr.BytesKind: + schema.Type = openapi.String + schema.Format = "byte" + } + case *expr.Array: + schema.Type = openapi.Array + schema.Items = openapi.NewSchema() + b.buildAttributeSchema(api, schema.Items, actual.ElemType, gen.ArrayElement(0)) + case *expr.Object: + schema.Type = openapi.Object + for _, nat := range *actual { + if !openapi.MustGenerate(nat.Attribute.Meta) { + continue + } + property := openapi.NewSchema() + b.buildAttributeSchema(api, property, nat.Attribute, gen.Member(nat.Name)) + schema.Properties[nat.Name] = property + } + case *expr.Map: + schema.Type = openapi.Object + keyType := actual.KeyType.Type + for expr.IsAlias(keyType) { + keyType = keyType.(expr.UserType).Attribute().Type + } + if keyType == expr.String && actual.ElemType.Type != expr.Any { + value := openapi.NewSchema() + schema.AdditionalProperties = b.buildAttributeSchema(api, value, actual.ElemType, gen.MapValue(0)) + } else { + schema.AdditionalProperties = true + } + case *expr.Union: + typeKey := actual.GetTypeKey() + valueKey := actual.GetValueKey() + schema.Type = openapi.Object + for _, val := range actual.Values { + valueSchema := b.typeSchemaWithPrefix(api, val.Attribute.Type, prefix, gen.UnionMember(val.Name)) + initSchemaValidation(valueSchema, val.Attribute) + schema.AnyOf = append(schema.AnyOf, &openapi.Schema{ + Type: openapi.Object, + Properties: map[string]*openapi.Schema{ + typeKey: { + Type: openapi.String, + Enum: []any{val.Name}, + }, + valueKey: valueSchema, + }, + Required: []string{typeKey, valueKey}, + }) + } + case *expr.UserTypeExpr: + if expr.IsAlias(actual) { + schema = b.typeSchemaWithPrefix(api, actual.Attribute().Type, prefix, gen.At(expr.UserTypeExampleIdentity(actual))) + initSchemaValidation(schema, actual.Attribute()) + break + } + schema.Ref = b.typeRefWithPrefix(api, actual, prefix, gen) + case *expr.ResultTypeExpr: + schema.Ref = b.resultTypeRefWithPrefix(api, actual, expr.DefaultView, prefix, gen) + } + return schema +} + +// attributeTypeSchemaWithPrefix builds a schema for at, including its +// validation rules, and adds prefix to new named definitions. +func (b *schemaBuilder) attributeTypeSchemaWithPrefix(api *expr.APIExpr, at *expr.AttributeExpr, prefix string, gen *expr.ExampleGenerator) *openapi.Schema { + schema := b.typeSchemaWithPrefix(api, at.Type, prefix, gen) + initSchemaValidation(schema, at) + return schema +} + +// buildAttributeSchema fills schema with the type, example, description, and +// validation rules from at. +func (b *schemaBuilder) buildAttributeSchema(api *expr.APIExpr, schema *openapi.Schema, at *expr.AttributeExpr, gen *expr.ExampleGenerator) *openapi.Schema { + schema.Merge(b.typeSchemaWithPrefix(api, at.Type, "", gen)) + if schema.Ref != "" { + return schema + } + schema.DefaultValue = openapi.ToStringMap(at.DefaultValue) + if description := b.values.Description(at.AuthoredAttribute(), at.Description); description != "" { + schema.Description = description + } + schema.Example = openapi.ProjectExample(at, b.values.Example(at, gen)) + schema.Extensions = openapi.ExtensionsFromExpr(at.Meta) + if additional := openapi.AdditionalPropertiesFromExpr(at.Meta); additional != nil { + schema.AdditionalProperties = additional + } + initSchemaValidation(schema, at) + return schema +} + +// initSchemaValidation copies the validation rules from at into schema. +func initSchemaValidation(schema *openapi.Schema, at *expr.AttributeExpr) { + validation := at.Validation + if validation == nil { + return + } + schema.Enum = validation.Values + if validation.Format != "" { + schema.Format = string(validation.Format) + } + schema.Pattern = validation.Pattern + if validation.ExclusiveMinimum != nil { + schema.ExclusiveMinimum = validation.ExclusiveMinimum + } + if validation.Minimum != nil { + schema.Minimum = validation.Minimum + } + if validation.ExclusiveMaximum != nil { + schema.ExclusiveMaximum = validation.ExclusiveMaximum + } + if validation.Maximum != nil { + schema.Maximum = validation.Maximum + } + if validation.MinLength != nil { + if _, ok := at.Type.(*expr.Array); ok { + schema.MinItems = validation.MinLength + } else { + schema.MinLength = validation.MinLength + } + } + if validation.MaxLength != nil { + if _, ok := at.Type.(*expr.Array); ok { + schema.MaxItems = validation.MaxLength + } else { + schema.MaxLength = validation.MaxLength + } + } + for _, name := range validation.Required { + if attribute := at.Find(name); attribute != nil && !openapi.MustGenerate(attribute.Meta) { + continue + } + schema.Required = append(schema.Required, name) + } +} + +// renamedResultType returns rt with name without changing the design result. +func renamedResultType(rt *expr.ResultTypeExpr, name string) *expr.ResultTypeExpr { + if rt.TypeName == name { + return rt + } + userType := *rt.UserTypeExpr + userType.TypeName = name + result := *rt + result.UserTypeExpr = &userType + return &result +} + +// buildResultTypeSchema fills schema with the requested result view. +func (b *schemaBuilder) buildResultTypeSchema(api *expr.APIExpr, mt *expr.ResultTypeExpr, view string, schema *openapi.Schema, gen *expr.ExampleGenerator) { + schema.Media = &openapi.Media{Type: mt.Identifier} + projected, err := expr.Project(mt, view) + if err != nil { + panic(fmt.Sprintf("failed to project media type %#v: %s", mt.Identifier, err)) // bug + } + b.buildAttributeSchema(api, schema, projected.AttributeExpr, gen.At(expr.UserTypeExampleIdentity(projected))) +} diff --git a/http/codegen/openapi/v2/json_schema_union_test.go b/http/codegen/openapi/v2/json_schema_union_test.go new file mode 100644 index 0000000000..d9828bf482 --- /dev/null +++ b/http/codegen/openapi/v2/json_schema_union_test.go @@ -0,0 +1,148 @@ +// This file checks that each Swagger union choice pairs its name with the +// schema for the matching value. +package openapiv2 + +import ( + "encoding/json" + "sync" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "goa.design/goa/v3/expr" + "goa.design/goa/v3/http/codegen/openapi" +) + +func TestAttributeTypeSchemaCorrelatesUnionDiscriminatorAndValue(t *testing.T) { + method := &expr.MethodExpr{Name: "union", Service: &expr.ServiceExpr{Name: "test"}} + generator := expr.NewExampleGenerator(expr.NewFakerRandomizerFactory("test")).At( + expr.MethodPayloadExampleIdentity(method), + ) + schema := newSchemaBuilder(openapi.Values{}).attributeTypeSchemaWithPrefix( + &expr.APIExpr{}, + unionAttribute(), + "", + generator, + ) + + require.Len(t, schema.AnyOf, 2) + assertUnionSchemaBranch(t, schema.AnyOf[0], "text", openapi.String) + assertUnionSchemaBranch(t, schema.AnyOf[1], "count", openapi.Integer) + assert.Empty(t, schema.Properties) +} + +func TestBuildAttributeSchemaKeepsDefinitionsSeparate(t *testing.T) { + type result struct { + field string + schema *openapi.Schema + } + start := make(chan struct{}) + results := make(chan result, 2) + var ready sync.WaitGroup + ready.Add(2) + build := func(field string) { + ready.Done() + <-start + generator := expr.NewExampleGenerator(expr.NewFakerRandomizerFactory(field)) + results <- result{ + field: field, + schema: BuildAttributeSchema( + &expr.APIExpr{}, + namedObjectAttribute(field), + generator, + ), + } + } + go build("first") + go build("second") + ready.Wait() + close(start) + + for range 2 { + built := <-results + require.Equal(t, "#/$defs/Shared", built.schema.Ref) + require.Contains(t, built.schema.Defs, "Shared") + require.Contains(t, built.schema.Defs["Shared"].Properties, built.field) + other := "first" + if built.field == "first" { + other = "second" + } + require.NotContains(t, built.schema.Defs["Shared"].Properties, other) + _, err := json.Marshal(built.schema) + require.NoError(t, err) + } +} + +func TestBuildAttributeSchemaKeepsValuesForNamedStringMapKeys(t *testing.T) { + key := &expr.UserTypeExpr{ + AttributeExpr: &expr.AttributeExpr{Type: expr.String}, + TypeName: "ConnectionKind", + } + value := &expr.UserTypeExpr{ + AttributeExpr: &expr.AttributeExpr{Type: &expr.Object{ + &expr.NamedAttributeExpr{ + Name: "connected", + Attribute: &expr.AttributeExpr{Type: expr.Boolean}, + }, + }}, + TypeName: "ConnectionStatus", + } + attribute := &expr.AttributeExpr{Type: &expr.Map{ + KeyType: &expr.AttributeExpr{Type: key}, + ElemType: &expr.AttributeExpr{Type: value}, + }} + root := &expr.UserTypeExpr{AttributeExpr: attribute, TypeName: "Connections"} + generator := expr.NewExampleGenerator(expr.NewFakerRandomizerFactory("named-string-map-key")).At( + expr.UserTypeExampleIdentity(root), + ) + + schema := BuildAttributeSchema(&expr.APIExpr{}, attribute, generator) + + values, ok := schema.AdditionalProperties.(*openapi.Schema) + require.True(t, ok) + assert.Equal(t, "#/$defs/ConnectionStatus", values.Ref) + require.Contains(t, schema.Defs, "ConnectionStatus") + assert.Equal(t, openapi.Type(openapi.Boolean), schema.Defs["ConnectionStatus"].Properties["connected"].Type) +} + +// assertUnionSchemaBranch checks one generated union branch's tag, required +// fields, and value type. +func assertUnionSchemaBranch(t *testing.T, branch *openapi.Schema, tag string, valueType openapi.Type) { + t.Helper() + assert.Equal(t, openapi.Type(openapi.Object), branch.Type) + assert.Equal(t, []string{"type", "value"}, branch.Required) + require.Contains(t, branch.Properties, "type") + assert.Equal(t, []any{tag}, branch.Properties["type"].Enum) + require.Contains(t, branch.Properties, "value") + assert.Equal(t, valueType, branch.Properties["value"].Type) +} + +// unionAttribute returns the string-or-integer union used by these schema tests. +func unionAttribute() *expr.AttributeExpr { + return &expr.AttributeExpr{ + Type: &expr.Union{ + TypeName: "outcome", + Values: []*expr.NamedAttributeExpr{ + {Name: "text", Attribute: &expr.AttributeExpr{Type: expr.String}}, + {Name: "count", Attribute: &expr.AttributeExpr{Type: expr.Int}}, + }, + }, + } +} + +// namedObjectAttribute returns a named object with one field. +func namedObjectAttribute(field string) *expr.AttributeExpr { + object := expr.Object{ + &expr.NamedAttributeExpr{ + Name: field, + Attribute: &expr.AttributeExpr{Type: expr.String}, + }, + } + return &expr.AttributeExpr{ + Type: &expr.UserTypeExpr{ + AttributeExpr: &expr.AttributeExpr{Type: &object}, + TypeName: "Shared", + }, + } +} diff --git a/http/codegen/openapi/v2/openapi.go b/http/codegen/openapi/v2/openapi.go index 828d575689..49b3ed3371 100644 --- a/http/codegen/openapi/v2/openapi.go +++ b/http/codegen/openapi/v2/openapi.go @@ -164,6 +164,8 @@ type ( Schema *openapi.Schema `json:"schema,omitempty" yaml:"schema,omitempty"` // Headers is a list of headers that are sent with the response. Headers map[string]*Header `json:"headers,omitempty" yaml:"headers,omitempty"` + // Examples contains one response body example for each content type. + Examples map[string]any `json:"examples,omitempty" yaml:"examples,omitempty"` // Ref references a global API response. // This field is exclusive with the other fields of Response. Ref string `json:"$ref,omitempty" yaml:"$ref,omitempty"` diff --git a/http/codegen/openapi/v2/public_api_test.go b/http/codegen/openapi/v2/public_api_test.go new file mode 100644 index 0000000000..a76d66ad7b --- /dev/null +++ b/http/codegen/openapi/v2/public_api_test.go @@ -0,0 +1,100 @@ +// This file protects the released OpenAPI v2 function signatures and checks +// that their default files match files produced with the root's example +// generator and no replacement values. +package openapiv2_test + +import ( + "bytes" + "testing" + "text/template" + + "github.com/stretchr/testify/require" + + "goa.design/goa/v3/codegen" + "goa.design/goa/v3/dsl" + "goa.design/goa/v3/expr" + "goa.design/goa/v3/http/codegen/openapi" + openapiv2 "goa.design/goa/v3/http/codegen/openapi/v2" +) + +var ( + _ func(*expr.RootExpr, *expr.HostExpr) (*openapiv2.V2, error) = openapiv2.NewV2 + _ func(*expr.RootExpr, string) ([]*codegen.File, error) = openapiv2.Files + + facadeDSL = func() { + dsl.API("facade", func() { + dsl.Server("facade", func() { + dsl.Host("localhost", func() { + dsl.URI("https://goa.design") + }) + }) + }) + dsl.Service("facade", func() { + dsl.Method("show", func() { + dsl.Payload(func() { + dsl.Attribute("message", dsl.String) + }) + dsl.Result(func() { + dsl.Attribute("answer", dsl.String) + }) + dsl.HTTP(func() { + dsl.POST("/items") + }) + }) + }) + } +) + +func TestDefaultFacadeMatchesWithValues(t *testing.T) { + root := expr.RunDSL(t, facadeDSL) + root.API.RandomizerFactory = expr.NewFakerRandomizerFactory("released facade") + host := root.API.Servers[0].Hosts[0] + + gotSpec, err := openapiv2.NewV2(root, host) + require.NoError(t, err) + wantSpec, err := openapiv2.NewV2WithValues( + root, + host, + expr.NewExampleGenerator(root.API.RandomizerFactory), + openapi.Values{}, + ) + require.NoError(t, err) + require.Equal(t, wantSpec, gotSpec) + otherSpec, err := openapiv2.NewV2WithValues( + root, + host, + expr.NewExampleGenerator(expr.NewFakerRandomizerFactory("other")), + openapi.Values{}, + ) + require.NoError(t, err) + require.NotEqual(t, otherSpec, gotSpec) + + gotFiles, err := openapiv2.Files(root, openapi.DefaultPath20) + require.NoError(t, err) + wantFiles, err := openapiv2.FilesWithValues( + root, + openapi.DefaultPath20, + expr.NewExampleGenerator(root.API.RandomizerFactory), + openapi.Values{}, + ) + require.NoError(t, err) + require.Equal(t, renderFiles(t, wantFiles), renderFiles(t, gotFiles)) +} + +// renderFiles runs each file template so the test compares the documents that +// users receive instead of comparing template implementation details. +func renderFiles(t *testing.T, files []*codegen.File) map[string]string { + t.Helper() + + rendered := make(map[string]string, len(files)) + for _, file := range files { + var buf bytes.Buffer + for _, section := range file.SectionTemplates { + tmpl, err := template.New("openapi").Funcs(section.FuncMap).Parse(section.Source) + require.NoError(t, err) + require.NoError(t, tmpl.Execute(&buf, section.Data)) + } + rendered[file.Path] = buf.String() + } + return rendered +} diff --git a/http/codegen/openapi/v2/testdata/TestSections/error-examples_file0.golden b/http/codegen/openapi/v2/testdata/TestSections/error-examples_file0.golden new file mode 100644 index 0000000000..a5a71ad4ed --- /dev/null +++ b/http/codegen/openapi/v2/testdata/TestSections/error-examples_file0.golden @@ -0,0 +1,472 @@ +{ + "consumes": [ + "application/json", + "application/xml", + "application/gob" + ], + "definitions": { + "ErrorsErrorBadRequestResponseBody": { + "description": "Error response result type (default view)", + "example": { + "fault": false, + "id": "123abc", + "message": "parameter 'p' must be an integer", + "name": "bad_request", + "temporary": true, + "timeout": false + }, + "properties": { + "fault": { + "description": "Is the error a server-side fault?", + "example": false, + "type": "boolean" + }, + "id": { + "description": "ID is a unique identifier for this particular occurrence of the problem.", + "example": "123abc", + "type": "string" + }, + "message": { + "description": "Message is a human-readable explanation specific to this occurrence of the problem.", + "example": "parameter 'p' must be an integer", + "type": "string" + }, + "name": { + "description": "Name is the name of this class of errors.", + "example": "bad_request", + "type": "string" + }, + "temporary": { + "description": "Is the error temporary?", + "example": true, + "type": "boolean" + }, + "timeout": { + "description": "Is the error a timeout?", + "example": false, + "type": "boolean" + } + }, + "required": [ + "name", + "id", + "message", + "temporary", + "timeout", + "fault" + ], + "title": "Mediatype identifier: application/vnd.goa.error; view=default", + "type": "object" + }, + "ErrorsErrorDeadlineResponseBody": { + "description": "Error response result type (default view)", + "example": { + "fault": false, + "id": "123abc", + "message": "parameter 'p' must be an integer", + "name": "bad_request", + "temporary": true, + "timeout": true + }, + "properties": { + "fault": { + "description": "Is the error a server-side fault?", + "example": false, + "type": "boolean" + }, + "id": { + "description": "ID is a unique identifier for this particular occurrence of the problem.", + "example": "123abc", + "type": "string" + }, + "message": { + "description": "Message is a human-readable explanation specific to this occurrence of the problem.", + "example": "parameter 'p' must be an integer", + "type": "string" + }, + "name": { + "description": "Name is the name of this class of errors.", + "example": "bad_request", + "type": "string" + }, + "temporary": { + "description": "Is the error temporary?", + "example": true, + "type": "boolean" + }, + "timeout": { + "description": "Is the error a timeout?", + "example": true, + "type": "boolean" + } + }, + "required": [ + "name", + "id", + "message", + "temporary", + "timeout", + "fault" + ], + "title": "Mediatype identifier: application/vnd.goa.error; view=default", + "type": "object" + }, + "ErrorsErrorInternalResponseBody": { + "description": "Error response result type (default view)", + "example": { + "fault": false, + "id": "123abc", + "message": "parameter 'p' must be an integer", + "name": "bad_request", + "temporary": false, + "timeout": false + }, + "properties": { + "fault": { + "description": "Is the error a server-side fault?", + "example": false, + "type": "boolean" + }, + "id": { + "description": "ID is a unique identifier for this particular occurrence of the problem.", + "example": "123abc", + "type": "string" + }, + "message": { + "description": "Message is a human-readable explanation specific to this occurrence of the problem.", + "example": "parameter 'p' must be an integer", + "type": "string" + }, + "name": { + "description": "Name is the name of this class of errors.", + "example": "bad_request", + "type": "string" + }, + "temporary": { + "description": "Is the error temporary?", + "example": false, + "type": "boolean" + }, + "timeout": { + "description": "Is the error a timeout?", + "example": false, + "type": "boolean" + } + }, + "required": [ + "name", + "id", + "message", + "temporary", + "timeout", + "fault" + ], + "title": "Mediatype identifier: application/vnd.goa.error; view=default", + "type": "object" + }, + "ErrorsErrorNotFoundResponseBody": { + "description": "Error response result type (default view)", + "example": { + "fault": false, + "id": "123abc", + "message": "parameter 'p' must be an integer", + "name": "bad_request", + "temporary": true, + "timeout": true + }, + "properties": { + "fault": { + "description": "Is the error a server-side fault?", + "example": false, + "type": "boolean" + }, + "id": { + "description": "ID is a unique identifier for this particular occurrence of the problem.", + "example": "123abc", + "type": "string" + }, + "message": { + "description": "Message is a human-readable explanation specific to this occurrence of the problem.", + "example": "parameter 'p' must be an integer", + "type": "string" + }, + "name": { + "description": "Name is the name of this class of errors.", + "example": "bad_request", + "type": "string" + }, + "temporary": { + "description": "Is the error temporary?", + "example": true, + "type": "boolean" + }, + "timeout": { + "description": "Is the error a timeout?", + "example": true, + "type": "boolean" + } + }, + "required": [ + "name", + "id", + "message", + "temporary", + "timeout", + "fault" + ], + "title": "Mediatype identifier: application/vnd.goa.error; view=default", + "type": "object" + }, + "ErrorsErrorRetryDeadlineResponseBody": { + "description": "Error response result type (default view)", + "example": { + "fault": false, + "id": "123abc", + "message": "parameter 'p' must be an integer", + "name": "bad_request", + "temporary": true, + "timeout": true + }, + "properties": { + "fault": { + "description": "Is the error a server-side fault?", + "example": false, + "type": "boolean" + }, + "id": { + "description": "ID is a unique identifier for this particular occurrence of the problem.", + "example": "123abc", + "type": "string" + }, + "message": { + "description": "Message is a human-readable explanation specific to this occurrence of the problem.", + "example": "parameter 'p' must be an integer", + "type": "string" + }, + "name": { + "description": "Name is the name of this class of errors.", + "example": "bad_request", + "type": "string" + }, + "temporary": { + "description": "Is the error temporary?", + "example": true, + "type": "boolean" + }, + "timeout": { + "description": "Is the error a timeout?", + "example": true, + "type": "boolean" + } + }, + "required": [ + "name", + "id", + "message", + "temporary", + "timeout", + "fault" + ], + "title": "Mediatype identifier: application/vnd.goa.error; view=default", + "type": "object" + }, + "ErrorsErrorRetryResponseBody": { + "description": "Error response result type (default view)", + "example": { + "fault": false, + "id": "123abc", + "message": "parameter 'p' must be an integer", + "name": "bad_request", + "temporary": false, + "timeout": true + }, + "properties": { + "fault": { + "description": "Is the error a server-side fault?", + "example": false, + "type": "boolean" + }, + "id": { + "description": "ID is a unique identifier for this particular occurrence of the problem.", + "example": "123abc", + "type": "string" + }, + "message": { + "description": "Message is a human-readable explanation specific to this occurrence of the problem.", + "example": "parameter 'p' must be an integer", + "type": "string" + }, + "name": { + "description": "Name is the name of this class of errors.", + "example": "bad_request", + "type": "string" + }, + "temporary": { + "description": "Is the error temporary?", + "example": false, + "type": "boolean" + }, + "timeout": { + "description": "Is the error a timeout?", + "example": true, + "type": "boolean" + } + }, + "required": [ + "name", + "id", + "message", + "temporary", + "timeout", + "fault" + ], + "title": "Mediatype identifier: application/vnd.goa.error; view=default", + "type": "object" + }, + "GoaCustomError": { + "description": "Error_custom_Response_Body result type (default view)", + "example": { + "message": "error message", + "name": "custom" + }, + "properties": { + "message": { + "example": "error message", + "type": "string" + }, + "name": { + "example": "custom", + "type": "string" + } + }, + "required": [ + "name", + "message" + ], + "title": "Mediatype identifier: application/vnd.goa.custom-error; view=default", + "type": "object" + } + }, + "host": "localhost:80", + "info": { + "title": "", + "version": "0.0.1" + }, + "paths": { + "/": { + "get": { + "operationId": "Errors#Error", + "responses": { + "204": { + "description": "No Content response." + }, + "400": { + "description": "bad_request: Bad Request response.", + "schema": { + "$ref": "#/definitions/ErrorsErrorBadRequestResponseBody" + } + }, + "404": { + "description": "not_found: Not Found response.", + "examples": { + "application/vnd.goa.error": { + "fault": false, + "id": "123abc", + "message": "parameter 'p' must be an integer", + "name": "not_found", + "temporary": false, + "timeout": false + } + }, + "schema": { + "$ref": "#/definitions/ErrorsErrorNotFoundResponseBody" + } + }, + "409": { + "description": "custom: Conflict response.", + "schema": { + "$ref": "#/definitions/GoaCustomError" + } + }, + "429": { + "description": "retry: Too Many Requests response.", + "examples": { + "application/vnd.goa.error": { + "fault": false, + "id": "123abc", + "message": "parameter 'p' must be an integer", + "name": "retry", + "temporary": true, + "timeout": false + } + }, + "schema": { + "$ref": "#/definitions/ErrorsErrorRetryResponseBody" + } + }, + "500": { + "description": "internal: Internal Server Error response.", + "examples": { + "application/vnd.goa.error": { + "fault": true, + "id": "123abc", + "message": "parameter 'p' must be an integer", + "name": "internal", + "temporary": false, + "timeout": false + } + }, + "schema": { + "$ref": "#/definitions/ErrorsErrorInternalResponseBody" + } + }, + "503": { + "description": "retry_deadline: Service Unavailable response.", + "examples": { + "application/vnd.goa.error": { + "fault": false, + "id": "123abc", + "message": "parameter 'p' must be an integer", + "name": "retry_deadline", + "temporary": true, + "timeout": true + } + }, + "schema": { + "$ref": "#/definitions/ErrorsErrorRetryDeadlineResponseBody" + } + }, + "504": { + "description": "deadline: Gateway Timeout response.", + "examples": { + "application/vnd.goa.error": { + "fault": false, + "id": "123abc", + "message": "parameter 'p' must be an integer", + "name": "deadline", + "temporary": false, + "timeout": true + } + }, + "schema": { + "$ref": "#/definitions/ErrorsErrorDeadlineResponseBody" + } + } + }, + "schemes": [ + "http" + ], + "summary": "Error Errors", + "tags": [ + "Errors" + ] + } + } + }, + "produces": [ + "application/json", + "application/xml", + "application/gob" + ], + "swagger": "2.0" +} diff --git a/http/codegen/openapi/v2/testdata/TestSections/error-examples_file1.golden b/http/codegen/openapi/v2/testdata/TestSections/error-examples_file1.golden new file mode 100644 index 0000000000..440df43548 --- /dev/null +++ b/http/codegen/openapi/v2/testdata/TestSections/error-examples_file1.golden @@ -0,0 +1,369 @@ +swagger: "2.0" +info: + title: "" + version: 0.0.1 +host: localhost:80 +consumes: + - application/json + - application/xml + - application/gob +produces: + - application/json + - application/xml + - application/gob +paths: + /: + get: + tags: + - Errors + summary: Error Errors + operationId: Errors#Error + responses: + "204": + description: No Content response. + "400": + description: 'bad_request: Bad Request response.' + schema: + $ref: '#/definitions/ErrorsErrorBadRequestResponseBody' + "404": + description: 'not_found: Not Found response.' + schema: + $ref: '#/definitions/ErrorsErrorNotFoundResponseBody' + examples: + application/vnd.goa.error: + fault: false + id: 123abc + message: parameter 'p' must be an integer + name: not_found + temporary: false + timeout: false + "409": + description: 'custom: Conflict response.' + schema: + $ref: '#/definitions/GoaCustomError' + "429": + description: 'retry: Too Many Requests response.' + schema: + $ref: '#/definitions/ErrorsErrorRetryResponseBody' + examples: + application/vnd.goa.error: + fault: false + id: 123abc + message: parameter 'p' must be an integer + name: retry + temporary: true + timeout: false + "500": + description: 'internal: Internal Server Error response.' + schema: + $ref: '#/definitions/ErrorsErrorInternalResponseBody' + examples: + application/vnd.goa.error: + fault: true + id: 123abc + message: parameter 'p' must be an integer + name: internal + temporary: false + timeout: false + "503": + description: 'retry_deadline: Service Unavailable response.' + schema: + $ref: '#/definitions/ErrorsErrorRetryDeadlineResponseBody' + examples: + application/vnd.goa.error: + fault: false + id: 123abc + message: parameter 'p' must be an integer + name: retry_deadline + temporary: true + timeout: true + "504": + description: 'deadline: Gateway Timeout response.' + schema: + $ref: '#/definitions/ErrorsErrorDeadlineResponseBody' + examples: + application/vnd.goa.error: + fault: false + id: 123abc + message: parameter 'p' must be an integer + name: deadline + temporary: false + timeout: true + schemes: + - http +definitions: + ErrorsErrorBadRequestResponseBody: + title: 'Mediatype identifier: application/vnd.goa.error; view=default' + type: object + properties: + fault: + type: boolean + description: Is the error a server-side fault? + example: false + id: + type: string + description: ID is a unique identifier for this particular occurrence of the problem. + example: 123abc + message: + type: string + description: Message is a human-readable explanation specific to this occurrence of the problem. + example: parameter 'p' must be an integer + name: + type: string + description: Name is the name of this class of errors. + example: bad_request + temporary: + type: boolean + description: Is the error temporary? + example: true + timeout: + type: boolean + description: Is the error a timeout? + example: false + description: Error response result type (default view) + example: + fault: false + id: 123abc + message: parameter 'p' must be an integer + name: bad_request + temporary: true + timeout: false + required: + - name + - id + - message + - temporary + - timeout + - fault + ErrorsErrorDeadlineResponseBody: + title: 'Mediatype identifier: application/vnd.goa.error; view=default' + type: object + properties: + fault: + type: boolean + description: Is the error a server-side fault? + example: false + id: + type: string + description: ID is a unique identifier for this particular occurrence of the problem. + example: 123abc + message: + type: string + description: Message is a human-readable explanation specific to this occurrence of the problem. + example: parameter 'p' must be an integer + name: + type: string + description: Name is the name of this class of errors. + example: bad_request + temporary: + type: boolean + description: Is the error temporary? + example: true + timeout: + type: boolean + description: Is the error a timeout? + example: true + description: Error response result type (default view) + example: + fault: false + id: 123abc + message: parameter 'p' must be an integer + name: bad_request + temporary: true + timeout: true + required: + - name + - id + - message + - temporary + - timeout + - fault + ErrorsErrorInternalResponseBody: + title: 'Mediatype identifier: application/vnd.goa.error; view=default' + type: object + properties: + fault: + type: boolean + description: Is the error a server-side fault? + example: false + id: + type: string + description: ID is a unique identifier for this particular occurrence of the problem. + example: 123abc + message: + type: string + description: Message is a human-readable explanation specific to this occurrence of the problem. + example: parameter 'p' must be an integer + name: + type: string + description: Name is the name of this class of errors. + example: bad_request + temporary: + type: boolean + description: Is the error temporary? + example: false + timeout: + type: boolean + description: Is the error a timeout? + example: false + description: Error response result type (default view) + example: + fault: false + id: 123abc + message: parameter 'p' must be an integer + name: bad_request + temporary: false + timeout: false + required: + - name + - id + - message + - temporary + - timeout + - fault + ErrorsErrorNotFoundResponseBody: + title: 'Mediatype identifier: application/vnd.goa.error; view=default' + type: object + properties: + fault: + type: boolean + description: Is the error a server-side fault? + example: false + id: + type: string + description: ID is a unique identifier for this particular occurrence of the problem. + example: 123abc + message: + type: string + description: Message is a human-readable explanation specific to this occurrence of the problem. + example: parameter 'p' must be an integer + name: + type: string + description: Name is the name of this class of errors. + example: bad_request + temporary: + type: boolean + description: Is the error temporary? + example: true + timeout: + type: boolean + description: Is the error a timeout? + example: true + description: Error response result type (default view) + example: + fault: false + id: 123abc + message: parameter 'p' must be an integer + name: bad_request + temporary: true + timeout: true + required: + - name + - id + - message + - temporary + - timeout + - fault + ErrorsErrorRetryDeadlineResponseBody: + title: 'Mediatype identifier: application/vnd.goa.error; view=default' + type: object + properties: + fault: + type: boolean + description: Is the error a server-side fault? + example: false + id: + type: string + description: ID is a unique identifier for this particular occurrence of the problem. + example: 123abc + message: + type: string + description: Message is a human-readable explanation specific to this occurrence of the problem. + example: parameter 'p' must be an integer + name: + type: string + description: Name is the name of this class of errors. + example: bad_request + temporary: + type: boolean + description: Is the error temporary? + example: true + timeout: + type: boolean + description: Is the error a timeout? + example: true + description: Error response result type (default view) + example: + fault: false + id: 123abc + message: parameter 'p' must be an integer + name: bad_request + temporary: true + timeout: true + required: + - name + - id + - message + - temporary + - timeout + - fault + ErrorsErrorRetryResponseBody: + title: 'Mediatype identifier: application/vnd.goa.error; view=default' + type: object + properties: + fault: + type: boolean + description: Is the error a server-side fault? + example: false + id: + type: string + description: ID is a unique identifier for this particular occurrence of the problem. + example: 123abc + message: + type: string + description: Message is a human-readable explanation specific to this occurrence of the problem. + example: parameter 'p' must be an integer + name: + type: string + description: Name is the name of this class of errors. + example: bad_request + temporary: + type: boolean + description: Is the error temporary? + example: false + timeout: + type: boolean + description: Is the error a timeout? + example: true + description: Error response result type (default view) + example: + fault: false + id: 123abc + message: parameter 'p' must be an integer + name: bad_request + temporary: false + timeout: true + required: + - name + - id + - message + - temporary + - timeout + - fault + GoaCustomError: + title: 'Mediatype identifier: application/vnd.goa.custom-error; view=default' + type: object + properties: + message: + type: string + example: error message + name: + type: string + example: custom + description: Error_custom_Response_Body result type (default view) + example: + message: error message + name: custom + required: + - name + - message diff --git a/http/codegen/openapi/v2/testdata/TestSections/released-response-collection-names_file0.golden b/http/codegen/openapi/v2/testdata/TestSections/released-response-collection-names_file0.golden new file mode 100644 index 0000000000..c5cb00388f --- /dev/null +++ b/http/codegen/openapi/v2/testdata/TestSections/released-response-collection-names_file0.golden @@ -0,0 +1,144 @@ +{ + "consumes": [ + "application/json", + "application/xml", + "application/gob" + ], + "definitions": { + "StorageStoredBottleResponseCollection": { + "description": "list_default_response_body is the result type for an array of StoredBottle (default view)", + "example": [ + { + "name": "Blue's Cuvee", + "vintage": 2003 + }, + { + "name": "Blue's Cuvee", + "vintage": 2003 + }, + { + "name": "Blue's Cuvee", + "vintage": 2003 + } + ], + "items": { + "$ref": "#/definitions/StoredBottleResponse" + }, + "title": "Mediatype identifier: application/vnd.stored-bottle; type=collection; view=default", + "type": "array" + }, + "StorageStoredBottleResponseTinyCollection": { + "description": "StorageStoredBottleResponseTinyCollection is the result type for an array of StoredBottleResponseTiny (default view)", + "example": [ + { + "name": "Blue's Cuvee" + }, + { + "name": "Blue's Cuvee" + }, + { + "name": "Blue's Cuvee" + } + ], + "items": { + "$ref": "#/definitions/StoredBottleResponseTiny" + }, + "title": "Mediatype identifier: application/vnd.stored-bottle; type=collection; view=tiny", + "type": "array" + }, + "StoredBottleResponse": { + "description": "StoredBottle result type (default view)", + "example": { + "name": "Blue's Cuvee", + "vintage": 2003 + }, + "properties": { + "name": { + "example": "Blue's Cuvee", + "type": "string" + }, + "vintage": { + "example": 2003, + "format": "int32", + "type": "integer" + } + }, + "required": [ + "name", + "vintage" + ], + "title": "Mediatype identifier: application/vnd.stored-bottle; view=default", + "type": "object" + }, + "StoredBottleResponseTiny": { + "description": "StoredBottle result type (tiny view) (default view)", + "example": { + "name": "Blue's Cuvee" + }, + "properties": { + "name": { + "example": "Blue's Cuvee", + "type": "string" + } + }, + "required": [ + "name" + ], + "title": "Mediatype identifier: application/vnd.stored-bottle; view=default", + "type": "object" + } + }, + "host": "localhost:80", + "info": { + "title": "", + "version": "0.0.1" + }, + "paths": { + "/default": { + "get": { + "operationId": "storage#list_default", + "responses": { + "200": { + "description": "OK response.", + "schema": { + "$ref": "#/definitions/StorageStoredBottleResponseCollection" + } + } + }, + "schemes": [ + "http" + ], + "summary": "list_default storage", + "tags": [ + "storage" + ] + } + }, + "/tiny": { + "get": { + "operationId": "storage#list_tiny", + "responses": { + "200": { + "description": "OK response.", + "schema": { + "$ref": "#/definitions/StorageStoredBottleResponseTinyCollection" + } + } + }, + "schemes": [ + "http" + ], + "summary": "list_tiny storage", + "tags": [ + "storage" + ] + } + } + }, + "produces": [ + "application/json", + "application/xml", + "application/gob" + ], + "swagger": "2.0" +} diff --git a/http/codegen/openapi/v2/testdata/TestSections/released-response-collection-names_file1.golden b/http/codegen/openapi/v2/testdata/TestSections/released-response-collection-names_file1.golden new file mode 100644 index 0000000000..3a23ea386b --- /dev/null +++ b/http/codegen/openapi/v2/testdata/TestSections/released-response-collection-names_file1.golden @@ -0,0 +1,94 @@ +swagger: "2.0" +info: + title: "" + version: 0.0.1 +host: localhost:80 +consumes: + - application/json + - application/xml + - application/gob +produces: + - application/json + - application/xml + - application/gob +paths: + /default: + get: + tags: + - storage + summary: list_default storage + operationId: storage#list_default + responses: + "200": + description: OK response. + schema: + $ref: '#/definitions/StorageStoredBottleResponseCollection' + schemes: + - http + /tiny: + get: + tags: + - storage + summary: list_tiny storage + operationId: storage#list_tiny + responses: + "200": + description: OK response. + schema: + $ref: '#/definitions/StorageStoredBottleResponseTinyCollection' + schemes: + - http +definitions: + StorageStoredBottleResponseCollection: + title: 'Mediatype identifier: application/vnd.stored-bottle; type=collection; view=default' + type: array + items: + $ref: '#/definitions/StoredBottleResponse' + description: list_default_response_body is the result type for an array of StoredBottle (default view) + example: + - name: Blue's Cuvee + vintage: 2003 + - name: Blue's Cuvee + vintage: 2003 + - name: Blue's Cuvee + vintage: 2003 + StorageStoredBottleResponseTinyCollection: + title: 'Mediatype identifier: application/vnd.stored-bottle; type=collection; view=tiny' + type: array + items: + $ref: '#/definitions/StoredBottleResponseTiny' + description: StorageStoredBottleResponseTinyCollection is the result type for an array of StoredBottleResponseTiny (default view) + example: + - name: Blue's Cuvee + - name: Blue's Cuvee + - name: Blue's Cuvee + StoredBottleResponse: + title: 'Mediatype identifier: application/vnd.stored-bottle; view=default' + type: object + properties: + name: + type: string + example: Blue's Cuvee + vintage: + type: integer + example: 2003 + format: int32 + description: StoredBottle result type (default view) + example: + name: Blue's Cuvee + vintage: 2003 + required: + - name + - vintage + StoredBottleResponseTiny: + title: 'Mediatype identifier: application/vnd.stored-bottle; view=default' + type: object + properties: + name: + type: string + example: Blue's Cuvee + description: StoredBottle result type (tiny view) (default view) + example: + name: Blue's Cuvee + required: + - name diff --git a/http/codegen/openapi/v2/testdata/TestSections/security_file0.golden b/http/codegen/openapi/v2/testdata/TestSections/security_file0.golden index dae601cbf0..caf6b7a4b4 100644 --- a/http/codegen/openapi/v2/testdata/TestSections/security_file0.golden +++ b/http/codegen/openapi/v2/testdata/TestSections/security_file0.golden @@ -24,9 +24,9 @@ ], "security": [ { - "api_key_query_k": null, - "basic_header_Authorization": null, - "jwt_header_X-Authorization": null, + "api_key_query_k": [], + "basic_header_Authorization": [], + "jwt_header_X-Authorization": [], "oauth2_header_Token": [ "api:read" ] @@ -49,7 +49,7 @@ ], "security": [ { - "api_key_header_Authorization": null + "api_key_header_Authorization": [] }, { "oauth2_query_auth": [ diff --git a/http/codegen/openapi/v2/testdata/TestSections/shared-error-description_file0.golden b/http/codegen/openapi/v2/testdata/TestSections/shared-error-description_file0.golden new file mode 100644 index 0000000000..8238819e72 --- /dev/null +++ b/http/codegen/openapi/v2/testdata/TestSections/shared-error-description_file0.golden @@ -0,0 +1,101 @@ +{ + "consumes": [ + "application/json", + "application/xml", + "application/gob" + ], + "definitions": { + "SharedError": { + "description": "Shared error value", + "example": { + "message": "shared failure", + "name": "Assumenda temporibus in exercitationem alias et." + }, + "properties": { + "message": { + "description": "Error message", + "example": "shared failure", + "type": "string" + }, + "name": { + "description": "Selected error name", + "example": "Assumenda temporibus in exercitationem alias et.", + "type": "string" + } + }, + "required": [ + "name", + "message" + ], + "title": "SharedError", + "type": "object" + } + }, + "host": "localhost:80", + "info": { + "title": "", + "version": "0.0.1" + }, + "paths": { + "/first": { + "get": { + "operationId": "errors#first", + "responses": { + "204": { + "description": "No Content response." + }, + "400": { + "description": "first_error: First failure", + "schema": { + "$ref": "#/definitions/SharedError", + "required": [ + "name", + "message" + ] + } + } + }, + "schemes": [ + "http" + ], + "summary": "first errors", + "tags": [ + "errors" + ] + } + }, + "/second": { + "get": { + "operationId": "errors#second", + "responses": { + "204": { + "description": "No Content response." + }, + "400": { + "description": "second_error: Second failure", + "schema": { + "$ref": "#/definitions/SharedError", + "required": [ + "name", + "message" + ] + } + } + }, + "schemes": [ + "http" + ], + "summary": "second errors", + "tags": [ + "errors" + ] + } + } + }, + "produces": [ + "application/json", + "application/xml", + "application/gob" + ], + "swagger": "2.0" +} diff --git a/http/codegen/openapi/v2/testdata/TestSections/shared-error-description_file1.golden b/http/codegen/openapi/v2/testdata/TestSections/shared-error-description_file1.golden new file mode 100644 index 0000000000..06e48722ae --- /dev/null +++ b/http/codegen/openapi/v2/testdata/TestSections/shared-error-description_file1.golden @@ -0,0 +1,70 @@ +swagger: "2.0" +info: + title: "" + version: 0.0.1 +host: localhost:80 +consumes: + - application/json + - application/xml + - application/gob +produces: + - application/json + - application/xml + - application/gob +paths: + /first: + get: + tags: + - errors + summary: first errors + operationId: errors#first + responses: + "204": + description: No Content response. + "400": + description: 'first_error: First failure' + schema: + $ref: '#/definitions/SharedError' + required: + - name + - message + schemes: + - http + /second: + get: + tags: + - errors + summary: second errors + operationId: errors#second + responses: + "204": + description: No Content response. + "400": + description: 'second_error: Second failure' + schema: + $ref: '#/definitions/SharedError' + required: + - name + - message + schemes: + - http +definitions: + SharedError: + title: SharedError + type: object + properties: + message: + type: string + description: Error message + example: shared failure + name: + type: string + description: Selected error name + example: Assumenda temporibus in exercitationem alias et. + description: Shared error value + example: + message: shared failure + name: Assumenda temporibus in exercitationem alias et. + required: + - name + - message diff --git a/http/codegen/openapi/v2/testdata/TestSections/with-any_file0.golden b/http/codegen/openapi/v2/testdata/TestSections/with-any_file0.golden index 8a2ad8deab..290850a547 100644 --- a/http/codegen/openapi/v2/testdata/TestSections/with-any_file0.golden +++ b/http/codegen/openapi/v2/testdata/TestSections/with-any_file0.golden @@ -9,12 +9,10 @@ "example": { "any": "", "any_array": [ - "", - "", "" ], "any_map": { - "": "" + "key": "" } }, "properties": { @@ -23,8 +21,6 @@ }, "any_array": { "example": [ - "", - "", "" ], "items": { @@ -35,7 +31,7 @@ "any_map": { "additionalProperties": true, "example": { - "": "" + "key": "" }, "type": "object" } @@ -47,13 +43,10 @@ "example": { "any": "", "any_array": [ - "", - "", - "", "" ], "any_map": { - "": "" + "key": "" } }, "properties": { @@ -62,9 +55,6 @@ }, "any_array": { "example": [ - "", - "", - "", "" ], "items": { @@ -75,7 +65,7 @@ "any_map": { "additionalProperties": true, "example": { - "": "" + "key": "" }, "type": "object" } diff --git a/http/codegen/openapi/v2/testdata/TestSections/with-any_file1.golden b/http/codegen/openapi/v2/testdata/TestSections/with-any_file1.golden index 442df6f3ea..8b2d7825be 100644 --- a/http/codegen/openapi/v2/testdata/TestSections/with-any_file1.golden +++ b/http/codegen/openapi/v2/testdata/TestSections/with-any_file1.golden @@ -44,21 +44,17 @@ definitions: example: "" example: - "" - - "" - - "" any_map: type: object example: - "": "" + key: "" additionalProperties: true example: any: "" any_array: - "" - - "" - - "" any_map: - "": "" + key: "" TestServiceTestEndpointResponseBody: title: TestServiceTestEndpointResponseBody type: object @@ -71,20 +67,14 @@ definitions: example: "" example: - "" - - "" - - "" - - "" any_map: type: object example: - "": "" + key: "" additionalProperties: true example: any: "" any_array: - "" - - "" - - "" - - "" any_map: - "": "" + key: "" diff --git a/http/codegen/openapi/v2/testdata/TestSections/with-map_file0.golden b/http/codegen/openapi/v2/testdata/TestSections/with-map_file0.golden index ac58094d55..fa511401da 100644 --- a/http/codegen/openapi/v2/testdata/TestSections/with-map_file0.golden +++ b/http/codegen/openapi/v2/testdata/TestSections/with-map_file0.golden @@ -19,7 +19,7 @@ "type": "object" }, "GoaFoobar": { - "description": "Foo BarResponseBody result type (default view)", + "description": "Foo Bar result type (default view)", "example": { "bar": [ { @@ -114,6 +114,9 @@ { "string": "" }, + { + "string": "" + }, { "string": "" } @@ -139,6 +142,9 @@ { "string": "" }, + { + "string": "" + }, { "string": "" } diff --git a/http/codegen/openapi/v2/testdata/TestSections/with-map_file1.golden b/http/codegen/openapi/v2/testdata/TestSections/with-map_file1.golden index d60d92056b..5fadaf92f4 100644 --- a/http/codegen/openapi/v2/testdata/TestSections/with-map_file1.golden +++ b/http/codegen/openapi/v2/testdata/TestSections/with-map_file1.golden @@ -55,7 +55,7 @@ definitions: foo: type: string example: "" - description: Foo BarResponseBody result type (default view) + description: Foo Bar result type (default view) example: bar: - string: "" @@ -107,6 +107,7 @@ definitions: bar: - string: "" - string: "" + - string: "" foo: "" additionalProperties: $ref: '#/definitions/GoaFoobar' @@ -132,6 +133,7 @@ definitions: bar: - string: "" - string: "" + - string: "" foo: "" uint32_map: "": 1 diff --git a/http/codegen/openapi/v2/testdata/TestSections/with-spaces_file0.golden b/http/codegen/openapi/v2/testdata/TestSections/with-spaces_file0.golden index e3551637c1..a9eee0c4bb 100644 --- a/http/codegen/openapi/v2/testdata/TestSections/with-spaces_file0.golden +++ b/http/codegen/openapi/v2/testdata/TestSections/with-spaces_file0.golden @@ -7,7 +7,7 @@ "definitions": { "Bar": { "example": { - "string": "" + "string": "item" }, "properties": { "string": { @@ -23,10 +23,7 @@ "example": { "bar": [ { - "string": "" - }, - { - "string": "" + "string": "item" } ], "foo": "" @@ -35,10 +32,7 @@ "bar": { "example": [ { - "string": "" - }, - { - "string": "" + "string": "item" } ], "items": { diff --git a/http/codegen/openapi/v2/testdata/TestSections/with-spaces_file1.golden b/http/codegen/openapi/v2/testdata/TestSections/with-spaces_file1.golden index 0988ba2ddf..0480b2e804 100644 --- a/http/codegen/openapi/v2/testdata/TestSections/with-spaces_file1.golden +++ b/http/codegen/openapi/v2/testdata/TestSections/with-spaces_file1.golden @@ -44,7 +44,7 @@ definitions: type: string example: "" example: - string: "" + string: item GoaFoobar: title: 'Mediatype identifier: application/vnd.goa.foobar; view=default' type: object @@ -54,14 +54,12 @@ definitions: items: $ref: '#/definitions/Bar' example: - - string: "" - - string: "" + - string: item foo: type: string example: "" description: Test EndpointOKResponseBody result type (default view) example: bar: - - string: "" - - string: "" + - string: item foo: "" diff --git a/http/codegen/openapi/v2/testdata/TestValidations/array_file0.golden b/http/codegen/openapi/v2/testdata/TestValidations/array_file0.golden index 4b648409e2..c359d133a6 100644 --- a/http/codegen/openapi/v2/testdata/TestValidations/array_file0.golden +++ b/http/codegen/openapi/v2/testdata/TestValidations/array_file0.golden @@ -7,11 +7,11 @@ "definitions": { "Bar": { "example": { - "string": "" + "string": "item" }, "properties": { "string": { - "example": "", + "example": "item", "maxLength": 42, "minLength": 0, "type": "string" @@ -22,15 +22,22 @@ }, "Foobar": { "example": { - "bar": [], + "bar": [ + { + "string": "item" + } + ], "foo": [ - "Totam doloremque exercitationem voluptate.", - "Repellat perspiciatis voluptas et quia deleniti." + "item" ] }, "properties": { "bar": { - "example": [], + "example": [ + { + "string": "item" + } + ], "items": { "$ref": "#/definitions/Bar" }, @@ -40,11 +47,10 @@ }, "foo": { "example": [ - "Totam doloremque exercitationem voluptate.", - "Repellat perspiciatis voluptas et quia deleniti." + "item" ], "items": { - "example": "Molestiae dolor eveniet omnis atque.", + "example": "item", "type": "string" }, "maxItems": 42, diff --git a/http/codegen/openapi/v2/testdata/TestValidations/array_file1.golden b/http/codegen/openapi/v2/testdata/TestValidations/array_file1.golden index b522e657a7..f1da44a905 100644 --- a/http/codegen/openapi/v2/testdata/TestValidations/array_file1.golden +++ b/http/codegen/openapi/v2/testdata/TestValidations/array_file1.golden @@ -42,11 +42,11 @@ definitions: properties: string: type: string - example: "" + example: item minLength: 0 maxLength: 42 example: - string: "" + string: item Foobar: title: Foobar type: object @@ -55,21 +55,21 @@ definitions: type: array items: $ref: '#/definitions/Bar' - example: [] + example: + - string: item minItems: 0 maxItems: 42 foo: type: array items: type: string - example: Molestiae dolor eveniet omnis atque. + example: item example: - - Totam doloremque exercitationem voluptate. - - Repellat perspiciatis voluptas et quia deleniti. + - item minItems: 0 maxItems: 42 example: - bar: [] + bar: + - string: item foo: - - Totam doloremque exercitationem voluptate. - - Repellat perspiciatis voluptas et quia deleniti. + - item diff --git a/http/codegen/openapi/v3/builder.go b/http/codegen/openapi/v3/builder.go index d3469ff347..068e5f2a87 100644 --- a/http/codegen/openapi/v3/builder.go +++ b/http/codegen/openapi/v3/builder.go @@ -1,3 +1,5 @@ +// This file builds OpenAPI 3 operations from HTTP endpoints. It uses the +// request or response being described to choose each example value. package openapiv3 import ( @@ -29,20 +31,27 @@ const ( ) // New returns the OpenAPI specification conforming to the given version -// (openapi.Version30 or openapi.Version32) for the given API. It returns nil -// if the design does not define HTTP endpoints. +// (openapi.Version30 or openapi.Version32) for the given API. It returns nil if +// the design does not define HTTP endpoints. func New(root *expr.RootExpr, ver openapi.Version) *OpenAPI { - if root == nil || root.API == nil || root.API.HTTP == nil || len(root.API.HTTP.Services) == 0 { - // No HTTP transport + if root == nil || root.API == nil { return nil } + return NewWithValues( + root, + ver, + expr.NewExampleGenerator(root.API.RandomizerFactory), + openapi.Values{}, + ) +} - m, ok := root.API.Meta.Last("openapi:example") - if !ok { - m, ok = root.API.Meta.Last("swagger:example") - } - if ok && m == "false" { - root.API.ExampleGenerator.Randomizer = nil +// NewWithValues returns an OpenAPI specification using values in place of +// matching titles, descriptions, and examples from the evaluated design. +// The generator supplies examples for attributes that have no matching value. +func NewWithValues(root *expr.RootExpr, ver openapi.Version, generator *expr.ExampleGenerator, values openapi.Values) *OpenAPI { + if root == nil || root.API == nil || root.API.HTTP == nil || len(root.API.HTTP.Services) == 0 { + // No HTTP transport + return nil } specVersion := OpenAPIVersion @@ -51,14 +60,14 @@ func New(root *expr.RootExpr, ver openapi.Version) *OpenAPI { } var ( - bodies, types = buildBodyTypes(root.API, root.Types, root.ResultTypes, ver) + bodies, types = buildBodyTypes(root.API, root.Types, root.ResultTypes, ver, generator, values) - info = buildInfo(root.API, ver) - comps = buildComponents(root, types) - servers = buildServers(root.API.Servers, ver) - paths = buildPaths(root.API.HTTP, bodies, root.API, ver) + info = buildInfo(root.API, ver, values) + comps = buildComponents(root, types, values) + servers = buildServers(root.API.Servers, ver, values) + paths = buildPaths(root.API.HTTP, bodies, root.API, ver, generator, values) security = buildSecurityRequirements(root.API.Requirements) - tags = buildTags(root.API, ver) + tags = buildTags(root.API, ver, values) ) return &OpenAPI{ @@ -73,14 +82,14 @@ func New(root *expr.RootExpr, ver openapi.Version) *OpenAPI { } // buildInfo builds the OpenAPI Info object. -func buildInfo(api *expr.APIExpr, ver openapi.Version) *Info { - title := api.Title +func buildInfo(api *expr.APIExpr, ver openapi.Version, values openapi.Values) *Info { + title := values.Title(api, api.Title) if title == "" { title = "Goa API" // cannot be empty as per OpenAPI spec } info := &Info{ Title: title, - Description: api.Description, + Description: values.Description(api, api.Description), TermsOfService: api.TermsOfService, Version: api.Version, Extensions: openapi.ExtensionsFromExpr(api.Meta), @@ -107,16 +116,22 @@ func buildInfo(api *expr.APIExpr, ver openapi.Version) *Info { } // buildComponents builds the OpenAPI Components object. -func buildComponents(root *expr.RootExpr, types map[string]*openapi.Schema) *Components { +func buildComponents(root *expr.RootExpr, types map[string]*openapi.Schema, values openapi.Values) *Components { var schemesRef map[string]*SecuritySchemeRef { schemesRef = make(map[string]*SecuritySchemeRef) for _, s := range root.API.HTTP.Services { + if !openapi.MustGenerate(s.Meta) || !openapi.MustGenerate(s.ServiceExpr.Meta) { + continue + } for _, e := range s.HTTPEndpoints { + if !openapi.MustGenerate(e.Meta) || !openapi.MustGenerate(e.MethodExpr.Meta) { + continue + } for _, r := range e.Requirements { for _, sch := range r.Schemes { schemesRef[sch.Hash()] = &SecuritySchemeRef{ - Value: buildSecurityScheme(sch), + Value: buildSecurityScheme(sch, values), } } } @@ -131,7 +146,7 @@ func buildComponents(root *expr.RootExpr, types map[string]*openapi.Schema) *Com // buildPaths builds the OpenAPI Paths map with key as the HTTP path string and // the value as the corresponding PathItem object. -func buildPaths(h *expr.HTTPExpr, bodies map[string]map[string]*EndpointBodies, api *expr.APIExpr, ver openapi.Version) map[string]*PathItem { +func buildPaths(h *expr.HTTPExpr, bodies map[string]map[string]*EndpointBodies, api *expr.APIExpr, ver openapi.Version, generator *expr.ExampleGenerator, values openapi.Values) map[string]*PathItem { var paths = make(map[string]*PathItem) for _, svc := range h.Services { if !openapi.MustGenerate(svc.Meta) || !openapi.MustGenerate(svc.ServiceExpr.Meta) { @@ -150,7 +165,7 @@ func buildPaths(h *expr.HTTPExpr, bodies map[string]map[string]*EndpointBodies, // Remove any wildcards that is defined in path as a workaround to // https://github.com/OAI/OpenAPI-Specification/issues/291 key = expr.HTTPWildcardRegex.ReplaceAllString(key, "/{$1}") - operation := buildOperation(key, r, sbod[e.Name()], api.ExampleGenerator, api.Meta, ver) + operation := buildOperation(key, r, sbod[e.Name()], generator, api.Meta, ver, values) path, ok := paths[key] if !ok { path = new(PathItem) @@ -191,7 +206,7 @@ func buildPaths(h *expr.HTTPExpr, bodies map[string]map[string]*EndpointBodies, // Replace wildcards in the path to OpenAPI path parameter form // e.g. "/ui/{*filepath}" -> "/ui/{filepath}" key = expr.HTTPWildcardRegex.ReplaceAllString(key, "/{$1}") - operation := buildFileServerOperation(key, f, api) + operation := buildFileServerOperation(key, f, api, values) path, ok := paths[key] if !ok { path = new(PathItem) @@ -205,7 +220,7 @@ func buildPaths(h *expr.HTTPExpr, bodies map[string]map[string]*EndpointBodies, } // buildOperation builds the OpenAPI Operation object for the given path. -func buildOperation(key string, r *expr.RouteExpr, bodies *EndpointBodies, rand *expr.ExampleGenerator, meta expr.MetaExpr, ver openapi.Version) *Operation { +func buildOperation(key string, r *expr.RouteExpr, bodies *EndpointBodies, rand *expr.ExampleGenerator, meta expr.MetaExpr, ver openapi.Version, values openapi.Values) *Operation { e := r.Endpoint m := e.MethodExpr svc := e.Service @@ -254,9 +269,9 @@ func buildOperation(key string, r *expr.RouteExpr, bodies *EndpointBodies, rand ct = "multipart/form-data" } mt := &MediaType{Schema: bodies.RequestBody} - initExamples(mt, e.Body, rand.Rebased(bodyExampleID(m.Service.Name, e.Name(), "request"))) + initExamples(mt, e.Body, rand.At(expr.RequestBodyExampleIdentity(e)), values) requestBody = &RequestBodyRef{Value: &RequestBody{ - Description: requestBodyDescription(e), + Description: requestBodyDescription(e, values), Required: e.Body.Type != expr.Empty, Content: map[string]*MediaType{ct: mt}, Extensions: openapi.ExtensionsFromExpr(e.Body.Meta), @@ -266,14 +281,8 @@ func buildOperation(key string, r *expr.RouteExpr, bodies *EndpointBodies, rand // parameters var params []*ParameterRef { - ps := paramsFromPath(e, key, rand) - ps = append(ps, paramsFromHeadersAndCookies(e, rand)...) - if ver == openapi.Version32 && e.UsesSSE() && e.SSE.RequestIDField != "" { - // The generated handler reads the Last-Event-ID header directly so - // the header does not appear in the endpoint headers expression. - att := expr.AsObject(m.Payload.Type).Attribute(e.SSE.RequestIDField) - ps = append(ps, paramFor(att, "Last-Event-ID", "header", false, rand.Field(m.Payload, e.SSE.RequestIDField))) - } + ps := paramsFromPath(e, key, rand, values) + ps = append(ps, paramsFromHeadersAndCookies(e, rand, values)...) if e.MapQueryParams != nil { name := *e.MapQueryParams if name == "" { @@ -299,7 +308,8 @@ func buildOperation(key string, r *expr.RouteExpr, bodies *EndpointBodies, rand // responses responses := make(map[string]*ResponseRef, len(e.Responses)) - for _, r := range e.Responses { + responseBodyIndexes := make(map[int]int) + for i, r := range e.Responses { var resultCT string switch { case e.UsesWebSocket(): @@ -314,29 +324,43 @@ func buildOperation(key string, r *expr.RouteExpr, bodies *EndpointBodies, rand bodies.ResponseBodies[r.StatusCode] = b } case e.UsesSSE(): - resultCT = responseContentType(r) + resultCT = openapi.ResponseContentType(r) r = r.Dup() r.ContentType = "text/event-stream" } - resp := responseFromExpr(r, bodies.ResponseBodies, rand) + var body *openapi.Schema + if r.Body.Type != expr.Empty { + bodyIndex := responseBodyIndexes[r.StatusCode] + body = bodies.ResponseBodies[r.StatusCode][bodyIndex] + responseBodyIndexes[r.StatusCode]++ + } + owner := expr.MethodResultExampleIdentity(m) + bodyOwner := expr.ResponseBodyExampleIdentity(e, e.Responses[i]) + resp := responseFromExpr(r, body, rand, m.Result, owner, bodyOwner, "", values) if ver == openapi.Version32 && e.UsesSSE() { setSSEContent(resp, bodies, resultCT, m.HasMixedResults()) } responses[strconv.Itoa(r.StatusCode)] = &ResponseRef{Value: resp} } for _, er := range e.HTTPErrors { - if er.Description != "" && er.Response.Description == "" { - er.Response.Description = er.Description - } - resp := responseFromExpr(er.Response, bodies.ResponseBodies, rand) + var body *openapi.Schema + if er.Response.Body.Type != expr.Empty { + bodyIndex := responseBodyIndexes[er.Response.StatusCode] + body = bodies.ResponseBodies[er.Response.StatusCode][bodyIndex] + responseBodyIndexes[er.Response.StatusCode]++ + } + owner := expr.MethodErrorExampleIdentity(m, er.ErrorExpr) + bodyOwner := expr.ErrorResponseBodyExampleIdentity(e, er) + errorDescription := values.Description(er.ErrorExpr, er.Description) + resp := responseFromExpr(er.Response, body, rand, er.AttributeExpr, owner, bodyOwner, errorDescription, values) desc := er.Name if resp.Description != nil { desc += ": " + *resp.Description } resp.Description = &desc - if er.Type == expr.ErrorResult && len(er.Response.Body.ExtractUserExamples()) == 0 { + if example, ok := openapi.ErrorResponseExample(er.ErrorExpr, er.Response.Body, rand.At(bodyOwner), values); ok { for _, content := range resp.Content { - content.Example = nil + content.Example = example } } responses[strconv.Itoa(er.Response.StatusCode)] = &ResponseRef{Value: resp} @@ -369,14 +393,14 @@ func buildOperation(key string, r *expr.RouteExpr, bodies *EndpointBodies, rand return &Operation{ Tags: tagNames, Summary: summary, - Description: e.Description(), + Description: values.Description(e.MethodExpr, e.Description()), OperationID: parseOperationIDTemplate(operationIDFormat, svc.Name(), e.Name(), routeIndex), Parameters: params, RequestBody: requestBody, Responses: responses, Security: security, Deprecated: deprecated, - ExternalDocs: openapi.DocsFromExpr(m.Docs, m.Meta), + ExternalDocs: openapi.DocsFromExprWithValues(m.Docs, m.Meta, values), Extensions: openapi.ExtensionsFromMethod(m), } } @@ -385,17 +409,19 @@ func buildOperation(key string, r *expr.RouteExpr, bodies *EndpointBodies, rand // HTTP body, payload, or referenced type. It uses a deterministic default for // computed bodies so generated OpenAPI requestBody objects are always // self-describing. -func requestBodyDescription(e *expr.HTTPEndpointExpr) string { - if e.Body.Description != "" { - return e.Body.Description +func requestBodyDescription(e *expr.HTTPEndpointExpr, values openapi.Values) string { + if description := values.Description(e.Body.AuthoredAttribute(), e.Body.Description); description != "" { + return description } if ut, ok := e.Body.Type.(expr.UserType); ok { - if desc := ut.Attribute().Description; desc != "" { + if desc := values.Description(ut.Attribute().AuthoredAttribute(), ut.Attribute().Description); desc != "" { return desc } } - if e.MethodExpr.Payload != nil && e.MethodExpr.Payload.Description != "" { - return e.MethodExpr.Payload.Description + if e.MethodExpr.Payload != nil { + if desc := values.Description(e.MethodExpr.Payload.AuthoredAttribute(), e.MethodExpr.Payload.Description); desc != "" { + return desc + } } return defaultRequestBodyDescription(e) } @@ -408,7 +434,7 @@ func defaultRequestBodyDescription(e *expr.HTTPEndpointExpr) string { } // buildFileServerOperation builds the OpenAPI Operation object for the given file server. -func buildFileServerOperation(key string, fs *expr.HTTPFileServerExpr, api *expr.APIExpr) *Operation { +func buildFileServerOperation(key string, fs *expr.HTTPFileServerExpr, api *expr.APIExpr, values openapi.Values) *Operation { wildcards := expr.ExtractHTTPWildcards(key) svc := fs.Service @@ -489,14 +515,14 @@ func buildFileServerOperation(key string, fs *expr.HTTPFileServerExpr, api *expr return &Operation{ OperationID: parseOperationIDTemplate(operationIDFormat, svc.Name(), key, 0), - Description: fs.Description, + Description: values.Description(fs, fs.Description), Summary: summary, Parameters: params, Responses: responses, Tags: tagNames, Security: buildSecurityRequirements(api.Requirements), Deprecated: false, - ExternalDocs: openapi.DocsFromExpr(fs.Docs, fs.Meta), + ExternalDocs: openapi.DocsFromExprWithValues(fs.Docs, fs.Meta, values), Extensions: openapi.ExtensionsFromExpr(fs.Meta), } } @@ -530,7 +556,7 @@ func parseOperationIDTemplate(template, service, method string, routeIndex int) // buildServers builds the OpenAPI Server objects from the given server // expressions. -func buildServers(servers []*expr.ServerExpr, ver openapi.Version) []*Server { +func buildServers(servers []*expr.ServerExpr, ver openapi.Version, values openapi.Values) []*Server { var svrs []*Server for _, svr := range servers { if !openapi.MustGenerate(svr.Meta) { @@ -542,11 +568,7 @@ func buildServers(servers []*expr.ServerExpr, ver openapi.Version) []*Server { continue } - var ( - serverVariable = make(map[string]*ServerVariable) - defaultValue any - validationValues []any - ) + serverVariable := make(map[string]*ServerVariable) // Get the first URL expression in the host by default. // Host expression must have at least one URI (validations would have failed @@ -564,10 +586,11 @@ func buildServers(servers []*expr.ServerExpr, ver openapi.Version) []*Server { // retrieve host variables vars := expr.AsObject(host.Variables.Type) for _, v := range *vars { - defaultValue = v.Attribute.DefaultValue + defaultValue := v.Attribute.DefaultValue + var validationValues []any if v.Attribute.Validation != nil && len(v.Attribute.Validation.Values) > 0 { - validationValues = append(validationValues, v.Attribute.Validation.Values...) + validationValues = append([]any(nil), v.Attribute.Validation.Values...) if defaultValue == nil { defaultValue = v.Attribute.Validation.Values[0] } @@ -577,14 +600,14 @@ func buildServers(servers []*expr.ServerExpr, ver openapi.Version) []*Server { serverVariable[v.Name] = &ServerVariable{ Enum: validationValues, Default: defaultValue, - Description: host.Variables.Description, + Description: values.Description(v.Attribute.AuthoredAttribute(), v.Attribute.Description), } } } server = &Server{ URL: string(uExpr), - Description: svr.Description, + Description: values.Description(svr, svr.Description), Variables: serverVariable, } if ver == openapi.Version32 { @@ -632,20 +655,21 @@ func buildSecurityRequirements(reqs []*expr.SecurityExpr) SecurityRequirements { // buildSecurityScheme builds the OpenAPI SecurityScheme object from the // top-level security scheme definition. -func buildSecurityScheme(se *expr.SchemeExpr) *SecurityScheme { +func buildSecurityScheme(se *expr.SchemeExpr, values openapi.Values) *SecurityScheme { + description := values.Description(se.AuthoredScheme(), se.Description) var scheme *SecurityScheme switch se.Kind { case expr.BasicAuthKind: scheme = &SecurityScheme{ Type: "http", Scheme: "basic", - Description: se.Description, + Description: description, Extensions: openapi.ExtensionsFromExpr(se.Meta), } case expr.APIKeyKind: scheme = &SecurityScheme{ Type: "apiKey", - Description: se.Description, + Description: description, In: se.In, Name: se.Name, Extensions: openapi.ExtensionsFromExpr(se.Meta), @@ -659,7 +683,7 @@ func buildSecurityScheme(se *expr.SchemeExpr) *SecurityScheme { Type: "http", Scheme: "bearer", BearerFormat: bearerFormat, - Description: se.Description, + Description: description, Extensions: openapi.ExtensionsFromExpr(se.Meta), } case expr.OAuth2Kind: @@ -699,7 +723,7 @@ func buildSecurityScheme(se *expr.SchemeExpr) *SecurityScheme { } scheme = &SecurityScheme{ Type: "oauth2", - Description: se.Description, + Description: description, Flows: &flows, Extensions: openapi.ExtensionsFromExpr(se.Meta), } @@ -708,7 +732,7 @@ func buildSecurityScheme(se *expr.SchemeExpr) *SecurityScheme { } // buildTags builds the OpenAPI Tag object from the API expression. -func buildTags(api *expr.APIExpr, ver openapi.Version) []*openapi.Tag { +func buildTags(api *expr.APIExpr, ver openapi.Version, values openapi.Values) []*openapi.Tag { m := make(map[string]*openapi.Tag) for _, t := range openapi.TagsFromExpr(api.Meta, ver) { m[t.Name] = t @@ -743,7 +767,7 @@ func buildTags(api *expr.APIExpr, ver openapi.Version) []*openapi.Tag { } tags = append(tags, &openapi.Tag{ Name: s.Name(), - Description: s.Description(), + Description: values.Description(s.ServiceExpr, s.Description()), }) } } diff --git a/http/codegen/openapi/v3/builder_test.go b/http/codegen/openapi/v3/builder_test.go index 9477a1fd23..3d8499df18 100644 --- a/http/codegen/openapi/v3/builder_test.go +++ b/http/codegen/openapi/v3/builder_test.go @@ -1,3 +1,5 @@ +// This file verifies OpenAPI v3 operation construction, body schemas, and +// examples produced from evaluated HTTP endpoint designs. package openapiv3 import ( @@ -67,7 +69,7 @@ func TestBuildInfo(t *testing.T) { License: &expr.LicenseExpr{Name: licenseName, URL: licenseURL}, } - info := buildInfo(api, openapi.Version30) + info := buildInfo(api, openapi.Version30, openapi.Values{}) expected := c.Title if api.Title == "" { @@ -92,6 +94,46 @@ func TestBuildInfo(t *testing.T) { } } +func TestNewWithValues(t *testing.T) { + root := codegen.RunDSL(t, localizedValuesDSL) + service := root.Service("messages") + method := service.Method("show") + values := (openapi.Values{}). + WithTitle(root.API, "Localized API"). + WithDescription(root.API, "Localized API description"). + WithDescription(service, "Localized service description"). + WithDescription(method, "Localized method description") + + spec := NewWithValues( + root, + openapi.Version30, + expr.NewExampleGenerator(root.API.RandomizerFactory), + values, + ) + require.Equal(t, "Localized API", spec.Info.Title) + require.Equal(t, "Localized API description", spec.Info.Description) + require.Equal(t, "Localized method description", spec.Paths["/messages"].Get.Description) + require.Contains(t, spec.Paths["/messages"].Get.Tags, "messages") + require.Equal(t, "Original API", root.API.Title) + require.Equal(t, "Original method description", method.Description) +} + +var localizedValuesDSL = func() { + dsl.API("messages", func() { + dsl.Title("Original API") + dsl.Description("Original API description") + }) + dsl.Service("messages", func() { + dsl.Description("Original service description") + dsl.Method("show", func() { + dsl.Description("Original method description") + dsl.HTTP(func() { + dsl.GET("/messages") + }) + }) + }) +} + func TestNoSecurityOverridesAPISecurity(t *testing.T) { root := codegen.RunDSL(t, noSecurityOverridesAPISecurityDSL) spec := New(root, openapi.Version30) @@ -181,6 +223,62 @@ func TestStreamingResponseStatusCodes(t *testing.T) { require.NotContains(t, websocketResponses, "200") } +// TestSSEItemSchemaMatchesDataContract verifies OpenAPI describes the same +// presence and encoding used by generated SSE clients and servers. +func TestSSEItemSchemaMatchesDataContract(t *testing.T) { + root := codegen.RunDSL(t, func() { + count := dsl.Type("EventCount", dsl.Int) + dsl.Service("Events", func() { + dsl.Method("Optional", func() { + dsl.StreamingResult(func() { + dsl.Attribute("value", dsl.String) + }) + dsl.HTTP(func() { + dsl.GET("/optional") + dsl.ServerSentEvents("value") + }) + }) + dsl.Method("RequiredAlias", func() { + dsl.StreamingResult(func() { + dsl.Attribute("value", count) + dsl.Required("value") + }) + dsl.HTTP(func() { + dsl.GET("/required-alias") + dsl.ServerSentEvents("value") + }) + }) + dsl.Method("Structured", func() { + dsl.StreamingResult(func() { + dsl.Attribute("value", func() { + dsl.Attribute("message", dsl.String) + }) + dsl.Required("value") + }) + dsl.HTTP(func() { + dsl.GET("/structured") + dsl.ServerSentEvents("value") + }) + }) + }) + }) + spec := New(root, openapi.Version32) + + optional := spec.Paths["/optional"].Get.Responses["200"].Value.Content["text/event-stream"].ItemSchema + require.NotContains(t, optional.Required, "data") + require.Equal(t, openapi.Type(openapi.String), optional.Properties["data"].Type) + require.Empty(t, optional.Properties["data"].ContentMediaType) + + requiredAlias := spec.Paths["/required-alias"].Get.Responses["200"].Value.Content["text/event-stream"].ItemSchema + require.Contains(t, requiredAlias.Required, "data") + require.Empty(t, requiredAlias.Properties["data"].ContentMediaType) + + structured := spec.Paths["/structured"].Get.Responses["200"].Value.Content["text/event-stream"].ItemSchema + require.Contains(t, structured.Required, "data") + require.Equal(t, "application/json", structured.Properties["data"].ContentMediaType) + require.NotNil(t, structured.Properties["data"].ContentSchema) +} + func TestOperationSecurityMarshal(t *testing.T) { securityCases := map[string]struct { operation Operation @@ -227,6 +325,34 @@ func TestOperationSecurityMarshal(t *testing.T) { } } +func TestSecuritySchemesIncludeVisibleOperationsOnly(t *testing.T) { + root := codegen.RunDSL(t, visibleSecuritySchemesDSL) + spec := New(root, openapi.Version30) + + visible := root.Service("visible").Method("read").Requirements[0].Schemes[0].Hash() + hiddenMethod := root.Service("mixed").Method("hidden").Requirements[0].Schemes[0].Hash() + hiddenService := root.Service("hidden").Method("read").Requirements[0].Schemes[0].Hash() + require.Contains(t, spec.Components.SecuritySchemes, visible) + require.NotContains(t, spec.Components.SecuritySchemes, hiddenMethod) + require.NotContains(t, spec.Components.SecuritySchemes, hiddenService) +} + +func TestBuildServersKeepsVariableValuesSeparate(t *testing.T) { + root := codegen.RunDSL(t, serverVariablesDSL) + servers := buildServers(root.API.Servers, openapi.Version30, openapi.Values{}) + require.Len(t, servers, 1) + + region := servers[0].Variables["region"] + require.Equal(t, []any{"west", "east"}, region.Enum) + require.Equal(t, "west", region.Default) + require.Equal(t, "Deployment region", region.Description) + + stage := servers[0].Variables["stage"] + require.Equal(t, []any{"test", "production"}, stage.Enum) + require.Equal(t, "production", stage.Default) + require.Equal(t, "Deployment stage", stage.Description) +} + type param struct { Name string In string @@ -236,6 +362,75 @@ type param struct { Type typ } +var visibleSecuritySchemesDSL = func() { + var ( + VisibleAuth = dsl.JWTSecurity("visible_auth") + HiddenMethodAuth = dsl.JWTSecurity("hidden_method_auth") + HiddenServiceAuth = dsl.JWTSecurity("hidden_service_auth") + ) + + dsl.Service("visible", func() { + dsl.Method("read", func() { + dsl.Security(VisibleAuth) + dsl.Payload(func() { + dsl.Token("token", dsl.String) + }) + dsl.HTTP(func() { + dsl.GET("/visible") + }) + }) + }) + dsl.Service("mixed", func() { + dsl.Method("hidden", func() { + dsl.Meta("openapi:generate", "false") + dsl.Security(HiddenMethodAuth) + dsl.Payload(func() { + dsl.Token("token", dsl.String) + }) + dsl.HTTP(func() { + dsl.GET("/hidden-method") + }) + }) + }) + dsl.Service("hidden", func() { + dsl.Meta("openapi:generate", "false") + dsl.Method("read", func() { + dsl.Security(HiddenServiceAuth) + dsl.Payload(func() { + dsl.Token("token", dsl.String) + }) + dsl.HTTP(func() { + dsl.GET("/hidden-service") + }) + }) + }) +} + +var serverVariablesDSL = func() { + dsl.API("server variables", func() { + dsl.Server("public", func() { + dsl.Host("production", func() { + dsl.URI("https://{region}.{stage}.example.com") + dsl.Variable("region", dsl.String, "Deployment region", func() { + dsl.Default("west") + dsl.Enum("west", "east") + }) + dsl.Variable("stage", dsl.String, "Deployment stage", func() { + dsl.Default("production") + dsl.Enum("test", "production") + }) + }) + }) + }) + dsl.Service("status", func() { + dsl.Method("read", func() { + dsl.HTTP(func() { + dsl.GET("/status") + }) + }) + }) +} + type requestBody struct { Description string Type typ @@ -334,7 +529,7 @@ func TestBuildOperation(t *testing.T) { var types map[string]*openapi.Schema { var bds map[string]map[string]*EndpointBodies - bds, types = buildBodyTypes(root.API, root.Types, root.ResultTypes, openapi.Version30) + bds, types = buildBodyTypes(root.API, root.Types, root.ResultTypes, openapi.Version30, expr.NewExampleGenerator(root.API.RandomizerFactory), openapi.Values{}) if svc, ok := bds[svcName]; ok { bodies, ok = svc[c.Name] if !ok { @@ -366,7 +561,8 @@ func TestBuildOperation(t *testing.T) { return } - op := buildOperation(c.Name, route, bodies, expr.NewRandom(c.Name), root.API.Meta, openapi.Version30) + generator := expr.NewExampleGenerator(expr.NewFakerRandomizerFactory(c.Name)) + op := buildOperation(c.Name, route, bodies, generator, root.API.Meta, openapi.Version30, openapi.Values{}) if op.Description != c.ExpectedDescription { t.Errorf("got description %q for method %q, expected %q", op.Description, c.Name, c.ExpectedDescription) @@ -455,7 +651,8 @@ func TestBuildOperationID(t *testing.T) { if s.Name() == svcName { for _, e := range s.HTTPEndpoints { for i, r := range e.Routes { - op := buildOperation(c.Name, r, &EndpointBodies{}, expr.NewRandom(c.Name), api.Meta, openapi.Version30) + generator := expr.NewExampleGenerator(expr.NewFakerRandomizerFactory(c.Name)) + op := buildOperation(c.Name, r, &EndpointBodies{}, generator, api.Meta, openapi.Version30, openapi.Values{}) if len(c.ExpectedOperationIDs) == 0 { t.Error("no expected operation IDs") diff --git a/http/codegen/openapi/v3/description_ownership_test.go b/http/codegen/openapi/v3/description_ownership_test.go new file mode 100644 index 0000000000..38e379b709 --- /dev/null +++ b/http/codegen/openapi/v3/description_ownership_test.go @@ -0,0 +1,98 @@ +// This file verifies that shared OpenAPI v3 components use the named Goa type +// description while each response keeps its own error description. +package openapiv3 + +import ( + "testing" + + "github.com/stretchr/testify/require" + + "goa.design/goa/v3/codegen" + "goa.design/goa/v3/expr" + "goa.design/goa/v3/http/codegen/openapi" + "goa.design/goa/v3/http/codegen/testdata" +) + +func TestSharedErrorComponentDescription(t *testing.T) { + versions := []struct { + name string + version openapi.Version + }{ + {"3.0", openapi.Version30}, + {"3.2", openapi.Version32}, + } + designs := []struct { + name string + dsl func() + description string + }{ + {"method order", testdata.SharedErrorDescriptionDSL, "Shared error value"}, + {"reversed method order", testdata.ReversedSharedErrorDescriptionDSL, "Shared error value"}, + {"undescribed type", testdata.UndescribedSharedErrorDSL, ""}, + } + for _, version := range versions { + t.Run(version.name, func(t *testing.T) { + for _, design := range designs { + t.Run(design.name, func(t *testing.T) { + root := codegen.RunDSL(t, design.dsl) + spec := New( + root, + version.version, + ) + require.Equal(t, design.description, spec.Components.Schemas["SharedError"].Description) + }) + } + }) + } +} + +func TestSharedErrorResponseDescriptions(t *testing.T) { + for _, version := range []openapi.Version{openapi.Version30, openapi.Version32} { + root := codegen.RunDSL(t, testdata.SharedErrorDescriptionDSL) + spec := New(root, version) + + first := spec.Paths["/first"].Get.Responses["400"].Value.Description + second := spec.Paths["/second"].Get.Responses["400"].Value.Description + require.NotNil(t, first) + require.NotNil(t, second) + require.Equal(t, "first_error: First failure", *first) + require.Equal(t, "second_error: Second failure", *second) + } +} + +func TestSharedErrorComponentLocalizedDescription(t *testing.T) { + for _, version := range []openapi.Version{openapi.Version30, openapi.Version32} { + root := codegen.RunDSL(t, testdata.SharedErrorDescriptionDSL) + sharedError := root.UserType("SharedError") + values := (openapi.Values{}).WithDescription(sharedError.Attribute(), "Localized shared error") + spec := NewWithValues( + root, + version, + expr.NewExampleGenerator(root.API.RandomizerFactory), + values, + ) + + require.Equal(t, "Localized shared error", spec.Components.Schemas["SharedError"].Description) + } +} + +func TestUndescribedSharedErrorIgnoresLocalizedResponseDescription(t *testing.T) { + for _, version := range []openapi.Version{openapi.Version30, openapi.Version32} { + root := codegen.RunDSL(t, testdata.UndescribedSharedErrorDSL) + firstError := root.Service("errors").Method("first").Error("first_error") + values := (openapi.Values{}). + WithDescription(firstError, "Localized first failure"). + WithDescription(firstError.AttributeExpr, "Localized first failure") + spec := NewWithValues( + root, + version, + expr.NewExampleGenerator(root.API.RandomizerFactory), + values, + ) + + require.Empty(t, spec.Components.Schemas["SharedError"].Description) + response := spec.Paths["/first"].Get.Responses["400"].Value.Description + require.NotNil(t, response) + require.Equal(t, "first_error: Localized first failure", *response) + } +} diff --git a/http/codegen/openapi/v3/example.go b/http/codegen/openapi/v3/example.go index 6c1e0291c7..a7eae4c7d7 100644 --- a/http/codegen/openapi/v3/example.go +++ b/http/codegen/openapi/v3/example.go @@ -1,3 +1,4 @@ +// This file adds authored or generated examples to OpenAPI 3 values. package openapiv3 import ( @@ -15,8 +16,13 @@ type ( ) // initExample sets the example or examples of the given object. -func initExamples(obj exampler, attr *expr.AttributeExpr, r *expr.ExampleGenerator) { - examples := attr.ExtractUserExamples() +func initExamples(obj exampler, attr *expr.AttributeExpr, r *expr.ExampleGenerator, values openapi.Values) { + selected := values.Example(attr, r) + if selected == nil { + obj.setExample(nil) + return + } + examples := values.Examples(attr, attr.ExtractUserExamples()) switch { case len(examples) > 1: refs := make(map[string]*ExampleRef, len(examples)) @@ -33,6 +39,6 @@ func initExamples(obj exampler, attr *expr.AttributeExpr, r *expr.ExampleGenerat case len(examples) > 0: obj.setExample(openapi.ProjectExample(attr, examples[0].Value)) default: - obj.setExample(openapi.Example(attr, r)) + obj.setExample(openapi.ProjectExample(attr, selected)) } } diff --git a/http/codegen/openapi/v3/example_test.go b/http/codegen/openapi/v3/example_test.go new file mode 100644 index 0000000000..8c90ee434a --- /dev/null +++ b/http/codegen/openapi/v3/example_test.go @@ -0,0 +1,33 @@ +// This file checks how OpenAPI 3 objects receive authored and replacement +// examples without changing the evaluated attribute. +package openapiv3 + +import ( + "testing" + + "github.com/stretchr/testify/require" + + "goa.design/goa/v3/expr" + "goa.design/goa/v3/http/codegen/openapi" +) + +func TestInitExamplesUsesReplacementDescription(t *testing.T) { + first := &expr.ExampleExpr{Summary: "first", Description: "original", Value: "one"} + second := &expr.ExampleExpr{Summary: "second", Description: "unchanged", Value: "two"} + attribute := &expr.AttributeExpr{ + Type: expr.String, + UserExamples: []*expr.ExampleExpr{first, second}, + } + values := (openapi.Values{}).WithDescription(first, "translated") + media := new(MediaType) + userType := &expr.UserTypeExpr{AttributeExpr: attribute, TypeName: "Message"} + generator := expr.NewExampleGenerator(expr.NewDeterministicRandomizerFactory()).At( + expr.UserTypeExampleIdentity(userType), + ) + + initExamples(media, attribute, generator, values) + + require.Equal(t, "translated", media.Examples["first"].Value.Description) + require.Equal(t, "unchanged", media.Examples["second"].Value.Description) + require.Equal(t, "original", first.Description) +} diff --git a/http/codegen/openapi/v3/files.go b/http/codegen/openapi/v3/files.go index 29cd8a0a83..80f63037e0 100644 --- a/http/codegen/openapi/v3/files.go +++ b/http/codegen/openapi/v3/files.go @@ -1,3 +1,5 @@ +// This file builds OpenAPI 3 JSON and YAML files from one HTTP design. Each +// example comes from the request or response described in the file. package openapiv3 import ( @@ -11,5 +13,18 @@ import ( // path is the output path of the files relative to the gen directory, without // extension. func Files(root *expr.RootExpr, ver openapi.Version, path string) []*codegen.File { - return openapi.Files(New(root, ver), root.API.Meta, "openapi_v3", path) + return FilesWithValues( + root, + ver, + path, + expr.NewExampleGenerator(root.API.RandomizerFactory), + openapi.Values{}, + ) +} + +// FilesWithValues returns OpenAPI files using values in place of matching +// titles, descriptions, and examples from the evaluated design. The generator +// supplies examples for attributes that have no matching value. +func FilesWithValues(root *expr.RootExpr, ver openapi.Version, path string, generator *expr.ExampleGenerator, values openapi.Values) []*codegen.File { + return openapi.Files(NewWithValues(root, ver, generator, values), root.API.Meta, "openapi_v3", path) } diff --git a/http/codegen/openapi/v3/files_test.go b/http/codegen/openapi/v3/files_test.go index 992a3a8618..a8f4dfc6f2 100644 --- a/http/codegen/openapi/v3/files_test.go +++ b/http/codegen/openapi/v3/files_test.go @@ -1,3 +1,5 @@ +// This file renders complete OpenAPI 3.0 and 3.2 documents from prepared HTTP +// designs and compares output produced with run-owned example state. package openapiv3_test import ( @@ -32,9 +34,11 @@ func TestFiles(t *testing.T) { {"file-service-swagger", testdata.FileServiceSwaggerDSL}, {"file-service-wildcard", testdata.FileServiceWildcardDSL}, {"valid", testdata.SimpleDSL}, + {"bytes-example", testdata.BytesExampleDSL}, {"multiple-services", testdata.MultipleServicesDSL}, {"multiple-views", testdata.MultipleViewsDSL}, {"explicit-view", testdata.ExplicitViewDSL}, + {"released-response-collection-names", testdata.ReleasedResponseCollectionNamesDSL}, {"security", testdata.SecurityDSL}, {"bearer-security", testdata.BearerSecurityDSL}, {"server-host-with-variables", testdata.ServerHostWithVariablesDSL}, @@ -67,6 +71,7 @@ func TestFiles(t *testing.T) { {"array", testdata.ArrayValidationDSL}, // Error examples {"error-examples", testdata.ErrorExamplesDSL}, + {"shared-error-description", testdata.SharedErrorDescriptionDSL}, // Streaming endpoints: OpenAPI 3.2 constructs must not leak into // 3.0 documents. {"sse-string", testdata.SSEStringDSL}, @@ -78,8 +83,6 @@ func TestFiles(t *testing.T) { } for _, c := range cases { t.Run(c.Name, func(t *testing.T) { - // Reset global variables - openapi.Definitions = make(map[string]*openapi.Schema) root := expr.RunDSL(t, c.DSL) oFiles := openapiv3.Files(root, openapi.Version30, openapi.DefaultPath30) for i, o := range oFiles { @@ -123,6 +126,7 @@ func TestFilesV32(t *testing.T) { DSL func() }{ {"valid", testdata.SimpleDSL}, + {"bytes-example", testdata.BytesExampleDSL}, {"v3.2-meta", testdata.OpenAPIV32MetaDSL}, {"with-tags", testdata.WithTagsDSL}, {"server-host-with-variables", testdata.ServerHostWithVariablesDSL}, @@ -134,11 +138,10 @@ func TestFilesV32(t *testing.T) { {"sse-mixed-results", testdata.MixedResultsDSL}, {"websocket", testdata.StreamingResultDSL}, {"alias-type", testdata.AliasTypeDSL}, + {"shared-error-description", testdata.SharedErrorDescriptionDSL}, } for _, c := range cases { t.Run(c.Name, func(t *testing.T) { - // Reset global variables - openapi.Definitions = make(map[string]*openapi.Schema) root := expr.RunDSL(t, c.DSL) oFiles := openapiv3.Files(root, openapi.Version32, openapi.DefaultPath32) wantPaths := []string{ @@ -183,6 +186,68 @@ func TestFilesV32(t *testing.T) { } } +// TestAuthoredExampleFixturesIgnoreRandomizer verifies that golden designs +// which do not test generated examples describe every displayed value. +func TestAuthoredExampleFixturesIgnoreRandomizer(t *testing.T) { + cases := []struct { + Name string + DSL func() + Version openapi.Version + }{ + {"alias-type", testdata.AliasTypeDSL, openapi.Version30}, + {"array", testdata.ArrayValidationDSL, openapi.Version30}, + {"headers", testdata.HeadersDSL, openapi.Version30}, + {"not-generate-host", testdata.NotGenerateHostDSL, openapi.Version30}, + {"not-generate-server", testdata.NotGenerateServerDSL, openapi.Version30}, + {"path-with-wildcards", testdata.PathWithWildcardDSL, openapi.Version30}, + {"path-with-multiple-wildcards", testdata.PathWithMultipleWildcardDSL, openapi.Version30}, + {"path-with-multiple-explicit-wildcards", testdata.PathWithMultipleExplicitWildcardDSL, openapi.Version30}, + {"sse-all-fields", testdata.SSEAllFieldsDSL, openapi.Version32}, + {"sse-data-field", testdata.SSEDataFieldDSL, openapi.Version32}, + {"sse-mixed-results", testdata.MixedResultsDSL, openapi.Version32}, + {"sse-object", testdata.SSEObjectDSL, openapi.Version32}, + {"sse-request-id", testdata.SSERequestIDDSL, openapi.Version32}, + {"sse-string", testdata.SSEStringDSL, openapi.Version32}, + {"type-extension", testdata.TypeExtensionDSL, openapi.Version30}, + {"websocket", testdata.StreamingResultDSL, openapi.Version32}, + {"with-any", testdata.WithAnyDSL, openapi.Version30}, + {"with-map", testdata.WithMapDSL, openapi.Version30}, + {"with-spaces", testdata.WithSpacesDSL, openapi.Version30}, + {"with-tags", testdata.WithTagsDSL, openapi.Version32}, + } + for _, c := range cases { + t.Run(c.Name, func(t *testing.T) { + firstRoot := expr.RunDSL(t, c.DSL) + first := openapiv3.NewWithValues( + firstRoot, + c.Version, + expr.NewExampleGenerator(expr.NewFakerRandomizerFactory("first")), + openapi.Values{}, + ) + firstJSON, err := json.Marshal(first) + if err != nil { + t.Fatalf("failed to encode first document: %s", err) + } + + secondRoot := expr.RunDSL(t, c.DSL) + second := openapiv3.NewWithValues( + secondRoot, + c.Version, + expr.NewExampleGenerator(expr.NewFakerRandomizerFactory("second")), + openapi.Values{}, + ) + secondJSON, err := json.Marshal(second) + if err != nil { + t.Fatalf("failed to encode second document: %s", err) + } + + if !bytes.Equal(firstJSON, secondJSON) { + t.Error("OpenAPI examples changed with the randomizer") + } + }) + } +} + func validateSwagger(t *testing.T, b []byte) { swagger, err := openapi3.NewLoader().LoadFromData(b) if err == nil { diff --git a/http/codegen/openapi/v3/parameters.go b/http/codegen/openapi/v3/parameters.go index 1c4c64bc31..cfc898e60a 100644 --- a/http/codegen/openapi/v3/parameters.go +++ b/http/codegen/openapi/v3/parameters.go @@ -1,3 +1,6 @@ +// This file converts HTTP parameters, headers, and cookies into OpenAPI v3 +// values. Each schema and its displayed example use the same new repeatable +// example key. package openapiv3 import ( @@ -11,11 +14,12 @@ import ( // paramsFromPath computes the OpenAPI spec parameters for the given endpoint // HTTP path and query parameters. -func paramsFromPath(endpoint *expr.HTTPEndpointExpr, path string, rand *expr.ExampleGenerator) []*Parameter { +func paramsFromPath(endpoint *expr.HTTPEndpointExpr, path string, rand *expr.ExampleGenerator, values openapi.Values) []*Parameter { var ( res []*Parameter params = endpoint.Params wildcards = expr.ExtractHTTPWildcards(path) + owner = expr.MethodPayloadExampleIdentity(endpoint.MethodExpr) ) codegen.WalkMappedAttr(params, func(n, pn string, required bool, at *expr.AttributeExpr) error { // nolint: errcheck in := "query" @@ -26,7 +30,8 @@ func paramsFromPath(endpoint *expr.HTTPEndpointExpr, path string, rand *expr.Exa if in != "path" && openapiinternal.IsSecurityParameter(endpoint, in, pn) { return nil } - res = append(res, paramFor(at, pn, in, required, rand.Field(endpoint.MethodExpr.Payload, n))) + identity := exampleFieldIdentity(endpoint.MethodExpr.Payload, n, owner) + res = append(res, paramFor(at, pn, in, required, rand, identity, values)) return nil }) return res @@ -34,15 +39,17 @@ func paramsFromPath(endpoint *expr.HTTPEndpointExpr, path string, rand *expr.Exa // paramsFromHeadersAndCookies computes the OpenAPI spec parameters for the // given endpoint HTTP headers and cookies. -func paramsFromHeadersAndCookies(endpoint *expr.HTTPEndpointExpr, rand *expr.ExampleGenerator) []*Parameter { +func paramsFromHeadersAndCookies(endpoint *expr.HTTPEndpointExpr, rand *expr.ExampleGenerator, values openapi.Values) []*Parameter { var params []*Parameter + owner := expr.MethodPayloadExampleIdentity(endpoint.MethodExpr) expr.WalkMappedAttr(endpoint.Headers, func(name, elem string, att *expr.AttributeExpr) error { // nolint: errcheck if openapiinternal.IsSecurityParameter(endpoint, "header", elem) { return nil } required := endpoint.Headers.IsRequiredNoDefault(name) - params = append(params, paramFor(att, elem, "header", required, rand.Field(endpoint.MethodExpr.Payload, name))) + identity := exampleFieldIdentity(endpoint.MethodExpr.Payload, name, owner) + params = append(params, paramFor(att, elem, "header", required, rand, identity, values)) return nil }) expr.WalkMappedAttr(endpoint.Cookies, func(name, elem string, att *expr.AttributeExpr) error { // nolint: errcheck @@ -50,24 +57,35 @@ func paramsFromHeadersAndCookies(endpoint *expr.HTTPEndpointExpr, rand *expr.Exa return nil } required := endpoint.Cookies.IsRequiredNoDefault(name) - params = append(params, paramFor(att, elem, "cookie", required, rand.Field(endpoint.MethodExpr.Payload, name))) + identity := exampleFieldIdentity(endpoint.MethodExpr.Payload, name, owner) + params = append(params, paramFor(att, elem, "cookie", required, rand, identity, values)) return nil }) return params } +// exampleFieldIdentity returns the repeatable example key for a named field. +// Named user types use a key derived from their type; anonymous objects append +// the field name to the key supplied for their parent. +func exampleFieldIdentity(parent *expr.AttributeExpr, name string, owner expr.ExampleIdentity) expr.ExampleIdentity { + if typ, ok := parent.Type.(expr.UserType); ok { + owner = expr.UserTypeExampleIdentity(typ) + } + return owner.Member(name) +} + // paramFor converts the given attribute into a OpenAPI spec parameter. -func paramFor(att *expr.AttributeExpr, name, in string, required bool, rand *expr.ExampleGenerator) *Parameter { +func paramFor(att *expr.AttributeExpr, name, in string, required bool, rand *expr.ExampleGenerator, identity expr.ExampleIdentity, values openapi.Values) *Parameter { param := &Parameter{ Name: name, In: in, - Description: att.Description, + Description: values.Description(att.AuthoredAttribute(), att.Description), AllowEmptyValue: in == "query", Required: required, - Schema: newSchemafier(rand).schemafy(att), + Schema: newSchemafier(rand.At(identity), values).schemafy(att), Extensions: openapi.ExtensionsFromExpr(att.Meta), } - initExamples(param, att, rand) + initExamples(param, att, rand.At(identity), values) return param } diff --git a/http/codegen/openapi/v3/parameters_test.go b/http/codegen/openapi/v3/parameters_test.go index 4b83cc9b51..52b5d2aa5c 100644 --- a/http/codegen/openapi/v3/parameters_test.go +++ b/http/codegen/openapi/v3/parameters_test.go @@ -1,3 +1,5 @@ +// This file verifies OpenAPI parameters and headers render one stable example +// in both their schema and their displayed example fields. package openapiv3 import ( @@ -6,6 +8,7 @@ import ( "github.com/stretchr/testify/require" "goa.design/goa/v3/expr" + "goa.design/goa/v3/http/codegen/openapi" ) func TestParamForAllowEmptyValue(t *testing.T) { @@ -22,15 +25,43 @@ func TestParamForAllowEmptyValue(t *testing.T) { for _, test := range tests { t.Run(test.name, func(t *testing.T) { + method := &expr.MethodExpr{Name: "parameter", Service: &expr.ServiceExpr{Name: "test"}} + generator := expr.NewExampleGenerator(expr.NewFakerRandomizerFactory(test.name)) + require.NotEmpty(t, generator.At(expr.MethodResultExampleIdentity(method)).String()) + identity := expr.MethodPayloadExampleIdentity(method).Member("value") param := paramFor( &expr.AttributeExpr{Type: expr.String}, "value", test.location, false, - expr.NewRandom(test.name), + generator, + identity, + openapi.Values{}, ) require.Equal(t, test.want, param.AllowEmptyValue) + require.Equal(t, param.Schema.Example, param.Example) }) } } + +func TestHeaderSchemaAndDisplayedExampleShareIdentity(t *testing.T) { + field := &expr.AttributeExpr{Type: expr.String} + parent := &expr.AttributeExpr{Type: &expr.Object{ + {Name: "request-id", Attribute: field}, + }} + headers := expr.NewMappedAttributeExpr(parent) + method := &expr.MethodExpr{Name: "headers", Service: &expr.ServiceExpr{Name: "test"}} + generator := expr.NewExampleGenerator(expr.NewFakerRandomizerFactory("headers")) + require.NotEmpty(t, generator.At(expr.MethodPayloadExampleIdentity(method)).String()) + + actual := headersFromAttr( + headers, + parent, + expr.MethodResultExampleIdentity(method), + generator, + openapi.Values{}, + )["request-id"].Value + + require.Equal(t, actual.Schema.Example, actual.Example) +} diff --git a/http/codegen/openapi/v3/public_api_test.go b/http/codegen/openapi/v3/public_api_test.go new file mode 100644 index 0000000000..110e52286c --- /dev/null +++ b/http/codegen/openapi/v3/public_api_test.go @@ -0,0 +1,95 @@ +// This file protects the released OpenAPI v3 function signatures and checks +// that their default files match files produced with the root's example +// generator and no replacement values. +package openapiv3_test + +import ( + "bytes" + "testing" + "text/template" + + "github.com/stretchr/testify/require" + + "goa.design/goa/v3/codegen" + "goa.design/goa/v3/dsl" + "goa.design/goa/v3/expr" + "goa.design/goa/v3/http/codegen/openapi" + openapiv3 "goa.design/goa/v3/http/codegen/openapi/v3" +) + +var ( + _ func(*expr.RootExpr, openapi.Version) *openapiv3.OpenAPI = openapiv3.New + _ func(*expr.RootExpr, openapi.Version, string) []*codegen.File = openapiv3.Files + + facadeDSL = func() { + dsl.API("facade", func() { + dsl.Server("facade", func() { + dsl.Host("localhost", func() { + dsl.URI("https://goa.design") + }) + }) + }) + dsl.Service("facade", func() { + dsl.Method("show", func() { + dsl.Payload(func() { + dsl.Attribute("message", dsl.String) + }) + dsl.Result(func() { + dsl.Attribute("answer", dsl.String) + }) + dsl.HTTP(func() { + dsl.POST("/items") + }) + }) + }) + } +) + +func TestDefaultFacadeMatchesWithValues(t *testing.T) { + root := expr.RunDSL(t, facadeDSL) + root.API.RandomizerFactory = expr.NewFakerRandomizerFactory("released facade") + + gotSpec := openapiv3.New(root, openapi.Version30) + wantSpec := openapiv3.NewWithValues( + root, + openapi.Version30, + expr.NewExampleGenerator(root.API.RandomizerFactory), + openapi.Values{}, + ) + require.Equal(t, wantSpec, gotSpec) + otherSpec := openapiv3.NewWithValues( + root, + openapi.Version30, + expr.NewExampleGenerator(expr.NewFakerRandomizerFactory("other")), + openapi.Values{}, + ) + require.NotEqual(t, otherSpec, gotSpec) + + gotFiles := openapiv3.Files(root, openapi.Version30, openapi.DefaultPath30) + wantFiles := openapiv3.FilesWithValues( + root, + openapi.Version30, + openapi.DefaultPath30, + expr.NewExampleGenerator(root.API.RandomizerFactory), + openapi.Values{}, + ) + require.Equal(t, renderFiles(t, wantFiles), renderFiles(t, gotFiles)) +} + +// renderFiles runs each file template so the test compares the documents that +// users receive instead of comparing template implementation details. +func renderFiles(t *testing.T, files []*codegen.File) map[string]string { + t.Helper() + + rendered := make(map[string]string, len(files)) + for _, file := range files { + var buf bytes.Buffer + for _, section := range file.SectionTemplates { + tmpl, err := template.New("openapi").Funcs(section.FuncMap).Parse(section.Source) + require.NoError(t, err) + require.NoError(t, tmpl.Execute(&buf, section.Data)) + } + rendered[file.Path] = buf.String() + } + return rendered +} diff --git a/http/codegen/openapi/v3/response.go b/http/codegen/openapi/v3/response.go index cb9de68ebb..9342dff650 100644 --- a/http/codegen/openapi/v3/response.go +++ b/http/codegen/openapi/v3/response.go @@ -1,16 +1,17 @@ +// This file converts HTTP response headers and cookies into OpenAPI v3 values +// without sharing consumed example streams between schema and display fields. package openapiv3 import ( "fmt" "net/http" - "strconv" "goa.design/goa/v3/eval" "goa.design/goa/v3/expr" "goa.design/goa/v3/http/codegen/openapi" ) -func headersFromAttr(attr *expr.MappedAttributeExpr, rand *expr.ExampleGenerator) map[string]*HeaderRef { +func headersFromAttr(attr *expr.MappedAttributeExpr, parent *expr.AttributeExpr, owner expr.ExampleIdentity, rand *expr.ExampleGenerator, values openapi.Values) map[string]*HeaderRef { o := expr.AsObject(attr.Type) if len(*o) == 0 { return nil @@ -19,25 +20,24 @@ func headersFromAttr(attr *expr.MappedAttributeExpr, rand *expr.ExampleGenerator expr.WalkMappedAttr(attr, func(name, elem string, hattr *expr.AttributeExpr) error { // nolint: errcheck // Anchor the header example stream to the header identity so the // example survives generator reorderings. - hrand := rand.Field(attr.AttributeExpr, name) + identity := exampleFieldIdentity(parent, name, owner) header := &Header{ - Description: hattr.Description, + Description: values.Description(hattr.AuthoredAttribute(), hattr.Description), Required: hattr.IsRequiredNoDefault(name), - Schema: newSchemafier(hrand).schemafy(hattr), - Example: openapi.Example(hattr, hrand), + Schema: newSchemafier(rand.At(identity), values).schemafy(hattr), Extensions: openapi.ExtensionsFromExpr(hattr.Meta), } - initExamples(header, hattr, hrand) + initExamples(header, hattr, rand.At(identity), values) headers[elem] = &HeaderRef{Value: header} return nil }) return headers } -func responseFromExpr(r *expr.HTTPResponseExpr, bodies map[int][]*openapi.Schema, rand *expr.ExampleGenerator) *Response { - ct := responseContentType(r) - headers := headersFromAttr(r.Headers, rand) - cookies := headersFromAttr(r.Cookies, rand) +func responseFromExpr(r *expr.HTTPResponseExpr, body *openapi.Schema, rand *expr.ExampleGenerator, parent *expr.AttributeExpr, fieldOwner, bodyOwner expr.ExampleIdentity, fallbackDescription string, values openapi.Values) *Response { + ct := openapi.ResponseContentType(r) + headers := headersFromAttr(r.Headers, parent, fieldOwner, rand, values) + cookies := headersFromAttr(r.Cookies, parent, fieldOwner, rand, values) if len(cookies) > 0 { if headers == nil { headers = make(map[string]*HeaderRef) @@ -65,12 +65,10 @@ func responseFromExpr(r *expr.HTTPResponseExpr, bodies map[int][]*openapi.Schema if r.Body.Type != expr.Empty { content = make(map[string]*MediaType) content[ct] = &MediaType{ - Schema: bodies[r.StatusCode][0], + Schema: body, Extensions: openapi.ExtensionsFromExpr(r.Body.Meta), } - ep := r.Parent.(*expr.HTTPEndpointExpr) - id := bodyExampleID(ep.Service.Name(), ep.Name(), "response."+strconv.Itoa(r.StatusCode)+".0") - initExamples(content[ct], staticViewBody(r), rand.Rebased(id)) + initExamples(content[ct], staticViewBody(r), rand.At(bodyOwner), values) } else if r.StatusCode != expr.StatusNoContent && isSkipResponseBodyEncodeDecode(r.Parent) { // When SkipResponseBodyEncodeDecode is declared, the response type @@ -85,7 +83,10 @@ func responseFromExpr(r *expr.HTTPResponseExpr, bodies map[int][]*openapi.Schema } } } - desc := r.Description + desc := values.Description(r, r.Description) + if desc == "" { + desc = fallbackDescription + } if desc == "" { desc = fmt.Sprintf("%s response.", http.StatusText(r.StatusCode)) } @@ -97,28 +98,12 @@ func responseFromExpr(r *expr.HTTPResponseExpr, bodies map[int][]*openapi.Schema } } -// responseContentType computes the content type of the given response: the -// explicitly defined content type if any, the content type of the response -// result type otherwise, defaulting to application/json. The result type is -// the view-projected one when the design pins the response to a single view; -// projected result types carry no content type. -func responseContentType(r *expr.HTTPResponseExpr) string { - if r.ContentType != "" { - return r.ContentType - } - if rt, ok := staticViewBody(r).Type.(*expr.ResultTypeExpr); ok && rt.ContentType != "" { - return rt.ContentType - } - return "application/json" -} - // setSSEContent rewrites the content of a successful server-sent events // response for OpenAPI 3.2 documents. The text/event-stream media type // describes each streamed event with an itemSchema instead of a whole-stream -// schema. When the method defines mixed results (distinct unary and streaming -// result types) the unary result is documented under its own content type -// (ct) next to the event stream to reflect the content negotiation performed -// by the generated handler. +// schema. When the method defines separate normal and streaming results, the +// normal result is documented under its own content type next to the event +// stream to match the response selected by the generated handler. func setSSEContent(resp *Response, bodies *EndpointBodies, ct string, mixed bool) { sse := &MediaType{ItemSchema: bodies.SSEItemSchema} if !mixed { diff --git a/http/codegen/openapi/v3/testdata/golden/alias-type_file0.golden b/http/codegen/openapi/v3/testdata/golden/alias-type_file0.golden index a3535c11c0..0ce9b4b10b 100644 --- a/http/codegen/openapi/v3/testdata/golden/alias-type_file0.golden +++ b/http/codegen/openapi/v3/testdata/golden/alias-type_file0.golden @@ -5,20 +5,14 @@ "description": "Request body for testEndpoint.", "example": { "completed": [ - "who", - "who", - "who", - "who" + "when" ], "current": "who" }, "properties": { "completed": { "example": [ - "who", - "who", - "who", - "who" + "when" ], "items": { "description": "Setup stage.", @@ -63,10 +57,7 @@ "application/json": { "example": { "completed": [ - "who", - "who", - "who", - "who" + "when" ], "current": "who" }, @@ -84,10 +75,9 @@ "application/json": { "example": { "completed": [ - "where", - "where" + "when" ], - "current": "where" + "current": "who" }, "schema": { "$ref": "#/components/schemas/Setup" diff --git a/http/codegen/openapi/v3/testdata/golden/alias-type_file1.golden b/http/codegen/openapi/v3/testdata/golden/alias-type_file1.golden index 63ba41157c..8d990da2be 100644 --- a/http/codegen/openapi/v3/testdata/golden/alias-type_file1.golden +++ b/http/codegen/openapi/v3/testdata/golden/alias-type_file1.golden @@ -21,10 +21,7 @@ paths: $ref: '#/components/schemas/Setup' example: completed: - - who - - who - - who - - who + - when current: who responses: "200": @@ -35,9 +32,8 @@ paths: $ref: '#/components/schemas/Setup' example: completed: - - where - - where - current: where + - when + current: who components: schemas: Setup: @@ -55,10 +51,7 @@ components: - where - what example: - - who - - who - - who - - who + - when current: type: string description: Setup stage. @@ -71,10 +64,7 @@ components: description: Request body for testEndpoint. example: completed: - - who - - who - - who - - who + - when current: who tags: - name: testService diff --git a/http/codegen/openapi/v3/testdata/golden/array_file0.golden b/http/codegen/openapi/v3/testdata/golden/array_file0.golden index 73d27f8c36..279166bec7 100644 --- a/http/codegen/openapi/v3/testdata/golden/array_file0.golden +++ b/http/codegen/openapi/v3/testdata/golden/array_file0.golden @@ -3,11 +3,11 @@ "schemas": { "Bar": { "example": { - "string": "" + "string": "item" }, "properties": { "string": { - "example": "", + "example": "item", "maxLength": 42, "minLength": 0, "type": "string" @@ -17,15 +17,22 @@ }, "Foobar": { "example": { - "bar": [], + "bar": [ + { + "string": "item" + } + ], "foo": [ - "Totam doloremque exercitationem voluptate.", - "Repellat perspiciatis voluptas et quia deleniti." + "item" ] }, "properties": { "bar": { - "example": [], + "example": [ + { + "string": "item" + } + ], "items": { "$ref": "#/components/schemas/Bar" }, @@ -35,11 +42,10 @@ }, "foo": { "example": [ - "Totam doloremque exercitationem voluptate.", - "Repellat perspiciatis voluptas et quia deleniti." + "item" ], "items": { - "example": "Molestiae dolor eveniet omnis atque.", + "example": "item", "type": "string" }, "maxItems": 42, @@ -65,48 +71,26 @@ "application/json": { "example": [ { - "bar": [], - "foo": [ - "Totam doloremque exercitationem voluptate.", - "Repellat perspiciatis voluptas et quia deleniti." - ] - }, - { - "bar": [], - "foo": [ - "Totam doloremque exercitationem voluptate.", - "Repellat perspiciatis voluptas et quia deleniti." - ] - }, - { - "bar": [], + "bar": [ + { + "string": "item" + } + ], "foo": [ - "Totam doloremque exercitationem voluptate.", - "Repellat perspiciatis voluptas et quia deleniti." + "item" ] } ], "schema": { "example": [ { - "bar": [], - "foo": [ - "Totam doloremque exercitationem voluptate.", - "Repellat perspiciatis voluptas et quia deleniti." - ] - }, - { - "bar": [], - "foo": [ - "Totam doloremque exercitationem voluptate.", - "Repellat perspiciatis voluptas et quia deleniti." - ] - }, - { - "bar": [], + "bar": [ + { + "string": "item" + } + ], "foo": [ - "Totam doloremque exercitationem voluptate.", - "Repellat perspiciatis voluptas et quia deleniti." + "item" ] } ], diff --git a/http/codegen/openapi/v3/testdata/golden/array_file1.golden b/http/codegen/openapi/v3/testdata/golden/array_file1.golden index aaaf460f0a..0464761079 100644 --- a/http/codegen/openapi/v3/testdata/golden/array_file1.golden +++ b/http/codegen/openapi/v3/testdata/golden/array_file1.golden @@ -21,31 +21,15 @@ paths: items: $ref: '#/components/schemas/Foobar' example: - - bar: [] + - bar: + - string: item foo: - - Totam doloremque exercitationem voluptate. - - Repellat perspiciatis voluptas et quia deleniti. - - bar: [] - foo: - - Totam doloremque exercitationem voluptate. - - Repellat perspiciatis voluptas et quia deleniti. - - bar: [] - foo: - - Totam doloremque exercitationem voluptate. - - Repellat perspiciatis voluptas et quia deleniti. + - item example: - - bar: [] - foo: - - Totam doloremque exercitationem voluptate. - - Repellat perspiciatis voluptas et quia deleniti. - - bar: [] + - bar: + - string: item foo: - - Totam doloremque exercitationem voluptate. - - Repellat perspiciatis voluptas et quia deleniti. - - bar: [] - foo: - - Totam doloremque exercitationem voluptate. - - Repellat perspiciatis voluptas et quia deleniti. + - item responses: "200": description: OK response. @@ -64,11 +48,11 @@ components: properties: string: type: string - example: "" + example: item minLength: 0 maxLength: 42 example: - string: "" + string: item Foobar: type: object properties: @@ -76,23 +60,23 @@ components: type: array items: $ref: '#/components/schemas/Bar' - example: [] + example: + - string: item minItems: 0 maxItems: 42 foo: type: array items: type: string - example: Molestiae dolor eveniet omnis atque. + example: item example: - - Totam doloremque exercitationem voluptate. - - Repellat perspiciatis voluptas et quia deleniti. + - item minItems: 0 maxItems: 42 example: - bar: [] + bar: + - string: item foo: - - Totam doloremque exercitationem voluptate. - - Repellat perspiciatis voluptas et quia deleniti. + - item tags: - name: testService diff --git a/http/codegen/openapi/v3/testdata/golden/bytes-example_file0.golden b/http/codegen/openapi/v3/testdata/golden/bytes-example_file0.golden new file mode 100644 index 0000000000..dd8979f0f7 --- /dev/null +++ b/http/codegen/openapi/v3/testdata/golden/bytes-example_file0.golden @@ -0,0 +1,44 @@ +{ + "components": {}, + "info": { + "title": "Goa API", + "version": "0.0.1" + }, + "openapi": "3.0.3", + "paths": { + "/download": { + "get": { + "operationId": "bytes#download", + "responses": { + "200": { + "content": { + "application/json": { + "example": "aGVsbG8=", + "schema": { + "example": "aGVsbG8=", + "format": "binary", + "type": "string" + } + } + }, + "description": "OK response." + } + }, + "summary": "download bytes", + "tags": [ + "bytes" + ] + } + } + }, + "servers": [ + { + "url": "https://goa.design" + } + ], + "tags": [ + { + "name": "bytes" + } + ] +} diff --git a/http/codegen/openapi/v3/testdata/golden/bytes-example_file1.golden b/http/codegen/openapi/v3/testdata/golden/bytes-example_file1.golden new file mode 100644 index 0000000000..56b5313f7c --- /dev/null +++ b/http/codegen/openapi/v3/testdata/golden/bytes-example_file1.golden @@ -0,0 +1,26 @@ +openapi: 3.0.3 +info: + title: Goa API + version: 0.0.1 +servers: + - url: https://goa.design +paths: + /download: + get: + tags: + - bytes + summary: download bytes + operationId: bytes#download + responses: + "200": + description: OK response. + content: + application/json: + schema: + type: string + example: aGVsbG8= + format: binary + example: aGVsbG8= +components: {} +tags: + - name: bytes diff --git a/http/codegen/openapi/v3/testdata/golden/error-examples_file0.golden b/http/codegen/openapi/v3/testdata/golden/error-examples_file0.golden index bbec695a5a..d791d2c180 100644 --- a/http/codegen/openapi/v3/testdata/golden/error-examples_file0.golden +++ b/http/codegen/openapi/v3/testdata/golden/error-examples_file0.golden @@ -4,17 +4,17 @@ "Error": { "description": "Error response result type", "example": { - "fault": true, + "fault": false, "id": "123abc", "message": "parameter 'p' must be an integer", "name": "bad_request", "temporary": true, - "timeout": false + "timeout": true }, "properties": { "fault": { "description": "Is the error a server-side fault?", - "example": true, + "example": false, "type": "boolean" }, "id": { @@ -39,7 +39,7 @@ }, "timeout": { "description": "Is the error a timeout?", - "example": false, + "example": true, "type": "boolean" } }, @@ -110,6 +110,14 @@ "404": { "content": { "application/vnd.goa.error": { + "example": { + "fault": false, + "id": "123abc", + "message": "parameter 'p' must be an integer", + "name": "not_found", + "temporary": false, + "timeout": false + }, "schema": { "$ref": "#/components/schemas/Error" } @@ -130,6 +138,78 @@ } }, "description": "custom: Conflict response." + }, + "429": { + "content": { + "application/vnd.goa.error": { + "example": { + "fault": false, + "id": "123abc", + "message": "parameter 'p' must be an integer", + "name": "retry", + "temporary": true, + "timeout": false + }, + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "retry: Too Many Requests response." + }, + "500": { + "content": { + "application/vnd.goa.error": { + "example": { + "fault": true, + "id": "123abc", + "message": "parameter 'p' must be an integer", + "name": "internal", + "temporary": false, + "timeout": false + }, + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "internal: Internal Server Error response." + }, + "503": { + "content": { + "application/vnd.goa.error": { + "example": { + "fault": false, + "id": "123abc", + "message": "parameter 'p' must be an integer", + "name": "retry_deadline", + "temporary": true, + "timeout": true + }, + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "retry_deadline: Service Unavailable response." + }, + "504": { + "content": { + "application/vnd.goa.error": { + "example": { + "fault": false, + "id": "123abc", + "message": "parameter 'p' must be an integer", + "name": "deadline", + "temporary": false, + "timeout": true + }, + "schema": { + "$ref": "#/components/schemas/Error" + } + } + }, + "description": "deadline: Gateway Timeout response." } }, "summary": "Error Errors", diff --git a/http/codegen/openapi/v3/testdata/golden/error-examples_file1.golden b/http/codegen/openapi/v3/testdata/golden/error-examples_file1.golden index d833ea63bd..86b5426dfd 100644 --- a/http/codegen/openapi/v3/testdata/golden/error-examples_file1.golden +++ b/http/codegen/openapi/v3/testdata/golden/error-examples_file1.golden @@ -34,6 +34,13 @@ paths: application/vnd.goa.error: schema: $ref: '#/components/schemas/Error' + example: + fault: false + id: 123abc + message: parameter 'p' must be an integer + name: not_found + temporary: false + timeout: false "409": description: 'custom: Conflict response.' content: @@ -43,6 +50,58 @@ paths: example: message: error message name: custom + "429": + description: 'retry: Too Many Requests response.' + content: + application/vnd.goa.error: + schema: + $ref: '#/components/schemas/Error' + example: + fault: false + id: 123abc + message: parameter 'p' must be an integer + name: retry + temporary: true + timeout: false + "500": + description: 'internal: Internal Server Error response.' + content: + application/vnd.goa.error: + schema: + $ref: '#/components/schemas/Error' + example: + fault: true + id: 123abc + message: parameter 'p' must be an integer + name: internal + temporary: false + timeout: false + "503": + description: 'retry_deadline: Service Unavailable response.' + content: + application/vnd.goa.error: + schema: + $ref: '#/components/schemas/Error' + example: + fault: false + id: 123abc + message: parameter 'p' must be an integer + name: retry_deadline + temporary: true + timeout: true + "504": + description: 'deadline: Gateway Timeout response.' + content: + application/vnd.goa.error: + schema: + $ref: '#/components/schemas/Error' + example: + fault: false + id: 123abc + message: parameter 'p' must be an integer + name: deadline + temporary: false + timeout: true components: schemas: Error: @@ -51,7 +110,7 @@ components: fault: type: boolean description: Is the error a server-side fault? - example: true + example: false id: type: string description: ID is a unique identifier for this particular occurrence of the problem. @@ -71,15 +130,15 @@ components: timeout: type: boolean description: Is the error a timeout? - example: false + example: true description: Error response result type example: - fault: true + fault: false id: 123abc message: parameter 'p' must be an integer name: bad_request temporary: true - timeout: false + timeout: true required: - name - id diff --git a/http/codegen/openapi/v3/testdata/golden/headers_file0.golden b/http/codegen/openapi/v3/testdata/golden/headers_file0.golden index 13cba070a2..ff8d2ff822 100644 --- a/http/codegen/openapi/v3/testdata/golden/headers_file0.golden +++ b/http/codegen/openapi/v3/testdata/golden/headers_file0.golden @@ -11,21 +11,21 @@ "operationId": "test service#test endpoint", "parameters": [ { - "example": 8568805688952666000, + "example": 1, "in": "header", "name": "foo", "schema": { - "example": 5490475434297746000, + "example": 1, "format": "int64", "type": "integer" } }, { - "example": 8380651525843656000, + "example": 2, "in": "header", "name": "bar", "schema": { - "example": 1475422799873681700, + "example": 2, "format": "int64", "type": "integer" } diff --git a/http/codegen/openapi/v3/testdata/golden/headers_file1.golden b/http/codegen/openapi/v3/testdata/golden/headers_file1.golden index 24f74063e0..980d5664b4 100644 --- a/http/codegen/openapi/v3/testdata/golden/headers_file1.golden +++ b/http/codegen/openapi/v3/testdata/golden/headers_file1.golden @@ -17,16 +17,16 @@ paths: in: header schema: type: integer - example: 5490475434297746524 + example: 1 format: int64 - example: 8568805688952666114 + example: 1 - name: bar in: header schema: type: integer - example: 1475422799873681639 + example: 2 format: int64 - example: 8380651525843655561 + example: 2 responses: "204": description: No Content response. diff --git a/http/codegen/openapi/v3/testdata/golden/not-generate-host_file0.golden b/http/codegen/openapi/v3/testdata/golden/not-generate-host_file0.golden index 439aa96af1..d370718f73 100644 --- a/http/codegen/openapi/v3/testdata/golden/not-generate-host_file0.golden +++ b/http/codegen/openapi/v3/testdata/golden/not-generate-host_file0.golden @@ -13,9 +13,9 @@ "200": { "content": { "application/json": { - "example": "Debitis repellendus at repellendus fugit iusto deleniti.", + "example": "ok", "schema": { - "example": "Debitis repellendus at repellendus fugit iusto deleniti.", + "example": "ok", "type": "string" } } diff --git a/http/codegen/openapi/v3/testdata/golden/not-generate-host_file1.golden b/http/codegen/openapi/v3/testdata/golden/not-generate-host_file1.golden index 8b151c2e6c..c8b1817e0b 100644 --- a/http/codegen/openapi/v3/testdata/golden/not-generate-host_file1.golden +++ b/http/codegen/openapi/v3/testdata/golden/not-generate-host_file1.golden @@ -16,8 +16,8 @@ paths: application/json: schema: type: string - example: Debitis repellendus at repellendus fugit iusto deleniti. - example: Debitis repellendus at repellendus fugit iusto deleniti. + example: ok + example: ok components: {} tags: - name: testService diff --git a/http/codegen/openapi/v3/testdata/golden/not-generate-server_file0.golden b/http/codegen/openapi/v3/testdata/golden/not-generate-server_file0.golden index 439aa96af1..d370718f73 100644 --- a/http/codegen/openapi/v3/testdata/golden/not-generate-server_file0.golden +++ b/http/codegen/openapi/v3/testdata/golden/not-generate-server_file0.golden @@ -13,9 +13,9 @@ "200": { "content": { "application/json": { - "example": "Debitis repellendus at repellendus fugit iusto deleniti.", + "example": "ok", "schema": { - "example": "Debitis repellendus at repellendus fugit iusto deleniti.", + "example": "ok", "type": "string" } } diff --git a/http/codegen/openapi/v3/testdata/golden/not-generate-server_file1.golden b/http/codegen/openapi/v3/testdata/golden/not-generate-server_file1.golden index 8b151c2e6c..c8b1817e0b 100644 --- a/http/codegen/openapi/v3/testdata/golden/not-generate-server_file1.golden +++ b/http/codegen/openapi/v3/testdata/golden/not-generate-server_file1.golden @@ -16,8 +16,8 @@ paths: application/json: schema: type: string - example: Debitis repellendus at repellendus fugit iusto deleniti. - example: Debitis repellendus at repellendus fugit iusto deleniti. + example: ok + example: ok components: {} tags: - name: testService diff --git a/http/codegen/openapi/v3/testdata/golden/path-with-multiple-explicit-wildcards_file0.golden b/http/codegen/openapi/v3/testdata/golden/path-with-multiple-explicit-wildcards_file0.golden index 3a95fa8fb2..717d087909 100644 --- a/http/codegen/openapi/v3/testdata/golden/path-with-multiple-explicit-wildcards_file0.golden +++ b/http/codegen/openapi/v3/testdata/golden/path-with-multiple-explicit-wildcards_file0.golden @@ -11,23 +11,23 @@ "operationId": "test service#test endpoint", "parameters": [ { - "example": 8568805688952666000, + "example": 1, "in": "path", "name": "foo", "required": true, "schema": { - "example": 5490475434297746000, + "example": 1, "format": "int64", "type": "integer" } }, { - "example": 8380651525843656000, + "example": 2, "in": "path", "name": "bar", "required": true, "schema": { - "example": 1475422799873681700, + "example": 2, "format": "int64", "type": "integer" } diff --git a/http/codegen/openapi/v3/testdata/golden/path-with-multiple-explicit-wildcards_file1.golden b/http/codegen/openapi/v3/testdata/golden/path-with-multiple-explicit-wildcards_file1.golden index 6cf912a664..b492ef170e 100644 --- a/http/codegen/openapi/v3/testdata/golden/path-with-multiple-explicit-wildcards_file1.golden +++ b/http/codegen/openapi/v3/testdata/golden/path-with-multiple-explicit-wildcards_file1.golden @@ -18,17 +18,17 @@ paths: required: true schema: type: integer - example: 5490475434297746524 + example: 1 format: int64 - example: 8568805688952666114 + example: 1 - name: bar in: path required: true schema: type: integer - example: 1475422799873681639 + example: 2 format: int64 - example: 8380651525843655561 + example: 2 responses: "204": description: No Content response. diff --git a/http/codegen/openapi/v3/testdata/golden/path-with-multiple-wildcards_file0.golden b/http/codegen/openapi/v3/testdata/golden/path-with-multiple-wildcards_file0.golden index 3a95fa8fb2..717d087909 100644 --- a/http/codegen/openapi/v3/testdata/golden/path-with-multiple-wildcards_file0.golden +++ b/http/codegen/openapi/v3/testdata/golden/path-with-multiple-wildcards_file0.golden @@ -11,23 +11,23 @@ "operationId": "test service#test endpoint", "parameters": [ { - "example": 8568805688952666000, + "example": 1, "in": "path", "name": "foo", "required": true, "schema": { - "example": 5490475434297746000, + "example": 1, "format": "int64", "type": "integer" } }, { - "example": 8380651525843656000, + "example": 2, "in": "path", "name": "bar", "required": true, "schema": { - "example": 1475422799873681700, + "example": 2, "format": "int64", "type": "integer" } diff --git a/http/codegen/openapi/v3/testdata/golden/path-with-multiple-wildcards_file1.golden b/http/codegen/openapi/v3/testdata/golden/path-with-multiple-wildcards_file1.golden index 6cf912a664..b492ef170e 100644 --- a/http/codegen/openapi/v3/testdata/golden/path-with-multiple-wildcards_file1.golden +++ b/http/codegen/openapi/v3/testdata/golden/path-with-multiple-wildcards_file1.golden @@ -18,17 +18,17 @@ paths: required: true schema: type: integer - example: 5490475434297746524 + example: 1 format: int64 - example: 8568805688952666114 + example: 1 - name: bar in: path required: true schema: type: integer - example: 1475422799873681639 + example: 2 format: int64 - example: 8380651525843655561 + example: 2 responses: "204": description: No Content response. diff --git a/http/codegen/openapi/v3/testdata/golden/path-with-wildcards_file0.golden b/http/codegen/openapi/v3/testdata/golden/path-with-wildcards_file0.golden index de0170e414..f9d8ece6a3 100644 --- a/http/codegen/openapi/v3/testdata/golden/path-with-wildcards_file0.golden +++ b/http/codegen/openapi/v3/testdata/golden/path-with-wildcards_file0.golden @@ -11,12 +11,12 @@ "operationId": "test service#test endpoint", "parameters": [ { - "example": 5691356309313629000, + "example": 1, "in": "path", "name": "int_map", "required": true, "schema": { - "example": 4595362125781949000, + "example": 1, "format": "int64", "type": "integer" } diff --git a/http/codegen/openapi/v3/testdata/golden/path-with-wildcards_file1.golden b/http/codegen/openapi/v3/testdata/golden/path-with-wildcards_file1.golden index df365d17e6..e25a083b6d 100644 --- a/http/codegen/openapi/v3/testdata/golden/path-with-wildcards_file1.golden +++ b/http/codegen/openapi/v3/testdata/golden/path-with-wildcards_file1.golden @@ -18,9 +18,9 @@ paths: required: true schema: type: integer - example: 4595362125781948859 + example: 1 format: int64 - example: 5691356309313628853 + example: 1 responses: "204": description: No Content response. diff --git a/http/codegen/openapi/v3/testdata/golden/released-response-collection-names_file0.golden b/http/codegen/openapi/v3/testdata/golden/released-response-collection-names_file0.golden new file mode 100644 index 0000000000..d071c8544e --- /dev/null +++ b/http/codegen/openapi/v3/testdata/golden/released-response-collection-names_file0.golden @@ -0,0 +1,175 @@ +{ + "components": { + "schemas": { + "StoredBottleResponse": { + "description": "StoredBottle result type (default view)", + "example": { + "name": "Blue's Cuvee", + "vintage": 2003 + }, + "properties": { + "name": { + "example": "Blue's Cuvee", + "type": "string" + }, + "vintage": { + "example": 2003, + "format": "int32", + "type": "integer" + } + }, + "required": [ + "name", + "vintage" + ], + "type": "object" + }, + "StoredBottleResponseCollection": { + "description": "list_default_response_body is the result type for an array of StoredBottle (default view)", + "example": [ + { + "name": "Blue's Cuvee", + "vintage": 2003 + }, + { + "name": "Blue's Cuvee", + "vintage": 2003 + }, + { + "name": "Blue's Cuvee", + "vintage": 2003 + } + ], + "items": { + "$ref": "#/components/schemas/StoredBottleResponse" + }, + "type": "array" + }, + "StoredBottleResponseTiny": { + "description": "StoredBottle result type (tiny view)", + "example": { + "name": "Blue's Cuvee" + }, + "properties": { + "name": { + "example": "Blue's Cuvee", + "type": "string" + } + }, + "required": [ + "name" + ], + "type": "object" + }, + "StoredBottleResponseTinyCollection": { + "description": "list_tiny_response_body is the result type for an array of StoredBottle (tiny view)", + "example": [ + { + "name": "Blue's Cuvee" + }, + { + "name": "Blue's Cuvee" + }, + { + "name": "Blue's Cuvee" + }, + { + "name": "Blue's Cuvee" + } + ], + "items": { + "$ref": "#/components/schemas/StoredBottleResponseTiny" + }, + "type": "array" + } + } + }, + "info": { + "title": "Goa API", + "version": "0.0.1" + }, + "openapi": "3.0.3", + "paths": { + "/default": { + "get": { + "operationId": "storage#list_default", + "responses": { + "200": { + "content": { + "application/json": { + "example": [ + { + "name": "Blue's Cuvee", + "vintage": 2003 + }, + { + "name": "Blue's Cuvee", + "vintage": 2003 + }, + { + "name": "Blue's Cuvee", + "vintage": 2003 + } + ], + "schema": { + "$ref": "#/components/schemas/StoredBottleResponseCollection" + } + } + }, + "description": "OK response." + } + }, + "summary": "list_default storage", + "tags": [ + "storage" + ] + } + }, + "/tiny": { + "get": { + "operationId": "storage#list_tiny", + "responses": { + "200": { + "content": { + "application/json": { + "example": [ + { + "name": "Blue's Cuvee" + }, + { + "name": "Blue's Cuvee" + }, + { + "name": "Blue's Cuvee" + }, + { + "name": "Blue's Cuvee" + } + ], + "schema": { + "$ref": "#/components/schemas/StoredBottleResponseTinyCollection" + } + } + }, + "description": "OK response." + } + }, + "summary": "list_tiny storage", + "tags": [ + "storage" + ] + } + } + }, + "servers": [ + { + "description": "Default server for test api", + "url": "http://localhost:80" + } + ], + "tags": [ + { + "name": "storage" + } + ] +} diff --git a/http/codegen/openapi/v3/testdata/golden/released-response-collection-names_file1.golden b/http/codegen/openapi/v3/testdata/golden/released-response-collection-names_file1.golden new file mode 100644 index 0000000000..7cf5b12f9a --- /dev/null +++ b/http/codegen/openapi/v3/testdata/golden/released-response-collection-names_file1.golden @@ -0,0 +1,100 @@ +openapi: 3.0.3 +info: + title: Goa API + version: 0.0.1 +servers: + - url: http://localhost:80 + description: Default server for test api +paths: + /default: + get: + tags: + - storage + summary: list_default storage + operationId: storage#list_default + responses: + "200": + description: OK response. + content: + application/json: + schema: + $ref: '#/components/schemas/StoredBottleResponseCollection' + example: + - name: Blue's Cuvee + vintage: 2003 + - name: Blue's Cuvee + vintage: 2003 + - name: Blue's Cuvee + vintage: 2003 + /tiny: + get: + tags: + - storage + summary: list_tiny storage + operationId: storage#list_tiny + responses: + "200": + description: OK response. + content: + application/json: + schema: + $ref: '#/components/schemas/StoredBottleResponseTinyCollection' + example: + - name: Blue's Cuvee + - name: Blue's Cuvee + - name: Blue's Cuvee + - name: Blue's Cuvee +components: + schemas: + StoredBottleResponse: + type: object + properties: + name: + type: string + example: Blue's Cuvee + vintage: + type: integer + example: 2003 + format: int32 + description: StoredBottle result type (default view) + example: + name: Blue's Cuvee + vintage: 2003 + required: + - name + - vintage + StoredBottleResponseCollection: + type: array + items: + $ref: '#/components/schemas/StoredBottleResponse' + description: list_default_response_body is the result type for an array of StoredBottle (default view) + example: + - name: Blue's Cuvee + vintage: 2003 + - name: Blue's Cuvee + vintage: 2003 + - name: Blue's Cuvee + vintage: 2003 + StoredBottleResponseTiny: + type: object + properties: + name: + type: string + example: Blue's Cuvee + description: StoredBottle result type (tiny view) + example: + name: Blue's Cuvee + required: + - name + StoredBottleResponseTinyCollection: + type: array + items: + $ref: '#/components/schemas/StoredBottleResponseTiny' + description: list_tiny_response_body is the result type for an array of StoredBottle (tiny view) + example: + - name: Blue's Cuvee + - name: Blue's Cuvee + - name: Blue's Cuvee + - name: Blue's Cuvee +tags: + - name: storage diff --git a/http/codegen/openapi/v3/testdata/golden/server-host-with-variables_file0.golden b/http/codegen/openapi/v3/testdata/golden/server-host-with-variables_file0.golden index 675ba8cf8b..438bf890a6 100644 --- a/http/codegen/openapi/v3/testdata/golden/server-host-with-variables_file0.golden +++ b/http/codegen/openapi/v3/testdata/golden/server-host-with-variables_file0.golden @@ -26,7 +26,8 @@ "url": "https://{version}.goa.design", "variables": { "version": { - "default": "v1" + "default": "v1", + "description": "API Version" } } } diff --git a/http/codegen/openapi/v3/testdata/golden/server-host-with-variables_file1.golden b/http/codegen/openapi/v3/testdata/golden/server-host-with-variables_file1.golden index 29e3c33130..b13193e3f8 100644 --- a/http/codegen/openapi/v3/testdata/golden/server-host-with-variables_file1.golden +++ b/http/codegen/openapi/v3/testdata/golden/server-host-with-variables_file1.golden @@ -7,6 +7,7 @@ servers: variables: version: default: v1 + description: API Version paths: /: post: diff --git a/http/codegen/openapi/v3/testdata/golden/shared-error-description_file0.golden b/http/codegen/openapi/v3/testdata/golden/shared-error-description_file0.golden new file mode 100644 index 0000000000..9f5c63d9cd --- /dev/null +++ b/http/codegen/openapi/v3/testdata/golden/shared-error-description_file0.golden @@ -0,0 +1,104 @@ +{ + "components": { + "schemas": { + "SharedError": { + "description": "Shared error value", + "example": { + "message": "shared failure", + "name": "Assumenda temporibus in exercitationem alias et." + }, + "properties": { + "message": { + "description": "Error message", + "example": "shared failure", + "type": "string" + }, + "name": { + "description": "Selected error name", + "example": "Assumenda temporibus in exercitationem alias et.", + "type": "string" + } + }, + "required": [ + "name", + "message" + ], + "type": "object" + } + } + }, + "info": { + "title": "Goa API", + "version": "0.0.1" + }, + "openapi": "3.0.3", + "paths": { + "/first": { + "get": { + "operationId": "errors#first", + "responses": { + "204": { + "description": "No Content response." + }, + "400": { + "content": { + "application/json": { + "example": { + "message": "shared failure", + "name": "Assumenda temporibus in exercitationem alias et." + }, + "schema": { + "$ref": "#/components/schemas/SharedError" + } + } + }, + "description": "first_error: First failure" + } + }, + "summary": "first errors", + "tags": [ + "errors" + ] + } + }, + "/second": { + "get": { + "operationId": "errors#second", + "responses": { + "204": { + "description": "No Content response." + }, + "400": { + "content": { + "application/json": { + "example": { + "message": "shared failure", + "name": "Reprehenderit eaque non ipsam rerum temporibus." + }, + "schema": { + "$ref": "#/components/schemas/SharedError" + } + } + }, + "description": "second_error: Second failure" + } + }, + "summary": "second errors", + "tags": [ + "errors" + ] + } + } + }, + "servers": [ + { + "description": "Default server for test api", + "url": "http://localhost:80" + } + ], + "tags": [ + { + "name": "errors" + } + ] +} diff --git a/http/codegen/openapi/v3/testdata/golden/shared-error-description_file1.golden b/http/codegen/openapi/v3/testdata/golden/shared-error-description_file1.golden new file mode 100644 index 0000000000..449cf9691f --- /dev/null +++ b/http/codegen/openapi/v3/testdata/golden/shared-error-description_file1.golden @@ -0,0 +1,66 @@ +openapi: 3.0.3 +info: + title: Goa API + version: 0.0.1 +servers: + - url: http://localhost:80 + description: Default server for test api +paths: + /first: + get: + tags: + - errors + summary: first errors + operationId: errors#first + responses: + "204": + description: No Content response. + "400": + description: 'first_error: First failure' + content: + application/json: + schema: + $ref: '#/components/schemas/SharedError' + example: + message: shared failure + name: Assumenda temporibus in exercitationem alias et. + /second: + get: + tags: + - errors + summary: second errors + operationId: errors#second + responses: + "204": + description: No Content response. + "400": + description: 'second_error: Second failure' + content: + application/json: + schema: + $ref: '#/components/schemas/SharedError' + example: + message: shared failure + name: Reprehenderit eaque non ipsam rerum temporibus. +components: + schemas: + SharedError: + type: object + properties: + message: + type: string + description: Error message + example: shared failure + name: + type: string + description: Selected error name + example: Assumenda temporibus in exercitationem alias et. + description: Shared error value + example: + message: shared failure + name: Assumenda temporibus in exercitationem alias et. + required: + - name + - message +tags: + - name: errors diff --git a/http/codegen/openapi/v3/testdata/golden/sse-all-fields_file0.golden b/http/codegen/openapi/v3/testdata/golden/sse-all-fields_file0.golden index 8c247e2e1b..c83086ccb0 100644 --- a/http/codegen/openapi/v3/testdata/golden/sse-all-fields_file0.golden +++ b/http/codegen/openapi/v3/testdata/golden/sse-all-fields_file0.golden @@ -1,19 +1,6 @@ { "components": { "schemas": { - "SSEAllFieldsMethodRequestBody": { - "description": "Request body for SSEAllFieldsMethod.", - "example": { - "id": "Ducimus vel." - }, - "properties": { - "id": { - "example": "Ducimus vel.", - "type": "string" - } - }, - "type": "object" - }, "SSEAllFieldsMethodResponseBody": { "example": { "data": { @@ -63,20 +50,17 @@ "/all-fields": { "get": { "operationId": "SSEAllFieldsService#SSEAllFieldsMethod", - "requestBody": { - "content": { - "application/json": { - "example": { - "id": "Ducimus vel." - }, - "schema": { - "$ref": "#/components/schemas/SSEAllFieldsMethodRequestBody" - } + "parameters": [ + { + "example": "request", + "in": "header", + "name": "Last-Event-ID", + "schema": { + "example": "request", + "type": "string" } - }, - "description": "Request body for SSEAllFieldsMethod.", - "required": true - }, + } + ], "responses": { "200": { "content": { diff --git a/http/codegen/openapi/v3/testdata/golden/sse-all-fields_file1.golden b/http/codegen/openapi/v3/testdata/golden/sse-all-fields_file1.golden index 53ba9572b0..2c192fe12d 100644 --- a/http/codegen/openapi/v3/testdata/golden/sse-all-fields_file1.golden +++ b/http/codegen/openapi/v3/testdata/golden/sse-all-fields_file1.golden @@ -12,15 +12,13 @@ paths: - SSEAllFieldsService summary: SSEAllFieldsMethod SSEAllFieldsService operationId: SSEAllFieldsService#SSEAllFieldsMethod - requestBody: - description: Request body for SSEAllFieldsMethod. - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/SSEAllFieldsMethodRequestBody' - example: - id: Ducimus vel. + parameters: + - name: Last-Event-ID + in: header + schema: + type: string + example: request + example: request responses: "200": description: OK response. @@ -36,15 +34,6 @@ paths: retry: 3000 components: schemas: - SSEAllFieldsMethodRequestBody: - type: object - properties: - id: - type: string - example: Ducimus vel. - description: Request body for SSEAllFieldsMethod. - example: - id: Ducimus vel. SSEAllFieldsMethodResponseBody: type: object properties: diff --git a/http/codegen/openapi/v3/testdata/golden/sse-mixed-results_file0.golden b/http/codegen/openapi/v3/testdata/golden/sse-mixed-results_file0.golden index ff7f14052a..82dbcf0fe9 100644 --- a/http/codegen/openapi/v3/testdata/golden/sse-mixed-results_file0.golden +++ b/http/codegen/openapi/v3/testdata/golden/sse-mixed-results_file0.golden @@ -4,11 +4,11 @@ "Payload": { "description": "Request body for Create.", "example": { - "x": "Doloribus qui aspernatur alias consectetur accusamus qui." + "x": "request" }, "properties": { "x": { - "example": "Doloribus qui aspernatur alias consectetur accusamus qui.", + "example": "request", "type": "string" } }, @@ -19,11 +19,11 @@ }, "Result": { "example": { - "id": "Tenetur aut quam ea repudiandae." + "id": "result" }, "properties": { "id": { - "example": "Tenetur aut quam ea repudiandae.", + "example": "result", "type": "string" } }, @@ -47,7 +47,7 @@ "content": { "application/json": { "example": { - "x": "Doloribus qui aspernatur alias consectetur accusamus qui." + "x": "request" }, "schema": { "$ref": "#/components/schemas/Payload" @@ -62,7 +62,7 @@ "content": { "text/event-stream": { "example": { - "id": "Tenetur aut quam ea repudiandae." + "id": "result" }, "schema": { "$ref": "#/components/schemas/Result" diff --git a/http/codegen/openapi/v3/testdata/golden/sse-mixed-results_file1.golden b/http/codegen/openapi/v3/testdata/golden/sse-mixed-results_file1.golden index 3b5145ddb9..9700c24d35 100644 --- a/http/codegen/openapi/v3/testdata/golden/sse-mixed-results_file1.golden +++ b/http/codegen/openapi/v3/testdata/golden/sse-mixed-results_file1.golden @@ -20,7 +20,7 @@ paths: schema: $ref: '#/components/schemas/Payload' example: - x: Doloribus qui aspernatur alias consectetur accusamus qui. + x: request responses: "200": description: OK response. @@ -29,7 +29,7 @@ paths: schema: $ref: '#/components/schemas/Result' example: - id: Tenetur aut quam ea repudiandae. + id: result components: schemas: Payload: @@ -37,10 +37,10 @@ components: properties: x: type: string - example: Doloribus qui aspernatur alias consectetur accusamus qui. + example: request description: Request body for Create. example: - x: Doloribus qui aspernatur alias consectetur accusamus qui. + x: request required: - x Result: @@ -48,9 +48,9 @@ components: properties: id: type: string - example: Tenetur aut quam ea repudiandae. + example: result example: - id: Tenetur aut quam ea repudiandae. + id: result required: - id tags: diff --git a/http/codegen/openapi/v3/testdata/golden/sse-string_file0.golden b/http/codegen/openapi/v3/testdata/golden/sse-string_file0.golden index c2465ac9ea..1c30e65baf 100644 --- a/http/codegen/openapi/v3/testdata/golden/sse-string_file0.golden +++ b/http/codegen/openapi/v3/testdata/golden/sse-string_file0.golden @@ -13,9 +13,9 @@ "200": { "content": { "text/event-stream": { - "example": "Adipisci necessitatibus enim voluptas asperiores corporis.", + "example": "event", "schema": { - "example": "Adipisci necessitatibus enim voluptas asperiores corporis.", + "example": "event", "type": "string" } } diff --git a/http/codegen/openapi/v3/testdata/golden/sse-string_file1.golden b/http/codegen/openapi/v3/testdata/golden/sse-string_file1.golden index a078bc4adf..2eb08cbbbe 100644 --- a/http/codegen/openapi/v3/testdata/golden/sse-string_file1.golden +++ b/http/codegen/openapi/v3/testdata/golden/sse-string_file1.golden @@ -19,8 +19,8 @@ paths: text/event-stream: schema: type: string - example: Adipisci necessitatibus enim voluptas asperiores corporis. - example: Adipisci necessitatibus enim voluptas asperiores corporis. + example: event + example: event components: {} tags: - name: SSEStringService diff --git a/http/codegen/openapi/v3/testdata/golden/type-extension_file0.golden b/http/codegen/openapi/v3/testdata/golden/type-extension_file0.golden index 60ad86fea6..7129eb7ec8 100644 --- a/http/codegen/openapi/v3/testdata/golden/type-extension_file0.golden +++ b/http/codegen/openapi/v3/testdata/golden/type-extension_file0.golden @@ -4,11 +4,11 @@ "Notification": { "description": "Request body for testEndpoint.", "example": { - "id": "Quia velit et." + "id": "notice" }, "properties": { "id": { - "example": "Quia velit et.", + "example": "notice", "type": "string" } }, @@ -30,7 +30,7 @@ "content": { "application/json": { "example": { - "id": "Quia velit et." + "id": "notice" }, "schema": { "$ref": "#/components/schemas/Notification" @@ -45,7 +45,7 @@ "content": { "application/json": { "example": { - "id": "Aut soluta voluptatem nisi corrupti." + "id": "notice" }, "schema": { "$ref": "#/components/schemas/Notification" diff --git a/http/codegen/openapi/v3/testdata/golden/type-extension_file1.golden b/http/codegen/openapi/v3/testdata/golden/type-extension_file1.golden index 615cb69668..03415ac180 100644 --- a/http/codegen/openapi/v3/testdata/golden/type-extension_file1.golden +++ b/http/codegen/openapi/v3/testdata/golden/type-extension_file1.golden @@ -20,7 +20,7 @@ paths: schema: $ref: '#/components/schemas/Notification' example: - id: Quia velit et. + id: notice responses: "200": description: OK response. @@ -29,16 +29,16 @@ paths: schema: $ref: '#/components/schemas/Notification' example: - id: Aut soluta voluptatem nisi corrupti. + id: notice components: schemas: Notification: description: Request body for testEndpoint. example: - id: Quia velit et. + id: notice properties: id: - example: Quia velit et. + example: notice type: string type: object x-test-include: true diff --git a/http/codegen/openapi/v3/testdata/golden/v3.2/alias-type_file0.golden b/http/codegen/openapi/v3/testdata/golden/v3.2/alias-type_file0.golden index f5160a5df7..7d3973366b 100644 --- a/http/codegen/openapi/v3/testdata/golden/v3.2/alias-type_file0.golden +++ b/http/codegen/openapi/v3/testdata/golden/v3.2/alias-type_file0.golden @@ -5,20 +5,14 @@ "description": "Request body for testEndpoint.", "example": { "completed": [ - "who", - "who", - "who", - "who" + "when" ], "current": "who" }, "properties": { "completed": { "example": [ - "who", - "who", - "who", - "who" + "when" ], "items": { "$ref": "#/components/schemas/Stage" @@ -58,10 +52,7 @@ "application/json": { "example": { "completed": [ - "who", - "who", - "who", - "who" + "when" ], "current": "who" }, @@ -79,10 +70,9 @@ "application/json": { "example": { "completed": [ - "where", - "where" + "when" ], - "current": "where" + "current": "who" }, "schema": { "$ref": "#/components/schemas/Setup" diff --git a/http/codegen/openapi/v3/testdata/golden/v3.2/alias-type_file1.golden b/http/codegen/openapi/v3/testdata/golden/v3.2/alias-type_file1.golden index 36fe6cd2b8..f768eeb2fe 100644 --- a/http/codegen/openapi/v3/testdata/golden/v3.2/alias-type_file1.golden +++ b/http/codegen/openapi/v3/testdata/golden/v3.2/alias-type_file1.golden @@ -22,10 +22,7 @@ paths: $ref: '#/components/schemas/Setup' example: completed: - - who - - who - - who - - who + - when current: who responses: "200": @@ -36,9 +33,8 @@ paths: $ref: '#/components/schemas/Setup' example: completed: - - where - - where - current: where + - when + current: who components: schemas: Setup: @@ -49,19 +45,13 @@ components: items: $ref: '#/components/schemas/Stage' example: - - who - - who - - who - - who + - when current: $ref: '#/components/schemas/Stage' description: Request body for testEndpoint. example: completed: - - who - - who - - who - - who + - when current: who Stage: type: string diff --git a/http/codegen/openapi/v3/testdata/golden/v3.2/bytes-example_file0.golden b/http/codegen/openapi/v3/testdata/golden/v3.2/bytes-example_file0.golden new file mode 100644 index 0000000000..c4ace5c082 --- /dev/null +++ b/http/codegen/openapi/v3/testdata/golden/v3.2/bytes-example_file0.golden @@ -0,0 +1,45 @@ +{ + "components": {}, + "info": { + "title": "Goa API", + "version": "0.0.1" + }, + "openapi": "3.2.0", + "paths": { + "/download": { + "get": { + "operationId": "bytes#download", + "responses": { + "200": { + "content": { + "application/json": { + "example": "aGVsbG8=", + "schema": { + "example": "aGVsbG8=", + "format": "binary", + "type": "string" + } + } + }, + "description": "OK response." + } + }, + "summary": "download bytes", + "tags": [ + "bytes" + ] + } + } + }, + "servers": [ + { + "name": "bytes", + "url": "https://goa.design" + } + ], + "tags": [ + { + "name": "bytes" + } + ] +} diff --git a/http/codegen/openapi/v3/testdata/golden/v3.2/bytes-example_file1.golden b/http/codegen/openapi/v3/testdata/golden/v3.2/bytes-example_file1.golden new file mode 100644 index 0000000000..b0019fd577 --- /dev/null +++ b/http/codegen/openapi/v3/testdata/golden/v3.2/bytes-example_file1.golden @@ -0,0 +1,27 @@ +openapi: 3.2.0 +info: + title: Goa API + version: 0.0.1 +servers: + - url: https://goa.design + name: bytes +paths: + /download: + get: + tags: + - bytes + summary: download bytes + operationId: bytes#download + responses: + "200": + description: OK response. + content: + application/json: + schema: + type: string + example: aGVsbG8= + format: binary + example: aGVsbG8= +components: {} +tags: + - name: bytes diff --git a/http/codegen/openapi/v3/testdata/golden/v3.2/server-host-with-variables_file0.golden b/http/codegen/openapi/v3/testdata/golden/v3.2/server-host-with-variables_file0.golden index b8739c4fa3..fe2fc87557 100644 --- a/http/codegen/openapi/v3/testdata/golden/v3.2/server-host-with-variables_file0.golden +++ b/http/codegen/openapi/v3/testdata/golden/v3.2/server-host-with-variables_file0.golden @@ -27,7 +27,8 @@ "url": "https://{version}.goa.design", "variables": { "version": { - "default": "v1" + "default": "v1", + "description": "API Version" } } } diff --git a/http/codegen/openapi/v3/testdata/golden/v3.2/server-host-with-variables_file1.golden b/http/codegen/openapi/v3/testdata/golden/v3.2/server-host-with-variables_file1.golden index 41168294ca..6b3dd5b102 100644 --- a/http/codegen/openapi/v3/testdata/golden/v3.2/server-host-with-variables_file1.golden +++ b/http/codegen/openapi/v3/testdata/golden/v3.2/server-host-with-variables_file1.golden @@ -8,6 +8,7 @@ servers: variables: version: default: v1 + description: API Version paths: /: post: diff --git a/http/codegen/openapi/v3/testdata/golden/v3.2/shared-error-description_file0.golden b/http/codegen/openapi/v3/testdata/golden/v3.2/shared-error-description_file0.golden new file mode 100644 index 0000000000..40fbf341bc --- /dev/null +++ b/http/codegen/openapi/v3/testdata/golden/v3.2/shared-error-description_file0.golden @@ -0,0 +1,105 @@ +{ + "components": { + "schemas": { + "SharedError": { + "description": "Shared error value", + "example": { + "message": "shared failure", + "name": "Assumenda temporibus in exercitationem alias et." + }, + "properties": { + "message": { + "description": "Error message", + "example": "shared failure", + "type": "string" + }, + "name": { + "description": "Selected error name", + "example": "Assumenda temporibus in exercitationem alias et.", + "type": "string" + } + }, + "required": [ + "name", + "message" + ], + "type": "object" + } + } + }, + "info": { + "title": "Goa API", + "version": "0.0.1" + }, + "openapi": "3.2.0", + "paths": { + "/first": { + "get": { + "operationId": "errors#first", + "responses": { + "204": { + "description": "No Content response." + }, + "400": { + "content": { + "application/json": { + "example": { + "message": "shared failure", + "name": "Assumenda temporibus in exercitationem alias et." + }, + "schema": { + "$ref": "#/components/schemas/SharedError" + } + } + }, + "description": "first_error: First failure" + } + }, + "summary": "first errors", + "tags": [ + "errors" + ] + } + }, + "/second": { + "get": { + "operationId": "errors#second", + "responses": { + "204": { + "description": "No Content response." + }, + "400": { + "content": { + "application/json": { + "example": { + "message": "shared failure", + "name": "Reprehenderit eaque non ipsam rerum temporibus." + }, + "schema": { + "$ref": "#/components/schemas/SharedError" + } + } + }, + "description": "second_error: Second failure" + } + }, + "summary": "second errors", + "tags": [ + "errors" + ] + } + } + }, + "servers": [ + { + "description": "Default server for test api", + "name": "test api", + "url": "http://localhost:80" + } + ], + "tags": [ + { + "name": "errors" + } + ] +} diff --git a/http/codegen/openapi/v3/testdata/golden/v3.2/shared-error-description_file1.golden b/http/codegen/openapi/v3/testdata/golden/v3.2/shared-error-description_file1.golden new file mode 100644 index 0000000000..3a56442a3f --- /dev/null +++ b/http/codegen/openapi/v3/testdata/golden/v3.2/shared-error-description_file1.golden @@ -0,0 +1,67 @@ +openapi: 3.2.0 +info: + title: Goa API + version: 0.0.1 +servers: + - url: http://localhost:80 + name: test api + description: Default server for test api +paths: + /first: + get: + tags: + - errors + summary: first errors + operationId: errors#first + responses: + "204": + description: No Content response. + "400": + description: 'first_error: First failure' + content: + application/json: + schema: + $ref: '#/components/schemas/SharedError' + example: + message: shared failure + name: Assumenda temporibus in exercitationem alias et. + /second: + get: + tags: + - errors + summary: second errors + operationId: errors#second + responses: + "204": + description: No Content response. + "400": + description: 'second_error: Second failure' + content: + application/json: + schema: + $ref: '#/components/schemas/SharedError' + example: + message: shared failure + name: Reprehenderit eaque non ipsam rerum temporibus. +components: + schemas: + SharedError: + type: object + properties: + message: + type: string + description: Error message + example: shared failure + name: + type: string + description: Selected error name + example: Assumenda temporibus in exercitationem alias et. + description: Shared error value + example: + message: shared failure + name: Assumenda temporibus in exercitationem alias et. + required: + - name + - message +tags: + - name: errors diff --git a/http/codegen/openapi/v3/testdata/golden/v3.2/sse-all-fields_file0.golden b/http/codegen/openapi/v3/testdata/golden/v3.2/sse-all-fields_file0.golden index d0b6443840..1e8b3cfa06 100644 --- a/http/codegen/openapi/v3/testdata/golden/v3.2/sse-all-fields_file0.golden +++ b/http/codegen/openapi/v3/testdata/golden/v3.2/sse-all-fields_file0.golden @@ -1,19 +1,6 @@ { "components": { "schemas": { - "SSEAllFieldsMethodRequestBody": { - "description": "Request body for SSEAllFieldsMethod.", - "example": { - "id": "Ducimus vel." - }, - "properties": { - "id": { - "example": "Ducimus vel.", - "type": "string" - } - }, - "type": "object" - }, "SSEAllFieldsMethodResponseBody": { "example": { "data": { @@ -65,29 +52,15 @@ "operationId": "SSEAllFieldsService#SSEAllFieldsMethod", "parameters": [ { - "example": "Non sed saepe voluptatem.", + "example": "request", "in": "header", "name": "Last-Event-ID", "schema": { - "example": "Natus magni voluptates consequatur suscipit.", + "example": "request", "type": "string" } } ], - "requestBody": { - "content": { - "application/json": { - "example": { - "id": "Ducimus vel." - }, - "schema": { - "$ref": "#/components/schemas/SSEAllFieldsMethodRequestBody" - } - } - }, - "description": "Request body for SSEAllFieldsMethod.", - "required": true - }, "responses": { "200": { "content": { @@ -124,9 +97,6 @@ "type": "integer" } }, - "required": [ - "data" - ], "type": "object" } } diff --git a/http/codegen/openapi/v3/testdata/golden/v3.2/sse-all-fields_file1.golden b/http/codegen/openapi/v3/testdata/golden/v3.2/sse-all-fields_file1.golden index de1487fe6c..c83f655cce 100644 --- a/http/codegen/openapi/v3/testdata/golden/v3.2/sse-all-fields_file1.golden +++ b/http/codegen/openapi/v3/testdata/golden/v3.2/sse-all-fields_file1.golden @@ -18,17 +18,8 @@ paths: in: header schema: type: string - example: Natus magni voluptates consequatur suscipit. - example: Non sed saepe voluptatem. - requestBody: - description: Request body for SSEAllFieldsMethod. - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/SSEAllFieldsMethodRequestBody' - example: - id: Ducimus vel. + example: request + example: request responses: "200": description: OK response. @@ -58,19 +49,8 @@ paths: type: integer example: 3000 format: int64 - required: - - data components: schemas: - SSEAllFieldsMethodRequestBody: - type: object - properties: - id: - type: string - example: Ducimus vel. - description: Request body for SSEAllFieldsMethod. - example: - id: Ducimus vel. SSEAllFieldsMethodResponseBody: type: object properties: diff --git a/http/codegen/openapi/v3/testdata/golden/v3.2/sse-data-field_file0.golden b/http/codegen/openapi/v3/testdata/golden/v3.2/sse-data-field_file0.golden index 79a3a87fd9..3bcde15c46 100644 --- a/http/codegen/openapi/v3/testdata/golden/v3.2/sse-data-field_file0.golden +++ b/http/codegen/openapi/v3/testdata/golden/v3.2/sse-data-field_file0.golden @@ -3,12 +3,12 @@ "schemas": { "SSEDataFieldMethodResponseBody": { "example": { - "data": "Ipsum voluptatem quaerat quo et non sed.", + "data": "event", "flag": true }, "properties": { "data": { - "example": "Ipsum voluptatem quaerat quo et non sed.", + "example": "event", "type": "string" }, "flag": { @@ -36,13 +36,10 @@ "itemSchema": { "properties": { "data": { - "example": "Quos qui dolore voluptas.", + "example": "event", "type": "string" } }, - "required": [ - "data" - ], "type": "object" } } diff --git a/http/codegen/openapi/v3/testdata/golden/v3.2/sse-data-field_file1.golden b/http/codegen/openapi/v3/testdata/golden/v3.2/sse-data-field_file1.golden index 36a166ec7c..750c350da1 100644 --- a/http/codegen/openapi/v3/testdata/golden/v3.2/sse-data-field_file1.golden +++ b/http/codegen/openapi/v3/testdata/golden/v3.2/sse-data-field_file1.golden @@ -23,9 +23,7 @@ paths: properties: data: type: string - example: Quos qui dolore voluptas. - required: - - data + example: event components: schemas: SSEDataFieldMethodResponseBody: @@ -33,12 +31,12 @@ components: properties: data: type: string - example: Ipsum voluptatem quaerat quo et non sed. + example: event flag: type: boolean example: true example: - data: Ipsum voluptatem quaerat quo et non sed. + data: event flag: true tags: - name: SSEDataFieldService diff --git a/http/codegen/openapi/v3/testdata/golden/v3.2/sse-mixed-results_file0.golden b/http/codegen/openapi/v3/testdata/golden/v3.2/sse-mixed-results_file0.golden index bfffbfb0ed..6814d282ca 100644 --- a/http/codegen/openapi/v3/testdata/golden/v3.2/sse-mixed-results_file0.golden +++ b/http/codegen/openapi/v3/testdata/golden/v3.2/sse-mixed-results_file0.golden @@ -3,11 +3,11 @@ "schemas": { "Event": { "example": { - "message": "Doloribus nemo quia dolores." + "message": "event" }, "properties": { "message": { - "example": "Doloribus nemo quia dolores.", + "example": "event", "type": "string" } }, @@ -19,11 +19,11 @@ "Payload": { "description": "Request body for Create.", "example": { - "x": "Doloribus qui aspernatur alias consectetur accusamus qui." + "x": "request" }, "properties": { "x": { - "example": "Doloribus qui aspernatur alias consectetur accusamus qui.", + "example": "request", "type": "string" } }, @@ -34,11 +34,11 @@ }, "Result": { "example": { - "id": "Tenetur aut quam ea repudiandae." + "id": "result" }, "properties": { "id": { - "example": "Tenetur aut quam ea repudiandae.", + "example": "result", "type": "string" } }, @@ -62,7 +62,7 @@ "content": { "application/json": { "example": { - "x": "Doloribus qui aspernatur alias consectetur accusamus qui." + "x": "request" }, "schema": { "$ref": "#/components/schemas/Payload" @@ -77,7 +77,7 @@ "content": { "application/json": { "example": { - "id": "Tenetur aut quam ea repudiandae." + "id": "result" }, "schema": { "$ref": "#/components/schemas/Result" diff --git a/http/codegen/openapi/v3/testdata/golden/v3.2/sse-mixed-results_file1.golden b/http/codegen/openapi/v3/testdata/golden/v3.2/sse-mixed-results_file1.golden index aa5911777e..12254d17f7 100644 --- a/http/codegen/openapi/v3/testdata/golden/v3.2/sse-mixed-results_file1.golden +++ b/http/codegen/openapi/v3/testdata/golden/v3.2/sse-mixed-results_file1.golden @@ -21,7 +21,7 @@ paths: schema: $ref: '#/components/schemas/Payload' example: - x: Doloribus qui aspernatur alias consectetur accusamus qui. + x: request responses: "200": description: OK response. @@ -30,7 +30,7 @@ paths: schema: $ref: '#/components/schemas/Result' example: - id: Tenetur aut quam ea repudiandae. + id: result text/event-stream: itemSchema: type: object @@ -49,9 +49,9 @@ components: properties: message: type: string - example: Doloribus nemo quia dolores. + example: event example: - message: Doloribus nemo quia dolores. + message: event required: - message Payload: @@ -59,10 +59,10 @@ components: properties: x: type: string - example: Doloribus qui aspernatur alias consectetur accusamus qui. + example: request description: Request body for Create. example: - x: Doloribus qui aspernatur alias consectetur accusamus qui. + x: request required: - x Result: @@ -70,9 +70,9 @@ components: properties: id: type: string - example: Tenetur aut quam ea repudiandae. + example: result example: - id: Tenetur aut quam ea repudiandae. + id: result required: - id tags: diff --git a/http/codegen/openapi/v3/testdata/golden/v3.2/sse-object_file0.golden b/http/codegen/openapi/v3/testdata/golden/v3.2/sse-object_file0.golden index 603a5cdc19..c185b5d794 100644 --- a/http/codegen/openapi/v3/testdata/golden/v3.2/sse-object_file0.golden +++ b/http/codegen/openapi/v3/testdata/golden/v3.2/sse-object_file0.golden @@ -4,8 +4,8 @@ "SSEObjectMethodResponseBody": { "example": { "flag": true, - "id": "Aspernatur dolorem velit tenetur.", - "value": 3199557017606053400 + "id": "event", + "value": 1 }, "properties": { "flag": { @@ -13,11 +13,11 @@ "type": "boolean" }, "id": { - "example": "Aspernatur dolorem velit tenetur.", + "example": "event", "type": "string" }, "value": { - "example": 3199557017606053400, + "example": 1, "format": "int64", "type": "integer" } @@ -45,21 +45,21 @@ "contentMediaType": "application/json", "contentSchema": { "example": { - "flag": false, - "id": "Consequuntur dolores eos voluptatem.", - "value": 7986166745691455000 + "flag": true, + "id": "event", + "value": 1 }, "properties": { "flag": { - "example": false, + "example": true, "type": "boolean" }, "id": { - "example": "Consequuntur dolores eos voluptatem.", + "example": "event", "type": "string" }, "value": { - "example": 7986166745691455000, + "example": 1, "format": "int64", "type": "integer" } diff --git a/http/codegen/openapi/v3/testdata/golden/v3.2/sse-object_file1.golden b/http/codegen/openapi/v3/testdata/golden/v3.2/sse-object_file1.golden index 6701eb1b08..8878e1bf5a 100644 --- a/http/codegen/openapi/v3/testdata/golden/v3.2/sse-object_file1.golden +++ b/http/codegen/openapi/v3/testdata/golden/v3.2/sse-object_file1.golden @@ -29,18 +29,18 @@ paths: properties: flag: type: boolean - example: false + example: true id: type: string - example: Consequuntur dolores eos voluptatem. + example: event value: type: integer - example: 7986166745691455230 + example: 1 format: int64 example: - flag: false - id: Consequuntur dolores eos voluptatem. - value: 7986166745691455230 + flag: true + id: event + value: 1 required: - data components: @@ -53,14 +53,14 @@ components: example: true id: type: string - example: Aspernatur dolorem velit tenetur. + example: event value: type: integer - example: 3199557017606053617 + example: 1 format: int64 example: flag: true - id: Aspernatur dolorem velit tenetur. - value: 3199557017606053617 + id: event + value: 1 tags: - name: SSEObjectService diff --git a/http/codegen/openapi/v3/testdata/golden/v3.2/sse-request-id_file0.golden b/http/codegen/openapi/v3/testdata/golden/v3.2/sse-request-id_file0.golden index ea60200bca..343c75c716 100644 --- a/http/codegen/openapi/v3/testdata/golden/v3.2/sse-request-id_file0.golden +++ b/http/codegen/openapi/v3/testdata/golden/v3.2/sse-request-id_file0.golden @@ -1,21 +1,5 @@ { - "components": { - "schemas": { - "SSERequestIDMethodRequestBody": { - "description": "Request body for SSERequestIDMethod.", - "example": { - "id": "Est voluptas est repellat." - }, - "properties": { - "id": { - "example": "Est voluptas est repellat.", - "type": "string" - } - }, - "type": "object" - } - } - }, + "components": {}, "info": { "title": "Goa API", "version": "0.0.1" @@ -27,29 +11,15 @@ "operationId": "SSERequestIDService#SSERequestIDMethod", "parameters": [ { - "example": "Non sed saepe voluptatem.", + "example": "request", "in": "header", "name": "Last-Event-ID", "schema": { - "example": "Natus magni voluptates consequatur suscipit.", + "example": "request", "type": "string" } } ], - "requestBody": { - "content": { - "application/json": { - "example": { - "id": "Est voluptas est repellat." - }, - "schema": { - "$ref": "#/components/schemas/SSERequestIDMethodRequestBody" - } - } - }, - "description": "Request body for SSERequestIDMethod.", - "required": true - }, "responses": { "200": { "content": { @@ -57,7 +27,7 @@ "itemSchema": { "properties": { "data": { - "example": "Quia molestias.", + "example": "event", "type": "string" } }, diff --git a/http/codegen/openapi/v3/testdata/golden/v3.2/sse-request-id_file1.golden b/http/codegen/openapi/v3/testdata/golden/v3.2/sse-request-id_file1.golden index bbd022e6c6..1869ac875c 100644 --- a/http/codegen/openapi/v3/testdata/golden/v3.2/sse-request-id_file1.golden +++ b/http/codegen/openapi/v3/testdata/golden/v3.2/sse-request-id_file1.golden @@ -18,17 +18,8 @@ paths: in: header schema: type: string - example: Natus magni voluptates consequatur suscipit. - example: Non sed saepe voluptatem. - requestBody: - description: Request body for SSERequestIDMethod. - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/SSERequestIDMethodRequestBody' - example: - id: Est voluptas est repellat. + example: request + example: request responses: "200": description: OK response. @@ -39,19 +30,9 @@ paths: properties: data: type: string - example: Quia molestias. + example: event required: - data -components: - schemas: - SSERequestIDMethodRequestBody: - type: object - properties: - id: - type: string - example: Est voluptas est repellat. - description: Request body for SSERequestIDMethod. - example: - id: Est voluptas est repellat. +components: {} tags: - name: SSERequestIDService diff --git a/http/codegen/openapi/v3/testdata/golden/v3.2/sse-string_file0.golden b/http/codegen/openapi/v3/testdata/golden/v3.2/sse-string_file0.golden index b23d98992c..be45a26deb 100644 --- a/http/codegen/openapi/v3/testdata/golden/v3.2/sse-string_file0.golden +++ b/http/codegen/openapi/v3/testdata/golden/v3.2/sse-string_file0.golden @@ -16,7 +16,7 @@ "itemSchema": { "properties": { "data": { - "example": "Quia molestias.", + "example": "event", "type": "string" } }, diff --git a/http/codegen/openapi/v3/testdata/golden/v3.2/sse-string_file1.golden b/http/codegen/openapi/v3/testdata/golden/v3.2/sse-string_file1.golden index c5fd93dd0b..12076372c5 100644 --- a/http/codegen/openapi/v3/testdata/golden/v3.2/sse-string_file1.golden +++ b/http/codegen/openapi/v3/testdata/golden/v3.2/sse-string_file1.golden @@ -23,7 +23,7 @@ paths: properties: data: type: string - example: Quia molestias. + example: event required: - data components: {} diff --git a/http/codegen/openapi/v3/testdata/golden/v3.2/websocket_file0.golden b/http/codegen/openapi/v3/testdata/golden/v3.2/websocket_file0.golden index a7b20cdb5e..aa1883c165 100644 --- a/http/codegen/openapi/v3/testdata/golden/v3.2/websocket_file0.golden +++ b/http/codegen/openapi/v3/testdata/golden/v3.2/websocket_file0.golden @@ -3,11 +3,11 @@ "schemas": { "UserType": { "example": { - "a": "Odio laborum quae ut quis nostrum." + "a": "event" }, "properties": { "a": { - "example": "Odio laborum quae ut quis nostrum.", + "example": "event", "type": "string" } }, @@ -26,12 +26,12 @@ "operationId": "StreamingResultService#StreamingResultMethod", "parameters": [ { - "example": "Et et quis sint ipsam doloribus.", + "example": "request", "in": "path", "name": "x", "required": true, "schema": { - "example": "Ipsam ut similique tempore.", + "example": "request", "type": "string" } } @@ -41,7 +41,7 @@ "content": { "application/json": { "example": { - "a": "Odio laborum quae ut quis nostrum." + "a": "event" }, "schema": { "$ref": "#/components/schemas/UserType" diff --git a/http/codegen/openapi/v3/testdata/golden/v3.2/websocket_file1.golden b/http/codegen/openapi/v3/testdata/golden/v3.2/websocket_file1.golden index b556fec6fc..c4d34fc79b 100644 --- a/http/codegen/openapi/v3/testdata/golden/v3.2/websocket_file1.golden +++ b/http/codegen/openapi/v3/testdata/golden/v3.2/websocket_file1.golden @@ -19,8 +19,8 @@ paths: required: true schema: type: string - example: Ipsam ut similique tempore. - example: Et et quis sint ipsam doloribus. + example: request + example: request responses: "101": description: Switching Protocols response. @@ -29,7 +29,7 @@ paths: schema: $ref: '#/components/schemas/UserType' example: - a: Odio laborum quae ut quis nostrum. + a: event components: schemas: UserType: @@ -37,8 +37,8 @@ components: properties: a: type: string - example: Odio laborum quae ut quis nostrum. + example: event example: - a: Odio laborum quae ut quis nostrum. + a: event tags: - name: StreamingResultService diff --git a/http/codegen/openapi/v3/testdata/golden/v3.2/with-tags_file0.golden b/http/codegen/openapi/v3/testdata/golden/v3.2/with-tags_file0.golden index 971bde70a4..270b353dce 100644 --- a/http/codegen/openapi/v3/testdata/golden/v3.2/with-tags_file0.golden +++ b/http/codegen/openapi/v3/testdata/golden/v3.2/with-tags_file0.golden @@ -11,12 +11,12 @@ "operationId": "test service#test endpoint", "parameters": [ { - "example": 5691356309313629000, + "example": 1, "in": "path", "name": "int_map", "required": true, "schema": { - "example": 4595362125781949000, + "example": 1, "format": "int64", "type": "integer" } diff --git a/http/codegen/openapi/v3/testdata/golden/v3.2/with-tags_file1.golden b/http/codegen/openapi/v3/testdata/golden/v3.2/with-tags_file1.golden index e7365c3307..caa349923d 100644 --- a/http/codegen/openapi/v3/testdata/golden/v3.2/with-tags_file1.golden +++ b/http/codegen/openapi/v3/testdata/golden/v3.2/with-tags_file1.golden @@ -19,9 +19,9 @@ paths: required: true schema: type: integer - example: 4595362125781948859 + example: 1 format: int64 - example: 5691356309313628853 + example: 1 responses: "204": description: No Content response. diff --git a/http/codegen/openapi/v3/testdata/golden/websocket_file0.golden b/http/codegen/openapi/v3/testdata/golden/websocket_file0.golden index 3d5a4c31f7..d139d0cf91 100644 --- a/http/codegen/openapi/v3/testdata/golden/websocket_file0.golden +++ b/http/codegen/openapi/v3/testdata/golden/websocket_file0.golden @@ -3,11 +3,11 @@ "schemas": { "UserType": { "example": { - "a": "Odio laborum quae ut quis nostrum." + "a": "event" }, "properties": { "a": { - "example": "Odio laborum quae ut quis nostrum.", + "example": "event", "type": "string" } }, @@ -26,12 +26,12 @@ "operationId": "StreamingResultService#StreamingResultMethod", "parameters": [ { - "example": "Et et quis sint ipsam doloribus.", + "example": "request", "in": "path", "name": "x", "required": true, "schema": { - "example": "Ipsam ut similique tempore.", + "example": "request", "type": "string" } } @@ -41,7 +41,7 @@ "content": { "application/json": { "example": { - "a": "Odio laborum quae ut quis nostrum." + "a": "event" }, "schema": { "$ref": "#/components/schemas/UserType" diff --git a/http/codegen/openapi/v3/testdata/golden/websocket_file1.golden b/http/codegen/openapi/v3/testdata/golden/websocket_file1.golden index 3fccfc7691..f16d59a634 100644 --- a/http/codegen/openapi/v3/testdata/golden/websocket_file1.golden +++ b/http/codegen/openapi/v3/testdata/golden/websocket_file1.golden @@ -18,8 +18,8 @@ paths: required: true schema: type: string - example: Ipsam ut similique tempore. - example: Et et quis sint ipsam doloribus. + example: request + example: request responses: "101": description: Switching Protocols response. @@ -28,7 +28,7 @@ paths: schema: $ref: '#/components/schemas/UserType' example: - a: Odio laborum quae ut quis nostrum. + a: event components: schemas: UserType: @@ -36,8 +36,8 @@ components: properties: a: type: string - example: Odio laborum quae ut quis nostrum. + example: event example: - a: Odio laborum quae ut quis nostrum. + a: event tags: - name: StreamingResultService diff --git a/http/codegen/openapi/v3/testdata/golden/with-any_file0.golden b/http/codegen/openapi/v3/testdata/golden/with-any_file0.golden index 18343b5c9e..46df3c525b 100644 --- a/http/codegen/openapi/v3/testdata/golden/with-any_file0.golden +++ b/http/codegen/openapi/v3/testdata/golden/with-any_file0.golden @@ -6,12 +6,10 @@ "example": { "any": "", "any_array": [ - "", - "", "" ], "any_map": { - "": "" + "key": "" } }, "properties": { @@ -20,8 +18,6 @@ }, "any_array": { "example": [ - "", - "", "" ], "items": { @@ -32,7 +28,7 @@ "any_map": { "additionalProperties": true, "example": { - "": "" + "key": "" }, "type": "object" } @@ -56,12 +52,10 @@ "example": { "any": "", "any_array": [ - "", - "", "" ], "any_map": { - "": "" + "key": "" } }, "schema": { @@ -79,13 +73,10 @@ "example": { "any": "", "any_array": [ - "", - "", - "", "" ], "any_map": { - "": "" + "key": "" } }, "schema": { diff --git a/http/codegen/openapi/v3/testdata/golden/with-any_file1.golden b/http/codegen/openapi/v3/testdata/golden/with-any_file1.golden index cb2e05152e..dac21362b4 100644 --- a/http/codegen/openapi/v3/testdata/golden/with-any_file1.golden +++ b/http/codegen/openapi/v3/testdata/golden/with-any_file1.golden @@ -23,10 +23,8 @@ paths: any: "" any_array: - "" - - "" - - "" any_map: - "": "" + key: "" responses: "200": description: OK response. @@ -38,11 +36,8 @@ paths: any: "" any_array: - "" - - "" - - "" - - "" any_map: - "": "" + key: "" components: schemas: TestEndpointRequestBody: @@ -56,21 +51,17 @@ components: example: "" example: - "" - - "" - - "" any_map: type: object example: - "": "" + key: "" additionalProperties: true description: Request body for testEndpoint. example: any: "" any_array: - "" - - "" - - "" any_map: - "": "" + key: "" tags: - name: testService diff --git a/http/codegen/openapi/v3/testdata/golden/with-map_file0.golden b/http/codegen/openapi/v3/testdata/golden/with-map_file0.golden index 12b5b2d556..81543dbb8b 100644 --- a/http/codegen/openapi/v3/testdata/golden/with-map_file0.golden +++ b/http/codegen/openapi/v3/testdata/golden/with-map_file0.golden @@ -19,6 +19,9 @@ { "string": "" }, + { + "string": "" + }, { "string": "" } @@ -31,6 +34,9 @@ { "string": "" }, + { + "string": "" + }, { "string": "" } @@ -107,6 +113,9 @@ { "string": "" }, + { + "string": "" + }, { "string": "" } @@ -132,6 +141,9 @@ { "string": "" }, + { + "string": "" + }, { "string": "" } @@ -212,6 +224,9 @@ { "string": "" }, + { + "string": "" + }, { "string": "" } diff --git a/http/codegen/openapi/v3/testdata/golden/with-map_file1.golden b/http/codegen/openapi/v3/testdata/golden/with-map_file1.golden index 14e848014a..5f2c43bcf5 100644 --- a/http/codegen/openapi/v3/testdata/golden/with-map_file1.golden +++ b/http/codegen/openapi/v3/testdata/golden/with-map_file1.golden @@ -40,6 +40,7 @@ paths: bar: - string: "" - string: "" + - string: "" foo: "" uint32_map: "": 1 @@ -65,6 +66,7 @@ components: example: - string: "" - string: "" + - string: "" foo: type: string example: "" @@ -72,6 +74,7 @@ components: bar: - string: "" - string: "" + - string: "" foo: "" TestEndpointRequestBody: type: object @@ -118,6 +121,7 @@ components: bar: - string: "" - string: "" + - string: "" foo: "" additionalProperties: $ref: '#/components/schemas/GoaFoobar' @@ -143,6 +147,7 @@ components: bar: - string: "" - string: "" + - string: "" foo: "" uint32_map: "": 1 diff --git a/http/codegen/openapi/v3/testdata/golden/with-spaces_file0.golden b/http/codegen/openapi/v3/testdata/golden/with-spaces_file0.golden index 683e7e562a..14b4367cf9 100644 --- a/http/codegen/openapi/v3/testdata/golden/with-spaces_file0.golden +++ b/http/codegen/openapi/v3/testdata/golden/with-spaces_file0.golden @@ -4,7 +4,7 @@ "Bar": { "description": "Request body for test endpoint.", "example": { - "string": "" + "string": "item" }, "properties": { "string": { @@ -18,10 +18,7 @@ "example": { "bar": [ { - "string": "" - }, - { - "string": "" + "string": "item" } ], "foo": "" @@ -30,10 +27,7 @@ "bar": { "example": [ { - "string": "" - }, - { - "string": "" + "string": "item" } ], "items": { @@ -63,7 +57,7 @@ "content": { "application/json": { "example": { - "string": "" + "string": "item" }, "schema": { "$ref": "#/components/schemas/Bar" @@ -77,19 +71,18 @@ "200": { "content": { "application/json": { - "example": { - "bar": [ - { - "string": "" - }, - { - "string": "" - }, - { - "string": "" + "examples": { + "default": { + "summary": "default", + "value": { + "bar": [ + { + "string": "item" + } + ], + "foo": "" } - ], - "foo": "" + } }, "schema": { "$ref": "#/components/schemas/GoaFoobar" @@ -101,19 +94,17 @@ "404": { "content": { "application/json": { - "example": { - "bar": [ - { - "string": "" - }, - { - "string": "" - }, - { - "string": "" + "examples": { + "default": { + "summary": "default", + "value": { + "bar": [ + { + "string": "item" + } + ] } - ], - "foo": "" + } }, "schema": { "$ref": "#/components/schemas/GoaFoobar" diff --git a/http/codegen/openapi/v3/testdata/golden/with-spaces_file1.golden b/http/codegen/openapi/v3/testdata/golden/with-spaces_file1.golden index 28ea024cc9..cbb69fb534 100644 --- a/http/codegen/openapi/v3/testdata/golden/with-spaces_file1.golden +++ b/http/codegen/openapi/v3/testdata/golden/with-spaces_file1.golden @@ -20,7 +20,7 @@ paths: schema: $ref: '#/components/schemas/Bar' example: - string: "" + string: item responses: "200": description: OK response. @@ -28,24 +28,25 @@ paths: application/json: schema: $ref: '#/components/schemas/GoaFoobar' - example: - bar: - - string: "" - - string: "" - - string: "" - foo: "" + examples: + default: + summary: default + value: + bar: + - string: item + foo: "" "404": description: Not Found response. content: application/json: schema: $ref: '#/components/schemas/GoaFoobar' - example: - bar: - - string: "" - - string: "" - - string: "" - foo: "" + examples: + default: + summary: default + value: + bar: + - string: item components: schemas: Bar: @@ -56,7 +57,7 @@ components: example: "" description: Request body for test endpoint. example: - string: "" + string: item GoaFoobar: type: object properties: @@ -65,15 +66,13 @@ components: items: $ref: '#/components/schemas/Bar' example: - - string: "" - - string: "" + - string: item foo: type: string example: "" example: bar: - - string: "" - - string: "" + - string: item foo: "" tags: - name: test service diff --git a/http/codegen/openapi/v3/testdata/golden/with-tags_file0.golden b/http/codegen/openapi/v3/testdata/golden/with-tags_file0.golden index e08c5e2a30..b17a1563d4 100644 --- a/http/codegen/openapi/v3/testdata/golden/with-tags_file0.golden +++ b/http/codegen/openapi/v3/testdata/golden/with-tags_file0.golden @@ -11,12 +11,12 @@ "operationId": "test service#test endpoint", "parameters": [ { - "example": 5691356309313629000, + "example": 1, "in": "path", "name": "int_map", "required": true, "schema": { - "example": 4595362125781949000, + "example": 1, "format": "int64", "type": "integer" } diff --git a/http/codegen/openapi/v3/testdata/golden/with-tags_file1.golden b/http/codegen/openapi/v3/testdata/golden/with-tags_file1.golden index c736bebb0d..cb444d1caf 100644 --- a/http/codegen/openapi/v3/testdata/golden/with-tags_file1.golden +++ b/http/codegen/openapi/v3/testdata/golden/with-tags_file1.golden @@ -18,9 +18,9 @@ paths: required: true schema: type: integer - example: 4595362125781948859 + example: 1 format: int64 - example: 5691356309313628853 + example: 1 responses: "204": description: No Content response. diff --git a/http/codegen/openapi/v3/types.go b/http/codegen/openapi/v3/types.go index ce258de253..2cef6e07e4 100644 --- a/http/codegen/openapi/v3/types.go +++ b/http/codegen/openapi/v3/types.go @@ -1,3 +1,5 @@ +// This file converts evaluated Goa types into OpenAPI v3 schemas while keeping +// generated examples anchored to their exact design locations. package openapiv3 import ( @@ -5,6 +7,7 @@ import ( "fmt" "hash" "hash/fnv" + "slices" "strconv" "strings" @@ -41,7 +44,10 @@ type ( schemas map[string]*openapi.Schema // type names indexed by hashes hashes map[uint64][]string - rand *expr.ExampleGenerator + // released response names indexed by schema hash + preferredNames map[uint64][]string + rand *expr.ExampleGenerator + values openapi.Values // nameAliases generates named component schemas for primitive alias // types instead of inlining them. Only set when the schemas map feeds // the document components (OpenAPI 3.2 documents): schemafiers whose @@ -51,47 +57,60 @@ type ( } ) -// derived returns a schemafier drawing example values from a stream derived -// from the given identity, sharing all other state. See -// expr.ExampleGenerator.Derived. -func (sf *schemafier) derived(id string) *schemafier { +// at returns a schemafier that draws example values from the sequence selected +// by identity. +func (sf *schemafier) at(identity expr.ExampleIdentity) *schemafier { c := *sf - c.rand = sf.rand.Derived(id) + c.rand = sf.rand.At(identity) return &c } -// rebased returns a schemafier whose example value stream is anchored to the -// given absolute design identity, sharing all other state. See -// expr.ExampleGenerator.Rebased. -func (sf *schemafier) rebased(id string) *schemafier { +// member returns a schemafier that draws examples from the named object's +// field sequence below the current example key. +func (sf *schemafier) member(name string) *schemafier { c := *sf - c.rand = sf.rand.Rebased(id) + c.rand = sf.rand.Member(name) return &c } -// bodyExampleID returns the absolute design identity anchoring the example -// streams of an endpoint request or response body. Anonymous body types -// (inline arrays, maps and primitives) have no type identity of their own so -// their examples anchor on the endpoint that owns them. -func bodyExampleID(svc, endpoint, role string) string { - return svc + "." + endpoint + "." + role +// arrayElement returns a schemafier drawing examples for one array element. +func (sf *schemafier) arrayElement(index int) *schemafier { + c := *sf + c.rand = sf.rand.ArrayElement(index) + return &c } -// fieldOf returns a schemafier whose example value stream is anchored to the -// identity of the named field of the given parent attribute, sharing all -// other state. See expr.ExampleGenerator.Field. -func (sf *schemafier) fieldOf(parent *expr.AttributeExpr, name string) *schemafier { +// mapValue returns a schemafier drawing examples for one map value. +func (sf *schemafier) mapValue(index int) *schemafier { c := *sf - c.rand = sf.rand.Field(parent, name) + c.rand = sf.rand.MapValue(index) + return &c +} + +// unionMember returns a schemafier drawing examples for one union member. +func (sf *schemafier) unionMember(name string) *schemafier { + c := *sf + c.rand = sf.rand.UnionMember(name) + return &c +} + +// field returns a schemafier for a field extracted from parent. Named user +// types use the repeatable key derived from their type; anonymous parents use +// the caller's key. +func (sf *schemafier) field(parent *expr.AttributeExpr, name string, owner expr.ExampleIdentity) *schemafier { + c := *sf + c.rand = sf.rand.At(exampleFieldIdentity(parent, name, owner)) return &c } // newSchemafier initializes a schemafier. -func newSchemafier(rand *expr.ExampleGenerator) *schemafier { +func newSchemafier(rand *expr.ExampleGenerator, values openapi.Values) *schemafier { return &schemafier{ - schemas: make(map[string]*openapi.Schema), - hashes: make(map[uint64][]string), - rand: rand, + schemas: make(map[string]*openapi.Schema), + hashes: make(map[uint64][]string), + preferredNames: make(map[uint64][]string), + rand: rand, + values: values, } } @@ -107,24 +126,25 @@ func newSchemafier(rand *expr.ExampleGenerator) *schemafier { // value indexed by type name. // // NOTE: entries are nil when the corresponding type is Empty. -func buildBodyTypes(api *expr.APIExpr, types []expr.UserType, resultTypes []*expr.ResultTypeExpr, ver openapi.Version) (map[string]map[string]*EndpointBodies, map[string]*openapi.Schema) { +func buildBodyTypes(api *expr.APIExpr, types []expr.UserType, resultTypes []*expr.ResultTypeExpr, ver openapi.Version, generator *expr.ExampleGenerator, values openapi.Values) (map[string]map[string]*EndpointBodies, map[string]*openapi.Schema) { bodies := make(map[string]map[string]*EndpointBodies) - sf := newSchemafier(api.ExampleGenerator) + sf := newSchemafier(generator, values) sf.nameAliases = ver == openapi.Version32 services := openAPIGeneratedServices(api) + sf.collectPreferredResponseNames(api) // Generates the types referenced from the endpoints. for _, t := range types { if !mustGenerateType(t.Attribute().Meta, services) { continue } - sf.schemafy(&expr.AttributeExpr{Type: t}) + sf.at(expr.UserTypeExampleIdentity(t)).schemafy(&expr.AttributeExpr{Type: t}) } for _, t := range resultTypes { if !mustGenerateType(t.Attribute().Meta, services) { continue } - sf.schemafy(&expr.AttributeExpr{Type: t}) + sf.at(expr.UserTypeExampleIdentity(t)).schemafy(&expr.AttributeExpr{Type: t}) } for _, s := range api.HTTP.Services { @@ -145,9 +165,9 @@ func buildBodyTypes(api *expr.APIExpr, types []expr.UserType, resultTypes []*exp reqBody.Description = defaultRequestBodyDescription(e) } } - req := sf.rebased(bodyExampleID(s.Name(), e.Name(), "request")).schemafy(reqBody) + req := sf.at(expr.RequestBodyExampleIdentity(e)).schemafy(reqBody) if e.StreamingBody != nil { - sreq := sf.schemafy(e.StreamingBody) + sreq := sf.at(expr.MethodStreamingPayloadExampleIdentity(e.MethodExpr)).schemafy(e.StreamingBody) var note string if sreq.Ref != "" { note = sreq.Ref @@ -168,13 +188,15 @@ func buildBodyTypes(api *expr.APIExpr, types []expr.UserType, resultTypes []*exp } } res := make(map[int][]*openapi.Schema) - resps := e.Responses - for _, er := range e.HTTPErrors { - resps = append(resps, er.Response) + for _, resp := range e.Responses { + identity := expr.ResponseBodyExampleIdentity(e, resp) + js := sf.at(identity).schemafy(staticViewBody(resp)) + res[resp.StatusCode] = append(res[resp.StatusCode], js) } - for i, resp := range resps { - id := bodyExampleID(s.Name(), e.Name(), "response."+strconv.Itoa(resp.StatusCode)+"."+strconv.Itoa(i)) - js := sf.rebased(id).schemafy(staticViewBody(resp)) + for _, httpError := range e.HTTPErrors { + identity := expr.ErrorResponseBodyExampleIdentity(e, httpError) + resp := httpError.Response + js := sf.at(identity).schemafy(staticViewBody(resp)) res[resp.StatusCode] = append(res[resp.StatusCode], js) } eb := &EndpointBodies{RequestBody: req, ResponseBodies: res} @@ -188,48 +210,86 @@ func buildBodyTypes(api *expr.APIExpr, types []expr.UserType, resultTypes []*exp return bodies, sf.schemas } +// collectPreferredResponseNames records released component names before any +// equal authored type can claim the same schema. +func (sf *schemafier) collectPreferredResponseNames(api *expr.APIExpr) { + for _, service := range api.HTTP.Services { + for _, endpoint := range service.HTTPEndpoints { + for _, response := range endpoint.Responses { + sf.collectPreferredResponseName(response) + } + for _, transportError := range endpoint.HTTPErrors { + sf.collectPreferredResponseName(transportError.Response) + } + } + } + for _, names := range sf.preferredNames { + slices.Sort(names) + } +} + +// collectPreferredResponseName records each unique released name for one +// response schema shape. Shapes with several names keep their shared name. +func (sf *schemafier) collectPreferredResponseName(response *expr.HTTPResponseExpr) { + _, preferred := responseBodyProjection(response) + for _, userType := range preferred { + if _, explicit := userType.Attribute().Meta["openapi:typename"]; explicit { + continue + } + attribute := &expr.AttributeExpr{Type: userType} + hash := sf.hashAttribute(attribute, fnv.New64()) + name := codegen.Goify(userType.Name(), true) + if !slices.Contains(sf.preferredNames[hash], name) { + sf.preferredNames[hash] = append(sf.preferredNames[hash], name) + } + } +} + // buildSSEItemSchema returns the JSON schema describing a single event // streamed by the given server-sent events endpoint as defined by the OpenAPI // 3.2 sequential media types. The schema is an object whose properties mirror -// the SSE event fields mapped by the design: data is always present, event, -// id and retry only when the design maps them. String and bytes data is -// written raw on the wire while other types are JSON-encoded, which the data -// property reflects using the JSON schema contentMediaType and contentSchema -// keywords. +// the SSE event fields mapped by the design. A selected optional data field may +// be absent; the full streaming result and required fields are always present. +// Primitive values are written as raw text while structured values are JSON, +// which the data property describes with contentMediaType and contentSchema. func (sf *schemafier) buildSSEItemSchema(e *expr.HTTPEndpointExpr) *openapi.Schema { sse := e.SSE sr := e.MethodExpr.StreamingResult data := sr - dsf := sf + owner := expr.MethodStreamingResultExampleIdentity(e.MethodExpr) + dsf := sf.at(owner) if sse.DataField != "" { data = expr.AsObject(sr.Type).Attribute(sse.DataField) - dsf = sf.fieldOf(sr, sse.DataField) + dsf = sf.field(sr, sse.DataField, owner) } var dataSchema *openapi.Schema - switch data.Type { - case expr.String, expr.Bytes: + if expr.IsPrimitive(data.Type) { dataSchema = dsf.schemafy(data) - default: + } else { dataSchema = &openapi.Schema{ Type: openapi.String, ContentMediaType: "application/json", ContentSchema: dsf.schemafy(data), } } + var required []string + if sse.DataField == "" || sr.IsRequired(sse.DataField) { + required = []string{"data"} + } props := map[string]*openapi.Schema{"data": dataSchema} if sse.EventField != "" { - props["event"] = sf.fieldOf(sr, sse.EventField).schemafy(expr.AsObject(sr.Type).Attribute(sse.EventField)) + props["event"] = sf.field(sr, sse.EventField, owner).schemafy(expr.AsObject(sr.Type).Attribute(sse.EventField)) } if sse.IDField != "" { - props["id"] = sf.fieldOf(sr, sse.IDField).schemafy(expr.AsObject(sr.Type).Attribute(sse.IDField)) + props["id"] = sf.field(sr, sse.IDField, owner).schemafy(expr.AsObject(sr.Type).Attribute(sse.IDField)) } if sse.RetryField != "" { - props["retry"] = sf.fieldOf(sr, sse.RetryField).schemafy(expr.AsObject(sr.Type).Attribute(sse.RetryField)) + props["retry"] = sf.field(sr, sse.RetryField, owner).schemafy(expr.AsObject(sr.Type).Attribute(sse.RetryField)) } return &openapi.Schema{ Type: openapi.Object, Properties: props, - Required: []string{"data"}, + Required: required, } } @@ -238,17 +298,28 @@ func (sf *schemafier) buildSSEItemSchema(e *expr.HTTPEndpointExpr) *openapi.Sche // view the result type is projected onto a detached copy of the body: the // design expression tree is read-only for the generators. func staticViewBody(resp *expr.HTTPResponseExpr) *expr.AttributeExpr { - view, ok := resp.Body.Meta.Last(expr.ViewMetaKey) - if !ok || view == "" { - return resp.Body + body, _ := responseBodyProjection(resp) + return body +} + +// responseBodyProjection returns a detached response body and the released +// component names that may describe its schemas. +func responseBodyProjection(resp *expr.HTTPResponseExpr) (*expr.AttributeExpr, []expr.UserType) { + result, ok := resp.Body.Type.(*expr.ResultTypeExpr) + if !ok { + return resp.Body, nil } - body := expr.DupAtt(resp.Body) - rt, err := expr.Project(body.Type.(*expr.ResultTypeExpr), view) - if err != nil { - panic(fmt.Sprintf("failed to project %q to view %q", body.Type.Name(), view)) // bug + view, selected := resp.Body.Meta.Last(expr.ViewMetaKey) + if (!selected || view == "") && expr.AsArray(result.Type) == nil { + return resp.Body, nil } - body.Type = rt - return body + if !selected || view == "" { + view = expr.DefaultView + } + body := expr.DupAtt(resp.Body) + projection := openapi.ProjectResponseResult(result, view) + body.Type = projection.Result + return body, projection.Preferred } func (sf *schemafier) schemafy(attr *expr.AttributeExpr, noref ...bool) *openapi.Schema { @@ -297,7 +368,7 @@ func (sf *schemafier) schemafy(attr *expr.AttributeExpr, noref ...bool) *openapi } case *expr.Array: s.Type = openapi.Array - s.Items = sf.derived("0").schemafy(t.ElemType) + s.Items = sf.arrayElement(0).schemafy(t.ElemType) case *expr.Object: s.Type = openapi.Object var itemNotes []string @@ -305,7 +376,7 @@ func (sf *schemafier) schemafy(attr *expr.AttributeExpr, noref ...bool) *openapi if !openapi.MustGenerate(nat.Attribute.Meta) { continue } - s.Properties[nat.Name] = sf.derived(nat.Name).schemafy(nat.Attribute) + s.Properties[nat.Name] = sf.member(nat.Name).schemafy(nat.Attribute) } if len(itemNotes) > 0 { note = strings.Join(itemNotes, "\n") @@ -317,7 +388,7 @@ func (sf *schemafier) schemafy(attr *expr.AttributeExpr, noref ...bool) *openapi // See https://swagger.io/docs/specification/data-models/dictionaries/. s.AdditionalProperties = true } else { - s.AdditionalProperties = sf.derived("val0").schemafy(t.ElemType) + s.AdditionalProperties = sf.mapValue(0).schemafy(t.ElemType) } case *expr.Union: // Each branch owns both its discriminator literal and value schema so @@ -334,14 +405,14 @@ func (sf *schemafier) schemafy(attr *expr.AttributeExpr, noref ...bool) *openapi Type: openapi.String, Enum: []any{val.Name}, }, - valueKey: sf.derived(val.Name).schemafy(val.Attribute), + valueKey: sf.unionMember(val.Name).schemafy(val.Attribute), }, Required: []string{typeKey, valueKey}, }) } case expr.UserType: if expr.IsAlias(t) && !sf.nameAliases { - s = sf.rebased(t.ID()).schemafy(t.Attribute()) + s = sf.at(expr.UserTypeExampleIdentity(t)).schemafy(t.Attribute()) break } h := sf.hashAttribute(attr, fnv.New64()) @@ -369,6 +440,8 @@ func (sf *schemafier) schemafy(attr *expr.AttributeExpr, noref ...bool) *openapi name := t.Name() if metaName != "" { name = metaName + } else if preferred := sf.preferredNames[h]; len(preferred) == 1 { + name = preferred[0] } else if n, ok := t.Attribute().Meta["name:original"]; ok { name = n[0] } @@ -376,17 +449,17 @@ func (sf *schemafier) schemafy(attr *expr.AttributeExpr, noref ...bool) *openapi typeName := sf.uniquify(codegen.Goify(name, true)) s.Ref = toRef(typeName) sf.hashes[h] = append(sf.hashes[h], s.Ref) - schema := sf.rebased(t.ID()).schemafy(t.Attribute(), true) + schema := sf.at(expr.UserTypeExampleIdentity(t)).schemafy(t.Attribute(), true) if schema.Description == "" { - schema.Description = userTypeDescription(t, attr) + schema.Description = sf.userTypeDescription(t, attr) } sf.schemas[typeName] = schema return s // All other schema properties are set in the reference default: panic(fmt.Sprintf("unknown type %T", t)) // bug } - if attr.Description != "" { - s.Description = attr.Description + if description := sf.values.Description(attr.AuthoredAttribute(), attr.Description); description != "" { + s.Description = description } if note != "" { s.Description += "\n" + note @@ -394,7 +467,7 @@ func (sf *schemafier) schemafy(attr *expr.AttributeExpr, noref ...bool) *openapi // Default value, example, extensions s.DefaultValue = toStringMap(attr.DefaultValue) - s.Example = openapi.Example(attr, sf.rand) + s.Example = openapi.ProjectExample(attr, sf.values.Example(attr, sf.rand)) s.Extensions = openapi.ExtensionsFromExpr(attr.Meta) // Validations @@ -448,10 +521,8 @@ func (sf *schemafier) schemafy(attr *expr.AttributeExpr, noref ...bool) *openapi return s } -// ensureSchemaDescription updates an existing component schema with the type or -// reference attribute description if the component was first created without -// one. This preserves user type descriptions when structurally equivalent types -// are reused under a component reference. +// ensureSchemaDescription gives an existing component the description owned by +// its Goa type when the component was first created without one. func (sf *schemafier) ensureSchemaDescription(ref string, t expr.UserType, attr *expr.AttributeExpr) { const prefix = "#/components/schemas/" typeName := strings.TrimPrefix(ref, prefix) @@ -462,16 +533,19 @@ func (sf *schemafier) ensureSchemaDescription(ref string, t expr.UserType, attr if schema == nil || schema.Description != "" { return } - schema.Description = userTypeDescription(t, attr) + schema.Description = sf.userTypeDescription(t, attr) } -// userTypeDescription returns the canonical description for a user type schema, -// falling back to the description of the attribute that introduced the type. -func userTypeDescription(t expr.UserType, attr *expr.AttributeExpr) string { - if desc := t.Attribute().Description; desc != "" { - return desc +// userTypeDescription returns text owned by the Goa type. A generated type may +// use text from its surrounding attribute only when both came from the same +// expression in the design. +func (sf *schemafier) userTypeDescription(t expr.UserType, attr *expr.AttributeExpr) string { + typeAtt := t.Attribute() + description := typeAtt.Description + if description == "" && typeAtt.AuthoredAttribute() == attr.AuthoredAttribute() { + description = attr.Description } - return attr.Description + return sf.values.Description(typeAtt.AuthoredAttribute(), description) } // uniquify returns n if n is not a known type name. Otherwise uniquify appends diff --git a/http/codegen/openapi/v3/types_test.go b/http/codegen/openapi/v3/types_test.go index 6b23e592c2..239a840db0 100644 --- a/http/codegen/openapi/v3/types_test.go +++ b/http/codegen/openapi/v3/types_test.go @@ -1,3 +1,5 @@ +// This file verifies OpenAPI v3 schema construction and stable example +// generation for primitive, collection, object, and transport body types. package openapiv3 import ( @@ -216,7 +218,7 @@ func TestBuildBodyTypes(t *testing.T) { t.Run(c.Name, func(t *testing.T) { root := codegen.RunDSL(t, c.DSL) - bodies, types := buildBodyTypes(root.API, root.Types, root.ResultTypes, openapi.Version30) + bodies, types := buildBodyTypes(root.API, root.Types, root.ResultTypes, openapi.Version30, expr.NewExampleGenerator(root.API.RandomizerFactory), openapi.Values{}) svc, ok := bodies[svcName] if !ok { @@ -411,7 +413,7 @@ func TestMapTypes(t *testing.T) { t.Run(tc.Name, func(t *testing.T) { // Build the OpenAPI spec root := codegen.RunDSL(t, tc.DSL) - bodies, types := buildBodyTypes(root.API, root.Types, root.ResultTypes, openapi.Version30) + bodies, types := buildBodyTypes(root.API, root.Types, root.ResultTypes, openapi.Version30, expr.NewExampleGenerator(root.API.RandomizerFactory), openapi.Values{}) // Find the service and method svcBodies, ok := bodies[svcName] @@ -500,7 +502,7 @@ func validateAdditionalPropsSchema(t *testing.T, ctx string, schema *openapi.Sch func TestTypesOnlyDifferByEnum(t *testing.T) { root := codegen.RunDSL(t, dsls.StringEnumBodyDSL()) - bodies, types := buildBodyTypes(root.API, root.Types, root.ResultTypes, openapi.Version30) + bodies, types := buildBodyTypes(root.API, root.Types, root.ResultTypes, openapi.Version30, expr.NewExampleGenerator(root.API.RandomizerFactory), openapi.Values{}) svc1, ok := bodies["svc_enum_1"] if !ok { @@ -549,7 +551,7 @@ func TestBuildBodyTypesPreservesPrimitiveAliasComponents(t *testing.T) { }) }) - bodies, types := buildBodyTypes(root.API, root.Types, root.ResultTypes, openapi.Version32) + bodies, types := buildBodyTypes(root.API, root.Types, root.ResultTypes, openapi.Version32, expr.NewExampleGenerator(root.API.RandomizerFactory), openapi.Values{}) tests := []struct { name string status int @@ -700,7 +702,7 @@ func TestHashAttribute(t *testing.T) { } h := fnv.New64() - sf := newSchemafier(expr.NewRandom("test")) + sf := newSchemafier(expr.NewExampleGenerator(expr.NewFakerRandomizerFactory("test")), openapi.Values{}) for _, group := range cases { t.Run(group.name, func(t *testing.T) { diff --git a/http/codegen/openapi/v3/types_union_test.go b/http/codegen/openapi/v3/types_union_test.go index c0a87ee798..f072544997 100644 --- a/http/codegen/openapi/v3/types_union_test.go +++ b/http/codegen/openapi/v3/types_union_test.go @@ -1,3 +1,5 @@ +// This file verifies OpenAPI 3 schema conversion for unions, including that a +// typed owner keeps each discriminator paired with its generated member value. package openapiv3 import ( @@ -11,7 +13,11 @@ import ( ) func TestSchemafyCorrelatesUnionDiscriminatorAndValue(t *testing.T) { - schema := (&schemafier{rand: expr.NewRandom("test")}).schemafy(unionAttribute()) + method := &expr.MethodExpr{Name: "union", Service: &expr.ServiceExpr{Name: "test"}} + generator := expr.NewExampleGenerator(expr.NewFakerRandomizerFactory("test")).At( + expr.MethodPayloadExampleIdentity(method), + ) + schema := (&schemafier{rand: generator}).schemafy(unionAttribute()) require.Len(t, schema.AnyOf, 2) assertUnionSchemaBranch(t, schema.AnyOf[0], "text", openapi.Type(openapi.String)) diff --git a/http/codegen/openapi/values.go b/http/codegen/openapi/values.go new file mode 100644 index 0000000000..37485458e8 --- /dev/null +++ b/http/codegen/openapi/values.go @@ -0,0 +1,139 @@ +// This file stores alternate OpenAPI text and examples for one specification +// build. Builders read these values without changing the evaluated Goa design. +package openapi + +import ( + "maps" + + "goa.design/goa/v3/eval" + "goa.design/goa/v3/expr" +) + +type ( + // storedExample keeps an immutable example value with the exact design + // expression used to find its translated description. + storedExample struct { + value *expr.ExampleExpr + source *expr.ExampleExpr + } + + // Values contains alternate titles, descriptions, and examples for one + // OpenAPI build. The zero value uses the evaluated Goa design unchanged. + // Methods that add values return a new independent Values. + Values struct { + titles map[eval.Expression]string + descriptions map[eval.Expression]string + examples map[*expr.AttributeExpr][]storedExample + } +) + +// WithTitle returns a copy of v that uses title for target. +func (v Values) WithTitle(target eval.Expression, title string) Values { + result := v.copy() + if result.titles == nil { + result.titles = make(map[eval.Expression]string) + } + result.titles[target] = title + return result +} + +// WithDescription returns a copy of v that uses description for target. +func (v Values) WithDescription(target eval.Expression, description string) Values { + result := v.copy() + if result.descriptions == nil { + result.descriptions = make(map[eval.Expression]string) + } + result.descriptions[target] = description + return result +} + +// WithExamples returns a copy of v that uses examples for attribute. Copies +// made from attribute by Goa use the same examples. +func (v Values) WithExamples(attribute *expr.AttributeExpr, examples []*expr.ExampleExpr) Values { + result := v.copy() + if result.examples == nil { + result.examples = make(map[*expr.AttributeExpr][]storedExample) + } + result.examples[attribute.AuthoredAttribute()] = storeExamples(examples) + return result +} + +// Title returns the title stored for target or fallback when none was stored. +func (v Values) Title(target eval.Expression, fallback string) string { + if title, ok := v.titles[target]; ok { + return title + } + return fallback +} + +// Description returns the description stored for target or fallback when none +// was stored. +func (v Values) Description(target eval.Expression, fallback string) string { + if description, ok := v.descriptions[target]; ok { + return description + } + return fallback +} + +// Examples returns the examples stored for attribute or a copy of fallback +// when none were stored. +func (v Values) Examples(attribute *expr.AttributeExpr, fallback []*expr.ExampleExpr) []*expr.ExampleExpr { + if examples, ok := v.examples[attribute.AuthoredAttribute()]; ok { + return v.materializeExamples(examples) + } + if userType, ok := attribute.Type.(expr.UserType); ok { + if examples, ok := v.examples[userType.Attribute().AuthoredAttribute()]; ok { + return v.materializeExamples(examples) + } + } + return v.materializeExamples(storeExamples(fallback)) +} + +// Example returns the last stored or authored example, or generates one when +// none exists. A generator configured to suppress examples returns nil. +func (v Values) Example(attribute *expr.AttributeExpr, generator *expr.ExampleGenerator) any { + copy := *attribute + copy.UserExamples = v.Examples(attribute, attribute.ExtractUserExamples()) + return copy.Example(generator) +} + +// copy returns independent maps while retaining the immutable values they +// contain. Example lists are copied again when they are changed or read. +func (v Values) copy() Values { + return Values{ + titles: maps.Clone(v.titles), + descriptions: maps.Clone(v.descriptions), + examples: maps.Clone(v.examples), + } +} + +// storeExamples copies a complete example list while retaining the exact +// expression used to look up each translated description. +func storeExamples(examples []*expr.ExampleExpr) []storedExample { + if examples == nil { + return nil + } + result := make([]storedExample, len(examples)) + for index, example := range examples { + copy := *example + copy.Value = duplicateJSONValue(example.Value) + result[index] = storedExample{value: ©, source: example} + } + return result +} + +// materializeExamples returns a fresh example list with every replacement +// description applied from its exact source expression. +func (v Values) materializeExamples(examples []storedExample) []*expr.ExampleExpr { + if examples == nil { + return nil + } + result := make([]*expr.ExampleExpr, len(examples)) + for index, stored := range examples { + copy := *stored.value + copy.Description = v.Description(stored.source, copy.Description) + copy.Value = duplicateJSONValue(stored.value.Value) + result[index] = © + } + return result +} diff --git a/http/codegen/openapi/values_test.go b/http/codegen/openapi/values_test.go new file mode 100644 index 0000000000..af10e5cb3d --- /dev/null +++ b/http/codegen/openapi/values_test.go @@ -0,0 +1,86 @@ +// This file verifies that OpenAPI text and examples can be replaced for one +// build without changing the evaluated Goa design. +package openapi + +import ( + "testing" + + "github.com/stretchr/testify/require" + + "goa.design/goa/v3/expr" +) + +func TestValues(t *testing.T) { + api := expr.NewAPIExpr("calc", nil) + attribute := &expr.AttributeExpr{Type: expr.String} + examples := []*expr.ExampleExpr{{Summary: "default", Value: "translated"}} + + var empty Values + require.Equal(t, "original title", empty.Title(api, "original title")) + require.Equal(t, "original description", empty.Description(api, "original description")) + require.Equal(t, []*expr.ExampleExpr(nil), empty.Examples(attribute, nil)) + + localized := empty. + WithTitle(api, "localized title"). + WithDescription(api, "localized description"). + WithExamples(attribute, examples) + + require.Equal(t, "localized title", localized.Title(api, "original title")) + require.Equal(t, "localized description", localized.Description(api, "original description")) + require.Equal(t, examples, localized.Examples(attribute, nil)) + require.Equal(t, "original title", empty.Title(api, "original title")) + + // Values owns its example list so neither caller nor reader can change it. + examples[0] = &expr.ExampleExpr{Summary: "changed", Value: "changed"} + firstRead := localized.Examples(attribute, nil) + require.Equal(t, "translated", firstRead[0].Value) + firstRead[0] = &expr.ExampleExpr{Summary: "changed again", Value: "changed again"} + require.Equal(t, "translated", localized.Examples(attribute, nil)[0].Value) +} + +func TestValuesUseAuthoredAttributeForCopies(t *testing.T) { + authored := &expr.AttributeExpr{Type: expr.String} + copy := expr.DupAtt(expr.DupAtt(authored)) + localized := (Values{}).WithExamples(authored, []*expr.ExampleExpr{{Value: "translated"}}) + + require.Equal(t, "translated", localized.Examples(copy, nil)[0].Value) +} + +func TestValuesOwnCompleteExampleLists(t *testing.T) { + attribute := &expr.AttributeExpr{Type: expr.String} + examples := []*expr.ExampleExpr{ + {Summary: "first", Value: map[string]any{"items": []any{"one"}}}, + {Summary: "second", Value: "two"}, + } + values := (Values{}).WithExamples(attribute, examples) + + examples[0].Value.(map[string]any)["items"].([]any)[0] = "changed" + firstRead := values.Examples(attribute, nil) + require.Len(t, firstRead, 2) + require.Equal(t, "one", firstRead[0].Value.(map[string]any)["items"].([]any)[0]) + require.Equal(t, "two", firstRead[1].Value) + + firstRead[0].Value.(map[string]any)["items"].([]any)[0] = "changed again" + require.Equal(t, "one", values.Examples(attribute, nil)[0].Value.(map[string]any)["items"].([]any)[0]) +} + +func TestValuesApplyExampleDescriptionsRegardlessOfCallOrder(t *testing.T) { + attribute := &expr.AttributeExpr{Type: expr.String} + example := &expr.ExampleExpr{Summary: "default", Description: "original", Value: "value"} + + values := (Values{}). + WithExamples(attribute, []*expr.ExampleExpr{example}). + WithDescription(example, "translated") + + require.Equal(t, "translated", values.Examples(attribute, nil)[0].Description) + require.Equal(t, "original", example.Description) +} + +func TestDocsFromExprWithValues(t *testing.T) { + docs := &expr.DocsExpr{Description: "original", URL: "https://goa.design"} + values := (Values{}).WithDescription(docs, "localized") + + localized := DocsFromExprWithValues(docs, nil, values) + require.Equal(t, "localized", localized.Description) + require.Equal(t, "original", DocsFromExpr(docs, nil).Description) +} diff --git a/http/codegen/openapi_disabled_examples_test.go b/http/codegen/openapi_disabled_examples_test.go new file mode 100644 index 0000000000..c8d3c9e0e4 --- /dev/null +++ b/http/codegen/openapi_disabled_examples_test.go @@ -0,0 +1,59 @@ +// This file verifies that disabling OpenAPI examples uses document-private +// disabled generators and never changes service or evaluated design state. +package codegen + +import ( + "bytes" + "maps" + "strings" + "testing" + "text/template" + + "github.com/stretchr/testify/require" + + goacodegen "goa.design/goa/v3/codegen" + "goa.design/goa/v3/codegen/service" + "goa.design/goa/v3/eval" + "goa.design/goa/v3/expr" + "goa.design/goa/v3/http/codegen/testdata" +) + +func TestOpenAPIDisabledExamplesDoNotConsumeServiceState(t *testing.T) { + root := expr.RunDSL(t, testdata.SimpleDSL) + root.API.Meta = expr.MetaExpr{"openapi:example": {"false"}} + factory := root.API.RandomizerFactory + meta := maps.Clone(root.API.Meta) + examples := expr.NewExampleGenerator(factory) + generation, err := goacodegen.NewGeneration("generated.local/gen", []eval.Root{root}) + require.NoError(t, err) + servicePlan, err := service.NewPlan(root, generation, examples) + require.NoError(t, err) + require.NoError(t, generation.Freeze()) + require.NoError(t, servicePlan.Link()) + services := servicePlan.Services() + method := services.Get("testService").Methods[0] + payloadExample := method.PayloadEx + require.NotNil(t, payloadExample) + + plan, err := NewOpenAPIPlan(root, examples) + require.NoError(t, err) + files := plan.Files() + require.Len(t, files, 6) + for _, file := range files { + require.Len(t, file.SectionTemplates, 1) + section := file.SectionTemplates[0] + var rendered bytes.Buffer + tmpl := template.Must(template.New("openapi").Funcs(section.FuncMap).Parse(section.Source)) + require.NoError(t, tmpl.Execute(&rendered, section.Data)) + content := rendered.String() + if strings.HasSuffix(file.Path, ".json") { + require.NotContains(t, content, `"example"`) + } else { + require.NotContains(t, content, "\nexample:") + } + } + + require.Equal(t, payloadExample, method.PayloadEx) + require.Equal(t, factory, root.API.RandomizerFactory) + require.Equal(t, meta, root.API.Meta) +} diff --git a/http/codegen/openapi_order_independence_test.go b/http/codegen/openapi_order_independence_test.go index 88431e87a9..84a7bce3cf 100644 --- a/http/codegen/openapi_order_independence_test.go +++ b/http/codegen/openapi_order_independence_test.go @@ -1,3 +1,5 @@ +// This file verifies that HTTP and OpenAPI generation produce the same examples +// regardless of which one reads the design first. package codegen import ( @@ -9,53 +11,42 @@ import ( "github.com/stretchr/testify/require" "goa.design/goa/v3/expr" - "goa.design/goa/v3/http/codegen/openapi" "goa.design/goa/v3/http/codegen/testdata" ) -// TestOpenAPIOrderIndependence verifies that the OpenAPI specifications do not -// depend on whether the HTTP transport data was computed first: the HTTP -// analyze pass must treat the design expression tree as read-only so the -// OpenAPI generators always see the pristine design. The production "goa gen" -// flow runs the transport generators before the OpenAPI one while the OpenAPI -// golden tests run on pristine roots; any difference between the two -// generations is output that production emits but no golden test covers. +// TestOpenAPIOrderIndependence verifies that building HTTP files first does not +// change the OpenAPI documents. HTTP generation must not change the design that +// the OpenAPI generator reads afterward. func TestOpenAPIOrderIndependence(t *testing.T) { cases := []struct { Name string DSL func() }{ - // Aliased payload/result attributes: makeHTTPType used to flatten - // the aliases in place which changed the schemas OpenAPI generated. + // Named request and result fields used to be flattened in place, which + // changed the OpenAPI schemas generated afterward. {"alias-type", testdata.AliasTypeDSL}, {"result-body-multiple-views", testdata.ResultBodyMultipleViewsDSL}, {"explicit-view", testdata.ExplicitViewDSL}, {"error-response", testdata.PrimitiveErrorResponseDSL}, {"streaming-result", testdata.StreamingResultDSL}, {"streaming-payload", testdata.StreamingPayloadDSL}, - // NOTE: methods declaring anonymous object results (e.g. - // testdata.SSEObjectDSL) only pass this check because the raw - // object wrapping moved out of the service analyze pass into - // codegen.NormalizeRoot which CreateHTTPServices applies before - // computing the transport data. The pristine root below is rendered - // without normalization, so designs whose OpenAPI output depends on - // the wrapping must normalize both roots (see - // TestGeneratorsTreatDesignAsReadOnly in codegen/generator for the - // full read-only guarantee). + // Anonymous object results pass because codegen.NewGeneration prepares + // those result objects before HTTP generation starts. See + // TestGeneratorsTreatDesignAsReadOnly for the check that covers the whole + // generation run. {"sse", testdata.SSEStringDSL}, } for _, c := range cases { t.Run(c.Name, func(t *testing.T) { - // Golden test order: generate the OpenAPI specifications from a - // pristine root. + // First build OpenAPI documents from an untouched design. pristine := renderOpenAPI(t, expr.RunDSL(t, c.DSL)) - // Production order: compute the HTTP transport data first, then - // generate the OpenAPI specifications from the same root. + // Then build HTTP files first and OpenAPI documents second from the + // same design. root := expr.RunDSL(t, c.DSL) - services := CreateHTTPServices(root) + plan := linkedHTTPPlanForRoot(t, root) for _, svc := range root.API.HTTP.Services { - require.NotNil(t, services.Get(svc.Name())) + require.NotNil(t, plan.services.Get(svc.Name())) } produced := renderOpenAPI(t, root) @@ -68,15 +59,13 @@ func TestOpenAPIOrderIndependence(t *testing.T) { } // renderOpenAPI generates and renders all the OpenAPI specification files for -// the given root and returns their content indexed by file path. The global -// schema registry and the example generator are reset first so that two -// generations of identical design trees yield identical documents. +// the given root and returns their content indexed by file path. The call uses +// a fresh example generator so two identical designs yield identical documents. func renderOpenAPI(t *testing.T, root *expr.RootExpr) map[string]string { t.Helper() - openapi.Definitions = make(map[string]*openapi.Schema) - root.API.ExampleGenerator = expr.NewRandom(root.API.Name) - files, err := OpenAPIFiles(root) + plan, err := NewOpenAPIPlan(root, expr.NewExampleGenerator(root.API.RandomizerFactory)) require.NoError(t, err) + files := plan.Files() out := make(map[string]string, len(files)) for _, f := range files { require.Len(t, f.SectionTemplates, 1) diff --git a/http/codegen/openapi_plan_test.go b/http/codegen/openapi_plan_test.go new file mode 100644 index 0000000000..3749f79dbc --- /dev/null +++ b/http/codegen/openapi_plan_test.go @@ -0,0 +1,143 @@ +// This file checks that an OpenAPI plan keeps the documents it built. +package codegen + +import ( + "bytes" + "path/filepath" + "testing" + "text/template" + + "github.com/stretchr/testify/require" + + goacodegen "goa.design/goa/v3/codegen" + "goa.design/goa/v3/expr" + "goa.design/goa/v3/http/codegen/openapi" + "goa.design/goa/v3/http/codegen/testdata" +) + +func TestOpenAPIPlanKeepsBuiltFiles(t *testing.T) { + root := expr.RunDSL(t, testdata.SimpleDSL) + root.API.Contact = &expr.ContactExpr{Name: "before"} + root.API.License = &expr.LicenseExpr{Name: "before"} + root.API.HTTP.Consumes = []string{"application/before"} + root.API.HTTP.Produces = []string{"application/before"} + plan, err := NewOpenAPIPlan(root, expr.NewExampleGenerator(root.API.RandomizerFactory)) + require.NoError(t, err) + files := plan.Files() + before := renderOpenAPIFiles(t, files) + + root.API.Title = "changed after planning" + root.API.HTTP.Services = nil + root.API.Contact.Name = "after" + root.API.License.Name = "after" + root.API.HTTP.Consumes[0] = "application/after" + root.API.HTTP.Produces[0] = "application/after" + + filesAgain := plan.Files() + require.Len(t, filesAgain, len(files)) + for i := range files { + require.Same(t, files[i], filesAgain[i]) + } + require.Equal(t, before, renderOpenAPIFiles(t, filesAgain)) +} + +func TestOpenAPIPlanWithValuesDoesNotChangeDesign(t *testing.T) { + root := expr.RunDSL(t, testdata.SimpleDSL) + values := (openapi.Values{}).WithTitle(root.API, "Localized API") + + plan, err := NewOpenAPIPlanWithValues( + root, + expr.NewExampleGenerator(root.API.RandomizerFactory), + values, + ) + require.NoError(t, err) + rendered := renderOpenAPIFiles(t, plan.Files()) + for _, document := range rendered { + require.Contains(t, document, "Localized API") + } + require.NotEqual(t, "Localized API", root.API.Title) +} + +func TestNewOpenAPIPlanFromSpecsUsesExactVersionsAndPaths(t *testing.T) { + root := expr.RunDSL(t, testdata.SimpleDSL) + plan, err := NewOpenAPIPlanFromSpecs( + root, + expr.NewExampleGenerator(root.API.RandomizerFactory), + []openapi.Spec{ + {Version: openapi.Version20, Path: "docs/api.v2"}, + {Version: openapi.Version32, Path: "reference/api"}, + }, + openapi.Values{}, + ) + require.NoError(t, err) + paths := make([]string, len(plan.Files())) + for index, file := range plan.Files() { + paths[index] = file.Path + } + require.Equal(t, []string{ + filepath.Join("gen", "docs", "api.v2.json"), + filepath.Join("gen", "docs", "api.v2.yaml"), + filepath.Join("gen", "reference", "api.json"), + filepath.Join("gen", "reference", "api.yaml"), + }, paths) +} + +func TestNewOpenAPIPlanFromSpecsRejectsInvalidSpecs(t *testing.T) { + root := expr.RunDSL(t, testdata.SimpleDSL) + tests := []struct { + name string + specs []openapi.Spec + err string + }{ + {name: "unknown version", specs: []openapi.Spec{{Version: "4.0", Path: "http/api"}}, err: `unsupported OpenAPI version "4.0"`}, + {name: "empty path", specs: []openapi.Spec{{Version: openapi.Version30}}, err: "path cannot be empty"}, + {name: "absolute path", specs: []openapi.Spec{{Version: openapi.Version30, Path: "/api"}}, err: "path must be relative"}, + {name: "escaping path", specs: []openapi.Spec{{Version: openapi.Version30, Path: "../api"}}, err: "path must not escape"}, + {name: "json extension", specs: []openapi.Spec{{Version: openapi.Version30, Path: "api.json"}}, err: "path must not include an extension"}, + {name: "same version", specs: []openapi.Spec{{Version: openapi.Version30, Path: "api"}, {Version: openapi.Version30, Path: "other"}}, err: `version "3.0" appears more than once`}, + {name: "same path", specs: []openapi.Spec{{Version: openapi.Version20, Path: "api"}, {Version: openapi.Version30, Path: "api"}}, err: `same output path "api"`}, + {name: "portable path collision", specs: []openapi.Spec{{Version: openapi.Version20, Path: "API"}, {Version: openapi.Version30, Path: "api"}}, err: "case-insensitive filesystem"}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + _, err := NewOpenAPIPlanFromSpecs( + root, + expr.NewExampleGenerator(root.API.RandomizerFactory), + test.specs, + openapi.Values{}, + ) + require.ErrorContains(t, err, test.err) + }) + } +} + +func TestNewOpenAPIPlanWrappersKeepOrdinaryOutput(t *testing.T) { + root := expr.RunDSL(t, testdata.SimpleDSL) + specs, err := openapi.Specs(root.API.Meta) + require.NoError(t, err) + ordinary, err := NewOpenAPIPlan(root, expr.NewExampleGenerator(root.API.RandomizerFactory)) + require.NoError(t, err) + explicit, err := NewOpenAPIPlanFromSpecs( + root, + expr.NewExampleGenerator(root.API.RandomizerFactory), + specs, + openapi.Values{}, + ) + require.NoError(t, err) + require.Equal(t, renderOpenAPIFiles(t, ordinary.Files()), renderOpenAPIFiles(t, explicit.Files())) +} + +// renderOpenAPIFiles renders each planned file and returns its text by path. +func renderOpenAPIFiles(t *testing.T, files []*goacodegen.File) map[string]string { + t.Helper() + rendered := make(map[string]string, len(files)) + for _, file := range files { + require.Len(t, file.SectionTemplates, 1) + section := file.SectionTemplates[0] + var output bytes.Buffer + tmpl := template.Must(template.New(section.Name).Funcs(section.FuncMap).Parse(section.Source)) + require.NoError(t, tmpl.Execute(&output, section.Data)) + rendered[file.Path] = output.String() + } + return rendered +} diff --git a/http/codegen/openapi_test.go b/http/codegen/openapi_test.go index 66228ae900..5d4f211f72 100644 --- a/http/codegen/openapi_test.go +++ b/http/codegen/openapi_test.go @@ -1,3 +1,5 @@ +// This file verifies HTTP OpenAPI generation uses prepared service data and +// run-owned example streams without mutating the evaluated design. package codegen import ( @@ -8,7 +10,6 @@ import ( "github.com/stretchr/testify/require" "goa.design/goa/v3/expr" - openapi "goa.design/goa/v3/http/codegen/openapi" "goa.design/goa/v3/http/codegen/testdata" ) @@ -21,12 +22,10 @@ func TestOpenAPI(t *testing.T) { "valid": {DSL: testdata.SimpleDSL, NilSpec: false}, } for k, c := range cases { - // Reset global variables - openapi.Definitions = make(map[string]*openapi.Schema) root := expr.RunDSL(t, c.DSL) - spec, err := OpenAPIFiles(root) + plan, err := NewOpenAPIPlan(root, expr.NewExampleGenerator(root.API.RandomizerFactory)) require.NoError(t, err) - assert.Equal(t, c.NilSpec, spec == nil, k) + assert.Equal(t, c.NilSpec, len(plan.Files()) == 0, k) } } @@ -72,15 +71,14 @@ func TestOutputPath(t *testing.T) { }} for _, c := range cases { t.Run(c.Name, func(t *testing.T) { - // Reset global variables - openapi.Definitions = make(map[string]*openapi.Schema) root := expr.RunDSL(t, c.DSL) - o, err := OpenAPIFiles(root) + plan, err := NewOpenAPIPlan(root, expr.NewExampleGenerator(root.API.RandomizerFactory)) if c.Err != "" { require.EqualError(t, err, c.Err) return } require.NoError(t, err) + o := plan.Files() require.Len(t, o, len(c.Paths)) for i, p := range c.Paths { assert.Equal(t, p, o[i].Path) diff --git a/http/codegen/optional_body_runtime_test.go b/http/codegen/optional_body_runtime_test.go new file mode 100644 index 0000000000..af4e70f6a5 --- /dev/null +++ b/http/codegen/optional_body_runtime_test.go @@ -0,0 +1,641 @@ +// This file renders ordinary HTTP clients and servers into a temporary module. +// The generated tests verify that an optional selected body stays absent without +// dropping query values, while present bodies still decode and validate normally. +package codegen + +import ( + "context" + "fmt" + "os" + "os/exec" + "path/filepath" + "testing" + "time" + + "github.com/stretchr/testify/require" + + goacodegen "goa.design/goa/v3/codegen" + "goa.design/goa/v3/codegen/service" + "goa.design/goa/v3/dsl" + "goa.design/goa/v3/eval" + "goa.design/goa/v3/expr" +) + +// TestGeneratedOptionalSelectedBodyPreservesOtherRequestValues checks both +// sides of the ordinary HTTP transport with absent, null, empty, and valid bodies. +func TestGeneratedOptionalSelectedBodyPreservesOtherRequestValues(t *testing.T) { + root := expr.RunDSL(t, optionalSelectedBodyDSL) + generation, err := goacodegen.NewGeneration("generated.local/gen", []eval.Root{root}) + require.NoError(t, err) + servicePlan, err := service.NewPlan(root, generation, expr.NewExampleGenerator(root.API.RandomizerFactory)) + require.NoError(t, err) + httpPlans, err := NewPlans(generation, PlanInput{Root: root, Service: servicePlan}) + require.NoError(t, err) + require.NoError(t, generation.Freeze()) + require.NoError(t, servicePlan.Link()) + require.NoError(t, httpPlans[0].Link()) + serviceData := httpPlans[0].services.Get("optional_body") + require.NotNil(t, serviceData) + for _, endpoint := range serviceData.Endpoints { + require.False(t, endpoint.Payload.Request.PayloadInit.ServerArgs[0].Required, endpoint.Method.Name) + } + + files, err := service.Files(servicePlan) + require.NoError(t, err) + files = append(files, httpPlans[0].ClientFiles()...) + files = append(files, httpPlans[0].ClientCLIFiles()...) + files = append(files, httpPlans[0].ServerFiles()...) + files = append(files, httpPlans[0].ClientTypeFiles()...) + files = append(files, httpPlans[0].ServerTypeFiles()...) + files = append(files, httpPlans[0].PathFiles()...) + runOptionalSelectedBodyRuntimeTest(t, files) +} + +// optionalSelectedBodyDSL selects optional object and primitive fields as the +// request body while a required query field remains outside the body. +func optionalSelectedBodyDSL() { + empty := dsl.Type("Empty", func() {}) + mode := dsl.Type("Mode", dsl.String) + values := dsl.Type("Values", dsl.ArrayOf(dsl.String)) + labels := dsl.Type("Labels", dsl.MapOf(dsl.String, dsl.String)) + details := dsl.Type("Details", func() { + dsl.Attribute("name", dsl.String) + dsl.Required("name") + }) + dsl.Service("optional_body", func() { + dsl.Method("object", func() { + dsl.Payload(func() { + dsl.Attribute("data", details) + dsl.Attribute("path", dsl.String) + dsl.Attribute("tag", dsl.String) + dsl.Attribute("header", dsl.String) + dsl.Attribute("session", dsl.String) + dsl.Required("path", "tag", "header", "session") + }) + dsl.HTTP(func() { + dsl.POST("/object/{path}") + dsl.Param("path") + dsl.Param("tag") + dsl.Header("header:X-Test") + dsl.Cookie("session:SID") + dsl.Body("data") + }) + }) + dsl.Method("text", func() { + dsl.Payload(func() { + dsl.Attribute("value", dsl.String, func() { + dsl.Pattern("^[a-z]*$") + }) + dsl.Attribute("tag", dsl.String) + dsl.Required("tag") + }) + dsl.HTTP(func() { + dsl.POST("/text") + dsl.Param("tag") + dsl.Body("value") + }) + }) + dsl.Method("inline", func() { + dsl.Payload(func() { + dsl.Attribute("data", func() { + dsl.Attribute("name", dsl.String) + }) + dsl.Attribute("tag", dsl.String) + dsl.Required("tag") + }) + dsl.HTTP(func() { + dsl.POST("/inline") + dsl.Param("tag") + dsl.Body("data") + }) + }) + dsl.Method("default_text", func() { + dsl.Payload(func() { + dsl.Attribute("value", mode, func() { + dsl.Default("safe") + }) + dsl.Attribute("tag", dsl.String) + dsl.Required("tag") + }) + dsl.HTTP(func() { + dsl.POST("/default-text") + dsl.Param("tag") + dsl.Body("value") + }) + }) + dsl.Method("default_array", func() { + dsl.Payload(func() { + dsl.Attribute("value", values, func() { + dsl.Default([]string{"safe"}) + }) + dsl.Attribute("tag", dsl.String) + dsl.Required("tag") + }) + dsl.HTTP(func() { + dsl.POST("/default-array") + dsl.Param("tag") + dsl.Body("value") + }) + }) + dsl.Method("default_object", func() { + dsl.Payload(func() { + dsl.Attribute("value", details, func() { + dsl.Default(map[string]any{"name": "safe"}) + }) + dsl.Attribute("tag", dsl.String) + dsl.Required("tag") + }) + dsl.HTTP(func() { + dsl.POST("/default-object") + dsl.Param("tag") + dsl.Body("value") + }) + }) + dsl.Method("default_map", func() { + dsl.Payload(func() { + dsl.Attribute("value", labels, func() { + dsl.Default(map[string]string{"mode": "safe"}) + }) + dsl.Attribute("tag", dsl.String) + dsl.Required("tag") + }) + dsl.HTTP(func() { + dsl.POST("/default-map") + dsl.Param("tag") + dsl.Body("value") + }) + }) + dsl.Method("default_bytes", func() { + dsl.Payload(func() { + dsl.Attribute("value", dsl.Bytes, func() { + dsl.Default("safe") + }) + dsl.Attribute("tag", dsl.String) + dsl.Required("tag") + }) + dsl.HTTP(func() { + dsl.POST("/default-bytes") + dsl.Param("tag") + dsl.Body("value") + }) + }) + dsl.Method("default_union", func() { + dsl.Payload(func() { + dsl.OneOf("value", func() { + dsl.TypeName("DefaultBodyValue") + dsl.Field(1, "name", dsl.String) + dsl.Field(2, "inactive", empty) + dsl.Default(map[string]any{"type": "inactive", "value": map[string]any{}}) + }) + dsl.Attribute("tag", dsl.String) + dsl.Required("tag") + }) + dsl.HTTP(func() { + dsl.POST("/default-union") + dsl.Param("tag") + dsl.Body("value") + }) + }) + for _, method := range []struct { + name string + type_ any + }{ + {name: "array", type_: values}, + {name: "map", type_: labels}, + } { + dsl.Method(method.name, func() { + dsl.Payload(func() { + dsl.Attribute("value", method.type_) + dsl.Attribute("tag", dsl.String) + dsl.Required("tag") + }) + dsl.HTTP(func() { + dsl.POST("/" + method.name) + dsl.Param("tag") + dsl.Body("value") + }) + }) + } + dsl.Method("union", func() { + dsl.Payload(func() { + dsl.OneOf("value", func() { + dsl.TypeName("OptionalBodyValue") + dsl.Field(1, "name", dsl.String) + dsl.Field(2, "inactive", empty) + }) + dsl.Attribute("tag", dsl.String) + dsl.Required("tag") + }) + dsl.HTTP(func() { + dsl.POST("/union") + dsl.Param("tag") + dsl.Body("value") + }) + }) + }) +} + +// runOptionalSelectedBodyRuntimeTest writes the generated packages and runs +// tests beside the generated client and server code. +func runOptionalSelectedBodyRuntimeTest(t *testing.T, files []*goacodegen.File) { + t.Helper() + directory := t.TempDir() + repository, err := filepath.Abs(filepath.Join("..", "..")) + require.NoError(t, err) + module := fmt.Sprintf( + "module generated.local\n\ngo 1.25\n\nrequire goa.design/goa/v3 v3.0.0\n\nreplace goa.design/goa/v3 => %s\n", + filepath.ToSlash(repository), + ) + require.NoError(t, os.WriteFile(filepath.Join(directory, "go.mod"), []byte(module), 0o600)) + for _, file := range files { + _, err := file.Render(directory) + require.NoError(t, err) + } + clientTest := filepath.Join(directory, "gen", "http", "optional_body", "client", "optional_body_test.go") + serverTest := filepath.Join(directory, "gen", "http", "optional_body", "server", "optional_body_test.go") + require.NoError(t, os.WriteFile(clientTest, []byte(generatedOptionalBodyClientTest), 0o600)) + require.NoError(t, os.WriteFile(serverTest, []byte(generatedOptionalBodyServerTest), 0o600)) + + ctx, cancel := context.WithTimeout(context.Background(), time.Minute) + defer cancel() + command := exec.CommandContext(ctx, "go", "test", "-mod=mod", "./gen/http/optional_body/...") + command.Dir = directory + command.Env = append(os.Environ(), "GOWORK=off") + output, err := command.CombinedOutput() + require.NoError(t, err, "run generated optional body tests:\n%s", output) +} + +const generatedOptionalBodyClientTest = `package client + +import ( + "io" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/require" + + service "generated.local/gen/optional_body" + goahttp "goa.design/goa/v3/http" +) + +func TestOptionalSelectedBodiesEncodeOnlyWhenPresent(t *testing.T) { + request := httptest.NewRequest(http.MethodPost, "/object/path-value", nil) + err := EncodeObjectRequest(goahttp.RequestEncoder)(request, &service.ObjectPayload{ + Path: "path-value", Tag: "kept", Header: "header-value", Session: "session-value", + }) + require.NoError(t, err) + require.Equal(t, "/object/path-value", request.URL.Path) + require.Equal(t, "kept", request.URL.Query().Get("tag")) + require.Equal(t, "header-value", request.Header.Get("X-Test")) + cookie, err := request.Cookie("SID") + require.NoError(t, err) + require.Equal(t, "session-value", cookie.Value) + body, err := io.ReadAll(request.Body) + require.NoError(t, err) + require.Empty(t, body) + + request = httptest.NewRequest(http.MethodPost, "/object/path-value", nil) + err = EncodeObjectRequest(goahttp.RequestEncoder)(request, &service.ObjectPayload{ + Data: &service.Details{Name: "value"}, Path: "path-value", Tag: "kept", + Header: "header-value", Session: "session-value", + }) + require.NoError(t, err) + body, err = io.ReadAll(request.Body) + require.NoError(t, err) + require.JSONEq(t, ` + "`" + `{"name":"value"}` + "`" + `, string(body)) + + request = httptest.NewRequest(http.MethodPost, "/text", nil) + err = EncodeTextRequest(goahttp.RequestEncoder)(request, &service.TextPayload{Tag: "kept"}) + require.NoError(t, err) + body, err = io.ReadAll(request.Body) + require.NoError(t, err) + require.Empty(t, body) + + empty := "" + request = httptest.NewRequest(http.MethodPost, "/text", nil) + err = EncodeTextRequest(goahttp.RequestEncoder)(request, &service.TextPayload{Value: &empty, Tag: "kept"}) + require.NoError(t, err) + body, err = io.ReadAll(request.Body) + require.NoError(t, err) + require.JSONEq(t, ` + "`" + `""` + "`" + `, string(body)) + + request = httptest.NewRequest(http.MethodPost, "/inline", nil) + err = EncodeInlineRequest(goahttp.RequestEncoder)(request, &service.InlinePayload{Tag: "kept"}) + require.NoError(t, err) + body, err = io.ReadAll(request.Body) + require.NoError(t, err) + require.Empty(t, body) + + request = httptest.NewRequest(http.MethodPost, "/array", nil) + err = EncodeArrayRequest(goahttp.RequestEncoder)(request, &service.ArrayPayload{Tag: "kept"}) + require.NoError(t, err) + body, err = io.ReadAll(request.Body) + require.NoError(t, err) + require.Empty(t, body) + + request = httptest.NewRequest(http.MethodPost, "/array", nil) + err = EncodeArrayRequest(goahttp.RequestEncoder)(request, &service.ArrayPayload{ + Value: service.Values{}, + Tag: "kept", + }) + require.NoError(t, err) + body, err = io.ReadAll(request.Body) + require.NoError(t, err) + require.JSONEq(t, ` + "`" + `[]` + "`" + `, string(body)) + + request = httptest.NewRequest(http.MethodPost, "/map", nil) + err = EncodeMapRequest(goahttp.RequestEncoder)(request, &service.MapPayload{Tag: "kept"}) + require.NoError(t, err) + body, err = io.ReadAll(request.Body) + require.NoError(t, err) + require.Empty(t, body) + + request = httptest.NewRequest(http.MethodPost, "/map", nil) + err = EncodeMapRequest(goahttp.RequestEncoder)(request, &service.MapPayload{ + Value: service.Labels{}, + Tag: "kept", + }) + require.NoError(t, err) + body, err = io.ReadAll(request.Body) + require.NoError(t, err) + require.JSONEq(t, ` + "`" + `{}` + "`" + `, string(body)) + + union := &service.UnionPayload{Tag: "kept"} + union.Value.SetInactive(&service.Empty{}) + request = httptest.NewRequest(http.MethodPost, "/union", nil) + err = EncodeUnionRequest(goahttp.RequestEncoder)(request, union) + require.NoError(t, err) + body, err = io.ReadAll(request.Body) + require.NoError(t, err) + require.JSONEq(t, ` + "`" + `{"type":"inactive","value":{}}` + "`" + `, string(body)) + + request = httptest.NewRequest(http.MethodPost, "/default-text", nil) + err = EncodeDefaultTextRequest(goahttp.RequestEncoder)(request, &service.DefaultTextPayload{ + Value: service.Mode(""), + Tag: "kept", + }) + require.NoError(t, err) + body, err = io.ReadAll(request.Body) + require.NoError(t, err) + require.JSONEq(t, ` + "`" + `""` + "`" + `, string(body)) + + request = httptest.NewRequest(http.MethodPost, "/default-array", nil) + err = EncodeDefaultArrayRequest(goahttp.RequestEncoder)(request, &service.DefaultArrayPayload{Tag: "kept"}) + require.NoError(t, err) + body, err = io.ReadAll(request.Body) + require.NoError(t, err) + require.Empty(t, body) + + request = httptest.NewRequest(http.MethodPost, "/default-array", nil) + err = EncodeDefaultArrayRequest(goahttp.RequestEncoder)(request, &service.DefaultArrayPayload{ + Value: service.Values{}, + Tag: "kept", + }) + require.NoError(t, err) + body, err = io.ReadAll(request.Body) + require.NoError(t, err) + require.JSONEq(t, ` + "`" + `[]` + "`" + `, string(body)) + + request = httptest.NewRequest(http.MethodPost, "/default-object", nil) + err = EncodeDefaultObjectRequest(goahttp.RequestEncoder)(request, &service.DefaultObjectPayload{Tag: "kept"}) + require.NoError(t, err) + body, err = io.ReadAll(request.Body) + require.NoError(t, err) + require.Empty(t, body) + + request = httptest.NewRequest(http.MethodPost, "/default-map", nil) + err = EncodeDefaultMapRequest(goahttp.RequestEncoder)(request, &service.DefaultMapPayload{Tag: "kept"}) + require.NoError(t, err) + body, err = io.ReadAll(request.Body) + require.NoError(t, err) + require.Empty(t, body) + + request = httptest.NewRequest(http.MethodPost, "/default-bytes", nil) + err = EncodeDefaultBytesRequest(goahttp.RequestEncoder)(request, &service.DefaultBytesPayload{Tag: "kept"}) + require.NoError(t, err) + body, err = io.ReadAll(request.Body) + require.NoError(t, err) + require.Empty(t, body) + + request = httptest.NewRequest(http.MethodPost, "/default-union", nil) + err = EncodeDefaultUnionRequest(goahttp.RequestEncoder)(request, &service.DefaultUnionPayload{Tag: "kept"}) + require.NoError(t, err) + body, err = io.ReadAll(request.Body) + require.NoError(t, err) + require.Empty(t, body) +} + +func TestOptionalSelectedBodyCLIBuildersPreserveFlagPresence(t *testing.T) { + tag := "kept" + + text, err := BuildTextPayload(nil, &tag) + require.NoError(t, err) + require.Nil(t, text.Value) + require.Equal(t, "kept", text.Tag) + empty := "" + text, err = BuildTextPayload(&empty, &tag) + require.NoError(t, err) + require.NotNil(t, text.Value) + require.Empty(t, *text.Value) + + path, header, session := "path-value", "header-value", "session-value" + object, err := BuildObjectPayload(nil, &path, &tag, &header, &session) + require.NoError(t, err) + require.Nil(t, object.Data) + require.Equal(t, "kept", object.Tag) + + inline, err := BuildInlinePayload(nil, &tag) + require.NoError(t, err) + require.Nil(t, inline.Data) + require.Equal(t, "kept", inline.Tag) + + union, err := BuildUnionPayload(nil, &tag) + require.NoError(t, err) + require.Empty(t, union.Value.Kind()) + require.Equal(t, "kept", union.Tag) +} +` + +const generatedOptionalBodyServerTest = `package server + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/stretchr/testify/require" + + service "generated.local/gen/optional_body" + goahttp "goa.design/goa/v3/http" +) + +type pathMux struct{} + +func (pathMux) Handle(string, string, http.HandlerFunc) {} + +func (pathMux) ServeHTTP(http.ResponseWriter, *http.Request) {} + +func (pathMux) Vars(request *http.Request) map[string]string { + return map[string]string{"path": request.PathValue("path")} +} + +func TestOptionalSelectedBodiesDecodeWithoutDroppingQueryValues(t *testing.T) { + request := httptest.NewRequest(http.MethodPost, "/object/path-value?tag=kept", nil) + request.SetPathValue("path", "path-value") + request.Header.Set("X-Test", "header-value") + request.AddCookie(&http.Cookie{Name: "SID", Value: "session-value"}) + payload, err := DecodeObjectRequest(pathMux{}, goahttp.RequestDecoder)(request) + require.NoError(t, err) + require.Equal(t, "path-value", payload.Path) + require.Equal(t, "kept", payload.Tag) + require.Equal(t, "header-value", payload.Header) + require.Equal(t, "session-value", payload.Session) + require.Nil(t, payload.Data) + + request = httptest.NewRequest(http.MethodPost, "/object/path-value?tag=kept", strings.NewReader(` + "`" + `{"name":"value"}` + "`" + `)) + request.SetPathValue("path", "path-value") + request.Header.Set("X-Test", "header-value") + request.AddCookie(&http.Cookie{Name: "SID", Value: "session-value"}) + payload, err = DecodeObjectRequest(pathMux{}, goahttp.RequestDecoder)(request) + require.NoError(t, err) + require.Equal(t, "kept", payload.Tag) + require.Equal(t, "value", payload.Data.Name) + + request = httptest.NewRequest(http.MethodPost, "/object/path-value?tag=kept", strings.NewReader(` + "`" + `{}` + "`" + `)) + request.SetPathValue("path", "path-value") + request.Header.Set("X-Test", "header-value") + request.AddCookie(&http.Cookie{Name: "SID", Value: "session-value"}) + _, err = DecodeObjectRequest(pathMux{}, goahttp.RequestDecoder)(request) + require.ErrorContains(t, err, ` + "`" + `"name" is missing` + "`" + `) + + request = httptest.NewRequest(http.MethodPost, "/object/path-value?tag=kept", strings.NewReader("null")) + request.SetPathValue("path", "path-value") + request.Header.Set("X-Test", "header-value") + request.AddCookie(&http.Cookie{Name: "SID", Value: "session-value"}) + payload, err = DecodeObjectRequest(pathMux{}, goahttp.RequestDecoder)(request) + require.NoError(t, err) + require.Equal(t, "kept", payload.Tag) + require.Nil(t, payload.Data) + + request = httptest.NewRequest(http.MethodPost, "/text?tag=kept", strings.NewReader("null")) + text, err := DecodeTextRequest(nil, goahttp.RequestDecoder)(request) + require.NoError(t, err) + require.Equal(t, "kept", text.Tag) + require.Nil(t, text.Value) + + request = httptest.NewRequest(http.MethodPost, "/text?tag=kept", strings.NewReader(` + "`" + `""` + "`" + `)) + text, err = DecodeTextRequest(nil, goahttp.RequestDecoder)(request) + require.NoError(t, err) + require.Equal(t, "kept", text.Tag) + require.NotNil(t, text.Value) + require.Empty(t, *text.Value) + + request = httptest.NewRequest(http.MethodPost, "/inline?tag=kept", nil) + inline, err := DecodeInlineRequest(nil, goahttp.RequestDecoder)(request) + require.NoError(t, err) + require.Equal(t, "kept", inline.Tag) + require.Nil(t, inline.Data) + + request = httptest.NewRequest(http.MethodPost, "/inline?tag=kept", strings.NewReader(` + "`" + `{"name":"value"}` + "`" + `)) + inline, err = DecodeInlineRequest(nil, goahttp.RequestDecoder)(request) + require.NoError(t, err) + require.NotNil(t, inline.Data.Name) + require.Equal(t, "value", *inline.Data.Name) + + request = httptest.NewRequest(http.MethodPost, "/text?tag=kept", strings.NewReader(` + "`" + `"BAD"` + "`" + `)) + _, err = DecodeTextRequest(nil, goahttp.RequestDecoder)(request) + require.ErrorContains(t, err, "must match") + + request = httptest.NewRequest(http.MethodPost, "/array?tag=kept", strings.NewReader("[]")) + array, err := DecodeArrayRequest(nil, goahttp.RequestDecoder)(request) + require.NoError(t, err) + require.Equal(t, "kept", array.Tag) + require.NotNil(t, array.Value) + require.Empty(t, array.Value) + + request = httptest.NewRequest(http.MethodPost, "/map?tag=kept", strings.NewReader("{}")) + values, err := DecodeMapRequest(nil, goahttp.RequestDecoder)(request) + require.NoError(t, err) + require.Equal(t, "kept", values.Tag) + require.NotNil(t, values.Value) + require.Empty(t, values.Value) + + request = httptest.NewRequest(http.MethodPost, "/union?tag=kept", strings.NewReader(` + "`" + `{"type":"inactive","value":{}}` + "`" + `)) + union, err := DecodeUnionRequest(nil, goahttp.RequestDecoder)(request) + require.NoError(t, err) + require.Equal(t, "kept", union.Tag) + inactive, ok := union.Value.AsInactive() + require.True(t, ok) + require.NotNil(t, inactive) + + request = httptest.NewRequest(http.MethodPost, "/array?tag=kept", nil) + array, err = DecodeArrayRequest(nil, goahttp.RequestDecoder)(request) + require.NoError(t, err) + require.Equal(t, "kept", array.Tag) + require.Nil(t, array.Value) + + request = httptest.NewRequest(http.MethodPost, "/map?tag=kept", strings.NewReader("null")) + values, err = DecodeMapRequest(nil, goahttp.RequestDecoder)(request) + require.NoError(t, err) + require.Equal(t, "kept", values.Tag) + require.Nil(t, values.Value) + + request = httptest.NewRequest(http.MethodPost, "/union?tag=kept", nil) + union, err = DecodeUnionRequest(nil, goahttp.RequestDecoder)(request) + require.NoError(t, err) + require.Equal(t, "kept", union.Tag) + require.Empty(t, union.Value.Kind()) + + request = httptest.NewRequest(http.MethodPost, "/default-text?tag=kept", nil) + defaultText, err := DecodeDefaultTextRequest(nil, goahttp.RequestDecoder)(request) + require.NoError(t, err) + require.Equal(t, "kept", defaultText.Tag) + require.Equal(t, "safe", string(defaultText.Value)) + + request = httptest.NewRequest(http.MethodPost, "/default-text?tag=kept", strings.NewReader(` + "`" + `""` + "`" + `)) + defaultText, err = DecodeDefaultTextRequest(nil, goahttp.RequestDecoder)(request) + require.NoError(t, err) + require.Empty(t, defaultText.Value) + + request = httptest.NewRequest(http.MethodPost, "/default-array?tag=kept", strings.NewReader("null")) + defaultArray, err := DecodeDefaultArrayRequest(nil, goahttp.RequestDecoder)(request) + require.NoError(t, err) + require.Equal(t, "kept", defaultArray.Tag) + require.Equal(t, service.Values{"safe"}, defaultArray.Value) + + request = httptest.NewRequest(http.MethodPost, "/default-array?tag=kept", strings.NewReader("[]")) + defaultArray, err = DecodeDefaultArrayRequest(nil, goahttp.RequestDecoder)(request) + require.NoError(t, err) + require.NotNil(t, defaultArray.Value) + require.Empty(t, defaultArray.Value) + + request = httptest.NewRequest(http.MethodPost, "/default-object?tag=kept", nil) + defaultObject, err := DecodeDefaultObjectRequest(nil, goahttp.RequestDecoder)(request) + require.NoError(t, err) + require.Equal(t, "kept", defaultObject.Tag) + require.NotNil(t, defaultObject.Value) + require.Equal(t, "safe", defaultObject.Value.Name) + + request = httptest.NewRequest(http.MethodPost, "/default-map?tag=kept", nil) + defaultMap, err := DecodeDefaultMapRequest(nil, goahttp.RequestDecoder)(request) + require.NoError(t, err) + require.Equal(t, "kept", defaultMap.Tag) + require.Equal(t, service.Labels{"mode": "safe"}, defaultMap.Value) + + request = httptest.NewRequest(http.MethodPost, "/default-bytes?tag=kept", nil) + defaultBytes, err := DecodeDefaultBytesRequest(nil, goahttp.RequestDecoder)(request) + require.NoError(t, err) + require.Equal(t, "kept", defaultBytes.Tag) + require.Equal(t, []byte("safe"), defaultBytes.Value) + + request = httptest.NewRequest(http.MethodPost, "/default-union?tag=kept", nil) + defaultUnion, err := DecodeDefaultUnionRequest(nil, goahttp.RequestDecoder)(request) + require.NoError(t, err) + require.Equal(t, "kept", defaultUnion.Tag) + inactive, ok = defaultUnion.Value.AsInactive() + require.True(t, ok) + require.NotNil(t, inactive) +} +` diff --git a/http/codegen/paths.go b/http/codegen/paths.go index ef537ccac0..62bc960a43 100644 --- a/http/codegen/paths.go +++ b/http/codegen/paths.go @@ -8,8 +8,8 @@ import ( "goa.design/goa/v3/expr" ) -// PathFiles returns the service path files. -func PathFiles(data *ServicesData) []*codegen.File { +// pathFiles builds the service path files read by Plan.Link. +func pathFiles(data *ServicesData) []*codegen.File { fw := make([]*codegen.File, 2*len(data.Expressions.Services)) for i := 0; i < len(data.Expressions.Services); i++ { fw[i*2] = serverPath(data.Expressions.Services[i], data) @@ -38,21 +38,22 @@ func clientPath(svc *expr.HTTPServiceExpr, services *ServicesData) *codegen.File // contains the request path constructors for the given service. func pathSections(svc *expr.HTTPServiceExpr, pkg string, services *ServicesData) []*codegen.SectionTemplate { title := fmt.Sprintf("%s request path constructors for the %s service.", services.label(), svc.Name()) + sd := services.Get(svc.Name()) + filePath := filepath.Join(codegen.Gendir, services.dir(), sd.Service.PathName, pkg, "paths.go") sections := make([]*codegen.SectionTemplate, 0, 1+len(svc.HTTPEndpoints)) - sections = append(sections, - codegen.Header(title, pkg, []*codegen.ImportSpec{ - {Path: "fmt"}, - {Path: "net/url"}, - {Path: "strconv"}, - {Path: "strings"}, - }), - ) - sdata := services.Get(svc.Name()) + sections = append(sections, plannedFileHeader(title, pkg, filePath, services)) for _, e := range svc.HTTPEndpoints { + data := struct { + *EndpointData + Client bool + }{ + EndpointData: sd.Endpoint(e.Name()), + Client: pkg == "client", + } sections = append(sections, &codegen.SectionTemplate{ Name: "path", Source: httpTemplates.Read(pathT), - Data: sdata.Endpoint(e.Name()), + Data: data, }) } diff --git a/http/codegen/paths_test.go b/http/codegen/paths_test.go index 00c17dcf6f..77eefb1cc6 100644 --- a/http/codegen/paths_test.go +++ b/http/codegen/paths_test.go @@ -38,8 +38,8 @@ func TestPaths(t *testing.T) { t.Run(c.Name, func(t *testing.T) { root := expr.RunDSL(t, c.DSL) require.Len(t, root.API.HTTP.Services, 1) - services := CreateHTTPServices(root) - fs := serverPath(root.API.HTTP.Services[0], services) + plan := linkedHTTPPlanForRoot(t, root) + fs := plan.PathFiles()[0] sections := fs.SectionTemplates code := codegen.SectionCode(t, sections[1]) testutil.AssertGo(t, "testdata/golden/paths_"+c.Name+".go.golden", code) @@ -64,8 +64,8 @@ func TestPathTrailingShash(t *testing.T) { t.Run(c.Name, func(t *testing.T) { root := expr.RunDSL(t, c.DSL) require.Len(t, root.API.HTTP.Services, 1) - services := CreateHTTPServices(root) - fs := serverPath(root.API.HTTP.Services[0], services) + plan := linkedHTTPPlanForRoot(t, root) + fs := plan.PathFiles()[0] sections := fs.SectionTemplates code := codegen.SectionCode(t, sections[1]) testutil.AssertGo(t, "testdata/golden/paths_"+c.Name+".go.golden", code) diff --git a/http/codegen/plan.go b/http/codegen/plan.go new file mode 100644 index 0000000000..e7d60f4bf1 --- /dev/null +++ b/http/codegen/plan.go @@ -0,0 +1,2299 @@ +// This file builds HTTP output in two steps. NewPlans requests every Go package +// name that the output files need. Plan.Link then builds the HTTP and JSON-RPC +// data after the service names are known. +package codegen + +import ( + "cmp" + "fmt" + "net/http" + "path" + "slices" + "strings" + + "goa.design/goa/v3/codegen" + "goa.design/goa/v3/codegen/cli" + "goa.design/goa/v3/codegen/example" + "goa.design/goa/v3/codegen/service" + "goa.design/goa/v3/expr" +) + +type ( + // PlanInput pairs one design with the generated service names chosen for it. + PlanInput struct { + // Root contains the HTTP services that Goa will generate. + Root *expr.RootExpr + // Service is the service plan created for Root. + Service *service.Plan + } + + // Plan records the generated Go declarations for one design and later builds + // its HTTP files. + Plan struct { + root *expr.RootExpr + servicePlan *service.Plan + generation *codegen.Generation + transport transportKind + serverPackages map[*expr.HTTPServiceExpr]*codegen.GeneratedPackage + extensions map[*expr.HTTPServiceExpr]*serverExtensions + constructors map[viewedConstructorKey]*codegen.NameDeclaration + payloads map[*expr.HTTPEndpointExpr]*codegen.NameDeclaration + streams map[*expr.HTTPEndpointExpr]*codegen.NameDeclaration + errors map[*expr.HTTPErrorExpr]*codegen.NameDeclaration + wireTypes map[*expr.HTTPServiceExpr]*plannedWireTypes + symbols map[*expr.HTTPServiceExpr]*httpSymbols + servicePaths map[*expr.HTTPServiceExpr]string + cliParsers map[string]*cli.ParserPlan + fileImports map[string]*codegen.GeneratedImportPlan + services *ServicesData + viewed map[viewedMethodKey]*viewedResultPlan + jsonServices map[string]*jsonRPCServicePlan + server []*codegen.File + client []*codegen.File + serverTypes []*codegen.File + clientTypes []*codegen.File + paths []*codegen.File + clientCLI []*codegen.File + } + + // ServerMountPoint describes one route added by a declared server mount. + // Goa includes it in Server.Mounts so logs and startup output list the added + // route with the routes defined in the design. + ServerMountPoint struct { + // Method is the operation name shown for the route. + Method string + // Verb is the HTTP method accepted by the route. + Verb string + // Pattern is the path pattern accepted by the route. + Pattern string + } + + // ServerMount gives server templates the chosen mount function name and + // the routes that function adds. + ServerMount struct { + // Declaration supplies the generated mount function name. + Declaration *codegen.NameDeclaration + // MountPoints lists the routes added by Declaration. + MountPoints []ServerMountPoint + } + + // ExamplePlan builds runnable HTTP programs from server data and generated + // services that came from the same design. + ExamplePlan struct { + root *example.Root + transport *Plan + } + + // jsonRPCServicePlan stores the HTTP data copied for the JSON-RPC file writer. + jsonRPCServicePlan struct { + data *ServiceData + fileImports map[string][]*codegen.ImportSpec + clientCodec *codegen.File + serverCodec *codegen.File + } + + // viewedResultPlan stores the HTTP response data copied for the JSON-RPC file + // writer. + viewedResultPlan struct { + variable bool + fixedView string + service *service.ViewedResultTypeData + representations []viewedRepresentationPlan + } + + // viewedRepresentationPlan associates one body conversion with the headers + // and cookies written by the same successful response. + viewedRepresentationPlan struct { + data *ViewedRepresentationData + headers []*HeaderData + cookies []*CookieData + } + + // serverExtensions stores declarations submitted for one HTTP service before + // Generation.Freeze chooses their Go names. Link copies these values into + // template data. + serverExtensions struct { + handlerWrappers []*codegen.NameDeclaration + endpointHandlerWrappers map[*expr.HTTPEndpointExpr][]*codegen.NameDeclaration + mounts []*ServerMount + } + + // JSONRPCServiceSnapshot holds a separate copy of the HTTP service data used to write + // JSON-RPC client and server files. Callers may change it without changing + // the HTTP plan or a later copy. + JSONRPCServiceSnapshot struct { + // Service is a copy of the generated Goa service description. + Service JSONRPCServiceData + // Endpoints contains the JSON-RPC method data in design order. + Endpoints []JSONRPCEndpointSnapshot + // ClientStruct is the client type name kept for existing plugins. Goa + // copies it after choosing all names. Changing it does not rename generated code. + // + // Deprecated: Use ClientStructDeclaration.Name() after planning. + ClientStruct string + // ClientStructDeclaration supplies the client type name written in HTTP files. + ClientStructDeclaration *codegen.NameDeclaration + // ClientInitDeclaration supplies the client constructor name. + ClientInitDeclaration *codegen.NameDeclaration + // ServerStruct is the server type name kept for existing plugins. Goa + // copies it after choosing all names. Changing it does not rename generated code. + // + // Deprecated: Use ServerStructDeclaration.Name() after planning. + ServerStruct string + // ServerStructDeclaration supplies the server type name written in HTTP files. + ServerStructDeclaration *codegen.NameDeclaration + // ServerInit is the server constructor name kept for existing plugins. Goa + // copies it after choosing all names. Changing it does not rename generated code. + // + // Deprecated: Use ServerInitDeclaration.Name() after planning. + ServerInit string + // ServerInitDeclaration supplies the server constructor name written in HTTP files. + ServerInitDeclaration *codegen.NameDeclaration + // MountServer is the route mount function name kept for existing plugins. + // Goa copies it after choosing all names. Changing it does not rename generated code. + // + // Deprecated: Use MountServerDeclaration.Name() after planning. + MountServer string + // MountServerDeclaration supplies the route mounting function name written in HTTP files. + MountServerDeclaration *codegen.NameDeclaration + // ServerService is the generated function that returns the service implementation. + ServerService string + fileImports map[string][]*codegen.ImportSpec + clientCodec *codegen.File + serverCodec *codegen.File + } + + // JSONRPCServiceData contains the service names written in JSON-RPC files. + JSONRPCServiceData struct { + // Name is the design service name. + Name string + // StructName is the exported Go spelling derived from Name. + StructName string + // EndpointsDeclaration supplies the service endpoint collection name. + EndpointsDeclaration *codegen.NameDeclaration + // MethodNamesDeclaration supplies the service method name list. + MethodNamesDeclaration *codegen.NameDeclaration + // PkgName is the import name of the generated service package. + PkgName string + // PathName is the generated service directory name. + PathName string + } + + // JSONRPCEndpointSnapshot holds a separate copy of the HTTP values that + // JSON-RPC files read for one service method. + JSONRPCEndpointSnapshot struct { + // IsJSONRPC is true because this value describes a JSON-RPC method. + IsJSONRPC bool + // IsJSONRPCNotification reports whether every call omits the request + // ID and receives no JSON-RPC response. + IsJSONRPCNotification bool + // JSONRPCRequestID contains the complete request-ID plan. It is nil for + // explicit notifications. + JSONRPCRequestID *JSONRPCRequestIDData + // Method contains the service method names and stream methods used in JSON-RPC files. + Method JSONRPCMethodData + // ServiceName is the design service name written in generated errors. + ServiceName string + // ServicePkgName is the import name used for service types. + ServicePkgName string + // Payload describes the JSON-RPC request. It is nil when the method has no payload. + Payload *JSONRPCPayloadData + // Result describes the JSON-RPC result. It is nil when the method has no result. + Result *JSONRPCResultData + // Errors lists the designed errors returned by the method. + Errors []JSONRPCErrorGroupData + // Routes lists the HTTP paths and verbs accepted by the JSON-RPC server. + Routes []JSONRPCRouteData + // RequestInit builds the HTTP request used for a JSON-RPC call. + RequestInit *InitData + // EndpointInit is the client method that builds the Goa endpoint. + EndpointInit string + // HandlerInit is the handler constructor name kept for existing plugins. + // Goa copies it after choosing all names. Changing it does not rename generated code. + // + // Deprecated: Use HandlerInitDeclaration.Name() after planning. + HandlerInit string + // HandlerInitDeclaration supplies the server handler constructor name. + HandlerInitDeclaration *codegen.NameDeclaration + // ClientStruct is the client type name kept for existing plugins. Goa + // copies it after choosing all names. Changing it does not rename generated code. + // + // Deprecated: Use ClientStructDeclaration.Name() after planning. + ClientStruct string + // ClientStructDeclaration supplies the client type name used by request builders. + ClientStructDeclaration *codegen.NameDeclaration + // RequestEncoder is the request encoder name kept for existing plugins. + // Goa copies it after choosing all names. Changing it does not rename + // generated code. It is empty when Goa does not generate a request encoder. + // + // Deprecated: Use RequestEncoderDeclaration.Name() after planning. + RequestEncoder string + // RequestEncoderDeclaration supplies the request encoder name written in HTTP files. + RequestEncoderDeclaration *codegen.NameDeclaration + // RequestDecoder is the request decoder name kept for existing plugins. + // Goa copies it after choosing all names. Changing it does not rename + // generated code. It is empty when Goa does not generate a request decoder. + // + // Deprecated: Use RequestDecoderDeclaration.Name() after planning. + RequestDecoder string + // RequestDecoderDeclaration supplies the request decoder name written in HTTP files. + RequestDecoderDeclaration *codegen.NameDeclaration + // ResponseDecoder is the response decoder name kept for existing plugins. + // Goa copies it after choosing all names. Changing it does not rename generated code. + // + // Deprecated: Use ResponseDecoderDeclaration.Name() after planning. + ResponseDecoder string + // ResponseDecoderDeclaration supplies the response decoder name written in HTTP files. + ResponseDecoderDeclaration *codegen.NameDeclaration + // SSE contains event-stream values when the method uses server-sent events. + SSE *JSONRPCSSEData + } + + // JSONRPCMethodData contains the service method values written in JSON-RPC files. + JSONRPCMethodData struct { + // Name is the design method name. + Name string + // VarName is the exported Go method name. + VarName string + // Result is the generated service result type name. + Result string + // HasMixedResults reports whether the method returns one synchronous type + // and streams another type. + HasMixedResults bool + // Idempotent reports whether the client may retry the same call. + Idempotent bool + // Errors lists the retry properties of the method errors. + Errors []JSONRPCMethodErrorData + // ViewedResult contains result-view names when the method returns a viewed result. + ViewedResult *JSONRPCMethodViewedResultData + // ServerStream contains server stream method names when the method streams. + ServerStream *JSONRPCStreamData + // ClientStream contains client stream method names when the method streams. + ClientStream *JSONRPCStreamData + // StreamKind identifies which side sends stream values. + StreamKind expr.StreamKind + // SkipRequestBodyEncodeDecode reports whether the service reads the raw request body. + SkipRequestBodyEncodeDecode bool + // RequestStruct is the service type that carries a raw request body. + RequestStruct string + } + + // JSONRPCMethodErrorData contains the two error values used by client retry code. + JSONRPCMethodErrorData struct { + // ErrName is the service error name. + ErrName string + // Temporary reports whether retrying the call may succeed. + Temporary bool + } + + // JSONRPCMethodViewedResultData contains the result-view fields written in method files. + JSONRPCMethodViewedResultData struct { + JSONRPCViewedResultData + // ViewName is the fixed view name. It is empty when each response selects a view. + ViewName string + } + + // JSONRPCStreamData contains the stream method names written by JSON-RPC files. + JSONRPCStreamData struct { + // Interface is the service stream interface implemented by the generated stream. + Interface string + // VarName is the generated stream implementation type name. + VarName string + // SendName is the method that sends one value. + SendName string + // SendDesc documents SendName. + SendDesc string + // SendWithContextName is the send method that accepts a context. + SendWithContextName string + // SendWithContextDesc documents SendWithContextName. + SendWithContextDesc string + // SendTypeName is the sent service type name. + SendTypeName string + // SendTypeRef is the sent service type reference. + SendTypeRef string + // RecvName is the method that receives one value. + RecvName string + // RecvDesc documents RecvName. + RecvDesc string + // RecvWithContextName is the receive method that accepts a context. + RecvWithContextName string + // RecvWithContextDesc documents RecvWithContextName. + RecvWithContextDesc string + // RecvTypeName is the received service type name. + RecvTypeName string + // RecvTypeRef is the received service type reference. + RecvTypeRef string + // EndpointStruct is the service type passed to a streaming endpoint. + EndpointStruct string + // Kind identifies which side sends stream values. + Kind expr.StreamKind + } + + // JSONRPCPayloadData contains the request values read by JSON-RPC files. + JSONRPCPayloadData struct { + // Ref is the service payload type reference. + Ref string + // Request describes the request body when the payload has one. + Request *JSONRPCRequestData + // IDAttribute is the payload field that receives the JSON-RPC request ID. + IDAttribute string + // IDAttributeTypeRef is the named string type assigned from the + // JSON-RPC request ID. It is empty for a plain string field. + IDAttributeTypeRef string + // IDAttributeRequired reports whether IDAttribute is a value instead of a pointer. + IDAttributeRequired bool + // JSONRPCRequestID contains the typed payload field and generated-client + // behavior for the request ID. + JSONRPCRequestID *JSONRPCRequestIDData + // DecoderReturnValue is returned directly when the server needs no payload constructor. + DecoderReturnValue string + } + + // JSONRPCRequestData contains the request body values read by JSON-RPC files. + JSONRPCRequestData struct { + // ClientBody describes the request body encoded by the client. + ClientBody *JSONRPCBodyData + // ServerBody describes the request body decoded by the server. + ServerBody *JSONRPCBodyData + // PayloadInit builds the service payload from decoded request values. + PayloadInit *InitData + // Headers contains the HTTP request headers read by shared JSON code. + Headers []JSONRPCHeaderData + // Cookies contains the HTTP request cookies read by shared JSON code. + Cookies []JSONRPCCookieData + // QueryParams is empty because JSON-RPC parameters are carried in the JSON request. + QueryParams []any + // PathParams is empty because every JSON-RPC method uses the service route. + PathParams []any + // PayloadAttr is the payload field encoded as the JSON request body. + PayloadAttr string + // MustHaveBody reports whether an empty JSON request is invalid. + MustHaveBody bool + // OptionalBody reports whether Body selects an optional payload field. + OptionalBody bool + // BodyIsUnion reports whether the selected optional field is a union whose + // empty kind represents absence. + BodyIsUnion bool + // BodyFieldPointer reports whether the selected service payload field is + // a pointer to a primitive value. + BodyFieldPointer bool + // BodyFieldCanBeAbsent reports whether the selected service payload field + // can represent absence. + BodyFieldCanBeAbsent bool + // MustValidate reports whether decoded request values require validation. + MustValidate bool + // Params describes how the request body is placed in JSON-RPC params. + Params *JSONRPCParamsData + } + + // JSONRPCParamsData describes how one service value is carried in the + // JSON-RPC params member. + JSONRPCParamsData struct { + // Positional reports whether params is a one-element array containing the + // service value. Structured values remain objects or arrays directly. + Positional bool + // TypeRef is the generated Go type placed in the positional array. + TypeRef string + // RejectNull reports whether a null positional value is missing required + // input. + RejectNull bool + // AllowAbsent reports whether the params member may be omitted because + // the mapped service field is optional. + AllowAbsent bool + // OmitAbsent reports whether an absent direct value must omit params. + // JSON-RPC does not allow params to be null. + OmitAbsent bool + } + + // JSONRPCResultData contains the response values read by JSON-RPC files. + JSONRPCResultData struct { + // Ref is the service result type reference. + Ref string + // Responses contains the successful HTTP responses in design order. + Responses []JSONRPCResponseData + // IDAttribute is retained for generator compatibility. + // + // Deprecated: JSON-RPC results cannot define an ID field. + IDAttribute string + // IDAttributeRequired is retained for generator compatibility. + // + // Deprecated: JSON-RPC results cannot define an ID field. + IDAttributeRequired bool + // View is the default result view selected by the design. + View string + } + + // JSONRPCResponseData contains one HTTP response read by JSON-RPC files. + JSONRPCResponseData struct { + // StatusCode is the JSON-RPC code used for a designed error. + StatusCode string + // Code is the numeric JSON-RPC code used for a designed error. + Code int + // Headers contains fields decoded from HTTP response headers. + Headers []JSONRPCHeaderData + // Cookies contains fields decoded from HTTP response cookies. + Cookies []JSONRPCCookieData + // ServerBody contains the response bodies written by the server. + ServerBody []JSONRPCBodyData + // ClientBody describes the response body read by the client. + ClientBody *JSONRPCBodyData + // ResultInit builds the service result or error from decoded response values. + ResultInit *InitData + // ResultAttr is the Go field selected by Body("name"). It is empty when + // the response body uses the complete service result or error. + ResultAttr string + // MustValidate reports whether decoded header or cookie values require validation. + MustValidate bool + } + + // JSONRPCErrorGroupData contains errors that use the same JSON-RPC code. + JSONRPCErrorGroupData struct { + // StatusCode is the JSON-RPC code shared by Errors. + StatusCode string + // Errors contains the designed errors for StatusCode. + Errors []JSONRPCErrorData + } + + // JSONRPCErrorData contains one designed error and its response conversion. + JSONRPCErrorData struct { + // Name is the design error name. + Name string + // Ref is the generated service error type reference. + Ref string + // Response describes the encoded error data. + Response JSONRPCResponseData + } + + // JSONRPCRouteData contains one HTTP path and verb used for JSON-RPC calls. + JSONRPCRouteData struct { + // Verb is the uppercase HTTP method. + Verb string + // Path is the full request path. + Path string + } + + // JSONRPCSSEData contains the event fields read by JSON-RPC stream files. + JSONRPCSSEData struct { + // StructDeclaration supplies the server stream type name. + StructDeclaration *codegen.NameDeclaration + // ClientInterfaceDeclaration supplies the client stream interface name. + ClientInterfaceDeclaration *codegen.NameDeclaration + // ClientStructDeclaration supplies the client stream implementation name. + ClientStructDeclaration *codegen.NameDeclaration + // ClientInitDeclaration supplies the client stream constructor name. + ClientInitDeclaration *codegen.NameDeclaration + // EventTypeRef is the service result type carried by each event. + EventTypeRef string + // EventTypeName is the service result type allocated by the client. + EventTypeName string + // EventIsStruct reports whether the service result is an object. + EventIsStruct bool + // DataField is the service result field carried by notification params. + // It is empty when params carry the complete result body. + DataField string + // Data describes the value carried by notification params. + Data SSEValueData + // IDField is the service result field carried by the SSE id line. + IDField string + // ID describes the value carried by the SSE id line. + ID *SSEValueData + // ClientIDPointer reports whether the decoded response body stores IDField as a pointer. + ClientIDPointer bool + // EventField is the service result field carried by the SSE event line. + EventField string + // Event describes the value carried by the SSE event line. + Event *SSEValueData + // ClientEventPointer reports whether the decoded response body stores EventField as a pointer. + ClientEventPointer bool + // RetryField is the service result field carried by the SSE retry line. + RetryField string + // Retry describes the value carried by the SSE retry line. + Retry *SSEValueData + // HasResponseBody reports whether Response converts the service result to JSON. + HasResponseBody bool + // Response is the successful response used to encode stream events. + Response *JSONRPCResponseData + // RequestIDField is the payload field that receives Last-Event-ID. + RequestIDField string + // RequestIDPointer reports whether RequestIDField stores a pointer. + RequestIDPointer bool + // Params describes how each streamed result is placed in notification + // params. + Params *JSONRPCParamsData + // ClientEventCode converts the decoded response body into the streamed + // service result when the ordinary and streamed result types differ. + ClientEventCode string + } + + // JSONRPCBodyData contains only the JSON body fields read by JSON-RPC files. + JSONRPCBodyData struct { + // Declaration supplies the generated body type name. It is nil when the + // body uses a Go type expression that does not declare a named type. + Declaration *codegen.NameDeclaration + // VarName is the generated body type name. + VarName string + // Ref is the generated body type reference. + Ref string + // ValidateRef is inline validation code run after decoding. + ValidateRef string + // ValidatorDeclaration supplies the named validator called after decoding. + ValidatorDeclaration *codegen.NameDeclaration + // ValidationTarget is the decoded value passed to ValidatorDeclaration. It + // is empty when this body does not need a named validator call. + ValidationTarget string + // Init converts between the body and the service value. + Init *InitData + } + + // JSONRPCElementData contains one header or cookie value read from a response. + JSONRPCElementData struct { + // Name is the service attribute name used in errors. + Name string + // VarName is the local Go variable name. + VarName string + // TypeName is the Goa primitive or array name. + TypeName string + // Type is the generated transport type used by shared request templates. + Type expr.DataType + // FieldType is the service field type used by shared request templates. + FieldType expr.DataType + // ElemTypeName is the Goa name of an array element. It is empty for non-arrays. + ElemTypeName string + // ElemTypeRef is the generated Go reference for an array element. It is empty for non-arrays. + ElemTypeRef string + // TypeRef is the generated Go type reference. + TypeRef string + // ValueTypeRef is TypeRef without the optional pointer. + ValueTypeRef string + // Pointer reports whether TypeRef is a pointer. + Pointer bool + // FieldName is the service result field that supplies the value on the server. + FieldName string + // FieldPointer reports whether FieldName holds a pointer. + FieldPointer bool + // IsAliased reports whether FieldName uses a user-defined primitive type. + IsAliased bool + // Required reports whether the response must contain the value. + Required bool + // DefaultValue is written when the response omits an optional value. + DefaultValue any + // HasDefault reports whether DefaultValue was authored, including zero. + HasDefault bool + // Validate contains the validation code run after conversion. + Validate string + // HTTPName is the header or cookie name sent over HTTP. + HTTPName string + // StringSlice reports whether the value is an array of strings. + StringSlice bool + // Slice reports whether the value is an array. + Slice bool + } + + // JSONRPCHeaderData contains one response header read by JSON-RPC clients. + JSONRPCHeaderData struct { + JSONRPCElementData + // CanonicalName is the standard HTTP spelling, such as "Content-Type". + CanonicalName string + // PreserveEmpty reports whether an empty header value is different from + // an absent header. + PreserveEmpty bool + } + + // JSONRPCCookieData contains one response cookie read by JSON-RPC clients. + JSONRPCCookieData struct { + JSONRPCElementData + // MaxAge is the cookie max-age text written to generated code. + MaxAge string + // Path is the cookie path written to generated code. + Path string + // Domain is the cookie domain written to generated code. + Domain string + // Secure reports whether the Secure cookie flag is set. + Secure bool + // HTTPOnly reports whether the HttpOnly cookie flag is set. + HTTPOnly bool + // SameSite is the cookie SameSite text written to generated code. + SameSite string + } + + // ViewedResultSnapshot holds a separate copy of the result views and HTTP response bodies + // used by one JSON-RPC method. + ViewedResultSnapshot struct { + // Variable reports whether each response carries its selected view. + Variable bool + // FixedView is the view selected in the generated method when it cannot vary. + FixedView string + // Service contains the viewed-result names written in JSON-RPC files. + Service JSONRPCViewedResultData + // Representations contains one copied response conversion for each legal view. + Representations []ViewedRepresentationSnapshot + } + + // JSONRPCViewedResultData contains the service package names and functions + // needed to validate and convert one viewed result. + JSONRPCViewedResultData struct { + // FullRef is the complete Go reference to the viewed-result type. + FullRef string + // VarName is the viewed-result type name without its package. + VarName string + // ViewsPkg is the import name of the generated views package. + ViewsPkg string + // Validate is the function that validates the viewed result. + Validate *codegen.NameDeclaration + // ResultInit converts a viewed result into the service result. + ResultInit *codegen.NameDeclaration + // Init converts the service result into a viewed result. + Init *codegen.NameDeclaration + // IsCollection reports whether the viewed result is a collection. + IsCollection bool + } + + // ViewedRepresentationSnapshot holds copied client and server body data + // for one legal result view. + ViewedRepresentationSnapshot struct { + // View is the result view carried by the response. + View string + // ResultAttr is the Go field selected by Body("name"). It is empty when + // the server converts the complete projected result. + ResultAttr string + // ServerBody describes the value encoded by the server. It is nil when a + // successful response carries only headers or cookies. + ServerBody *JSONRPCBodyData + // ClientBody describes the value decoded by the client. It is nil when a + // successful response carries only headers or cookies. + ClientBody *JSONRPCBodyData + // ResultInit describes how the decoded body rebuilds the service result. + ResultInit InitData + // Headers contains copied response header mappings. + Headers []JSONRPCHeaderData + // Cookies contains copied response cookie mappings. + Cookies []JSONRPCCookieData + } + + // viewedMethodKey identifies one service method without joining its names. + viewedMethodKey struct { + service string + method string + } + + // viewedConstructorKey identifies one view-specific client result function + // in the input design. + viewedConstructorKey struct { + endpoint *expr.HTTPEndpointExpr + response *expr.HTTPResponseExpr + view string + } + + // viewedConstructorOrder provides a stable total order for colliding + // constructor preferences in one generated client package. + viewedConstructorOrder struct { + transport string + api string + service string + method string + status int + tagName string + tagValue string + view string + role string + } + + // plannedWireTypes stores each copied request and response field with the + // client or server package that defines it. Plan.Link uses the same copies + // after Generation.Freeze chooses every generated Go name. + plannedWireTypes struct { + bodies shapedBodies + server *wireTypeCatalog + client *wireTypeCatalog + transforms plannedWireTransforms + streamPayloads map[*expr.HTTPEndpointExpr]*wireTypeRecord + clientBodyConstructors map[clientBodyConstructorKey]*codegen.NameDeclaration + clientBodyConstructorNames map[clientBodyConstructorKey]string + } + + // plannedWireTransforms retains the exact conversion selected while HTTP + // request and response shapes are collected. + plannedWireTransforms struct { + requests map[clientBodyConstructorKey]*plannedRequestTransforms + responses map[viewedConstructorKey]*plannedResponseTransforms + errors map[*expr.HTTPErrorExpr]*plannedResponseTransforms + streamingResults map[*expr.HTTPEndpointExpr]*plannedResponseTransforms + } + + // plannedRequestTransforms contains each direction used by request body and + // streaming payload code. + plannedRequestTransforms struct { + clientEncode wireTransformHandle + serverDecode wireTransformHandle + clientDecode wireTransformHandle + } + + // plannedResponseTransforms contains the server encoder and client decoder + // for one response representation. + plannedResponseTransforms struct { + serverEncode wireTransformHandle + clientDecode wireTransformHandle + clientDecodeDirect bool + } + + // clientBodyConstructorKey identifies an unnamed request body constructor. + clientBodyConstructorKey struct { + endpoint *expr.HTTPEndpointExpr + role wireTypeRole + } + + // transportKind records whether a plan writes HTTP or JSON-RPC files. + transportKind uint8 +) + +const ( + httpTransport transportKind = iota + 1 + jsonrpcTransport +) + +// NewPlans submits the Go names used by every ordinary HTTP design in inputs. +// All inputs are required so two designs that write the same package resolve +// name conflicts together. +func NewPlans(generation *codegen.Generation, inputs ...PlanInput) ([]*Plan, error) { + return newPlans(generation, httpTransport, inputs) +} + +// NewJSONRPCPlans requests the HTTP body, encoder, and decoder names used by +// every JSON-RPC design in inputs. JSON-RPC writes its files from these plans. +func NewJSONRPCPlans(generation *codegen.Generation, inputs ...PlanInput) ([]*Plan, error) { + return newPlans(generation, jsonrpcTransport, inputs) +} + +// NewExamplePlan returns an example renderer only when examples contains the +// server data copied from transport's service design. +func NewExamplePlan(transport *Plan, examples *example.Plan) (*ExamplePlan, error) { + root, ok := examples.Root(transport.servicePlan) + if !ok { + return nil, fmt.Errorf("HTTP examples require server data created from the same service design") + } + if err := planExampleFileImports(transport, nil, root); err != nil { + return nil, err + } + return &ExamplePlan{root: root, transport: transport}, nil +} + +// NewCombinedExamplePlan records the imports used by runnable servers that +// mount JSON-RPC and ordinary HTTP services from the same design. +func NewCombinedExamplePlan(transport, application *Plan, examples *example.Plan) (*ExamplePlan, error) { + root, ok := examples.Root(transport.servicePlan) + if !ok { + return nil, fmt.Errorf("HTTP examples require server data created from the same service design") + } + if application != nil && application.servicePlan != transport.servicePlan { + return nil, fmt.Errorf("combined HTTP examples require transports created from the same service design") + } + if err := planExampleFileImports(transport, application, root); err != nil { + return nil, err + } + return &ExamplePlan{root: root, transport: transport}, nil +} + +// planExampleFileImports records the packages named by runnable transport +// files. application adds ordinary HTTP services to a JSON-RPC server file. +func planExampleFileImports(transport, application *Plan, root *example.Root) error { + rootPath := path.Dir(transport.generation.GenPkg()) + for _, server := range root.Servers { + serverOutput, err := transport.generation.ClaimOutputPackage( + path.Join(rootPath, "cmd", server.Dir), + path.Join("cmd", server.Dir), + ) + if err != nil { + return err + } + fixed := []*codegen.ImportSpec{ + codegen.SimpleImport("context"), + codegen.SimpleImport("net/http"), + codegen.SimpleImport("net/url"), + codegen.SimpleImport("os"), + codegen.SimpleImport("sync"), + codegen.SimpleImport("time"), + codegen.GoaNamedImport("http", "goahttp"), + codegen.SimpleImport("goa.design/clue/debug"), + codegen.SimpleImport("goa.design/clue/log"), + } + var generated []*codegen.ImportSpec + ordinary := application + if transport.transport == httpTransport { + ordinary = transport + } + if ordinary != nil { + services := configuredTransportServices(ordinary, server.Services) + if slices.ContainsFunc(services, func(service *expr.HTTPServiceExpr) bool { + return len(httpWebSocketEndpoints(service)) > 0 + }) { + fixed = append(fixed, codegen.SimpleImport("github.com/gorilla/websocket")) + } + imports, err := exampleServerGeneratedImports(ordinary, services) + if err != nil { + return err + } + generated = append(generated, imports...) + if slices.ContainsFunc(services, serviceHasMultipartRequest) { + generated = append(generated, codegen.NewImport(examplePackageImportName(ordinary.root), rootPath)) + } + } + if transport != ordinary { + imports, err := exampleServerGeneratedImports(transport, configuredTransportServices(transport, server.Services)) + if err != nil { + return err + } + generated = append(generated, imports...) + } + if err := retainPlannedFileImports( + transport, + serverOutput, + fixed, + generated, + nil, + nil, + path.Join("cmd", server.Dir, "http.go"), + ); err != nil { + return err + } + + services := configuredTransportClientServices(transport, server.Services) + if len(services) == 0 { + continue + } + clientOutput, err := transport.generation.ClaimOutputPackage( + path.Join(rootPath, "cmd", server.Dir+"-cli"), + path.Join("cmd", server.Dir+"-cli"), + ) + if err != nil { + return err + } + fixed = []*codegen.ImportSpec{ + codegen.SimpleImport("context"), + codegen.SimpleImport("flag"), + codegen.SimpleImport("fmt"), + codegen.SimpleImport("io"), + codegen.SimpleImport("net/http"), + codegen.SimpleImport("time"), + codegen.GoaNamedImport("http", "goahttp"), + } + if exampleServicesHaveInputStreams(services) { + fixed = append(fixed, codegen.SimpleImport("errors")) + } + if slices.ContainsFunc(services, func(service *expr.HTTPServiceExpr) bool { + return len(httpWebSocketEndpoints(service)) > 0 + }) { + fixed = append(fixed, codegen.SimpleImport("github.com/gorilla/websocket")) + } + generated = []*codegen.ImportSpec{codegen.NewImport( + "cli", + path.Join(transport.generation.GenPkg(), transportDirectory(transport.transport), "cli", server.Dir), + )} + hasInterceptors := false + for _, service := range services { + if len(service.ServiceExpr.ClientInterceptors) > 0 || exampleServiceHasOutputStream(service) { + servicePackage, _, err := servicePackagePreferences(transport.servicePlan, service) + if err != nil { + return err + } + generated = append(generated, servicePackage) + } + hasInterceptors = hasInterceptors || len(service.ServiceExpr.ClientInterceptors) > 0 + } + if hasInterceptors { + generated = append(generated, codegen.NewImport("interceptors", path.Join(rootPath, "interceptors"))) + } + if slices.ContainsFunc(services, serviceHasMultipartRequest) { + generated = append(generated, codegen.NewImport(examplePackageImportName(transport.root), rootPath)) + } + if err := retainPlannedFileImports( + transport, + clientOutput, + fixed, + generated, + nil, + nil, + path.Join("cmd", server.Dir+"-cli", transportDirectory(transport.transport)+".go"), + ); err != nil { + return err + } + } + return nil +} + +// configuredTransportServices returns only services mounted by one server and +// implemented by the selected transport. +func configuredTransportServices(plan *Plan, names []string) []*expr.HTTPServiceExpr { + expressions := transportExpressions(plan.root, plan.transport) + services := make([]*expr.HTTPServiceExpr, 0, len(names)) + for _, name := range names { + if service := expressions.Service(name); service != nil { + services = append(services, service) + } + } + return services +} + +// configuredTransportClientServices returns configured services that expose +// at least one method through the selected transport. +func configuredTransportClientServices(plan *Plan, names []string) []*expr.HTTPServiceExpr { + services := configuredTransportServices(plan, names) + return slices.DeleteFunc(services, func(service *expr.HTTPServiceExpr) bool { + return len(service.HTTPEndpoints) == 0 + }) +} + +// exampleServerGeneratedImports returns service and transport server packages +// named by one runnable server file. +func exampleServerGeneratedImports(plan *Plan, services []*expr.HTTPServiceExpr) ([]*codegen.ImportSpec, error) { + imports := make([]*codegen.ImportSpec, 0, 2*len(services)) + for _, service := range services { + servicePackage, _, err := servicePackagePreferences(plan.servicePlan, service) + if err != nil { + return nil, err + } + preferred := servicePackage.Name + "svr" + if plan.transport == jsonrpcTransport { + preferred = servicePackage.Name + "jssvr" + } + imports = append(imports, + servicePackage, + codegen.NewImport(preferred, path.Join( + plan.generation.GenPkg(), + transportDirectory(plan.transport), + plan.servicePaths[service], + "server", + )), + ) + } + return imports, nil +} + +// exampleServicesHaveInputStreams reports whether the example rejects any +// configured method because it sends values after the call starts. +func exampleServicesHaveInputStreams(services []*expr.HTTPServiceExpr) bool { + for _, service := range services { + for _, endpoint := range service.HTTPEndpoints { + kind := endpoint.MethodExpr.Stream + if kind == expr.ClientStreamKind || kind == expr.BidirectionalStreamKind { + return true + } + } + } + return false +} + +// exampleServiceHasOutputStream reports whether the example asserts a direct +// server-stream result to its generated service interface. +func exampleServiceHasOutputStream(service *expr.HTTPServiceExpr) bool { + for _, endpoint := range service.HTTPEndpoints { + if endpoint.MethodExpr.Stream == expr.ServerStreamKind && !endpoint.MethodExpr.HasMixedResults() { + return true + } + } + return false +} + +// MatchesHTTP reports whether NewPlans created p for root and servicePlan. +func (p *Plan) MatchesHTTP(root *expr.RootExpr, servicePlan *service.Plan) bool { + return p.transport == httpTransport && p.root == root && p.servicePlan == servicePlan +} + +// MatchesJSONRPC reports whether NewJSONRPCPlans created p for root and +// servicePlan. +func (p *Plan) MatchesJSONRPC(root *expr.RootExpr, servicePlan *service.Plan) bool { + return p.transport == jsonrpcTransport && p.root == root && p.servicePlan == servicePlan +} + +// DeclareServerHandlerWrapper records an exported func(http.Handler) +// http.Handler that wraps each designed endpoint handler, file handler, and +// redirect mounted for service. Wrappers are applied in registration order, +// with the first registered function surrounding the others. Routes added by +// DeclareServerMount are not wrapped automatically. +func (p *Plan) DeclareServerHandlerWrapper(service *expr.HTTPServiceExpr, preferred string, order codegen.PackageNameOrder) (*codegen.NameDeclaration, error) { + pkg, err := p.serverExtensionPackage(service) + if err != nil { + return nil, err + } + declaration := codegen.NewPreferredName(codegen.NameFunction, preferred, codegen.ExportedName, order) + if err := pkg.DeclareName(declaration); err != nil { + return nil, err + } + p.extensions[service].handlerWrappers = append(p.extensions[service].handlerWrappers, declaration) + return declaration, nil +} + +// DeclareServerEndpointHandlerWrapper records an unexported func(http.Handler) +// http.Handler that wraps the designed routes for endpoint. Service wrappers +// surround endpoint wrappers. File handlers and routes added by plugins are not +// affected. +func (p *Plan) DeclareServerEndpointHandlerWrapper(endpoint *expr.HTTPEndpointExpr, preferred string, order codegen.PackageNameOrder) (*codegen.NameDeclaration, error) { + pkg, service, err := p.serverEndpointExtensionPackage(endpoint) + if err != nil { + return nil, err + } + declaration := codegen.NewPreferredName(codegen.NameFunction, preferred, codegen.UnexportedName, order) + if err := pkg.DeclareName(declaration); err != nil { + return nil, err + } + extensions := p.extensions[service] + extensions.endpointHandlerWrappers[endpoint] = append(extensions.endpointHandlerWrappers[endpoint], declaration) + return declaration, nil +} + +// DeclareServerMount records an exported func(goahttp.Muxer) that adds routes +// to the HTTP server mux. Goa calls the function after mounting routes from the +// design and includes mountPoints in the server's route list. +func (p *Plan) DeclareServerMount(service *expr.HTTPServiceExpr, preferred string, order codegen.PackageNameOrder, mountPoints []ServerMountPoint) (*codegen.NameDeclaration, error) { + pkg, err := p.serverExtensionPackage(service) + if err != nil { + return nil, err + } + if len(mountPoints) == 0 { + return nil, fmt.Errorf("HTTP server mount requires at least one mount point") + } + for index, mount := range mountPoints { + switch { + case mount.Method == "": + return nil, fmt.Errorf("HTTP server mount point %d has an empty method", index) + case mount.Verb == "": + return nil, fmt.Errorf("HTTP server mount point %d has an empty verb", index) + case mount.Pattern == "": + return nil, fmt.Errorf("HTTP server mount point %d has an empty pattern", index) + } + } + declaration := codegen.NewPreferredName(codegen.NameFunction, preferred, codegen.ExportedName, order) + if err := pkg.DeclareName(declaration); err != nil { + return nil, err + } + p.extensions[service].mounts = append(p.extensions[service].mounts, &ServerMount{ + Declaration: declaration, + MountPoints: append([]ServerMountPoint(nil), mountPoints...), + }) + return declaration, nil +} + +// Link reads the chosen Go declaration and import names, builds template data +// for each HTTP service once, and builds every file returned by this plan. +func (p *Plan) Link() error { + if !p.generation.Frozen() { + return fmt.Errorf("HTTP plan cannot link before generation freeze") + } + if p.services != nil { + return fmt.Errorf("HTTP plan is already linked") + } + if err := p.link(); err != nil { + return err + } + return nil +} + +// ServerFiles returns the HTTP server files built by Link. +func (p *Plan) ServerFiles() []*codegen.File { + p.requireLinked() + return p.server +} + +// Service returns the template data built by Link for the supplied HTTP service +// expression. Callers must call Link before reading the service data. +func (p *Plan) Service(service *expr.HTTPServiceExpr) (*ServiceData, bool) { + p.requireLinked() + if _, ok := p.extensions[service]; !ok { + return nil, false + } + data := p.services.Get(service.Name()) + return data, data != nil +} + +// ClientFiles returns the HTTP client files built by Link. +func (p *Plan) ClientFiles() []*codegen.File { + p.requireLinked() + return p.client +} + +// ServerTypeFiles returns the server request and response type files built by Link. +func (p *Plan) ServerTypeFiles() []*codegen.File { + p.requireLinked() + return p.serverTypes +} + +// ClientTypeFiles returns the client request and response type files built by Link. +func (p *Plan) ClientTypeFiles() []*codegen.File { + p.requireLinked() + return p.clientTypes +} + +// PathFiles returns the URL path helper files built by Link. +func (p *Plan) PathFiles() []*codegen.File { + p.requireLinked() + return p.paths +} + +// ClientCLIFiles returns the command-line client files built by Link. +func (p *Plan) ClientCLIFiles() []*codegen.File { + p.requireLinked() + return p.clientCLI +} + +// ServerFiles builds runnable HTTP servers from the copied server data. +func (p *ExamplePlan) ServerFiles() []*codegen.File { + p.transport.requireLinked() + return exampleServerFiles(p.root, p.transport.services) +} + +// CLIFiles builds runnable HTTP clients from the copied server data. +func (p *ExamplePlan) CLIFiles() []*codegen.File { + p.transport.requireLinked() + return exampleCLIFiles(p.root, p.transport.services) +} + +// CombinedServerFiles returns new runnable server files containing this plan's +// JSON-RPC services and application's ordinary HTTP services. Pass nil when +// the design has no ordinary HTTP services. +func (p *ExamplePlan) CombinedServerFiles(application *Plan) []*codegen.File { + p.transport.requireLinked() + if p.transport.transport != jsonrpcTransport { + panic("combined example servers require a JSON-RPC HTTP plan") + } + var applicationServices *ServicesData + if application != nil { + application.requireLinked() + if application.transport != httpTransport || application.root != p.transport.root || application.servicePlan != p.transport.servicePlan { + panic("ordinary HTTP and JSON-RPC plans must use the same design root and service plan") + } + applicationServices = application.services + } + return combinedExampleServerFiles(p.root, p.transport.services, applicationServices) +} + +// ViewedResult returns copied HTTP response data for the named method's result +// views. The second result is false when the method does not use result views. +func (p *Plan) ViewedResult(serviceName, methodName string) (ViewedResultSnapshot, bool) { + p.requireLinked() + viewed, ok := p.viewed[viewedMethodKey{service: serviceName, method: methodName}] + if !ok { + return ViewedResultSnapshot{}, false + } + representations := make([]ViewedRepresentationSnapshot, len(viewed.representations)) + for index, planned := range viewed.representations { + representation := planned.data + if representation.ResultInit == nil { + panic("viewed result representation is missing its result constructor") + } + representations[index] = ViewedRepresentationSnapshot{ + View: representation.View, + ResultAttr: representation.ResultAttr, + ServerBody: copyJSONRPCBody(representation.ServerBody), + ClientBody: copyJSONRPCBody(representation.ClientBody), + ResultInit: *copyInitData(representation.ResultInit), + Headers: copyJSONRPCHeaders(planned.headers), + Cookies: copyJSONRPCCookies(planned.cookies), + } + } + return ViewedResultSnapshot{ + Variable: viewed.variable, + FixedView: viewed.fixedView, + Service: copyJSONRPCViewedResult(viewed.service), + Representations: representations, + }, true +} + +// JSONRPCService returns copied HTTP information used to write one JSON-RPC +// service. The second result is false when the plan has no service with name. +func (p *Plan) JSONRPCService(name string) (JSONRPCServiceSnapshot, bool) { + p.requireLinked() + planned, ok := p.jsonServices[name] + if !ok { + return JSONRPCServiceSnapshot{}, false + } + endpoints := make([]JSONRPCEndpointSnapshot, len(planned.data.Endpoints)) + for index, endpoint := range planned.data.Endpoints { + endpoints[index] = copyJSONRPCEndpoint(endpoint) + } + fileImports := make(map[string][]*codegen.ImportSpec, len(planned.fileImports)) + for filePath, imports := range planned.fileImports { + fileImports[filePath] = cloneImportSpecs(imports) + } + return JSONRPCServiceSnapshot{ + Service: JSONRPCServiceData{ + Name: planned.data.Service.Name, + StructName: planned.data.Service.StructName, + EndpointsDeclaration: planned.data.Service.EndpointsDeclaration, + MethodNamesDeclaration: planned.data.Service.MethodNamesDeclaration, + PkgName: planned.data.Service.PkgName, + PathName: planned.data.Service.PathName, + }, + Endpoints: endpoints, + ClientStruct: planned.data.ClientStructDeclaration.Name(), + ClientStructDeclaration: planned.data.ClientStructDeclaration, + ClientInitDeclaration: planned.data.ClientInitDeclaration, + ServerStruct: planned.data.ServerStructDeclaration.Name(), + ServerStructDeclaration: planned.data.ServerStructDeclaration, + ServerInit: planned.data.ServerInitDeclaration.Name(), + ServerInitDeclaration: planned.data.ServerInitDeclaration, + MountServer: planned.data.MountServerDeclaration.Name(), + MountServerDeclaration: planned.data.MountServerDeclaration, + ServerService: planned.data.ServerService, + fileImports: fileImports, + clientCodec: planned.clientCodec, + serverCodec: planned.serverCodec, + }, true +} + +// FileImports returns a new copy of the service-type imports needed by one +// JSON-RPC output file. It rejects paths that this service does not generate. +func (p JSONRPCServiceSnapshot) FileImports(filePath string) []*codegen.ImportSpec { + imports, ok := p.fileImports[strings.ReplaceAll(filePath, "\\", "/")] + if !ok { + panic("JSON-RPC file is not part of this HTTP service plan") + } + return cloneImportSpecs(imports) +} + +// ClientCodecFile returns a new client encoder and decoder file for this +// service. The JSON-RPC file writer may change the returned file. It returns +// nil when the service needs neither function. +func (p JSONRPCServiceSnapshot) ClientCodecFile() *codegen.File { + return cloneJSONRPCCodecFile(p.clientCodec) +} + +// ServerCodecFile returns a new server encoder and decoder file for this +// service. The JSON-RPC file writer may change the returned file. It returns +// nil when the service needs neither function. +func (p JSONRPCServiceSnapshot) ServerCodecFile() *codegen.File { + return cloneJSONRPCCodecFile(p.serverCodec) +} + +// serverExtensionPackage returns the generated server package that will contain +// service's extension functions. It first checks that this ordinary HTTP plan +// still accepts new declarations. +func (p *Plan) serverExtensionPackage(service *expr.HTTPServiceExpr) (*codegen.GeneratedPackage, error) { + if err := p.validateServerExtensionLifecycle(); err != nil { + return nil, err + } + if service == nil { + return nil, fmt.Errorf("HTTP server extension requires a service from this plan") + } + pkg, ok := p.serverPackages[service] + if !ok { + return nil, fmt.Errorf("HTTP service does not belong to this plan") + } + return pkg, nil +} + +// serverEndpointExtensionPackage returns the generated server package and +// service that contain endpoint. It first checks that this ordinary HTTP plan +// still accepts new declarations. +func (p *Plan) serverEndpointExtensionPackage(endpoint *expr.HTTPEndpointExpr) (*codegen.GeneratedPackage, *expr.HTTPServiceExpr, error) { + if err := p.validateServerExtensionLifecycle(); err != nil { + return nil, nil, err + } + if endpoint == nil { + return nil, nil, fmt.Errorf("HTTP server endpoint wrapper requires an endpoint from this plan") + } + for service, symbols := range p.symbols { + if _, ok := symbols.endpoints[endpoint]; ok { + return p.serverPackages[service], service, nil + } + } + return nil, nil, fmt.Errorf("HTTP endpoint does not belong to this plan") +} + +// validateServerExtensionLifecycle checks that the plan still accepts new Go +// declarations and that it writes ordinary HTTP files. +func (p *Plan) validateServerExtensionLifecycle() error { + if p.transport != httpTransport { + return fmt.Errorf("JSON-RPC HTTP plans do not support server extensions") + } + if p.services != nil { + return fmt.Errorf("HTTP server extension cannot be declared after plan linking") + } + if p.generation.Frozen() { + return fmt.Errorf("HTTP server extension cannot be declared after generation freeze") + } + return nil +} + +// planImports records each import on the generated package that writes the +// reference. NewPlans calls it after those packages have been claimed. +func planImports(generation *codegen.Generation, transport transportKind, plans []*Plan) error { + for _, candidate := range generation.Roots() { + design, ok := candidate.(*expr.RootExpr) + if !ok { + continue + } + expressions := transportExpressions(design, transport) + if len(expressions.Services) == 0 { + continue + } + plan := planForRoot(plans, design) + if plan == nil { + return fmt.Errorf("%s design has no HTTP plan", transportLabel(transport)) + } + dir := transportDirectory(transport) + for _, transportService := range expressions.Services { + pathName := plan.servicePaths[transportService] + clientPath := path.Join(generation.GenPkg(), dir, pathName, "client") + serverPath := path.Join(generation.GenPkg(), dir, pathName, "server") + servicePackage, viewsPackage, err := servicePackagePreferences(plan.servicePlan, transportService) + if err != nil { + return err + } + for index, outputPackage := range []*codegen.GeneratedPackage{ + generation.Package(clientPath), + generation.Package(serverPath), + } { + side := "server" + if index == 0 { + side = "client" + } + fileKinds := []httpGeneratedFile{ + {kind: httpCodecFile, name: "encode_decode.go"}, + {kind: httpTypesFile, name: "types.go"}, + } + if transport == httpTransport { + fileKinds = append(fileKinds, + httpGeneratedFile{kind: httpTransportFile, name: side + ".go"}, + httpGeneratedFile{kind: httpPathsFile, name: "paths.go"}, + ) + } + if index == 0 && httpServiceHasPayloadBuilder(transportService) { + fileKinds = append(fileKinds, httpGeneratedFile{kind: httpPayloadBuilderFile, name: "cli.go"}) + } + for _, file := range fileKinds { + var definitions, references []*expr.AttributeExpr + fixedImports := httpFixedFileImports(transportService, index == 0, file.kind) + switch file.kind { + case httpTypesFile: + catalog := plan.wireTypes[transportService].server + if index == 0 { + catalog = plan.wireTypes[transportService].client + } + definitions, references = wireCatalogImportAttributes(catalog) + fixedImports = append(fixedImports, wireCatalogValidationImports(catalog)...) + references = append(references, serviceReferenceAttributes(transportService.HTTPEndpoints...)...) + case httpCodecFile: + fixedImports = append(fixedImports, httpCodecValidationImports(transportService, index == 0)...) + references = serviceReferenceAttributes(transportService.HTTPEndpoints...) + case httpPayloadBuilderFile: + references = httpCLIPayloadBuilderReferenceAttributes(transportService) + case httpTransportFile: + references = httpTransportReferenceAttributes(transportService) + case httpPathsFile: + for _, endpoint := range transportService.HTTPEndpoints { + parameters := endpoint.PathParams() + if parameters.IsEmpty() { + continue + } + for _, parameter := range *expr.AsObject(parameters.Attribute().Type) { + if custom, _ := codegen.GetMetaType(parameter.Attribute); custom != "" { + definitions = append(definitions, parameter.Attribute) + } + } + } + } + filePath := path.Join(codegen.Gendir, dir, pathName, side, file.name) + if err := retainPlannedFileImports( + plan, + outputPackage, + fixedImports, + httpGeneratedImportPlan(transportService, index == 0, file.kind, servicePackage, viewsPackage), + definitions, + references, + filePath, + ); err != nil { + return err + } + } + if transport == httpTransport && len(httpWebSocketEndpoints(transportService)) > 0 { + if err := retainPlannedFileImports( + plan, + outputPackage, + httpFixedFileImports(transportService, index == 0, httpWebSocketFile), + httpGeneratedImportPlan(transportService, index == 0, httpWebSocketFile, servicePackage, viewsPackage), + nil, + serviceReferenceAttributes(httpWebSocketEndpoints(transportService)...), + path.Join(codegen.Gendir, dir, pathName, side, "websocket.go"), + ); err != nil { + return err + } + } + sseFile := "sse.go" + if transport == jsonrpcTransport && index == 0 { + sseFile = "stream.go" + } + if transport == httpTransport && len(httpSSEEndpoints(transportService)) > 0 { + if err := retainPlannedFileImports( + plan, + outputPackage, + httpFixedFileImports(transportService, index == 0, httpSSEFile), + httpGeneratedImportPlan(transportService, index == 0, httpSSEFile, servicePackage, viewsPackage), + nil, + serviceReferenceAttributes(httpSSEEndpoints(transportService)...), + path.Join(codegen.Gendir, dir, pathName, side, sseFile), + ); err != nil { + return err + } + } + } + } + if transport == httpTransport { + var rootOutput *codegen.GeneratedPackage + for _, transportService := range expressions.Services { + if !serviceHasMultipartRequest(transportService) { + continue + } + rootPath := path.Dir(generation.GenPkg()) + if rootOutput == nil { + var err error + rootOutput, err = generation.ClaimOutputPackage(rootPath, ".") + if err != nil { + return err + } + } + servicePackage, _, err := servicePackagePreferences(plan.servicePlan, transportService) + if err != nil { + return err + } + servicePath := plan.servicePaths[transportService] + serverPackage := codegen.NewImport( + servicePackage.Name+"svr", + path.Join(generation.GenPkg(), "http", servicePath, "server"), + ) + var multipartEndpoints []*expr.HTTPEndpointExpr + for _, endpoint := range transportService.HTTPEndpoints { + if endpoint.MultipartRequest { + multipartEndpoints = append(multipartEndpoints, endpoint) + } + } + if err := retainPlannedFileImports( + plan, + rootOutput, + []*codegen.ImportSpec{codegen.SimpleImport("mime/multipart")}, + []*codegen.ImportSpec{servicePackage, serverPackage}, + nil, + serviceReferenceAttributes(multipartEndpoints...), + "multipart.go", + ); err != nil { + return err + } + } + } + for _, server := range design.API.Servers { + if plan.cliParsers[server.Name] == nil { + continue + } + serverName := codegen.SnakeCase(codegen.Goify(server.Name, true)) + cliPath := path.Join(generation.GenPkg(), dir, "cli", serverName) + cliPackage := generation.Package(cliPath) + var cliGenerated []*codegen.ImportSpec + var cliServices []*expr.HTTPServiceExpr + for _, serviceName := range server.Services { + transportService := expressions.Service(serviceName) + if transportService == nil || len(transportService.HTTPEndpoints) == 0 { + continue + } + cliServices = append(cliServices, transportService) + pathName := plan.servicePaths[transportService] + servicePackage, _, err := servicePackagePreferences(plan.servicePlan, transportService) + if err != nil { + return err + } + clientImport := codegen.NewImport( + servicePackage.Name+"c", + path.Join(generation.GenPkg(), dir, pathName, "client"), + ) + cliGenerated = append(cliGenerated, clientImport) + if len(transportService.ServiceExpr.ClientInterceptors) > 0 { + servicePackage, _, err := plan.servicePlan.ServicePackageImports(transportService.ServiceExpr) + if err != nil { + return err + } + cliGenerated = append(cliGenerated, servicePackage) + } + } + if err := retainPlannedFileImports( + plan, + cliPackage, + httpCLIParserFixedImports(cliServices...), + cliGenerated, + nil, + nil, + path.Join(codegen.Gendir, dir, "cli", serverName, "cli.go"), + ); err != nil { + return err + } + } + } + return nil +} + +// planForRoot returns the transport plan that owns one evaluated design. +func planForRoot(plans []*Plan, root *expr.RootExpr) *Plan { + for _, plan := range plans { + if plan.root == root { + return plan + } + } + return nil +} + +// servicePackagePreferences returns the service package and optional views +// package recorded for every transport method. All methods in one service must +// agree because their generated files share imports. +func servicePackagePreferences(plan *service.Plan, transportService *expr.HTTPServiceExpr) (*codegen.ImportSpec, *codegen.ImportSpec, error) { + servicePackage, availableViewsPackage, err := plan.ServicePackageImports(transportService.ServiceExpr) + if err != nil { + return nil, nil, err + } + var viewsPackage *codegen.ImportSpec + for _, endpoint := range transportService.HTTPEndpoints { + methodService, methodViews, err := plan.MethodPackageImports(endpoint.MethodExpr) + if err != nil { + return nil, nil, err + } + if *servicePackage != *methodService { + return nil, nil, fmt.Errorf("HTTP service %q methods use different generated service packages", transportService.Name()) + } + if methodViews == nil { + continue + } + if *availableViewsPackage != *methodViews { + return nil, nil, fmt.Errorf("HTTP service %q methods use different generated views packages", transportService.Name()) + } + viewsPackage = availableViewsPackage + } + return servicePackage, viewsPackage, nil +} + +// retainPlannedFileImports records every package named by one generated file. +// Repeated calls merge imports when several services write the same file. +func retainPlannedFileImports( + plan *Plan, + output *codegen.GeneratedPackage, + fixed, generated []*codegen.ImportSpec, + definitions, references []*expr.AttributeExpr, + filePaths ...string, +) error { + for _, filePath := range filePaths { + key := filepathKey(filePath) + imports := plan.fileImports[key] + if imports == nil { + imports = codegen.NewGeneratedImportPlan(output) + plan.fileImports[key] = imports + } + if err := imports.Require(fixed...); err != nil { + return err + } + if err := imports.AddGenerated(generated...); err != nil { + return err + } + if err := imports.AddTypeExpressions(definitions...); err != nil { + return err + } + if err := imports.AddRecursiveTypeReferences(references...); err != nil { + return err + } + } + return nil +} + +// examplePackageImportName returns the package name imported by runnable +// examples for the starter service implementations at the module root. +func examplePackageImportName(root *expr.RootExpr) string { + scope := codegen.NewNameScope() + for _, service := range root.Services { + scope.Unique(strings.ToLower(codegen.Goify(service.Name, false))) + } + return scope.Unique(strings.ToLower(codegen.Goify(root.API.Name, false)), "api") +} + +// serviceHasMultipartRequest reports whether the example package writes a +// multipart callback whose signature uses this service's generated server. +func serviceHasMultipartRequest(service *expr.HTTPServiceExpr) bool { + for _, endpoint := range service.HTTPEndpoints { + if endpoint.MultipartRequest { + return true + } + } + return false +} + +// newPlans validates the full input set and submits names for every plan. +func newPlans(generation *codegen.Generation, transport transportKind, inputs []PlanInput) ([]*Plan, error) { + if generation == nil { + return nil, fmt.Errorf("HTTP plans require a generation") + } + if generation.Frozen() { + return nil, fmt.Errorf("HTTP plans must be collected before generation freeze") + } + owned := make(map[*expr.RootExpr]struct{}) + for _, candidate := range generation.Roots() { + root, ok := candidate.(*expr.RootExpr) + if ok && len(transportExpressions(root, transport).Services) > 0 { + owned[root] = struct{}{} + } + } + seen := make(map[*expr.RootExpr]struct{}, len(inputs)) + for _, input := range inputs { + if input.Root == nil { + return nil, fmt.Errorf("HTTP plan requires a prepared design root") + } + if input.Service == nil { + return nil, fmt.Errorf("HTTP plan requires a service plan") + } + if input.Service.Root() != input.Root { + return nil, fmt.Errorf("%s root does not match its service plan root", transportLabel(transport)) + } + if _, ok := owned[input.Root]; !ok { + return nil, fmt.Errorf("%s root %p is not a transport root owned by generation", transportLabel(transport), input.Root) + } + if _, ok := seen[input.Root]; ok { + return nil, fmt.Errorf("%s root %p is planned more than once", transportLabel(transport), input.Root) + } + seen[input.Root] = struct{}{} + } + if len(inputs) != len(owned) { + return nil, fmt.Errorf("%s planning requires all %d transport roots, got %d", transportLabel(transport), len(owned), len(inputs)) + } + packages := make(map[string]*wireTypeCatalog) + plans := make([]*Plan, len(inputs)) + for index, input := range inputs { + plan, err := newPlan(generation, transport, input, packages) + if err != nil { + return nil, err + } + plans[index] = plan + } + if err := planImports(generation, transport, plans); err != nil { + return nil, err + } + for _, catalog := range packages { + if err := catalog.Declare(); err != nil { + return nil, err + } + } + for _, plan := range plans { + for _, serviceTypes := range plan.wireTypes { + for endpoint, record := range serviceTypes.streamPayloads { + plan.streams[endpoint] = record.constructor + } + } + } + return plans, nil +} + +// newPlan records one design's HTTP services and submits every function name +// that its generated client and server packages will define. +func newPlan(generation *codegen.Generation, transport transportKind, input PlanInput, packages map[string]*wireTypeCatalog) (*Plan, error) { + plan := &Plan{ + root: input.Root, + servicePlan: input.Service, + generation: generation, + transport: transport, + serverPackages: make(map[*expr.HTTPServiceExpr]*codegen.GeneratedPackage), + extensions: make(map[*expr.HTTPServiceExpr]*serverExtensions), + constructors: make(map[viewedConstructorKey]*codegen.NameDeclaration), + payloads: make(map[*expr.HTTPEndpointExpr]*codegen.NameDeclaration), + streams: make(map[*expr.HTTPEndpointExpr]*codegen.NameDeclaration), + errors: make(map[*expr.HTTPErrorExpr]*codegen.NameDeclaration), + wireTypes: make(map[*expr.HTTPServiceExpr]*plannedWireTypes), + symbols: make(map[*expr.HTTPServiceExpr]*httpSymbols), + servicePaths: make(map[*expr.HTTPServiceExpr]string), + cliParsers: make(map[string]*cli.ParserPlan), + fileImports: make(map[string]*codegen.GeneratedImportPlan), + } + expressions := transportExpressions(input.Root, transport) + dir := transportDirectory(transport) + for _, transportService := range expressions.Services { + servicePackage, _, err := input.Service.ServicePackageImports(transportService.ServiceExpr) + if err != nil { + return nil, err + } + servicePath := path.Base(servicePackage.Path) + plan.servicePaths[transportService] = servicePath + clientPath := path.Join(generation.GenPkg(), dir, servicePath, "client") + clientPackage, err := generation.ClaimPackage(clientPath) + if err != nil { + return nil, err + } + serverPath := path.Join(generation.GenPkg(), dir, servicePath, "server") + serverPackage, err := generation.ClaimPackage(serverPath) + if err != nil { + return nil, err + } + plan.serverPackages[transportService] = serverPackage + plan.extensions[transportService] = &serverExtensions{ + endpointHandlerWrappers: make(map[*expr.HTTPEndpointExpr][]*codegen.NameDeclaration), + } + clientCatalog := packages[clientPath] + if clientCatalog == nil { + clientCatalog = newWireTypeCatalog(clientPackage) + packages[clientPath] = clientCatalog + } + serverCatalog := packages[serverPath] + if serverCatalog == nil { + serverCatalog = newWireTypeCatalog(serverPackage) + packages[serverPath] = serverCatalog + } + planned := &plannedWireTypes{ + server: serverCatalog, + client: clientCatalog, + transforms: plannedWireTransforms{ + requests: make(map[clientBodyConstructorKey]*plannedRequestTransforms), + responses: make(map[viewedConstructorKey]*plannedResponseTransforms), + errors: make(map[*expr.HTTPErrorExpr]*plannedResponseTransforms), + streamingResults: make(map[*expr.HTTPEndpointExpr]*plannedResponseTransforms), + }, + streamPayloads: make(map[*expr.HTTPEndpointExpr]*wireTypeRecord), + clientBodyConstructors: make(map[clientBodyConstructorKey]*codegen.NameDeclaration), + clientBodyConstructorNames: make(map[clientBodyConstructorKey]string), + } + collectPlannedWireTypes(input.Root.API.Name, transportService, planned, input.Service) + plan.wireTypes[transportService] = planned + symbols, err := collectHTTPSymbols(plan, transportService, clientPackage, serverPackage) + if err != nil { + return nil, err + } + plan.symbols[transportService] = symbols + for _, endpoint := range transportService.HTTPEndpoints { + order := viewedConstructorOrder{ + transport: dir, + api: input.Root.API.Name, + service: transportService.Name(), + method: endpoint.Name(), + } + for _, role := range []wireTypeRole{wireRequestBody, wireStreamPayload} { + key := clientBodyConstructorKey{endpoint: endpoint, role: role} + preferred := planned.clientBodyConstructorNames[key] + if preferred == "" { + continue + } + body := planned.bodies.request(endpoint) + if role == wireStreamPayload { + body = planned.bodies.streaming(endpoint) + } + preferred = planned.client.releasedCompositeConstructorName(body, jsonBodyPolicy(true, false, false, "")) + orderRole := "request body" + if role == wireStreamPayload { + orderRole = "streaming body" + } + declaration, err := declareHTTPConstructor(clientPackage, preferred, order.withRole(orderRole)) + if err != nil { + return nil, err + } + planned.clientBodyConstructors[key] = declaration + } + if needInit(endpoint.MethodExpr.Payload.Type) { + declaration, err := declareHTTPConstructor(serverPackage, endpointPayloadConstructorName(endpoint), order.withRole("payload")) + if err != nil { + return nil, err + } + plan.payloads[endpoint] = declaration + } + if endpoint.UsesWebSocket() && endpoint.MethodExpr.StreamingPayload.Type != expr.Empty && needInit(endpoint.MethodExpr.StreamingPayload.Type) && planned.streamPayloads[endpoint] == nil { + preferred := "New" + codegen.Goify(endpoint.Name(), true) + codegen.Goify(endpoint.MethodExpr.StreamingPayload.Type.Name(), true) + declaration, err := declareHTTPConstructor(serverPackage, preferred, order.withRole("streaming payload")) + if err != nil { + return nil, err + } + plan.streams[endpoint] = declaration + } + if needClientResponseInit(endpoint.MethodExpr.Result.Type) { + resultType, viewed := endpoint.MethodExpr.Result.Type.(*expr.ResultTypeExpr) + var projectedResult *codegen.TypeDeclaration + if viewed { + projectedResult, err = input.Service.ProjectedResultDeclaration(endpoint.MethodExpr) + if err != nil { + return nil, err + } + } + for _, response := range endpoint.Responses { + views := []string{""} + body := planned.bodies.response(response) + _, explicitBody := body.Meta["http:body"] + if viewed && + clientResponseViewNameExpr(endpoint, resultType) == "" && + !explicitBody { + views = make([]string, len(resultType.Views)) + for index, view := range resultType.Views { + views[index] = view.Name + } + } + for _, view := range views { + key := viewedConstructorKey{endpoint: endpoint, response: response, view: view} + responseOrder := order.withRole("result") + responseOrder.status = response.StatusCode + responseOrder.tagName = response.Tag[0] + responseOrder.tagValue = response.Tag[1] + responseOrder.view = view + declaration, err := declareViewedResultConstructor(clientPackage, endpoint, response, view, projectedResult, responseOrder) + if err != nil { + return nil, err + } + plan.constructors[key] = declaration + } + } + } + for _, transportError := range endpoint.HTTPErrors { + if !needInit(transportError.Type) { + continue + } + errorOrder := order.withRole("error") + errorOrder.status = transportError.Response.StatusCode + errorOrder.tagName = transportError.Name + preferred := "New" + codegen.Goify(endpoint.Name(), true) + codegen.Goify(transportError.ErrorExpr.Name, true) + declaration, err := declareHTTPConstructor(clientPackage, preferred, errorOrder) + if err != nil { + return nil, err + } + plan.errors[transportError] = declaration + } + } + } + for _, server := range input.Root.API.Servers { + var commands []cli.CommandDeclarationInput + for _, serviceName := range server.Services { + transportService := expressions.Service(serviceName) + if transportService == nil || len(transportService.HTTPEndpoints) == 0 { + continue + } + command := cli.CommandDeclarationInput{Service: serviceName} + for _, endpoint := range transportService.HTTPEndpoints { + command.Methods = append(command.Methods, endpoint.MethodExpr.Name) + command.NeedsFlagPresence = command.NeedsFlagPresence || httpEndpointNeedsCLIFlagPresence(endpoint) + } + commands = append(commands, command) + } + if len(commands) == 0 { + continue + } + serverPath := path.Join(generation.GenPkg(), dir, "cli", codegen.SnakeCase(codegen.Goify(server.Name, true))) + serverPackage, err := generation.ClaimPackage(serverPath) + if err != nil { + return nil, err + } + parser, err := cli.DeclareParser(serverPackage, dir, input.Root.API.Name, server.Name, commands) + if err != nil { + return nil, err + } + plan.cliParsers[server.Name] = parser + } + return plan, nil +} + +// httpEndpointNeedsCLIFlagPresence reports whether the generated command must +// distinguish a missing flag from a flag whose value is empty. Each check uses +// the same authored default that the generated payload builder receives. +func httpEndpointNeedsCLIFlagPresence(endpoint *expr.HTTPEndpointExpr) bool { + if endpoint.SkipRequestBodyEncodeDecode { + return true + } + if endpoint.MethodExpr.Payload.Type == expr.Empty { + return false + } + if endpoint.Body.Type != expr.Empty { + origin := endpoint.Body.Meta["origin:attribute"] + if len(origin) == 0 || endpoint.MethodExpr.Payload.GetDefault(origin[0]) == nil { + return true + } + } + if !endpoint.PathParams().IsEmpty() { + return true + } + for _, mapped := range []*expr.MappedAttributeExpr{ + endpoint.QueryParams(), + endpoint.Headers, + endpoint.Cookies, + } { + for _, field := range *expr.AsObject(mapped.Type) { + if requestElementDefault(endpoint.MethodExpr.Payload, field.Name, field.Attribute) == nil { + return true + } + } + } + if policy := jsonRPCRequestIDPolicyFor(endpoint); policy != nil && policy.attribute != nil && policy.defaultValue == nil { + return true + } + for _, requirement := range endpoint.Requirements { + for _, scheme := range requirement.Schemes { + if scheme.Kind == expr.BasicAuthKind { + return true + } + } + } + return !expr.IsObject(endpoint.MethodExpr.Payload.Type) && endpoint.MethodExpr.Payload.DefaultValue == nil +} + +// link reads the generated service names, builds data for every selected service once, +// and stores all files that the public methods on Plan return. +func (p *Plan) link() error { + serviceData := p.servicePlan.Services() + if serviceData.Root != p.root { + return fmt.Errorf("HTTP plan root does not match linked service plan root") + } + expressions := transportExpressions(p.root, p.transport) + services := newServicesData(serviceData, expressions) + services.jsonrpc = p.transport == jsonrpcTransport + services.viewedResultConstructors = p.constructors + services.payloadConstructors = p.payloads + services.streamConstructors = p.streams + services.errorConstructors = p.errors + services.plannedWireTypes = p.wireTypes + services.plannedSymbols = p.symbols + services.cliParsers = p.cliParsers + services.fileImports = make(map[string][]*codegen.ImportSpec, len(p.fileImports)) + services.clientServicePackages = make(map[string]string, len(expressions.Services)) + for filePath, retained := range p.fileImports { + if err := retained.Link(); err != nil { + return err + } + services.fileImports[filePath] = retained.Imports() + } + for _, transportService := range expressions.Services { + servicePackage, _, err := servicePackagePreferences(p.servicePlan, transportService) + if err != nil { + return err + } + clientPath := path.Join(p.generation.GenPkg(), transportDirectory(p.transport), p.servicePaths[transportService], "client") + name := servicePackage.Name + filePrefix := path.Join(codegen.Gendir, transportDirectory(p.transport), p.servicePaths[transportService], "client") + "/" + for filePath, retained := range p.fileImports { + if !strings.HasPrefix(filePath, filePrefix) || !slices.Contains(retained.Paths(), servicePackage.Path) { + continue + } + name = p.generation.Package(clientPath).ImportName(servicePackage.Path) + break + } + services.clientServicePackages[transportService.Name()] = name + } + for _, transportService := range services.Expressions.Services { + if services.ServicesData.Get(transportService.Name()) == nil { + return fmt.Errorf("HTTP service %q has no linked service model", transportService.Name()) + } + data := services.analyze(transportService) + if services.linkErr != nil { + return services.linkErr + } + extensions := p.extensions[transportService] + data.ServerHandlerWrappers = append([]*codegen.NameDeclaration(nil), extensions.handlerWrappers...) + for index, endpoint := range data.Endpoints { + endpoint.ServerHandlerWrappers = combinedHandlerWrappers(extensions, transportService.HTTPEndpoints[index]) + } + for _, fileServer := range data.FileServers { + fileServer.ServerHandlerWrappers = append([]*codegen.NameDeclaration(nil), extensions.handlerWrappers...) + } + data.ServerMounts = copyServerMounts(extensions.mounts) + services.HTTPData[transportService.Name()] = data + } + for _, planned := range p.wireTypes { + if err := planned.checkTransformsUsed(); err != nil { + return err + } + } + p.services = services + p.viewed = make(map[viewedMethodKey]*viewedResultPlan) + p.jsonServices = make(map[string]*jsonRPCServicePlan, len(services.HTTPData)) + for serviceName, serviceData := range services.HTTPData { + transportService := expressions.Service(serviceName) + if transportService == nil { + return fmt.Errorf("HTTP service %q has no transport expression", serviceName) + } + if len(transportService.HTTPEndpoints) != len(serviceData.Endpoints) { + return fmt.Errorf("HTTP service %q endpoint analysis does not match its design", serviceName) + } + jsonService := &jsonRPCServicePlan{ + data: serviceData, + fileImports: make(map[string][]*codegen.ImportSpec), + clientCodec: clientEncodeDecodeFile(transportService, services), + serverCodec: serverEncodeDecodeFile(transportService, services), + } + if p.transport == jsonrpcTransport { + jsonService.prepareFileImports(services) + } + p.jsonServices[serviceName] = jsonService + for _, endpoint := range serviceData.Endpoints { + if endpoint.Method.ViewedResult == nil || endpoint.SSE == nil && !endpoint.IsJSONRPC { + continue + } + var representations []viewedRepresentationPlan + for _, response := range endpoint.Result.Responses { + for _, representation := range response.ViewedRepresentations { + representations = append(representations, viewedRepresentationPlan{ + data: representation, + headers: response.Headers, + cookies: response.Cookies, + }) + } + } + variable := endpoint.Method.ViewedResult.ViewName == "" + if len(representations) == 0 { + return fmt.Errorf("HTTP viewed method %q has no response representations", endpoint.Method.Name) + } + p.viewed[viewedMethodKey{service: serviceName, method: endpoint.Method.Name}] = &viewedResultPlan{ + variable: variable, + fixedView: endpoint.Method.ViewedResult.ViewName, + service: endpoint.Method.ViewedResult, + representations: representations, + } + } + } + if p.transport == httpTransport { + p.server = serverFiles(services) + p.client = clientFiles(services) + } + p.serverTypes = serverTypeFiles(services) + p.clientTypes = clientTypeFiles(services) + p.paths = pathFiles(services) + p.clientCLI = clientCLIFiles(services) + return nil +} + +// checkTransformsUsed verifies that every conversion retained by this service +// plan was written exactly once while its template data was built. +func (p *plannedWireTypes) checkTransformsUsed() error { + checkRequest := func(transforms *plannedRequestTransforms) error { + for _, use := range []struct { + catalog *wireTypeCatalog + handle wireTransformHandle + }{ + {p.client, transforms.clientEncode}, + {p.server, transforms.serverDecode}, + {p.client, transforms.clientDecode}, + } { + if err := use.catalog.checkTransformUsed(use.handle); err != nil { + return err + } + } + return nil + } + checkResponse := func(transforms *plannedResponseTransforms) error { + for _, use := range []struct { + catalog *wireTypeCatalog + handle wireTransformHandle + }{ + {p.server, transforms.serverEncode}, + {p.client, transforms.clientDecode}, + } { + if err := use.catalog.checkTransformUsed(use.handle); err != nil { + return err + } + } + return nil + } + for _, transforms := range p.transforms.requests { + if err := checkRequest(transforms); err != nil { + return err + } + } + for _, transforms := range p.transforms.responses { + if err := checkResponse(transforms); err != nil { + return err + } + } + for _, transforms := range p.transforms.errors { + if err := checkResponse(transforms); err != nil { + return err + } + } + for _, transforms := range p.transforms.streamingResults { + if err := checkResponse(transforms); err != nil { + return err + } + } + return nil +} + +// combinedHandlerWrappers copies the service wrappers followed by the wrappers +// declared for one endpoint. Templates nest the first entry outermost. +func combinedHandlerWrappers(extensions *serverExtensions, endpoint *expr.HTTPEndpointExpr) []*codegen.NameDeclaration { + wrappers := make([]*codegen.NameDeclaration, 0, len(extensions.handlerWrappers)+len(extensions.endpointHandlerWrappers[endpoint])) + wrappers = append(wrappers, extensions.handlerWrappers...) + return append(wrappers, extensions.endpointHandlerWrappers[endpoint]...) +} + +// copyServerMounts gives render code its own mount functions and route entries. +func copyServerMounts(source []*ServerMount) []*ServerMount { + result := make([]*ServerMount, len(source)) + for index, mount := range source { + result[index] = &ServerMount{ + Declaration: mount.Declaration, + MountPoints: append([]ServerMountPoint(nil), mount.MountPoints...), + } + } + return result +} + +// transportExpressions returns the HTTP or JSON-RPC designs requested +// by the caller. +func transportExpressions(root *expr.RootExpr, transport transportKind) *expr.HTTPExpr { + if transport == jsonrpcTransport { + return &root.API.JSONRPC.HTTPExpr + } + return root.API.HTTP +} + +// transportDirectory returns the output directory for HTTP or JSON-RPC files. +func transportDirectory(transport transportKind) string { + if transport == jsonrpcTransport { + return "jsonrpc" + } + return "http" +} + +// transportLabel returns "HTTP" or "JSON-RPC" for error messages. +func transportLabel(transport transportKind) string { + if transport == jsonrpcTransport { + return "JSON-RPC" + } + return "HTTP" +} + +// requireLinked rejects file and service access before Link builds them. +func (p *Plan) requireLinked() { + if p.services == nil { + panic("HTTP render model requested before plan linking") + } +} + +// prepareFileImports copies the package paths collected for each JSON-RPC file +// before generation names were frozen. +func (p *jsonRPCServicePlan) prepareFileImports(services *ServicesData) { + servicePath := p.data.Service.PathName + clientPath := path.Join(codegen.Gendir, "jsonrpc", servicePath, "client", "client.go") + serverPath := path.Join(codegen.Gendir, "jsonrpc", servicePath, "server", "server.go") + p.fileImports[clientPath] = cloneImportSpecs(services.fileImports[filepathKey(clientPath)]) + p.fileImports[serverPath] = cloneImportSpecs(services.fileImports[filepathKey(serverPath)]) + if p.clientCodec != nil { + p.fileImports[filepathKey(p.clientCodec.Path)] = cloneImportSpecs(services.fileImports[filepathKey(p.clientCodec.Path)]) + } + if p.serverCodec != nil { + p.fileImports[filepathKey(p.serverCodec.Path)] = cloneImportSpecs(services.fileImports[filepathKey(p.serverCodec.Path)]) + } + hasSSE := false + for _, endpoint := range p.data.Endpoints { + if endpoint.SSE != nil { + hasSSE = true + break + } + } + if hasSSE { + clientStreamPath := path.Join(codegen.Gendir, "jsonrpc", servicePath, "client", "stream.go") + serverStreamPath := path.Join(codegen.Gendir, "jsonrpc", servicePath, "server", "sse.go") + p.fileImports[clientStreamPath] = cloneImportSpecs(services.fileImports[filepathKey(clientStreamPath)]) + p.fileImports[serverStreamPath] = cloneImportSpecs(services.fileImports[filepathKey(serverStreamPath)]) + } +} + +// cloneImportSpecs copies an import list and each import value so a caller can +// change both without changing the list stored by the HTTP plan. +func cloneImportSpecs(source []*codegen.ImportSpec) []*codegen.ImportSpec { + result := make([]*codegen.ImportSpec, len(source)) + for index, spec := range source { + copy := *spec + result[index] = © + } + return result +} + +// declareViewedResultConstructor records the function that converts one HTTP +// response into a service result. Released overlapping method and result names +// use the exact projected view type selected by the service plan. +func declareViewedResultConstructor( + pkg *codegen.GeneratedPackage, + endpoint *expr.HTTPEndpointExpr, + response *expr.HTTPResponseExpr, + view string, + projectedResult *codegen.TypeDeclaration, + order viewedConstructorOrder, +) (*codegen.NameDeclaration, error) { + status := codegen.Goify(http.StatusText(response.StatusCode), true) + method := codegen.Goify(endpoint.Name(), true) + result := codegen.Goify(releasedMethodTypeName(endpoint.MethodExpr.Result, "Result"), true) + if view == "" && projectedResult != nil && strings.HasPrefix(result, method) { + return pkg.DeclareDependentName(codegen.NameFunction, projectedResult.Declaration(), "New", status, order) + } + return declareHTTPConstructor(pkg, viewedResultConstructorName(endpoint, response, view), order) +} + +// viewedResultConstructorName returns the preferred constructor spelling for +// results whose released name does not depend on a projected view type. +func viewedResultConstructorName(endpoint *expr.HTTPEndpointExpr, response *expr.HTTPResponseExpr, view string) string { + status := codegen.Goify(http.StatusText(response.StatusCode), true) + if view != "" { + return "New" + codegen.Goify(endpoint.Name(), true) + "Result" + codegen.Goify(view, true) + status + } + return releasedMethodTypeConstructorName(endpoint.Name(), releasedMethodTypeName(endpoint.MethodExpr.Result, "Result"), "Result") + status +} + +// endpointPayloadConstructorName returns the preferred server function name +// that builds one method payload from its HTTP request values. +func endpointPayloadConstructorName(endpoint *expr.HTTPEndpointExpr) string { + method := codegen.Goify(endpoint.Name(), true) + payload := codegen.Goify(releasedMethodTypeName(endpoint.MethodExpr.Payload, "Payload"), true) + if strings.HasPrefix(payload, method) { + return "New" + payload + } + return "New" + method + payload +} + +// releasedMethodTypeName returns the service-side spelling used before HTTP +// constructors were planned separately. It specializes arrays and maps from +// their element types, such as ElemType and MapKeyTypeElemType. +func releasedMethodTypeName(attribute *expr.AttributeExpr, role string) string { + name := codegen.NewNameScope().GoTypeName(attribute) + if name == "" { + return role + } + return name +} + +// releasedMethodTypeConstructorName joins a method and its service type while +// avoiding a repeated type stem. For example, FetchCustomer and Customer +// produce NewFetchCustomerResult. +func releasedMethodTypeConstructorName(method, typeName, role string) string { + method = codegen.Goify(method, true) + typeName = codegen.Goify(typeName, true) + stem := strings.TrimSuffix(typeName, role) + if stem != typeName && stem != "" && strings.HasSuffix(method, stem) { + return "New" + method + role + } + return "New" + method + typeName +} + +// declareHTTPConstructor submits one constructor name to the generated package +// that will contain both its definition and calls. +func declareHTTPConstructor(pkg *codegen.GeneratedPackage, preferred string, order viewedConstructorOrder) (*codegen.NameDeclaration, error) { + declaration := codegen.NewPreferredName(codegen.NameFunction, preferred, codegen.ExportedName, order) + if err := pkg.DeclareName(declaration); err != nil { + return nil, err + } + return declaration, nil +} + +// withRole returns an ordering value for one kind of endpoint constructor. +func (o viewedConstructorOrder) withRole(role string) viewedConstructorOrder { + o.role = role + return o +} + +// ComparePackageName orders view constructors by design service, method, +// response status, and view so input iteration order cannot change names. +func (o viewedConstructorOrder) ComparePackageName(other codegen.PackageNameOrder) int { + right := other.(viewedConstructorOrder) + for _, compared := range []int{ + cmp.Compare(o.transport, right.transport), + cmp.Compare(o.api, right.api), + cmp.Compare(o.service, right.service), + cmp.Compare(o.method, right.method), + cmp.Compare(o.role, right.role), + cmp.Compare(o.status, right.status), + cmp.Compare(o.tagName, right.tagName), + cmp.Compare(o.tagValue, right.tagValue), + cmp.Compare(o.view, right.view), + } { + if compared != 0 { + return compared + } + } + return 0 +} diff --git a/http/codegen/plan_extensions_test.go b/http/codegen/plan_extensions_test.go new file mode 100644 index 0000000000..e6febfc0fa --- /dev/null +++ b/http/codegen/plan_extensions_test.go @@ -0,0 +1,180 @@ +// This file checks the handler wrappers and extra routes that plugins declare +// before Goa chooses generated package names. +package codegen + +import ( + "cmp" + "testing" + + "github.com/stretchr/testify/require" + + "goa.design/goa/v3/codegen" + "goa.design/goa/v3/codegen/service" + "goa.design/goa/v3/dsl" + "goa.design/goa/v3/eval" + "goa.design/goa/v3/expr" +) + +type extensionNameOrder string + +// ComparePackageName gives extension declarations a stable order in tests. +func (o extensionNameOrder) ComparePackageName(other codegen.PackageNameOrder) int { + return cmp.Compare(string(o), string(other.(extensionNameOrder))) +} + +func TestPlanDeclaresServerExtensions(t *testing.T) { + root := extensionRoot(t) + plan, generation, servicePlan := plannedHTTPPlan(t, root, false) + serviceExpr := root.API.HTTP.Services[0] + + first, err := plan.DeclareServerHandlerWrapper(serviceExpr, "WrapHandler", extensionNameOrder("first")) + require.NoError(t, err) + second, err := plan.DeclareServerHandlerWrapper(serviceExpr, "WrapHandler", extensionNameOrder("second")) + require.NoError(t, err) + endpointWrapper, err := plan.DeclareServerEndpointHandlerWrapper(serviceExpr.HTTPEndpoints[0], "wrapEndpoint", extensionNameOrder("endpoint")) + require.NoError(t, err) + secondEndpointWrapper, err := plan.DeclareServerEndpointHandlerWrapper(serviceExpr.HTTPEndpoints[0], "wrapEndpoint", extensionNameOrder("second endpoint")) + require.NoError(t, err) + descriptions := []ServerMountPoint{{Method: "Preflight", Verb: "OPTIONS", Pattern: "/items/{id}"}} + mount, err := plan.DeclareServerMount(serviceExpr, "MountPreflight", extensionNameOrder("mount"), descriptions) + require.NoError(t, err) + descriptions[0].Method = "changed" + + require.NoError(t, generation.Freeze()) + require.NoError(t, servicePlan.Link()) + require.NoError(t, plan.Link()) + + data := plan.services.Get("Files") + require.Equal(t, "WrapHandler", first.Name()) + require.Equal(t, "WrapHandler2", second.Name()) + require.Equal(t, []*codegen.NameDeclaration{first, second}, data.ServerHandlerWrappers) + require.Equal(t, "wrapEndpoint", endpointWrapper.Name()) + require.Equal(t, "wrapEndpoint2", secondEndpointWrapper.Name()) + require.Equal(t, []*codegen.NameDeclaration{first, second, endpointWrapper, secondEndpointWrapper}, data.Endpoints[0].ServerHandlerWrappers) + for _, fileServer := range data.FileServers { + require.Equal(t, []*codegen.NameDeclaration{first, second}, fileServer.ServerHandlerWrappers) + } + require.Equal(t, mount, data.ServerMounts[0].Declaration) + require.Equal(t, []ServerMountPoint{{Method: "Preflight", Verb: "OPTIONS", Pattern: "/items/{id}"}}, data.ServerMounts[0].MountPoints) +} + +func TestPlanRejectsInvalidServerExtensions(t *testing.T) { + root := extensionRoot(t) + plan, generation, servicePlan := plannedHTTPPlan(t, root, false) + serviceExpr := root.API.HTTP.Services[0] + foreignRoot := extensionRoot(t) + foreignService := foreignRoot.API.HTTP.Services[0] + foreignEndpoint := foreignService.HTTPEndpoints[0] + + tests := []struct { + name string + call func() error + want string + }{ + {"nil service", func() error { + _, err := plan.DeclareServerHandlerWrapper(nil, "Wrap", extensionNameOrder("nil")) + return err + }, "HTTP server extension requires a service from this plan"}, + {"foreign service", func() error { + _, err := plan.DeclareServerHandlerWrapper(foreignService, "Wrap", extensionNameOrder("foreign")) + return err + }, "HTTP service does not belong to this plan"}, + {"nil endpoint", func() error { + _, err := plan.DeclareServerEndpointHandlerWrapper(nil, "wrap", extensionNameOrder("nil endpoint")) + return err + }, "HTTP server endpoint wrapper requires an endpoint from this plan"}, + {"foreign endpoint", func() error { + _, err := plan.DeclareServerEndpointHandlerWrapper(foreignEndpoint, "wrap", extensionNameOrder("foreign endpoint")) + return err + }, "HTTP endpoint does not belong to this plan"}, + {"empty preferred name", func() error { + _, err := plan.DeclareServerHandlerWrapper(serviceExpr, "", extensionNameOrder("empty")) + return err + }, "package name must not be empty"}, + {"nil order", func() error { + _, err := plan.DeclareServerHandlerWrapper(serviceExpr, "Wrap", nil) + return err + }, `generated package "generated.local/gen/http/files/server" cannot declare preferred function "Wrap": package name order must be a stable concrete named value type`}, + {"no mount descriptions", func() error { + _, err := plan.DeclareServerMount(serviceExpr, "Mount", extensionNameOrder("none"), nil) + return err + }, "HTTP server mount requires at least one mount point"}, + {"empty method", func() error { + _, err := plan.DeclareServerMount(serviceExpr, "Mount", extensionNameOrder("method"), []ServerMountPoint{{Verb: "OPTIONS", Pattern: "/"}}) + return err + }, "HTTP server mount point 0 has an empty method"}, + {"empty verb", func() error { + _, err := plan.DeclareServerMount(serviceExpr, "Mount", extensionNameOrder("verb"), []ServerMountPoint{{Method: "Preflight", Pattern: "/"}}) + return err + }, "HTTP server mount point 0 has an empty verb"}, + {"empty pattern", func() error { + _, err := plan.DeclareServerMount(serviceExpr, "Mount", extensionNameOrder("pattern"), []ServerMountPoint{{Method: "Preflight", Verb: "OPTIONS"}}) + return err + }, "HTTP server mount point 0 has an empty pattern"}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + require.EqualError(t, test.call(), test.want) + }) + } + + require.NoError(t, generation.Freeze()) + _, err := plan.DeclareServerHandlerWrapper(serviceExpr, "Late", extensionNameOrder("late")) + require.EqualError(t, err, "HTTP server extension cannot be declared after generation freeze") + _, err = plan.DeclareServerEndpointHandlerWrapper(serviceExpr.HTTPEndpoints[0], "late", extensionNameOrder("late endpoint")) + require.EqualError(t, err, "HTTP server extension cannot be declared after generation freeze") + require.NoError(t, servicePlan.Link()) + require.NoError(t, plan.Link()) + _, err = plan.DeclareServerHandlerWrapper(serviceExpr, "Linked", extensionNameOrder("linked")) + require.EqualError(t, err, "HTTP server extension cannot be declared after plan linking") + _, err = plan.DeclareServerEndpointHandlerWrapper(serviceExpr.HTTPEndpoints[0], "linked", extensionNameOrder("linked endpoint")) + require.EqualError(t, err, "HTTP server extension cannot be declared after plan linking") +} + +func TestJSONRPCPlanRejectsServerExtensions(t *testing.T) { + root := expr.RunDSL(t, func() { + dsl.Service("RPC", func() { + dsl.Method("Read", func() { dsl.JSONRPC(func() {}) }) + }) + }) + plan, _, _ := plannedHTTPPlan(t, root, true) + _, err := plan.DeclareServerHandlerWrapper(root.API.JSONRPC.Services[0], "Wrap", extensionNameOrder("rpc")) + require.EqualError(t, err, "JSON-RPC HTTP plans do not support server extensions") + _, err = plan.DeclareServerEndpointHandlerWrapper(root.API.JSONRPC.Services[0].HTTPEndpoints[0], "wrap", extensionNameOrder("rpc endpoint")) + require.EqualError(t, err, "JSON-RPC HTTP plans do not support server extensions") +} + +// extensionRoot builds the HTTP service used to test endpoint and file wrappers. +func extensionRoot(t *testing.T) *expr.RootExpr { + t.Helper() + return expr.RunDSL(t, func() { + dsl.Service("Files", func() { + dsl.Method("Read", func() { + dsl.Payload(func() { dsl.Attribute("id", dsl.String) }) + dsl.HTTP(func() { dsl.GET("/items/{id}") }) + }) + dsl.Files("/assets/{*path}", "assets") + dsl.Files("/old", "old.html", func() { dsl.Redirect("/new", 301) }) + }) + }) +} + +// plannedHTTPPlan creates an HTTP or JSON-RPC plan without linking it so each +// test can add server functions before generated names become final. +func plannedHTTPPlan(t *testing.T, root *expr.RootExpr, jsonrpc bool) (*Plan, *codegen.Generation, *service.Plan) { + t.Helper() + generation, err := codegen.NewGeneration("generated.local/gen", []eval.Root{root}) + require.NoError(t, err) + servicePlan, err := service.NewPlan(root, generation, expr.NewExampleGenerator(root.API.RandomizerFactory)) + require.NoError(t, err) + input := PlanInput{Root: root, Service: servicePlan} + var plans []*Plan + if jsonrpc { + plans, err = NewJSONRPCPlans(generation, input) + } else { + plans, err = NewPlans(generation, input) + } + require.NoError(t, err) + require.Len(t, plans, 1) + return plans[0], generation, servicePlan +} diff --git a/http/codegen/plan_service_test.go b/http/codegen/plan_service_test.go new file mode 100644 index 0000000000..2047413ba3 --- /dev/null +++ b/http/codegen/plan_service_test.go @@ -0,0 +1,60 @@ +// This file checks that plugins can read only the finalized HTTP service data +// that belongs to the exact service expression used to build a retained plan. +package codegen + +import ( + "testing" + + "github.com/stretchr/testify/require" + + "goa.design/goa/v3/dsl" + "goa.design/goa/v3/expr" +) + +func TestPlanServiceRequiresLink(t *testing.T) { + root := serviceLookupRoot(t) + plan, _, _ := plannedHTTPPlan(t, root, false) + + require.PanicsWithValue(t, "HTTP render model requested before plan linking", func() { + plan.Service(root.API.HTTP.Services[0]) + }) +} + +func TestPlanServiceUsesExactExpression(t *testing.T) { + root := serviceLookupRoot(t) + plan, generation, servicePlan := plannedHTTPPlan(t, root, false) + require.NoError(t, generation.Freeze()) + require.NoError(t, servicePlan.Link()) + require.NoError(t, plan.Link()) + + alpha, ok := plan.Service(root.API.HTTP.Services[0]) + require.True(t, ok) + require.Equal(t, "Alpha", alpha.Service.Name) + + beta, ok := plan.Service(root.API.HTTP.Services[1]) + require.True(t, ok) + require.Equal(t, "Beta", beta.Service.Name) + require.NotSame(t, alpha, beta) + + foreign := serviceLookupRoot(t) + _, ok = plan.Service(foreign.API.HTTP.Services[0]) + require.False(t, ok) +} + +// serviceLookupRoot creates two services so the test can distinguish exact +// service identity from a lookup by a repeated name or position. +func serviceLookupRoot(t *testing.T) *expr.RootExpr { + t.Helper() + return expr.RunDSL(t, func() { + dsl.Service("Alpha", func() { + dsl.Method("Read", func() { + dsl.HTTP(func() { dsl.GET("/alpha") }) + }) + }) + dsl.Service("Beta", func() { + dsl.Method("Read", func() { + dsl.HTTP(func() { dsl.GET("/beta") }) + }) + }) + }) +} diff --git a/http/codegen/plan_test.go b/http/codegen/plan_test.go new file mode 100644 index 0000000000..8551202cf3 --- /dev/null +++ b/http/codegen/plan_test.go @@ -0,0 +1,1133 @@ +// This file verifies that HTTP package names are requested before Goa assigns +// them and that files are built only after service names are available. +package codegen + +import ( + "fmt" + "go/parser" + "go/token" + "os" + "path" + "path/filepath" + "slices" + "strconv" + "strings" + "testing" + + "github.com/stretchr/testify/require" + + "goa.design/goa/v3/codegen" + "goa.design/goa/v3/codegen/example" + "goa.design/goa/v3/codegen/service" + "goa.design/goa/v3/codegen/testutil" + "goa.design/goa/v3/dsl" + "goa.design/goa/v3/eval" + "goa.design/goa/v3/expr" + "goa.design/goa/v3/http/codegen/testdata" +) + +func TestPlanDoesNotReserveUnusedStaticAliasesBeforeFreeze(t *testing.T) { + root := expr.RunDSL(t, func() { + dsl.Service("Path", func() { + dsl.Method("Read", func() { dsl.HTTP(func() { dsl.GET("/") }) }) + }) + }) + generation, err := codegen.NewGeneration("generated.local/gen", []eval.Root{root}) + require.NoError(t, err) + servicePlan, err := service.NewPlan(root, generation, expr.NewExampleGenerator(root.API.RandomizerFactory)) + require.NoError(t, err) + plans, err := NewPlans(generation, PlanInput{Root: root, Service: servicePlan}) + require.NoError(t, err) + require.NoError(t, generation.Freeze()) + require.NoError(t, servicePlan.Link()) + require.Len(t, plans, 1) + for filePath, file := range plans[0].fileImports { + if strings.HasPrefix(filePath, "gen/http/path/client/") { + require.NotContains(t, file.Paths(), "generated.local/gen/path") + } + } +} + +func TestPlanRejectsFrozenGeneration(t *testing.T) { + generation, err := codegen.NewGeneration("generated.local/gen", nil) + require.NoError(t, err) + require.NoError(t, generation.Freeze()) + + _, err = NewPlans(generation) + require.Error(t, err) +} + +func TestEndpointPayloadConstructorUsesReleasedTypeName(t *testing.T) { + cases := []struct { + name string + method string + payload string + want string + }{ + { + name: "named payload", + method: "MethodBodyUnion", + payload: "Union", + want: "NewMethodBodyUnionUnion", + }, + { + name: "overlapping payload", + method: "MethodA", + payload: "APayload", + want: "NewMethodAAPayload", + }, + } + for _, test := range cases { + t.Run(test.name, func(t *testing.T) { + endpoint := &expr.HTTPEndpointExpr{MethodExpr: &expr.MethodExpr{ + Name: test.method, + Payload: &expr.AttributeExpr{Type: wireCatalogType(test.payload, test.payload, "value", true)}, + }} + require.Equal(t, test.want, endpointPayloadConstructorName(endpoint)) + }) + } +} + +// TestNewExamplePlanRejectsAnotherServicePlan checks that server names and +// URLs cannot come from a different design with the same authored names. +func TestNewExamplePlanRejectsAnotherServicePlan(t *testing.T) { + root := codegen.RunDSL(t, func() { + dsl.Service("Service", func() { + dsl.Method("Read", func() { dsl.HTTP(func() { dsl.GET("/") }) }) + }) + }) + transport := linkedHTTPPlanForRoot(t, root) + + otherRoot := codegen.RunDSL(t, func() { + dsl.Service("Service", func() { + dsl.Method("Read", func() { dsl.HTTP(func() { dsl.GET("/") }) }) + }) + }) + otherGeneration, err := codegen.NewGeneration("other.local/gen", []eval.Root{otherRoot}) + require.NoError(t, err) + otherService, err := service.NewPlan(otherRoot, otherGeneration, expr.NewExampleGenerator(otherRoot.API.RandomizerFactory)) + require.NoError(t, err) + examples, err := example.NewPlan(otherGeneration, otherService) + require.NoError(t, err) + + _, err = NewExamplePlan(transport, examples) + require.EqualError(t, err, "HTTP examples require server data created from the same service design") +} + +// TestNewPlansRequiresEveryHTTPRoot proves package names cannot be requested +// from only some of the HTTP designs in one generation. +func TestNewPlansRequiresEveryHTTPRoot(t *testing.T) { + first := expr.RunDSL(t, func() { + dsl.Service("First", func() { + dsl.Method("Read", func() { dsl.HTTP(func() { dsl.GET("/first") }) }) + }) + }) + second := expr.RunDSL(t, func() { + dsl.Service("Second", func() { + dsl.Method("Read", func() { dsl.HTTP(func() { dsl.GET("/second") }) }) + }) + }) + generation, err := codegen.NewGeneration("generated.local/gen", []eval.Root{first, second}) + require.NoError(t, err) + services, err := service.NewPlans(generation, + service.PlanInput{Root: first, Examples: expr.NewExampleGenerator(first.API.RandomizerFactory)}, + service.PlanInput{Root: second, Examples: expr.NewExampleGenerator(second.API.RandomizerFactory)}, + ) + require.NoError(t, err) + + _, err = NewPlans(generation, PlanInput{Root: first, Service: services[0]}) + require.EqualError(t, err, "HTTP planning requires all 2 transport roots, got 1") +} + +// TestNewPlansRejectsDuplicateRoot proves one service plan cannot be paired +// with the same design twice in one call. +func TestNewPlansRejectsDuplicateRoot(t *testing.T) { + root := expr.RunDSL(t, func() { + dsl.Service("Calc", func() { + dsl.Method("Read", func() { dsl.HTTP(func() { dsl.GET("/calc") }) }) + }) + }) + generation, err := codegen.NewGeneration("generated.local/gen", []eval.Root{root}) + require.NoError(t, err) + servicePlan, err := service.NewPlan(root, generation, expr.NewExampleGenerator(root.API.RandomizerFactory)) + require.NoError(t, err) + + _, err = NewPlans(generation, + PlanInput{Root: root, Service: servicePlan}, + PlanInput{Root: root, Service: servicePlan}, + ) + require.EqualError(t, err, fmt.Sprintf("HTTP root %p is planned more than once", root)) +} + +// TestPlanReservesGeneratedHTTPPackages verifies that client, server, and CLI +// packages receive distinct import names before files are written. +func TestPlanReservesGeneratedHTTPPackages(t *testing.T) { + root := expr.RunDSL(t, func() { + for _, name := range []string{"Foo", "Fooc", "Foosvr"} { + dsl.Service(name, func() { + dsl.Method("Read", func() { + dsl.HTTP(func() { dsl.GET("/" + name) }) + }) + }) + } + }) + generation, err := codegen.NewGeneration("generated.local/gen", []eval.Root{root}) + require.NoError(t, err) + servicePlan, err := service.NewPlan(root, generation, expr.NewExampleGenerator(root.API.RandomizerFactory)) + require.NoError(t, err) + plans, err := NewPlans(generation, PlanInput{Root: root, Service: servicePlan}) + require.NoError(t, err) + examples, err := example.NewPlan(generation, servicePlan) + require.NoError(t, err) + _, err = NewExamplePlan(plans[0], examples) + require.NoError(t, err) + require.NoError(t, generation.Freeze()) + require.NoError(t, servicePlan.Link()) + services := servicePlan.Services() + + cliOutput := path.Join( + "generated.local/gen/http/cli", + codegen.SnakeCase(codegen.Goify(root.API.Servers[0].Name, true)), + ) + client := services.PackageImport(cliOutput, "generated.local/gen/http/foo/client") + serverOutput := path.Join("generated.local", "cmd", codegen.SnakeCase(codegen.Goify(root.API.Servers[0].Name, true))) + server := services.PackageImport(serverOutput, "generated.local/gen/http/foo/server") + require.Equal(t, "fooc", client.Name) + require.Equal(t, "foosvr2", server.Name) +} + +// TestPlanLinkEagerlyRetainsHTTPFiles proves Link analyzes every HTTP service +// once and decides which generated files exist before callers request them. +func TestPlanLinkEagerlyRetainsHTTPFiles(t *testing.T) { + root := expr.RunDSL(t, func() { + dsl.Service("Calc", func() { + dsl.Method("Add", func() { + dsl.Payload(func() { dsl.Attribute("value", dsl.Int) }) + dsl.Result(func() { dsl.Attribute("total", dsl.Int) }) + dsl.HTTP(func() { dsl.POST("/add") }) + }) + }) + }) + generation, err := codegen.NewGeneration("generated.local/gen", []eval.Root{root}) + require.NoError(t, err) + servicePlan, err := service.NewPlan(root, generation, expr.NewExampleGenerator(root.API.RandomizerFactory)) + require.NoError(t, err) + plans, err := NewPlans(generation, PlanInput{Root: root, Service: servicePlan}) + require.NoError(t, err) + examplePlan, err := example.NewPlan(generation, servicePlan) + require.NoError(t, err) + examples, err := NewExamplePlan(plans[0], examplePlan) + require.NoError(t, err) + require.NoError(t, generation.Freeze()) + require.NoError(t, servicePlan.Link()) + plan := plans[0] + require.NoError(t, plan.Link()) + _, ok := plan.JSONRPCService("Calc") + require.True(t, ok) + require.NotEmpty(t, examples.ServerFiles()) + require.NotEmpty(t, examples.CLIFiles()) + serverCount := len(plan.ServerFiles()) + clientCount := len(plan.ClientFiles()) + + root.API.HTTP.Services = append(root.API.HTTP.Services, &expr.HTTPServiceExpr{}) + require.Len(t, plan.ServerFiles(), serverCount) + require.Len(t, plan.ClientFiles(), clientCount) +} + +// TestJSONRPCCodecFilesAreIndependent checks that the JSON-RPC file writer can +// change a returned encoder and decoder file without changing a later copy. +func TestJSONRPCCodecFilesAreIndependent(t *testing.T) { + root := expr.RunDSL(t, func() { + value := dsl.Type("Value", func() { + dsl.Meta("struct:pkg:path", "example.com/types") + dsl.Attribute("number", dsl.Int) + }) + dsl.Service("Calc", func() { + dsl.Method("Add", func() { + dsl.Payload(value) + dsl.Result(func() { dsl.Attribute("total", dsl.Int) }) + dsl.JSONRPC(func() {}) + }) + }) + }) + generation, err := codegen.NewGeneration("generated.local/gen", []eval.Root{root}) + require.NoError(t, err) + servicePlan, err := service.NewPlan(root, generation, expr.NewExampleGenerator(root.API.RandomizerFactory)) + require.NoError(t, err) + plans, err := NewJSONRPCPlans(generation, PlanInput{Root: root, Service: servicePlan}) + require.NoError(t, err) + require.NoError(t, generation.Freeze()) + require.NoError(t, servicePlan.Link()) + require.NoError(t, plans[0].Link()) + + service, ok := plans[0].JSONRPCService("Calc") + require.True(t, ok) + serverBody := service.Endpoints[0].Payload.Request.ServerBody + require.NotNil(t, serverBody.Declaration) + require.Equal(t, serverBody.Declaration.Name(), serverBody.VarName) + stored := plans[0].jsonServices["Calc"] + assertIndependentCodecFile(t, stored.clientCodec, service.ClientCodecFile) + assertIndependentCodecFile(t, stored.serverCodec, service.ServerCodecFile) + + clientPath := stored.clientCodec.Path + imports := service.FileImports(clientPath) + require.NotEmpty(t, imports) + require.Equal(t, imports, service.FileImports(strings.ReplaceAll(clientPath, "/", `\`))) + original := *imports[0] + imports[0].Path = "changed.example/package" + freshImports := service.FileImports(clientPath) + require.Equal(t, original, *freshImports[0]) + require.PanicsWithValue(t, "JSON-RPC file is not part of this HTTP service plan", func() { + service.FileImports("gen/jsonrpc/calc/client/unknown.go") + }) + + service.Service.Name = "changed" + service.Endpoints[0].ServiceName = "changed" + service.Endpoints[0].Payload.Request.Headers = append( + service.Endpoints[0].Payload.Request.Headers, + JSONRPCHeaderData{CanonicalName: "Changed"}, + ) + fresh, ok := plans[0].JSONRPCService("Calc") + require.True(t, ok) + require.Equal(t, "Calc", fresh.Service.Name) + require.Equal(t, "Calc", fresh.Endpoints[0].ServiceName) + require.Empty(t, fresh.Endpoints[0].Payload.Request.Headers) +} + +// TestPlanRetainsAttributeImportsBeforeFreeze proves ordinary HTTP and +// JSON-RPC files use the type packages recorded during planning, even if the +// design expression is changed before linking. +func TestPlanRetainsAttributeImportsBeforeFreeze(t *testing.T) { + for _, transport := range []struct { + name string + plan func(*codegen.Generation, PlanInput) ([]*Plan, error) + file func(*Plan) []*codegen.ImportSpec + }{ + { + name: "HTTP", + plan: func(generation *codegen.Generation, input PlanInput) ([]*Plan, error) { + return NewPlans(generation, input) + }, + file: func(plan *Plan) []*codegen.ImportSpec { + for _, file := range plan.ClientFiles() { + if strings.HasSuffix(filepath.ToSlash(file.Path), "/client/encode_decode.go") { + return file.SectionTemplates[0].Data.(map[string]any)["Imports"].([]*codegen.ImportSpec) + } + } + return nil + }, + }, + { + name: "JSON-RPC", + plan: func(generation *codegen.Generation, input PlanInput) ([]*Plan, error) { + return NewJSONRPCPlans(generation, input) + }, + file: func(plan *Plan) []*codegen.ImportSpec { + service, ok := plan.JSONRPCService("Calc") + require.True(t, ok) + return service.FileImports("gen/jsonrpc/calc/client/encode_decode.go") + }, + }, + } { + t.Run(transport.name, func(t *testing.T) { + root := expr.RunDSL(t, func() { + dsl.Service("Calc", func() { + dsl.Method("Add", func() { + dsl.Payload(func() { + dsl.Attribute("number", dsl.Int, func() { + dsl.Meta("struct:field:type", "values.Number", "example.com/values", "values") + }) + }) + dsl.Result(dsl.Int) + if transport.name == "HTTP" { + dsl.HTTP(func() { dsl.POST("/add") }) + } else { + dsl.JSONRPC(func() {}) + } + }) + }) + }) + generation, err := codegen.NewGeneration("generated.local/gen", []eval.Root{root}) + require.NoError(t, err) + servicePlan, err := service.NewPlan(root, generation, expr.NewExampleGenerator(root.API.RandomizerFactory)) + require.NoError(t, err) + plans, err := transport.plan(generation, PlanInput{Root: root, Service: servicePlan}) + require.NoError(t, err) + + payload := root.Service("Calc").Method("Add").Payload + delete(expr.AsObject(payload.Type).Attribute("number").Meta, "struct:field:type") + require.NoError(t, generation.Freeze()) + require.NoError(t, servicePlan.Link()) + require.NoError(t, plans[0].Link()) + + imports := transport.file(plans[0]) + require.Contains(t, importPaths(imports), "example.com/values") + }) + } +} + +// TestHTTPPlanReservesOnlyFixedImportsWrittenByThePackage verifies that a +// package name stays available to user types when no generated HTTP file uses +// the standard library package with that name. +func TestHTTPPlanReservesOnlyFixedImportsWrittenByThePackage(t *testing.T) { + const customRoot = "example.com/custom/" + root := expr.RunDSL(t, func() { + dsl.Service("Calc", func() { + dsl.Method("Read", func() { + dsl.Result(func() { + for _, name := range []string{"errors", "strings", "strconv"} { + dsl.Attribute(name, dsl.String, func() { + dsl.Meta("struct:field:type", name+".Value", customRoot+name, name) + }) + } + }) + dsl.HTTP(func() { dsl.GET("/") }) + }) + }) + }) + generation, err := codegen.NewGeneration("generated.local/gen", []eval.Root{root}) + require.NoError(t, err) + servicePlan, err := service.NewPlan(root, generation, expr.NewExampleGenerator(root.API.RandomizerFactory)) + require.NoError(t, err) + plans, err := NewPlans(generation, PlanInput{Root: root, Service: servicePlan}) + require.NoError(t, err) + require.NoError(t, generation.Freeze()) + require.NoError(t, servicePlan.Link()) + require.NoError(t, plans[0].Link()) + + server := generation.Package("generated.local/gen/http/calc/server") + for _, name := range []string{"errors", "strings", "strconv"} { + require.Equal(t, name, server.ImportName(customRoot+name)) + } +} + +// TestHTTPPlanReservesFixedImportsWrittenByThePackage verifies that standard +// packages keep the names written by HTTP templates when generated sections +// use them. +func TestHTTPPlanReservesFixedImportsWrittenByThePackage(t *testing.T) { + const customRoot = "example.com/custom/" + root := expr.RunDSL(t, func() { + failure := dsl.Type("Failure", func() { + dsl.Attribute("message", dsl.String) + }) + dsl.Service("Calc", func() { + dsl.Error("failed", failure) + dsl.Method("Read", func() { + dsl.Result(func() { + for _, name := range []string{"errors", "strings", "strconv"} { + dsl.Attribute(name, dsl.String, func() { + dsl.Meta("struct:field:type", name+".Value", customRoot+name, name) + }) + } + dsl.Attribute("numbers", dsl.ArrayOf(dsl.Int)) + }) + dsl.Error("failed") + dsl.HTTP(func() { + dsl.GET("/") + dsl.Response(func() { + dsl.Header("numbers:X-Numbers") + }) + dsl.Response("failed", dsl.StatusBadRequest) + }) + }) + }) + }) + generation, err := codegen.NewGeneration("generated.local/gen", []eval.Root{root}) + require.NoError(t, err) + servicePlan, err := service.NewPlan(root, generation, expr.NewExampleGenerator(root.API.RandomizerFactory)) + require.NoError(t, err) + plans, err := NewPlans(generation, PlanInput{Root: root, Service: servicePlan}) + require.NoError(t, err) + require.NoError(t, generation.Freeze()) + require.NoError(t, servicePlan.Link()) + require.NoError(t, plans[0].Link()) + + server := generation.Package("generated.local/gen/http/calc/server") + for _, name := range []string{"errors", "strings", "strconv"} { + require.Equal(t, name+"2", server.ImportName(customRoot+name)) + } +} + +// TestHTTPFilePlansMatchRenderedImports verifies that planning and rendering +// agree on every import used by a simple HTTP service. +func TestHTTPFilePlansMatchRenderedImports(t *testing.T) { + root := expr.RunDSL(t, func() { + dsl.Service("Calc", func() { + dsl.Method("Read", func() { + dsl.Result(dsl.String) + dsl.HTTP(func() { dsl.GET("/") }) + }) + }) + }) + generation, err := codegen.NewGeneration("generated.local/gen", []eval.Root{root}) + require.NoError(t, err) + servicePlan, err := service.NewPlan(root, generation, expr.NewExampleGenerator(root.API.RandomizerFactory)) + require.NoError(t, err) + plans, err := NewPlans(generation, PlanInput{Root: root, Service: servicePlan}) + require.NoError(t, err) + require.NoError(t, generation.Freeze()) + require.NoError(t, servicePlan.Link()) + require.NoError(t, plans[0].Link()) + + files := append(plans[0].ClientFiles(), plans[0].ServerFiles()...) + files = append(files, plans[0].ClientTypeFiles()...) + files = append(files, plans[0].ServerTypeFiles()...) + files = append(files, plans[0].PathFiles()...) + files = append(files, plans[0].ClientCLIFiles()...) + directory := t.TempDir() + for _, file := range files { + if file == nil || filepath.Ext(file.Path) != ".go" { + continue + } + planned := importPaths(file.SectionTemplates[0].Data.(map[string]any)["Imports"].([]*codegen.ImportSpec)) + renderedPath, err := file.Render(directory) + require.NoError(t, err) + source, err := os.ReadFile(renderedPath) + require.NoError(t, err) + parsed, err := parser.ParseFile(token.NewFileSet(), renderedPath, source, parser.ImportsOnly) + require.NoError(t, err) + actual := make([]string, len(parsed.Imports)) + for index, spec := range parsed.Imports { + actual[index], err = strconv.Unquote(spec.Path.Value) + require.NoError(t, err) + } + require.ElementsMatch(t, actual, planned, file.Path) + } +} + +// TestHTTPFilePlansIncludeGeneratedUses verifies that files reserve the +// packages named by copied validators, constructors, and query conversion. +func TestHTTPFilePlansIncludeGeneratedUses(t *testing.T) { + t.Run("required basic authentication", func(t *testing.T) { + root := expr.RunDSL(t, func() { + basic := dsl.BasicAuthSecurity("basic") + dsl.API("secure", func() { + dsl.Security(basic) + }) + dsl.Service("Account", func() { + dsl.Method("Read", func() { + dsl.Payload(func() { + dsl.Username("username") + dsl.Password("password") + dsl.Required("username", "password") + }) + dsl.HTTP(func() { + dsl.GET("/account") + }) + }) + }) + }) + plan := linkedHTTPPlanForRoot(t, root) + serverImports := plannedHTTPFileImports(t, plan.ServerFiles(), "/server/encode_decode.go") + clientCLIImports := plannedHTTPFileImports(t, plan.ClientCLIFiles(), "/client/cli.go") + require.Contains(t, serverImports, codegen.GoaImport("").Path) + require.Contains(t, clientCLIImports, "fmt") + }) + + t.Run("authorization bearer header", func(t *testing.T) { + root := expr.RunDSL(t, testdata.PayloadJWTAuthorizationHeaderDSL) + plan := linkedHTTPPlanForRoot(t, root) + clientImports := plannedHTTPFileImports(t, plan.ClientFiles(), "/client/encode_decode.go") + require.Contains(t, clientImports, "strings") + }) + + t.Run("path default ignored by client command", func(t *testing.T) { + root := expr.RunDSL(t, testdata.PayloadPathStringDefaultDSL) + plan := linkedHTTPPlanForRoot(t, root) + clientImports := plannedHTTPFileImports(t, plan.ClientCLIFiles(), "/client/cli.go") + require.Contains(t, clientImports, "fmt") + }) + + t.Run("nested string validation", func(t *testing.T) { + root := expr.RunDSL(t, testdata.ResultWithResultCollectionDSL) + plan := linkedHTTPPlanForRoot(t, root) + file := plan.ClientTypeFiles()[0] + imports := importPaths(file.SectionTemplates[0].Data.(map[string]any)["Imports"].([]*codegen.ImportSpec)) + require.Contains(t, imports, "unicode/utf8") + }) + + t.Run("numeric map query", func(t *testing.T) { + root := expr.RunDSL(t, testdata.PayloadMapQueryObjectDSL) + plan := linkedHTTPPlanForRoot(t, root) + client := plan.ClientFiles()[1] + server := plan.ServerFiles()[1] + clientImports := importPaths(client.SectionTemplates[0].Data.(map[string]any)["Imports"].([]*codegen.ImportSpec)) + serverImports := importPaths(server.SectionTemplates[0].Data.(map[string]any)["Imports"].([]*codegen.ImportSpec)) + require.Contains(t, clientImports, "strconv") + require.Contains(t, serverImports, "strconv") + }) + + t.Run("built-in error constructor", func(t *testing.T) { + root := expr.RunDSL(t, func() { + dsl.Service("Records", func() { + dsl.Method("Read", func() { + dsl.Error("not_found") + dsl.HTTP(func() { + dsl.GET("/records") + dsl.Response("not_found", dsl.StatusNotFound) + }) + }) + }) + }) + plan := linkedHTTPPlanForRoot(t, root) + file := plan.ServerTypeFiles()[0] + imports := importPaths(file.SectionTemplates[0].Data.(map[string]any)["Imports"].([]*codegen.ImportSpec)) + require.Contains(t, imports, codegen.GoaImport("").Path) + }) +} + +// TestHTTPCodecFilesPlanStringLengthValidationImports checks the complete +// decoder files that validate request and response headers directly. +func TestHTTPCodecFilesPlanStringLengthValidationImports(t *testing.T) { + root := httpCodecStringLengthRoot(t, true, true) + plan := linkedHTTPPlanForRoot(t, root) + clientCodec := plannedHTTPFile(t, plan.ClientFiles(), "/client/encode_decode.go") + serverCodec := plannedHTTPFile(t, plan.ServerFiles(), "/server/encode_decode.go") + require.Contains(t, plannedHTTPFileImports(t, plan.ClientFiles(), "/client/encode_decode.go"), "unicode/utf8") + require.Contains(t, plannedHTTPFileImports(t, plan.ServerFiles(), "/server/encode_decode.go"), "unicode/utf8") + testutil.AssertGo(t, "testdata/golden/validation_imports_client_codec.go.golden", renderedHTTPFile(t, clientCodec)) + testutil.AssertGo(t, "testdata/golden/validation_imports_server_codec.go.golden", renderedHTTPFile(t, serverCodec)) + + serviceFiles, err := service.Files(plan.servicePlan) + require.NoError(t, err) + files := slices.Clone(serviceFiles) + files = append(files, plan.ClientFiles()...) + files = append(files, plan.ServerFiles()...) + files = append(files, plan.ClientTypeFiles()...) + files = append(files, plan.ServerTypeFiles()...) + files = append(files, plan.PathFiles()...) + runGeneratedMixedSSECompile(t, files, "./gen/...") +} + +// TestHTTPCodecStringLengthImportsStayOnDecodingSide checks that a request +// rule does not reserve UTF-8 in the client and a response rule does not +// reserve it in the server. +func TestHTTPCodecStringLengthImportsStayOnDecodingSide(t *testing.T) { + tests := []struct { + name string + request bool + response bool + wantClient bool + wantServer bool + }{ + {name: "request header", request: true, wantServer: true}, + {name: "response header", response: true, wantClient: true}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + plan := linkedHTTPPlanForRoot(t, httpCodecStringLengthRoot(t, test.request, test.response)) + clientImports := plannedHTTPFileImports(t, plan.ClientFiles(), "/client/encode_decode.go") + serverImports := plannedHTTPFileImports(t, plan.ServerFiles(), "/server/encode_decode.go") + require.Equal(t, test.wantClient, slices.Contains(clientImports, "unicode/utf8")) + require.Equal(t, test.wantServer, slices.Contains(serverImports, "unicode/utf8")) + }) + } +} + +// httpCodecStringLengthRoot returns an HTTP service with selected request and +// response headers that validate their Unicode character count. +func httpCodecStringLengthRoot(t *testing.T, request, response bool) *expr.RootExpr { + t.Helper() + return expr.RunDSL(t, func() { + dsl.Service("Validation Imports", func() { + dsl.Method("Check", func() { + if request { + dsl.Payload(func() { + dsl.Field(1, "request", dsl.String, func() { + dsl.MinLength(2) + }) + dsl.Required("request") + }) + } + if response { + dsl.Result(func() { + dsl.Field(1, "response", dsl.String, func() { + dsl.MinLength(3) + }) + dsl.Required("response") + }) + } + dsl.HTTP(func() { + dsl.GET("/") + if request { + dsl.Header("request:X-Request") + } + if response { + dsl.Response(func() { + dsl.Header("response:X-Response") + }) + } + }) + }) + }) + }) +} + +// importPaths returns the package paths from one generated file header. +func importPaths(imports []*codegen.ImportSpec) []string { + paths := make([]string, len(imports)) + for index, spec := range imports { + paths[index] = spec.Path + } + return paths +} + +// plannedHTTPFileImports returns the packages recorded for the generated file +// whose path ends in suffix. +func plannedHTTPFileImports(t *testing.T, files []*codegen.File, suffix string) []string { + t.Helper() + file := plannedHTTPFile(t, files, suffix) + return importPaths(file.SectionTemplates[0].Data.(map[string]any)["Imports"].([]*codegen.ImportSpec)) +} + +// plannedHTTPFile returns the generated file whose slash-separated path ends +// in suffix. +func plannedHTTPFile(t *testing.T, files []*codegen.File, suffix string) *codegen.File { + t.Helper() + for _, file := range files { + if strings.HasSuffix(filepath.ToSlash(file.Path), suffix) { + return file + } + } + require.Fail(t, "generated HTTP file was not planned", suffix) + return nil +} + +// renderedHTTPFile writes one planned file and returns its complete source. +func renderedHTTPFile(t *testing.T, file *codegen.File) string { + t.Helper() + filePath, err := file.Render(t.TempDir()) + require.NoError(t, err) + source, err := os.ReadFile(filePath) + require.NoError(t, err) + return string(source) +} + +// TestJSONRPCSnapshotsExposeReleasedNames checks that copied JSON-RPC data +// gives existing plugins the final Go name stored in each declaration. +func TestJSONRPCSnapshotsExposeReleasedNames(t *testing.T) { + root := expr.RunDSL(t, func() { + dsl.Service("Calc", func() { + for _, name := range []string{"read-data", "read_data"} { + dsl.Method(name, func() { + dsl.Payload(func() { + dsl.Attribute("value", dsl.Int) + }) + dsl.Result(dsl.Int) + dsl.JSONRPC(func() {}) + }) + } + }) + }) + generation, err := codegen.NewGeneration("generated.local/gen", []eval.Root{root}) + require.NoError(t, err) + servicePlan, err := service.NewPlan(root, generation, expr.NewExampleGenerator(root.API.RandomizerFactory)) + require.NoError(t, err) + plans, err := NewJSONRPCPlans(generation, PlanInput{Root: root, Service: servicePlan}) + require.NoError(t, err) + require.NoError(t, generation.Freeze()) + require.NoError(t, servicePlan.Link()) + require.NoError(t, plans[0].Link()) + + snapshot, ok := plans[0].JSONRPCService("Calc") + require.True(t, ok) + assertReleasedName(t, snapshot.ServerStruct, snapshot.ServerStructDeclaration) + assertReleasedName(t, snapshot.ServerInit, snapshot.ServerInitDeclaration) + assertReleasedName(t, snapshot.MountServer, snapshot.MountServerDeclaration) + assertReleasedName(t, snapshot.ClientStruct, snapshot.ClientStructDeclaration) + require.Len(t, snapshot.Endpoints, 2) + for index := range snapshot.Endpoints { + endpoint := &snapshot.Endpoints[index] + assertReleasedName(t, endpoint.HandlerInit, endpoint.HandlerInitDeclaration) + assertReleasedName(t, endpoint.ClientStruct, endpoint.ClientStructDeclaration) + assertReleasedName(t, endpoint.RequestEncoder, endpoint.RequestEncoderDeclaration) + assertReleasedName(t, endpoint.RequestDecoder, endpoint.RequestDecoderDeclaration) + assertReleasedName(t, endpoint.ResponseDecoder, endpoint.ResponseDecoderDeclaration) + } + require.NotEqual(t, snapshot.Endpoints[0].HandlerInit, snapshot.Endpoints[1].HandlerInit) +} + +// TestViewedResultSnapshotsReturnIndependentBodies changes one returned body +// and confirms that a later copy still contains the planned value. +func TestViewedResultSnapshotsReturnIndependentBodies(t *testing.T) { + root := expr.RunDSL(t, func() { + result := dsl.ResultType("application/vnd.body-view", func() { + dsl.Attribute("id", dsl.String) + dsl.Required("id") + dsl.View("default", func() { dsl.Attribute("id") }) + }) + dsl.Service("Bodies", func() { + dsl.Method("Fetch", func() { + dsl.Result(result) + dsl.JSONRPC(func() {}) + }) + }) + }) + generation, err := codegen.NewGeneration("generated.local/gen", []eval.Root{root}) + require.NoError(t, err) + servicePlan, err := service.NewPlan(root, generation, expr.NewExampleGenerator(root.API.RandomizerFactory)) + require.NoError(t, err) + plans, err := NewJSONRPCPlans(generation, PlanInput{Root: root, Service: servicePlan}) + require.NoError(t, err) + require.NoError(t, generation.Freeze()) + require.NoError(t, servicePlan.Link()) + require.NoError(t, plans[0].Link()) + + viewed, ok := plans[0].ViewedResult("Bodies", "Fetch") + require.True(t, ok) + require.Len(t, viewed.Representations, 1) + require.NotNil(t, viewed.Representations[0].ServerBody) + require.NotNil(t, viewed.Representations[0].ClientBody) + originalRef := viewed.Representations[0].ServerBody.Ref + viewed.Representations[0].ServerBody.Ref = "Changed" + fresh, ok := plans[0].ViewedResult("Bodies", "Fetch") + require.True(t, ok) + require.Equal(t, originalRef, fresh.Representations[0].ServerBody.Ref) +} + +// TestViewedResultCopiesBodyFieldSelection checks that JSON-RPC receives the +// Go field selected by Body("value"). An empty field keeps the whole-result +// body constructor responsible for the server conversion. +func TestViewedResultCopiesBodyFieldSelection(t *testing.T) { + root := expr.RunDSL(t, func() { + result := dsl.ResultType("application/vnd.body-field", func() { + dsl.TypeName("BodyField") + dsl.Attribute("value", dsl.String) + dsl.Required("value") + dsl.View("default", func() { dsl.Attribute("value") }) + dsl.View("summary", func() { dsl.Attribute("value") }) + }) + dsl.Service("Values", func() { + dsl.Method("Field", func() { + dsl.Result(result) + dsl.JSONRPC(func() { + dsl.Response(func() { dsl.Body("value") }) + }) + }) + dsl.Method("Whole", func() { + dsl.Result(result) + dsl.JSONRPC(func() { dsl.Response(dsl.StatusOK) }) + }) + }) + }) + generation, err := codegen.NewGeneration("generated.local/gen", []eval.Root{root}) + require.NoError(t, err) + servicePlan, err := service.NewPlan(root, generation, expr.NewExampleGenerator(root.API.RandomizerFactory)) + require.NoError(t, err) + plans, err := NewJSONRPCPlans(generation, PlanInput{Root: root, Service: servicePlan}) + require.NoError(t, err) + require.NoError(t, generation.Freeze()) + require.NoError(t, servicePlan.Link()) + require.NoError(t, plans[0].Link()) + + field, ok := plans[0].ViewedResult("Values", "Field") + require.True(t, ok) + require.NotEmpty(t, field.Representations) + for _, representation := range field.Representations { + require.Equal(t, "Value", representation.ResultAttr) + require.NotNil(t, representation.ServerBody) + require.Nil(t, representation.ServerBody.Init) + } + + whole, ok := plans[0].ViewedResult("Values", "Whole") + require.True(t, ok) + require.NotEmpty(t, whole.Representations) + for _, representation := range whole.Representations { + require.Empty(t, representation.ResultAttr) + require.NotNil(t, representation.ServerBody) + require.NotNil(t, representation.ServerBody.Init) + } +} + +// TestEndpointConstructorsUsePackageDeclarations checks that request payload, +// response result, and error result functions all use the names chosen before +// files are written. +func TestEndpointConstructorsUsePackageDeclarations(t *testing.T) { + root := expr.RunDSL(t, func() { + dsl.Service("Calc", func() { + dsl.Method("Add", func() { + dsl.Payload(func() { dsl.Attribute("value", dsl.Int) }) + dsl.Result(func() { dsl.Attribute("total", dsl.Int) }) + dsl.Error("BadInput", func() { dsl.Attribute("message", dsl.String) }) + dsl.HTTP(func() { + dsl.POST("/add") + dsl.Response("BadInput", dsl.StatusBadRequest) + }) + }) + }) + }) + generation, err := codegen.NewGeneration("generated.local/gen", []eval.Root{root}) + require.NoError(t, err) + servicePlan, err := service.NewPlan(root, generation, expr.NewExampleGenerator(root.API.RandomizerFactory)) + require.NoError(t, err) + plans, err := NewPlans(generation, PlanInput{Root: root, Service: servicePlan}) + require.NoError(t, err) + require.NoError(t, generation.Freeze()) + require.NoError(t, servicePlan.Link()) + require.NoError(t, plans[0].Link()) + + endpoint := plans[0].services.Get("Calc").Endpoints[0] + require.NotNil(t, endpoint.Payload.Request.PayloadInit.Declaration) + require.Equal(t, endpoint.Payload.Request.PayloadInit.Declaration.Name(), endpoint.Payload.Request.PayloadInit.Name) + require.NotNil(t, endpoint.Result.Responses[0].ResultInit.Declaration) + require.Equal(t, endpoint.Result.Responses[0].ResultInit.Declaration.Name(), endpoint.Result.Responses[0].ResultInit.Name) + require.NotNil(t, endpoint.Errors[0].Errors[0].Response.ResultInit.Declaration) + require.Equal(t, endpoint.Errors[0].Errors[0].Response.ResultInit.Declaration.Name(), endpoint.Errors[0].Errors[0].Response.ResultInit.Name) +} + +// TestHTTPTypeAndConstructorNamesShareOnePackage checks a body type and a +// payload constructor that request the same spelling. Goa must give them +// different names, and generated definitions and calls must use those names. +func TestHTTPTypeAndConstructorNamesShareOnePackage(t *testing.T) { + root := expr.RunDSL(t, func() { + payload := dsl.Type("NewAddPayload", func() { + dsl.Attribute("value", dsl.Int) + }) + dsl.Service("Calc", func() { + dsl.Method("Add", func() { + dsl.Payload(payload) + dsl.HTTP(func() { dsl.POST("/add") }) + }) + }) + }) + generation, err := codegen.NewGeneration("generated.local/gen", []eval.Root{root}) + require.NoError(t, err) + servicePlan, err := service.NewPlan(root, generation, expr.NewExampleGenerator(root.API.RandomizerFactory)) + require.NoError(t, err) + plans, err := NewPlans(generation, PlanInput{Root: root, Service: servicePlan}) + require.NoError(t, err) + require.NoError(t, generation.Freeze()) + require.NoError(t, servicePlan.Link()) + require.NoError(t, plans[0].Link()) + + request := plans[0].services.Get("Calc").Endpoints[0].Payload.Request + require.NotEqual(t, request.ServerBody.Name, request.PayloadInit.Name) + definitions := renderedFiles(t, plans[0].ServerTypeFiles()) + calls := renderedFiles(t, plans[0].ServerFiles()) + require.Contains(t, definitions, "type "+request.ServerBody.Name+" ") + require.Contains(t, definitions, "func "+request.PayloadInit.Name+"(") + require.Contains(t, calls, request.PayloadInit.Name+"(") +} + +// TestNewPlansAssignsNamesAcrossRoots checks two designs whose service names +// resolve to the same generated directory. NewPlans must submit both sets of +// function names together so definitions and calls remain distinct. +func TestNewPlansAssignsNamesAcrossRoots(t *testing.T) { + makeRoot := func(apiName string) *expr.RootExpr { + return expr.RunDSL(t, func() { + dsl.API(apiName, func() {}) + dsl.Service("Shared", func() { + dsl.Method("Add", func() { + dsl.Payload(func() { dsl.Attribute("value", dsl.Int) }) + dsl.HTTP(func() { dsl.POST("/add") }) + }) + }) + }) + } + first := makeRoot("First") + second := makeRoot("Second") + generation, err := codegen.NewGeneration("generated.local/gen", []eval.Root{first, second}) + require.NoError(t, err) + servicePlans, err := service.NewPlans(generation, + service.PlanInput{Root: first, Examples: expr.NewExampleGenerator(first.API.RandomizerFactory)}, + service.PlanInput{Root: second, Examples: expr.NewExampleGenerator(second.API.RandomizerFactory)}, + ) + require.NoError(t, err) + plans, err := NewPlans(generation, + PlanInput{Root: first, Service: servicePlans[0]}, + PlanInput{Root: second, Service: servicePlans[1]}, + ) + require.NoError(t, err) + require.NoError(t, generation.Freeze()) + for index := range plans { + require.NoError(t, servicePlans[index].Link()) + require.NoError(t, plans[index].Link()) + } + + firstService := plans[0].services.Get("Shared") + secondService := plans[1].services.Get("Shared") + firstInit := firstService.Endpoints[0].Payload.Request.PayloadInit + secondInit := secondService.Endpoints[0].Payload.Request.PayloadInit + require.NotEqual(t, firstInit.Name, secondInit.Name) + require.Equal(t, plans[0].ServerTypeFiles()[0].Path, plans[1].ServerTypeFiles()[0].Path) + for index, init := range []*InitData{firstInit, secondInit} { + definitions := renderedFiles(t, plans[index].ServerTypeFiles()) + calls := renderedFiles(t, plans[index].ServerFiles()) + require.Contains(t, definitions, "func "+init.Name+"(") + require.Contains(t, calls, init.Name+"(") + } +} + +// TestHTTPHelperDefinitionsUseAssignedNames checks two designs that write the +// same server package. File helpers and mixed-result stream helpers must define +// the same names that their call sites use. +func TestHTTPHelperDefinitionsUseAssignedNames(t *testing.T) { + makeRoot := func(serviceName, typePrefix string) *expr.RootExpr { + return expr.RunDSL(t, func() { + payload := dsl.Type(typePrefix+"Payload", func() { + dsl.Attribute("value", dsl.String) + }) + result := dsl.Type(typePrefix+"Result", func() { + dsl.Attribute("value", dsl.String) + }) + event := dsl.Type(typePrefix+"Event", func() { + dsl.Attribute("value", dsl.String) + }) + dsl.Service(serviceName, func() { + dsl.Method("Create", func() { + dsl.Payload(payload) + dsl.Result(result) + dsl.StreamingResult(event) + dsl.HTTP(func() { + dsl.POST("/create") + dsl.ServerSentEvents() + }) + }) + dsl.Files("/asset.json", "/embedded/file.json") + }) + }) + } + first := makeRoot("Foo Bar", "First") + second := makeRoot("Foo-Bar", "Second") + generation, err := codegen.NewGeneration("generated.local/gen", []eval.Root{first, second}) + require.NoError(t, err) + servicePlans, err := service.NewPlans(generation, + service.PlanInput{Root: first, Examples: expr.NewExampleGenerator(first.API.RandomizerFactory)}, + service.PlanInput{Root: second, Examples: expr.NewExampleGenerator(second.API.RandomizerFactory)}, + ) + require.NoError(t, err) + plans, err := NewPlans(generation, + PlanInput{Root: first, Service: servicePlans[0]}, + PlanInput{Root: second, Service: servicePlans[1]}, + ) + require.NoError(t, err) + require.NoError(t, generation.Freeze()) + for index := range plans { + require.NoError(t, servicePlans[index].Link()) + require.NoError(t, plans[index].Link()) + } + + for index, name := range []string{"Foo Bar", "Foo-Bar"} { + data := plans[index].services.Get(name) + endpoint := data.Endpoints[0] + code := renderedFiles(t, plans[index].ServerFiles()) + require.Contains(t, code, "type "+endpoint.DiscardStreamDeclaration.Name()+" struct{}") + require.Contains(t, code, "type "+data.AppendFSDeclaration.Name()+" struct {") + require.Contains(t, code, "func "+data.AppendPrefixDeclaration.Name()+"(") + require.Contains(t, code, "return "+data.AppendFSDeclaration.Name()+"{") + } +} + +// TestNewPlansRejectsDifferentServiceRoot checks the pairing before HTTP +// planning changes any generated package. The valid retry proves the rejected +// call did not reserve imports or names for the wrong design. +func TestNewPlansRejectsDifferentServiceRoot(t *testing.T) { + first := expr.RunDSL(t, func() { + dsl.Service("First", func() { + dsl.Method("Read", func() { dsl.HTTP(func() { dsl.GET("/first") }) }) + }) + }) + second := expr.RunDSL(t, func() { + dsl.Service("Second", func() { + dsl.Method("Read", func() { dsl.HTTP(func() { dsl.GET("/second") }) }) + }) + }) + generation, err := codegen.NewGeneration("generated.local/gen", []eval.Root{first, second}) + require.NoError(t, err) + plans, err := service.NewPlans(generation, + service.PlanInput{Root: first, Examples: expr.NewExampleGenerator(first.API.RandomizerFactory)}, + service.PlanInput{Root: second, Examples: expr.NewExampleGenerator(second.API.RandomizerFactory)}, + ) + require.NoError(t, err) + _, err = NewPlans(generation, + PlanInput{Root: first, Service: plans[1]}, + PlanInput{Root: second, Service: plans[0]}, + ) + require.EqualError(t, err, "HTTP root does not match its service plan root") + + _, err = NewPlans(generation, + PlanInput{Root: first, Service: plans[0]}, + PlanInput{Root: second, Service: plans[1]}, + ) + require.NoError(t, err) +} + +// TestPlanRequiresLinkedServicePlan proves HTTP generation cannot read service +// names before Goa has assigned every package name. +func TestPlanRequiresLinkedServicePlan(t *testing.T) { + root := expr.RunDSL(t, func() { + dsl.Service("Calc", func() { + dsl.Method("Add", func() { dsl.HTTP(func() { dsl.POST("/add") }) }) + }) + }) + generation, err := codegen.NewGeneration("generated.local/gen", []eval.Root{root}) + require.NoError(t, err) + servicePlan, err := service.NewPlan(root, generation, expr.NewExampleGenerator(root.API.RandomizerFactory)) + require.NoError(t, err) + plans, err := NewPlans(generation, PlanInput{Root: root, Service: servicePlan}) + require.NoError(t, err) + require.NoError(t, generation.Freeze()) + require.PanicsWithValue(t, "service render model requested before plan linking", func() { + _ = plans[0].Link() + }) +} + +// assertIndependentCodecFile changes every file field that the JSON-RPC +// generator edits, then checks that both the saved file and a new copy keep +// their original values. +func assertIndependentCodecFile(t *testing.T, saved *codegen.File, copyFile func() *codegen.File) { + t.Helper() + require.NotNil(t, saved) + require.NotEmpty(t, saved.SectionTemplates) + + originalPath := saved.Path + originalName := saved.SectionTemplates[0].Name + originalSource := saved.SectionTemplates[0].Source + originalImports := append([]*codegen.ImportSpec(nil), saved.SectionTemplates[0].Data.(map[string]any)["Imports"].([]*codegen.ImportSpec)...) + + changed := copyFile() + changed.Path = "changed.go" + changed.SectionTemplates[0].Name = "changed" + changed.SectionTemplates[0].Source = "changed" + changed.SectionTemplates[0].FuncMap = map[string]any{"changed": true} + codegen.AddImport(changed.SectionTemplates[0], &codegen.ImportSpec{Path: "changed.example/package"}) + var changedEndpoint bool + for _, section := range changed.SectionTemplates { + if endpoint := codecEndpointData(section.Data); endpoint != nil { + endpoint.Method.Name = "Changed" + changedEndpoint = true + break + } + } + require.True(t, changedEndpoint) + + fresh := copyFile() + require.Equal(t, originalPath, saved.Path) + require.Equal(t, originalPath, fresh.Path) + require.Equal(t, originalName, saved.SectionTemplates[0].Name) + require.Equal(t, originalName, fresh.SectionTemplates[0].Name) + require.Equal(t, originalSource, saved.SectionTemplates[0].Source) + require.Equal(t, originalSource, fresh.SectionTemplates[0].Source) + require.Equal(t, originalImports, saved.SectionTemplates[0].Data.(map[string]any)["Imports"]) + require.Equal(t, originalImports, fresh.SectionTemplates[0].Data.(map[string]any)["Imports"]) + for _, section := range fresh.SectionTemplates { + if endpoint := codecEndpointData(section.Data); endpoint != nil { + require.Equal(t, "Add", endpoint.Method.Name) + return + } + } + require.Fail(t, "copied codec file has no endpoint section") +} + +// codecEndpointData returns the copied endpoint value used by one encoder or +// decoder section. +func codecEndpointData(data any) *JSONRPCEndpointSnapshot { + switch actual := data.(type) { + case *JSONRPCEndpointSnapshot: + return actual + case *jsonRPCRequestCodecData: + return actual.JSONRPCEndpointSnapshot + default: + return nil + } +} diff --git a/http/codegen/plan_test_helpers_test.go b/http/codegen/plan_test_helpers_test.go new file mode 100644 index 0000000000..6ba6bc6422 --- /dev/null +++ b/http/codegen/plan_test_helpers_test.go @@ -0,0 +1,51 @@ +// This file prepares HTTP plans for tests through the same name assignment and +// file-building steps used by the generator. +package codegen + +import ( + "testing" + + "github.com/stretchr/testify/require" + + "goa.design/goa/v3/codegen" + "goa.design/goa/v3/codegen/example" + "goa.design/goa/v3/codegen/service" + "goa.design/goa/v3/eval" + "goa.design/goa/v3/expr" +) + +// linkedHTTPPlanForRoot builds the HTTP files for root after every generated +// package has received its final names. +func linkedHTTPPlanForRoot(t *testing.T, root *expr.RootExpr) *Plan { + t.Helper() + generation, err := codegen.NewGeneration("generated.local/gen", []eval.Root{root}) + require.NoError(t, err) + servicePlan, err := service.NewPlan(root, generation, expr.NewExampleGenerator(root.API.RandomizerFactory)) + require.NoError(t, err) + plans, err := NewPlans(generation, PlanInput{Root: root, Service: servicePlan}) + require.NoError(t, err) + require.NoError(t, generation.Freeze()) + require.NoError(t, servicePlan.Link()) + require.NoError(t, plans[0].Link()) + return plans[0] +} + +// linkedHTTPExamplePlanForRoot builds an HTTP plan whose copied server data +// belongs to the same service plan. +func linkedHTTPExamplePlanForRoot(t *testing.T, root *expr.RootExpr) *ExamplePlan { + t.Helper() + generation, err := codegen.NewGeneration("generated.local/gen", []eval.Root{root}) + require.NoError(t, err) + servicePlan, err := service.NewPlan(root, generation, expr.NewExampleGenerator(root.API.RandomizerFactory)) + require.NoError(t, err) + plans, err := NewPlans(generation, PlanInput{Root: root, Service: servicePlan}) + require.NoError(t, err) + examplePlan, err := example.NewPlan(generation, servicePlan) + require.NoError(t, err) + examples, err := NewExamplePlan(plans[0], examplePlan) + require.NoError(t, err) + require.NoError(t, generation.Freeze()) + require.NoError(t, servicePlan.Link()) + require.NoError(t, plans[0].Link()) + return examples +} diff --git a/http/codegen/planned_name_collision_test.go b/http/codegen/planned_name_collision_test.go new file mode 100644 index 0000000000..8d824360a1 --- /dev/null +++ b/http/codegen/planned_name_collision_test.go @@ -0,0 +1,270 @@ +// This file proves generated HTTP definitions and their callers use the same +// package names after another generator claims the preferred spelling. +package codegen + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/require" + + "goa.design/goa/v3/codegen" + "goa.design/goa/v3/codegen/codegentest" + "goa.design/goa/v3/codegen/service" + "goa.design/goa/v3/codegen/testutil" + "goa.design/goa/v3/dsl" + "goa.design/goa/v3/eval" + "goa.design/goa/v3/expr" +) + +func TestHTTPPlannedNamesSurvivePackageCollisions(t *testing.T) { + root := expr.RunDSL(t, func() { + child := dsl.Type("ChildPayload", func() { + dsl.Attribute("value", dsl.String, func() { + dsl.Pattern("value") + }) + dsl.Required("value") + }) + dsl.Service("Names", func() { + dsl.Method("Complete", func() { + dsl.Payload(func() { + dsl.Attribute("child", child) + dsl.Required("child") + }) + dsl.HTTP(func() { + dsl.POST("/complete") + }) + }) + dsl.Method("Socket", func() { + dsl.StreamingPayload(dsl.String) + dsl.StreamingResult(dsl.String) + dsl.HTTP(func() { + dsl.GET("/socket") + }) + }) + }) + }) + generation, err := codegen.NewGeneration("generated.local/gen", []eval.Root{root}) + require.NoError(t, err) + servicePlan, err := service.NewPlan(root, generation, expr.NewExampleGenerator(root.API.RandomizerFactory)) + require.NoError(t, err) + clientPackage, err := generation.ClaimPackage("generated.local/gen/http/names/client") + require.NoError(t, err) + serverPackage, err := generation.ClaimPackage("generated.local/gen/http/names/server") + require.NoError(t, err) + for _, declaration := range []*codegen.NameDeclaration{ + codegen.NewExactName(codegen.NameFunction, "BuildCompleteRequest"), + } { + require.NoError(t, clientPackage.DeclareName(declaration)) + } + for _, declaration := range []*codegen.NameDeclaration{ + codegen.NewExactName(codegen.NameType, "ChildPayloadRequestBody"), + codegen.NewExactName(codegen.NameFunction, "ValidateChildPayloadRequestBody"), + codegen.NewExactName(codegen.NameFunction, "validateChildPayloadRequestBody"), + codegen.NewExactName(codegen.NameType, "SocketServerStream"), + } { + require.NoError(t, serverPackage.DeclareName(declaration)) + } + plans, err := NewPlans(generation, PlanInput{Root: root, Service: servicePlan}) + require.NoError(t, err) + require.NoError(t, generation.Freeze()) + require.NoError(t, servicePlan.Link()) + require.NoError(t, plans[0].Link()) + + serviceData := plans[0].services.Get("Names") + complete := serviceData.Endpoint("Complete") + require.Equal(t, "BuildCompleteRequest2", complete.RequestInit.Declaration.Name()) + require.Equal(t, complete.RequestInit.Declaration.Name(), complete.RequestInit.Name) + childData := releasedTypeData(t, serviceData, func(data *TypeData) bool { + return data.Declaration != nil && strings.HasPrefix(data.Declaration.Name(), "ChildPayload") + }) + require.Equal(t, "ChildPayloadRequestBody2", childData.Declaration.Name()) + require.Equal(t, "ValidateChildPayloadRequestBody2", childData.ValidatorDeclaration.Name()) + require.Equal(t, "validateChildPayloadRequestBody2", childData.NestedValidatorDeclaration.Name()) + require.Equal(t, childData.Declaration.Name(), childData.VarName) + require.Equal(t, childData.ValidatorDeclaration.Name(), childData.ValidatorName) + require.Equal(t, childData.NestedValidatorDeclaration.Name(), childData.NestedValidatorName) + socket := serviceData.Endpoint("Socket").ServerWebSocket + require.Equal(t, "SocketServerStream2", socket.VarDeclaration.Name()) + require.Equal(t, socket.VarDeclaration.Name(), socket.VarName) + + var source strings.Builder + for _, selection := range []struct { + files []*codegen.File + file string + section string + match func(any) bool + }{ + {plans[0].ClientFiles(), "encode_decode.go", "request-builder", func(data any) bool { + endpoint, ok := data.(*EndpointData) + return ok && endpoint.Method.Name == "Complete" + }}, + {plans[0].ClientFiles(), "client.go", "client-endpoint-init", func(data any) bool { + endpoint, ok := data.(*EndpointData) + return ok && endpoint.Method.Name == "Complete" + }}, + {plans[0].ServerTypeFiles(), "types.go", "server-body-attributes", func(data any) bool { + body, ok := data.(*TypeData) + return ok && body.Declaration == childData.Declaration + }}, + {plans[0].ServerTypeFiles(), "types.go", "server-validate", func(data any) bool { + body, ok := data.(*TypeData) + return ok && body.Declaration == complete.Payload.Request.ServerBody.Declaration + }}, + {plans[0].ServerTypeFiles(), "types.go", "server-validate", func(data any) bool { + body, ok := data.(*TypeData) + return ok && body.Declaration == childData.Declaration + }}, + {plans[0].ServerFiles(), "encode_decode.go", "request-decoder", func(data any) bool { + endpoint, ok := data.(*EndpointData) + return ok && endpoint.Method.Name == "Complete" + }}, + {plans[0].ServerFiles(), "websocket.go", "server-websocket-struct-type", func(data any) bool { + stream, ok := data.(*WebSocketData) + return ok && stream.VarDeclaration == socket.VarDeclaration + }}, + {plans[0].ServerFiles(), "server.go", "server-handler-init", func(data any) bool { + endpoint, ok := data.(*EndpointData) + return ok && endpoint.Method.Name == "Socket" + }}, + } { + sections := codegentest.Sections(selection.files, selection.file, selection.section) + matched := false + for _, section := range sections { + if selection.match(section.Data) { + source.WriteString(codegen.SectionCode(t, section)) + source.WriteString("\n") + matched = true + break + } + } + require.True(t, matched, "missing %s section in %s", selection.section, selection.file) + } + testutil.AssertGo(t, "testdata/golden/planned_name_collisions.go.golden", strings.TrimSpace(source.String())+"\n") +} + +// TestHTTPUnionPlannedNamesRejectPackageCollisions checks that an authored +// union name never receives a moving numeric suffix. +func TestHTTPUnionPlannedNamesRejectPackageCollisions(t *testing.T) { + root := expr.RunDSL(t, func() { + dsl.Service("Names", func() { + dsl.Method("Choose", func() { + dsl.Payload(func() { + dsl.OneOf("choice", func() { + dsl.Attribute("text", dsl.String) + dsl.Attribute("count", dsl.Int) + }) + dsl.Required("choice") + }) + dsl.HTTP(func() { + dsl.POST("/choose") + }) + }) + }) + }) + generation, err := codegen.NewGeneration("generated.local/gen", []eval.Root{root}) + require.NoError(t, err) + servicePlan, err := service.NewPlan(root, generation, expr.NewExampleGenerator(root.API.RandomizerFactory)) + require.NoError(t, err) + serverPackage, err := generation.ClaimPackage("generated.local/gen/http/names/server") + require.NoError(t, err) + require.NoError(t, serverPackage.DeclareName(codegen.NewExactName(codegen.NameType, "ChoiceRequestBody"))) + _, err = NewPlans(generation, PlanInput{Root: root, Service: servicePlan}) + require.ErrorContains(t, err, `declare HTTP OneOf "choice" for RequestBody`) + require.ErrorContains(t, err, `cannot declare exact type "ChoiceRequestBody"`) + require.ErrorContains(t, err, "set TypeName on the OneOf to a unique name") +} + +// TestJSONRPCValidatorPlannedNamesSurvivePackageCollisions checks that the +// JSON-RPC body validator definition and decoder call use the same planned +// declaration after the preferred names are already taken. +func TestJSONRPCValidatorPlannedNamesSurvivePackageCollisions(t *testing.T) { + root := expr.RunDSL(t, func() { + payload := dsl.Type("ChoosePayload", func() { + dsl.Attribute("value", dsl.String, func() { + dsl.MinLength(2) + }) + dsl.Required("value") + }) + dsl.Service("Names", func() { + dsl.Method("Choose", func() { + dsl.Payload(payload) + dsl.JSONRPC(func() { + }) + }) + }) + }) + generation, err := codegen.NewGeneration("generated.local/gen", []eval.Root{root}) + require.NoError(t, err) + servicePlan, err := service.NewPlan(root, generation, expr.NewExampleGenerator(root.API.RandomizerFactory)) + require.NoError(t, err) + serverPackage, err := generation.ClaimPackage("generated.local/gen/jsonrpc/names/server") + require.NoError(t, err) + for _, declaration := range []*codegen.NameDeclaration{ + codegen.NewExactName(codegen.NameType, "ChooseRequestBody"), + codegen.NewExactName(codegen.NameFunction, "ValidateChooseRequestBody"), + codegen.NewExactName(codegen.NameFunction, "DecodeChooseRequest"), + } { + require.NoError(t, serverPackage.DeclareName(declaration)) + } + plans, err := NewJSONRPCPlans(generation, PlanInput{Root: root, Service: servicePlan}) + require.NoError(t, err) + require.NoError(t, generation.Freeze()) + require.NoError(t, servicePlan.Link()) + require.NoError(t, plans[0].Link()) + + serviceData := plans[0].services.Get("Names") + body := serviceData.Endpoint("Choose").Payload.Request.ServerBody + require.NotEqual(t, "ChooseRequestBody", body.Declaration.Name()) + require.NotEqual(t, "ValidateChooseRequestBody", body.ValidatorDeclaration.Name()) + snapshot, ok := plans[0].JSONRPCService("Names") + require.True(t, ok) + require.NotEqual(t, "DecodeChooseRequest", snapshot.Endpoints[0].RequestDecoderDeclaration.Name()) + + var source strings.Builder + for _, selection := range []struct { + files []*codegen.File + file string + section string + match func(any) bool + }{ + {plans[0].ServerTypeFiles(), "types.go", "request-body-type-decl", func(data any) bool { + candidate, ok := data.(*TypeData) + return ok && candidate.Declaration == body.Declaration + }}, + {plans[0].ServerTypeFiles(), "types.go", "server-validate", func(data any) bool { + candidate, ok := data.(*TypeData) + return ok && candidate.Declaration == body.Declaration + }}, + {[]*codegen.File{snapshot.ServerCodecFile()}, "encode_decode.go", "request-decoder", func(data any) bool { + endpoint := plannedJSONRPCEndpoint(data) + return endpoint != nil && endpoint.Method.Name == "Choose" + }}, + } { + sections := codegentest.Sections(selection.files, selection.file, selection.section) + matched := false + for _, section := range sections { + if selection.match(section.Data) { + source.WriteString(codegen.SectionCode(t, section)) + source.WriteString("\n") + matched = true + break + } + } + require.True(t, matched, "missing %s section in %s", selection.section, selection.file) + } + testutil.AssertGo(t, "testdata/golden/planned_jsonrpc_validator_collisions.go.golden", strings.TrimSpace(source.String())+"\n") +} + +// plannedJSONRPCEndpoint returns the copied endpoint stored in a JSON-RPC +// request codec section. +func plannedJSONRPCEndpoint(data any) *JSONRPCEndpointSnapshot { + switch actual := data.(type) { + case *jsonRPCRequestCodecData: + return actual.JSONRPCEndpointSnapshot + case *JSONRPCEndpointSnapshot: + return actual + default: + return nil + } +} diff --git a/http/codegen/planned_service_name_uses_test.go b/http/codegen/planned_service_name_uses_test.go new file mode 100644 index 0000000000..444af5300e --- /dev/null +++ b/http/codegen/planned_service_name_uses_test.go @@ -0,0 +1,211 @@ +// This file verifies that service declarations and transport callers keep the +// same Go names when authored types claim the usual generated spellings. +package codegen + +import ( + "bytes" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/require" + + "goa.design/goa/v3/codegen" + "goa.design/goa/v3/codegen/codegentest" + "goa.design/goa/v3/codegen/example" + "goa.design/goa/v3/codegen/service" + "goa.design/goa/v3/codegen/testutil" + "goa.design/goa/v3/dsl" + "goa.design/goa/v3/eval" + "goa.design/goa/v3/expr" + gencodegengrpc "goa.design/goa/v3/grpc/codegen" +) + +type ( + // plannedNameSection identifies one complete generated section or one file + // body included in the cross-transport golden output. + plannedNameSection struct { + label string + files []*codegen.File + file string + name string + whole bool + } +) + +// TestPlannedServiceNamesUsedAcrossTransports renders each definition and use +// from the same generation so a changed or rebuilt name breaks one fixture. +func TestPlannedServiceNamesUsedAcrossTransports(t *testing.T) { + root := expr.RunDSL(t, plannedServiceNameUsesDSL) + generation, err := codegen.NewGeneration("generated.local/gen", []eval.Root{root}) + require.NoError(t, err) + servicePlan, err := service.NewPlan(root, generation, expr.NewExampleGenerator(root.API.RandomizerFactory)) + require.NoError(t, err) + httpPlans, err := NewPlans(generation, PlanInput{Root: root, Service: servicePlan}) + require.NoError(t, err) + grpcPlans, err := gencodegengrpc.NewPlans(generation, gencodegengrpc.PlanInput{ + Root: root, + Service: servicePlan, + }) + require.NoError(t, err) + examplePlan, err := example.NewPlan(generation, servicePlan) + require.NoError(t, err) + httpExamples, err := NewExamplePlan(httpPlans[0], examplePlan) + require.NoError(t, err) + grpcExamples, err := gencodegengrpc.NewExamplePlan(grpcPlans[0], examplePlan) + require.NoError(t, err) + + require.NoError(t, generation.Freeze()) + require.NoError(t, servicePlan.Link()) + require.NoError(t, httpPlans[0].Link()) + require.NoError(t, grpcPlans[0].Link()) + + serviceData := servicePlan.Services().Get("Collisions") + require.NotNil(t, serviceData) + method := serviceData.Methods[0] + require.Equal(t, "Endpoints2", serviceData.EndpointsDeclaration.Name()) + require.Equal(t, "MethodNames2", serviceData.MethodNamesDeclaration.Name()) + require.Equal(t, "ClientInterceptors2", serviceData.ClientInterceptorsDeclaration.Name()) + require.Equal(t, "WrapReadClientEndpoint2", method.ClientEndpointWrapperDeclaration.Name()) + + serviceFiles, err := service.Files(servicePlan) + require.NoError(t, err) + sections := []plannedNameSection{ + { + label: "service method names definition", + files: serviceFiles, + file: "service.go", + name: "service", + }, + { + label: "service endpoints definition", + files: serviceFiles, + file: "endpoints.go", + name: "endpoints-struct", + }, + { + label: "client interceptors definition", + files: serviceFiles, + file: "client_interceptors.go", + name: "client-interceptors-type", + }, + { + label: "client endpoint wrapper definition", + files: serviceFiles, + file: "client_interceptors.go", + name: "client-wrapper", + }, + { + label: "HTTP server endpoints use", + files: httpPlans[0].ServerFiles(), + file: "server.go", + name: "server-init", + }, + { + label: "HTTP server method names use", + files: httpPlans[0].ServerFiles(), + file: "server.go", + name: "server-method-names", + }, + { + label: "HTTP command parser", + files: httpPlans[0].ClientCLIFiles(), + file: "cli.go", + name: "parse-endpoint", + }, + { + label: "HTTP example client interceptor use", + files: httpExamples.CLIFiles(), + file: "http.go", + whole: true, + }, + { + label: "gRPC server endpoints use", + files: grpcPlans[0].ServerFiles(), + file: "server.go", + name: "server-init", + }, + { + label: "gRPC example server endpoints use", + files: grpcExamples.ServerFiles(), + file: "grpc.go", + whole: true, + }, + { + label: "gRPC command parser", + files: grpcPlans[0].ClientCLIFiles(), + file: "cli.go", + name: "parse-endpoint-grpc", + }, + } + + var source strings.Builder + for index, section := range sections { + if index > 0 { + source.WriteString("\n") + } + source.WriteString("===== ") + source.WriteString(section.label) + source.WriteString(" =====\n") + source.WriteString(strings.TrimSuffix(plannedNameSectionCode(t, section), "\n")) + source.WriteString("\n") + } + testutil.AssertString(t, "testdata/golden/planned_service_name_uses.go.golden", source.String()) +} + +// plannedNameSectionCode renders either one complete section or the complete +// body of a file whose opening and closing statements span several sections. +func plannedNameSectionCode(t *testing.T, section plannedNameSection) string { + t.Helper() + if !section.whole { + matches := codegentest.Sections(section.files, section.file, section.name) + require.Len(t, matches, 1, section.label) + return codegen.SectionCode(t, matches[0]) + } + for _, file := range section.files { + if filepath.Base(file.Path) == section.file { + var source bytes.Buffer + for _, part := range file.SectionTemplates[1:] { + require.NoError(t, part.Write(&source)) + } + return codegen.FormatTestCode(t, "package foo\n"+source.String()) + } + } + require.Fail(t, "missing generated file", section.label) + return "" +} + +// plannedServiceNameUsesDSL makes authored types claim four names normally +// chosen for the generated service and interceptor code. +func plannedServiceNameUsesDSL() { + endpointType := dsl.Type("Endpoints", dsl.String) + methodNamesType := dsl.Type("MethodNames", dsl.String) + interceptorsType := dsl.Type("ClientInterceptors", dsl.String) + wrapperType := dsl.Type("WrapReadClientEndpoint", dsl.String) + trace := dsl.Interceptor("Trace") + + dsl.API("Name Test", func() { + dsl.Server("test", func() { + dsl.Host("development", func() { + dsl.URI("http://localhost:80") + }) + }) + }) + dsl.Service("Collisions", func() { + dsl.ClientInterceptor(trace) + dsl.Method("Read", func() { + dsl.Payload(func() { + dsl.Field(1, "endpoint", endpointType) + dsl.Field(2, "method_names", methodNamesType) + dsl.Field(3, "interceptors", interceptorsType) + dsl.Field(4, "wrapper", wrapperType) + }) + dsl.Result(dsl.String) + dsl.HTTP(func() { + dsl.POST("/read") + }) + dsl.GRPC(func() { + }) + }) + }) +} diff --git a/http/codegen/plugin_api_compatibility_test.go b/http/codegen/plugin_api_compatibility_test.go new file mode 100644 index 0000000000..42a6762fb7 --- /dev/null +++ b/http/codegen/plugin_api_compatibility_test.go @@ -0,0 +1,183 @@ +// This file checks the public HTTP plugin fields kept for existing plugins. +package codegen + +import ( + "bytes" + "testing" + "text/template" + + "github.com/stretchr/testify/require" + + "goa.design/goa/v3/codegen" + "goa.design/goa/v3/dsl" + "goa.design/goa/v3/expr" + "goa.design/goa/v3/http/codegen/testdata" +) + +var ( + _ func(string, *ServicesData) []*codegen.File = ClientFiles + _ func(string, *ServicesData) []*codegen.File = ClientCLIFiles + _ func(string, *ServicesData) []*codegen.File = ServerFiles + _ func(string, *ServicesData) []*codegen.File = ServerTypeFiles + _ func(string, *ServicesData) []*codegen.File = ClientTypeFiles + _ func(*ServicesData) []*codegen.File = PathFiles + _ func(string, *expr.HTTPServiceExpr, *ServicesData) *codegen.File = ClientEncodeDecodeFile + _ func(string, *expr.HTTPServiceExpr, *ServicesData) *codegen.File = ServerEncodeDecodeFile + _ func(string, *expr.HTTPServiceExpr, *ServicesData) *codegen.File = WebsocketClientFile +) + +// TestReleasedHTTPNamesMatchDeclarations checks all 23 released string fields, +// including optional fields that are empty when no declaration exists. +func TestReleasedHTTPNamesMatchDeclarations(t *testing.T) { + plan := linkedHTTPPlanForRoot(t, releasedHTTPNamesRoot(t)) + service := plan.services.Get("Names") + assertReleasedName(t, service.ServerStruct, service.ServerStructDeclaration) + assertReleasedName(t, service.MountPointStruct, service.MountPointStructDeclaration) + assertReleasedName(t, service.ServerInit, service.ServerInitDeclaration) + assertReleasedName(t, service.MountServer, service.MountServerDeclaration) + assertReleasedName(t, service.ClientStruct, service.ClientStructDeclaration) + + for _, endpoint := range service.Endpoints { + assertReleasedName(t, endpoint.MountHandler, endpoint.MountHandlerDeclaration) + assertReleasedName(t, endpoint.HandlerInit, endpoint.HandlerInitDeclaration) + assertReleasedName(t, endpoint.RequestDecoder, endpoint.RequestDecoderDeclaration) + assertReleasedName(t, endpoint.ResponseEncoder, endpoint.ResponseEncoderDeclaration) + assertReleasedName(t, endpoint.ErrorEncoder, endpoint.ErrorEncoderDeclaration) + assertReleasedName(t, endpoint.ClientStruct, endpoint.ClientStructDeclaration) + assertReleasedName(t, endpoint.RequestEncoder, endpoint.RequestEncoderDeclaration) + assertReleasedName(t, endpoint.ResponseDecoder, endpoint.ResponseDecoderDeclaration) + assertReleasedName(t, endpoint.BuildStreamPayload, endpoint.BuildStreamPayloadDeclaration) + } + + multipart := service.Endpoint("Multipart") + for _, data := range []*MultipartData{multipart.MultipartRequestDecoder, multipart.MultipartRequestEncoder} { + require.NotNil(t, data) + assertReleasedName(t, data.FuncName, data.FuncDeclaration) + assertReleasedName(t, data.InitName, data.InitDeclaration) + } + stream := service.Endpoint("Watch").SSE + require.NotNil(t, stream) + assertReleasedName(t, stream.StructName, stream.StructDeclaration) + require.NotEmpty(t, service.FileServers) + assertReleasedName(t, service.FileServers[0].MountHandler, service.FileServers[0].MountHandlerDeclaration) + empty := service.Endpoint("Empty") + require.Nil(t, empty.RequestDecoderDeclaration) + require.Empty(t, empty.RequestDecoder) + socket := service.Endpoint("Socket").ServerWebSocket + require.NotNil(t, socket) + assertReleasedName(t, socket.VarName, socket.VarDeclaration) + assertReleasedName(t, service.Endpoint("Complete").RequestInit.Name, service.Endpoint("Complete").RequestInit.Declaration) + typeData := releasedTypeData(t, service, func(data *TypeData) bool { return data.NestedValidatorDeclaration != nil }) + assertReleasedName(t, typeData.VarName, typeData.Declaration) + assertReleasedName(t, typeData.ValidatorName, typeData.ValidatorDeclaration) + assertReleasedName(t, typeData.NestedValidatorName, typeData.NestedValidatorDeclaration) +} + +// TestReleasedHTTPFileFunctionsUsePlannedPackage checks that public helpers +// render the retained HTTP plan and reject a different generated package. +func TestReleasedHTTPFileFunctionsUsePlannedPackage(t *testing.T) { + plan := linkedHTTPPlanForRoot(t, expr.RunDSL(t, testdata.MultiSimpleDSL)) + genpkg := plan.services.GenPkg() + for _, files := range []struct { + name string + released func(string, *ServicesData) []*codegen.File + planned func(*ServicesData) []*codegen.File + }{ + {name: "client", released: ClientFiles, planned: clientFiles}, + {name: "client CLI", released: ClientCLIFiles, planned: clientCLIFiles}, + {name: "server", released: ServerFiles, planned: serverFiles}, + {name: "server types", released: ServerTypeFiles, planned: serverTypeFiles}, + {name: "client types", released: ClientTypeFiles, planned: clientTypeFiles}, + } { + t.Run(files.name, func(t *testing.T) { + require.Len(t, files.released(genpkg, plan.services), len(files.planned(plan.services))) + require.PanicsWithValue( + t, + `HTTP generation package "other.local/gen" does not match planned package "generated.local/gen"`, + func() { + files.released("other.local/gen", plan.services) + }, + ) + }) + } + require.Len(t, PathFiles(plan.services), len(pathFiles(plan.services))) + service := plan.root.API.HTTP.Services[0] + require.Equal(t, clientEncodeDecodeFile(service, plan.services).Path, ClientEncodeDecodeFile(genpkg, service, plan.services).Path) + require.Equal(t, serverEncodeDecodeFile(service, plan.services).Path, ServerEncodeDecodeFile(genpkg, service, plan.services).Path) + + websocket := linkedHTTPPlanForRoot(t, releasedHTTPNamesRoot(t)) + websocketService := websocket.root.API.HTTP.Service("Names") + require.Equal( + t, + websocketClientFile(websocketService, websocket.services).Path, + WebsocketClientFile(websocket.services.GenPkg(), websocketService, websocket.services).Path, + ) +} + +// TestReleasedHTTPNameUsesCollisionSuffix checks that a compatibility field +// copies the final package name instead of rebuilding an unsuffixed name. +func TestReleasedHTTPNameUsesCollisionSuffix(t *testing.T) { + root := expr.RunDSL(t, func() { + dsl.Service("Calc", func() { + dsl.Method("read-data", func() { dsl.HTTP(func() { dsl.GET("/first") }) }) + dsl.Method("read_data", func() { dsl.HTTP(func() { dsl.GET("/second") }) }) + }) + }) + plan := linkedHTTPPlanForRoot(t, root) + endpoint := plan.services.Get("Calc").Endpoint("read_data") + require.NotEqual(t, "MountReadDataHandler", endpoint.MountHandlerDeclaration.Name()) + require.Contains(t, endpoint.MountHandlerDeclaration.Name(), "MountReadData") + require.Equal(t, endpoint.MountHandlerDeclaration.Name(), endpoint.MountHandler) +} + +// TestReleasedSSEDataFieldTypeMatchesPlannedValue verifies existing plugins +// can still read the final type of an explicitly mapped SSE data field. +func TestReleasedSSEDataFieldTypeMatchesPlannedValue(t *testing.T) { + root := expr.RunDSL(t, func() { + dsl.Service("Events", func() { + dsl.Method("Watch", func() { + dsl.StreamingResult(func() { + dsl.Attribute("value", dsl.String) + }) + dsl.HTTP(func() { + dsl.GET("/watch") + dsl.ServerSentEvents("value") + }) + }) + }) + }) + stream := linkedHTTPPlanForRoot(t, root).services.Get("Events").Endpoint("Watch").SSE + require.NotNil(t, stream) + require.NotEmpty(t, stream.DataField) + require.Equal(t, stream.Data.TypeRef, stream.DataFieldTypeRef) +} + +// TestReleasedHTTPNameFieldsRemainSourceCompatible checks old keyed literals +// and template reads still compile. +func TestReleasedHTTPNameFieldsRemainSourceCompatible(t *testing.T) { + data := struct { + Endpoint *EndpointData + Service *ServiceData + FileServer *FileServerData + Multipart *MultipartData + Stream *SSEData + WebSocket *WebSocketData + Init *InitData + Type *TypeData + }{ + &EndpointData{MountHandler: "MountReadHandler"}, + &ServiceData{ServerStruct: "Server"}, + &FileServerData{MountHandler: "MountAssetJSON"}, + &MultipartData{FuncName: "DecoderFunc", InitName: "NewDecoder"}, + &SSEData{StructName: "ReadServerStream"}, + &WebSocketData{VarName: "SocketServerStream"}, + &InitData{Name: "NewBody"}, + &TypeData{VarName: "Body", ValidatorName: "ValidateBody", NestedValidatorName: "validateBodyAt"}, + } + tmpl := template.Must(template.New("released-fields").Parse( + `{{.Endpoint.MountHandler}} {{.Service.ServerStruct}} {{.FileServer.MountHandler}} {{.Multipart.FuncName}} {{.Stream.StructName}} {{.WebSocket.VarName}} {{.Init.Name}} {{.Type.VarName}} {{.Type.ValidatorName}} {{.Type.NestedValidatorName}}`, + )) + var rendered bytes.Buffer + require.NoError(t, tmpl.Execute(&rendered, data)) + require.Equal(t, "MountReadHandler Server MountAssetJSON DecoderFunc ReadServerStream SocketServerStream NewBody Body ValidateBody validateBodyAt", rendered.String()) +} diff --git a/http/codegen/plugin_api_test_helpers_test.go b/http/codegen/plugin_api_test_helpers_test.go new file mode 100644 index 0000000000..3626963fe1 --- /dev/null +++ b/http/codegen/plugin_api_test_helpers_test.go @@ -0,0 +1,108 @@ +// This file builds one HTTP design that exercises every public name kept for +// existing plugins and provides small assertions shared by compatibility tests. +package codegen + +import ( + "testing" + + "github.com/stretchr/testify/require" + + "goa.design/goa/v3/codegen" + "goa.design/goa/v3/dsl" + "goa.design/goa/v3/expr" +) + +// releasedHTTPNamesRoot returns a service with ordinary, multipart, streaming, +// empty, and raw-body endpoints plus a file server. +func releasedHTTPNamesRoot(t *testing.T) *expr.RootExpr { + t.Helper() + return expr.RunDSL(t, func() { + child := dsl.Type("Child", func() { + dsl.Attribute("value", dsl.String, func() { + dsl.Pattern("value") + }) + dsl.Required("value") + }) + dsl.Service("Names", func() { + dsl.Method("Complete", func() { + dsl.Payload(func() { + dsl.Attribute("child", child) + dsl.Required("child") + }) + dsl.Result(dsl.String) + dsl.HTTP(func() { + dsl.POST("/complete") + }) + }) + dsl.Method("Multipart", func() { + dsl.Payload(dsl.String) + dsl.HTTP(func() { + dsl.POST("/multipart") + dsl.MultipartRequest() + }) + }) + dsl.Method("Watch", func() { + dsl.StreamingResult(dsl.String) + dsl.HTTP(func() { + dsl.GET("/watch") + dsl.ServerSentEvents() + }) + }) + dsl.Method("Socket", func() { + dsl.StreamingPayload(dsl.String) + dsl.StreamingResult(dsl.String) + dsl.HTTP(func() { + dsl.GET("/socket") + }) + }) + dsl.Method("Raw", func() { + dsl.HTTP(func() { + dsl.POST("/raw") + dsl.SkipRequestBodyEncodeDecode() + }) + }) + dsl.Method("Empty", func() { + dsl.HTTP(func() { + dsl.GET("/empty") + }) + }) + dsl.Files("/asset.json", "asset.json") + }) + }) +} + +// assertReleasedName checks that one public compatibility string contains the +// final name selected by its declaration. +func assertReleasedName(t *testing.T, name string, declaration *codegen.NameDeclaration) { + t.Helper() + if declaration == nil { + require.Empty(t, name) + return + } + require.Equal(t, declaration.Name(), name) +} + +// releasedTypeData returns the first planned HTTP body type accepted by match. +func releasedTypeData(t *testing.T, service *ServiceData, match func(*TypeData) bool) *TypeData { + t.Helper() + candidates := append([]*TypeData(nil), service.ServerBodyAttributeTypes...) + candidates = append(candidates, service.ClientBodyAttributeTypes...) + for _, endpoint := range service.Endpoints { + if endpoint.Payload != nil && endpoint.Payload.Request != nil { + candidates = append(candidates, endpoint.Payload.Request.ServerBody, endpoint.Payload.Request.ClientBody) + } + if endpoint.Result != nil { + for _, response := range endpoint.Result.Responses { + candidates = append(candidates, response.ServerBody...) + candidates = append(candidates, response.ClientBody) + } + } + } + for _, candidate := range candidates { + if candidate != nil && match(candidate) { + return candidate + } + } + require.FailNow(t, "planned HTTP body type was not found") + return nil +} diff --git a/http/codegen/released_streaming_name_test.go b/http/codegen/released_streaming_name_test.go new file mode 100644 index 0000000000..68c8cc7e6a --- /dev/null +++ b/http/codegen/released_streaming_name_test.go @@ -0,0 +1,33 @@ +// This file checks that WebSocket response collections keep the public Go +// names generated by released Goa versions. +package codegen + +import ( + "testing" + + "goa.design/goa/v3/codegen" + "goa.design/goa/v3/codegen/testutil" + "goa.design/goa/v3/expr" + "goa.design/goa/v3/http/codegen/testdata" +) + +// TestReleasedStreamingResponseCollectionNames catches renaming a response +// collection merely because it is sent after receiving streamed input. +func TestReleasedStreamingResponseCollectionNames(t *testing.T) { + root := expr.RunDSL(t, testdata.StreamingPayloadResultCollectionWithExplicitViewDSL) + plan := linkedHTTPPlanForRoot(t, root) + + t.Run("server", func(t *testing.T) { + file := plan.ServerTypeFiles()[0] + sections := append(file.Section("response-server-body"), file.Section("server-body-init")...) + code := codegen.SectionsCode(t, sections) + testutil.AssertGo(t, "testdata/golden/released_streaming_response_collection_server.go.golden", code) + }) + + t.Run("client", func(t *testing.T) { + file := plan.ClientTypeFiles()[0] + sections := append(file.Section("client-response-body"), file.Section("client-body-init")...) + code := codegen.SectionsCode(t, sections) + testutil.AssertGo(t, "testdata/golden/released_streaming_response_collection_client.go.golden", code) + }) +} diff --git a/http/codegen/security_alias_test.go b/http/codegen/security_alias_test.go new file mode 100644 index 0000000000..ada8ea3e23 --- /dev/null +++ b/http/codegen/security_alias_test.go @@ -0,0 +1,30 @@ +// This file checks every HTTP security scheme with named string credentials. +// Both directions must preserve service types while HTTP carries plain strings. +package codegen + +import ( + "testing" + + "github.com/stretchr/testify/require" + + "goa.design/goa/v3/codegen" + "goa.design/goa/v3/codegen/testutil" + "goa.design/goa/v3/expr" + "goa.design/goa/v3/http/codegen/testdata" +) + +func TestNamedSecurityTypes(t *testing.T) { + root := expr.RunDSL(t, testdata.NamedSecurityTypesDSL) + plan := linkedHTTPPlanForRoot(t, root) + + serverFiles := plan.ServerFiles() + require.Len(t, serverFiles, 2) + serverCode := codegen.SectionsCode(t, serverFiles[1].Section("request-decoder")) + serverCode += codegen.SectionsCode(t, plan.ServerTypeFiles()[0].Section("server-payload-init")) + testutil.AssertGo(t, "testdata/golden/security_alias_server.go.golden", serverCode) + + clientFiles := plan.ClientFiles() + require.Len(t, clientFiles, 2) + clientCode := codegen.SectionsCode(t, clientFiles[1].Section("request-encoder")) + testutil.AssertGo(t, "testdata/golden/security_alias_client.go.golden", clientCode) +} diff --git a/http/codegen/server.go b/http/codegen/server.go index ec7e4976f6..cf6d07465e 100644 --- a/http/codegen/server.go +++ b/http/codegen/server.go @@ -1,3 +1,5 @@ +// This file renders HTTP server handlers and encoders per service; each file +// receives imports derived only from the endpoint sections it contains. package codegen import ( @@ -11,20 +13,30 @@ import ( "goa.design/goa/v3/expr" ) -// ServerFiles returns the generated HTTP server files. -func ServerFiles(genpkg string, data *ServicesData) []*codegen.File { +type ( + // appendFSData gives the server file its chosen file helper names and path + // replacements. + appendFSData struct { + *ServiceData + // Mappings pairs each requested path with the embedded file path opened for it. + Mappings map[string]string + } +) + +// serverFiles builds the HTTP server files read by Plan.Link. +func serverFiles(data *ServicesData) []*codegen.File { files := make([]*codegen.File, 0, len(data.Expressions.Services)*3) for _, svc := range data.Expressions.Services { - files = append(files, serverFile(genpkg, svc, data)) - if f := websocketServerFile(genpkg, svc, data); f != nil { + files = append(files, serverFile(svc, data)) + if f := websocketServerFile(svc, data); f != nil { files = append(files, f) } - if f := sseServerFile(genpkg, svc, data); f != nil { + if f := sseServerFile(svc, data); f != nil { files = append(files, f) } } for _, svc := range data.Expressions.Services { - if f := ServerEncodeDecodeFile(genpkg, svc, data); f != nil { + if f := serverEncodeDecodeFile(svc, data); f != nil { files = append(files, f) } } @@ -32,10 +44,12 @@ func ServerFiles(genpkg string, data *ServicesData) []*codegen.File { } // serverFile returns the file implementing the HTTP server. -func serverFile(genpkg string, svc *expr.HTTPServiceExpr, services *ServicesData) *codegen.File { +func serverFile(svc *expr.HTTPServiceExpr, services *ServicesData) *codegen.File { data := services.Get(svc.Name()) svcName := data.Service.PathName fpath := filepath.Join(codegen.Gendir, "http", svcName, "server", "server.go") + outputPackage := generatedFileOutputPackage(services, fpath) + data = serviceDataForOutput(data, services, outputPackage) title := fmt.Sprintf("%s HTTP server", svc.Name()) funcs := map[string]any{ "join": strings.Join, @@ -48,23 +62,8 @@ func serverFile(genpkg string, svc *expr.HTTPServiceExpr, services *ServicesData "dir": path.Dir, "isObject": expr.IsObject, } - imports := []*codegen.ImportSpec{ - {Path: "bufio"}, - {Path: "context"}, - {Path: "fmt"}, - {Path: "io"}, - {Path: "mime/multipart"}, - {Path: "net/http"}, - {Path: "path"}, - {Path: "strings"}, - {Path: "github.com/gorilla/websocket"}, - codegen.GoaImport(""), - codegen.GoaNamedImport("http", "goahttp"), - {Path: genpkg + "/" + svcName, Name: data.Service.PkgName}, - {Path: genpkg + "/" + svcName + "/" + "views", Name: data.Service.ViewsPkg}, - } sections := []*codegen.SectionTemplate{ - codegen.Header(title, "server", imports), + plannedFileHeader(title, "server", fpath, services), } sections = append(sections, @@ -107,7 +106,12 @@ func serverFile(genpkg string, svc *expr.HTTPServiceExpr, services *ServicesData } } } - sections = append(sections, &codegen.SectionTemplate{Name: "append-fs", Source: httpTemplates.Read(appendFsT), FuncMap: funcs, Data: mappedFiles}) + sections = append(sections, &codegen.SectionTemplate{ + Name: "append-fs", + Source: httpTemplates.Read(appendFsT), + FuncMap: funcs, + Data: appendFSData{ServiceData: data, Mappings: mappedFiles}, + }) } for _, s := range data.FileServers { sections = append(sections, &codegen.SectionTemplate{Name: "server-files", Source: httpTemplates.Read(fileServerT), FuncMap: funcs, Data: s}) @@ -116,30 +120,16 @@ func serverFile(genpkg string, svc *expr.HTTPServiceExpr, services *ServicesData return &codegen.File{Path: fpath, SectionTemplates: sections} } -// ServerEncodeDecodeFile returns the file defining the HTTP server encoding and +// serverEncodeDecodeFile returns the file defining the HTTP server encoding and // decoding logic. -func ServerEncodeDecodeFile(genpkg string, svc *expr.HTTPServiceExpr, services *ServicesData) *codegen.File { +func serverEncodeDecodeFile(svc *expr.HTTPServiceExpr, services *ServicesData) *codegen.File { data := services.Get(svc.Name()) svcName := data.Service.PathName path := filepath.Join(codegen.Gendir, services.dir(), svcName, "server", "encode_decode.go") + outputPackage := generatedFileOutputPackage(services, path) + data = serviceDataForOutput(data, services, outputPackage) title := fmt.Sprintf("%s %s server encoders and decoders", svc.Name(), services.label()) - imports := []*codegen.ImportSpec{ - {Path: "context"}, - {Path: "errors"}, - {Path: "fmt"}, - {Path: "io"}, - {Path: "net/http"}, - {Path: "strconv"}, - {Path: "strings"}, - {Path: "encoding/json"}, - {Path: "mime/multipart"}, - {Path: "unicode/utf8"}, - codegen.GoaImport(""), - codegen.GoaNamedImport("http", "goahttp"), - {Path: genpkg + "/" + svcName, Name: data.Service.PkgName}, - {Path: genpkg + "/" + svcName + "/" + "views", Name: data.Service.ViewsPkg}, - } - sections := []*codegen.SectionTemplate{codegen.Header(title, "server", imports)} + sections := []*codegen.SectionTemplate{plannedFileHeader(title, "server", path, services)} for _, e := range data.Endpoints { if e.Redirect == nil && !IsWebSocketEndpoint(e) && !e.IsJSONRPC { @@ -199,7 +189,7 @@ func ServerEncodeDecodeFile(genpkg string, svc *expr.HTTPServiceExpr, services * func transTmplFuncs(s *expr.HTTPServiceExpr, services *ServicesData) map[string]any { return map[string]any{ "goTypeRef": func(dt expr.DataType) string { - return services.ServicesData.Get(s.Name()).Scope.GoTypeRef(&expr.AttributeExpr{Type: dt}) + return services.Get(s.Name()).Scope.GoTypeRef(&expr.AttributeExpr{Type: dt}) }, "isAliased": func(dt expr.DataType) bool { _, ok := dt.(expr.UserType) diff --git a/http/codegen/server_decode_test.go b/http/codegen/server_decode_test.go index b1ac5dd444..ee6d8bae66 100644 --- a/http/codegen/server_decode_test.go +++ b/http/codegen/server_decode_test.go @@ -175,6 +175,7 @@ func TestDecode(t *testing.T) { {"decode-body-primitive-bool-validate", testdata.PayloadBodyPrimitiveBoolValidateDSL}, {"decode-body-primitive-array-string-validate", testdata.PayloadBodyPrimitiveArrayStringValidateDSL}, {"decode-body-primitive-array-bool-validate", testdata.PayloadBodyPrimitiveArrayBoolValidateDSL}, + {"decode-body-required-primitive-arrays", testdata.RequiredPrimitiveArrayDSL}, {"decode-body-primitive-array-user-required", testdata.PayloadBodyPrimitiveArrayUserRequiredDSL}, {"decode-body-primitive-array-user-validate", testdata.PayloadBodyPrimitiveArrayUserValidateDSL}, @@ -203,8 +204,11 @@ func TestDecode(t *testing.T) { {"decode-map-query-object", testdata.PayloadMapQueryObjectDSL}, {"decode-multipart-body-primitive", testdata.PayloadMultipartPrimitiveDSL}, {"decode-multipart-body-user-type", testdata.PayloadMultipartUserTypeDSL}, + {"decode-multipart-body-validation", testdata.PayloadMultipartValidationDSL}, {"decode-multipart-body-array-type", testdata.PayloadMultipartArrayTypeDSL}, {"decode-multipart-body-map-type", testdata.PayloadMultipartMapTypeDSL}, + {"decode-multipart-with-param", testdata.PayloadMultipartWithParamDSL}, + {"decode-multipart-with-params-and-headers", testdata.PayloadMultipartWithParamsAndHeadersDSL}, {"decode-with-params-and-headers-dsl", testdata.WithParamsAndHeadersBlockDSL}, {"decode-query-int-alias", testdata.QueryIntAliasDSL}, @@ -226,8 +230,8 @@ func TestDecode(t *testing.T) { for _, c := range cases { t.Run(c.Name, func(t *testing.T) { root := expr.RunDSL(t, c.DSL) - services := CreateHTTPServices(root) - fs := ServerFiles("", services) + plan := linkedHTTPPlanForRoot(t, root) + fs := plan.ServerFiles() require.Len(t, fs, 2) sections := fs[1].SectionTemplates require.Greater(t, len(sections), 2) diff --git a/http/codegen/server_encode_test.go b/http/codegen/server_encode_test.go index 898ef907f1..f3a95e6cb8 100644 --- a/http/codegen/server_encode_test.go +++ b/http/codegen/server_encode_test.go @@ -83,6 +83,7 @@ func TestEncode(t *testing.T) { {"empty-server-response", testdata.EmptyServerResponseDSL}, {"empty-server-response-with-tags", testdata.EmptyServerResponseWithTagsDSL}, + {"default-response-before-tags", testdata.ResultDefaultResponseBeforeTagsDSL}, {"skip-response-body-encode-decode", testdata.ResponseEncoderSkipResponseBodyEncodeDecodeDSL}, @@ -92,8 +93,8 @@ func TestEncode(t *testing.T) { for _, c := range cases { t.Run(c.Name, func(t *testing.T) { root := expr.RunDSL(t, c.DSL) - services := CreateHTTPServices(root) - fs := ServerFiles("", services) + plan := linkedHTTPPlanForRoot(t, root) + fs := plan.ServerFiles() require.Len(t, fs, 2) sections := fs[1].SectionTemplates require.Greater(t, len(sections), 1) @@ -117,8 +118,8 @@ func TestEncodeMarshallingAndUnmarshalling(t *testing.T) { for _, c := range cases { t.Run(c.Name, func(t *testing.T) { root := expr.RunDSL(t, c.DSL) - services := CreateHTTPServices(root) - fs := ServerFiles("", services) + plan := linkedHTTPPlanForRoot(t, root) + fs := plan.ServerFiles() require.Len(t, fs, 2) sections := fs[1].SectionTemplates totalSectionsExpected := c.SectionsOffset + c.SectionCount diff --git a/http/codegen/server_error_encoder_test.go b/http/codegen/server_error_encoder_test.go index ccd7a216e6..486b2f0b7c 100644 --- a/http/codegen/server_error_encoder_test.go +++ b/http/codegen/server_error_encoder_test.go @@ -31,12 +31,13 @@ func TestEncodeError(t *testing.T) { {"api-no-body-error-response-with-content-type", testdata.APINoBodyErrorResponseWithContentTypeDSL}, {"empty-error-response-body", testdata.EmptyErrorResponseBodyDSL}, {"empty-custom-error-response-body", testdata.EmptyCustomErrorResponseBodyDSL}, + {"error-body-attribute-response", testdata.ErrorBodyAttributeResponseDSL}, } for _, c := range cases { t.Run(c.Name, func(t *testing.T) { root := expr.RunDSL(t, c.DSL) - services := CreateHTTPServices(root) - fs := ServerFiles("", services) + plan := linkedHTTPPlanForRoot(t, root) + fs := plan.ServerFiles() require.Len(t, fs, 2) sections := fs[1].SectionTemplates require.Greater(t, len(sections), 1) diff --git a/http/codegen/server_extensions_test.go b/http/codegen/server_extensions_test.go new file mode 100644 index 0000000000..10dba84b01 --- /dev/null +++ b/http/codegen/server_extensions_test.go @@ -0,0 +1,74 @@ +// This file compares generated server source for plugin-declared handler +// wrappers and additional routes with reviewed golden files. +package codegen + +import ( + "testing" + + "github.com/stretchr/testify/require" + + "goa.design/goa/v3/codegen" + "goa.design/goa/v3/codegen/codegentest" + "goa.design/goa/v3/codegen/testutil" + "goa.design/goa/v3/dsl" + "goa.design/goa/v3/expr" +) + +func TestServerExtensions(t *testing.T) { + root := extensionRoot(t) + plan, generation, servicePlan := plannedHTTPPlan(t, root, false) + serviceExpr := root.API.HTTP.Services[0] + _, err := plan.DeclareServerHandlerWrapper(serviceExpr, "First", extensionNameOrder("first")) + require.NoError(t, err) + _, err = plan.DeclareServerHandlerWrapper(serviceExpr, "Second", extensionNameOrder("second")) + require.NoError(t, err) + _, err = plan.DeclareServerEndpointHandlerWrapper(serviceExpr.HTTPEndpoints[0], "wrapEndpoint", extensionNameOrder("endpoint")) + require.NoError(t, err) + _, err = plan.DeclareServerMount(serviceExpr, "MountPreflight", extensionNameOrder("mount"), []ServerMountPoint{ + {Method: "Preflight item", Verb: "OPTIONS", Pattern: "/items/{id}"}, + {Method: "Preflight assets", Verb: "OPTIONS", Pattern: "/assets/{*path}"}, + }) + require.NoError(t, err) + require.NoError(t, generation.Freeze()) + require.NoError(t, servicePlan.Link()) + require.NoError(t, plan.Link()) + + files := plan.ServerFiles() + for _, test := range []struct { + section string + sectionIndex int + golden string + }{ + {"server-mount", 0, "testdata/golden/server_extensions_mount.go.golden"}, + {"server-handler", 0, "testdata/golden/server_extensions_endpoint_helper.go.golden"}, + {"server-files", 0, "testdata/golden/server_extensions_file_helper.go.golden"}, + {"server-files", 1, "testdata/golden/server_extensions_redirect_helper.go.golden"}, + {"server-init", 0, "testdata/golden/server_extensions_init.go.golden"}, + } { + sections := codegentest.Sections(files, "server.go", test.section) + require.Greater(t, len(sections), test.sectionIndex) + testutil.AssertGo(t, test.golden, codegen.SectionCode(t, sections[test.sectionIndex])) + } +} + +func TestServerExtensionMountPointEscaping(t *testing.T) { + root := expr.RunDSL(t, func() { + dsl.Service("Escape", func() { + dsl.Method("Ping", func() { dsl.HTTP(func() { dsl.GET("/") }) }) + }) + }) + plan, generation, servicePlan := plannedHTTPPlan(t, root, false) + _, err := plan.DeclareServerMount(root.API.HTTP.Services[0], "MountQuoted", extensionNameOrder("quoted"), []ServerMountPoint{{ + Method: "Quoted \"method\"\nnext", + Verb: "CUSTOM\\VERB", + Pattern: "/quoted/\"value\"\\next\nline", + }}) + require.NoError(t, err) + require.NoError(t, generation.Freeze()) + require.NoError(t, servicePlan.Link()) + require.NoError(t, plan.Link()) + + sections := codegentest.Sections(plan.ServerFiles(), "server.go", "server-init") + require.Len(t, sections, 1) + testutil.AssertGo(t, "testdata/golden/server_extensions_escaping.go.golden", codegen.SectionCode(t, sections[0])) +} diff --git a/http/codegen/server_handler_test.go b/http/codegen/server_handler_test.go index 824d81c80d..23aa0f770a 100644 --- a/http/codegen/server_handler_test.go +++ b/http/codegen/server_handler_test.go @@ -14,7 +14,6 @@ import ( ) func TestServerHandler(t *testing.T) { - const genpkg = "gen" cases := []struct { Name string DSL func() @@ -26,8 +25,8 @@ func TestServerHandler(t *testing.T) { for _, c := range cases { t.Run(c.Name, func(t *testing.T) { root := expr.RunDSL(t, c.DSL) - services := CreateHTTPServices(root) - fs := ServerFiles(genpkg, services) + plan := linkedHTTPPlanForRoot(t, root) + fs := plan.ServerFiles() sections := codegentest.Sections(fs, "server.go", "server-handler") require.Greater(t, len(sections), 0) code := codegen.SectionCode(t, sections[0]) diff --git a/http/codegen/server_init_test.go b/http/codegen/server_init_test.go index f9bedc77a6..4bc152d230 100644 --- a/http/codegen/server_init_test.go +++ b/http/codegen/server_init_test.go @@ -13,7 +13,6 @@ import ( ) func TestServerInit(t *testing.T) { - const genpkg = "gen" cases := []struct { Name string DSL func() @@ -32,8 +31,8 @@ func TestServerInit(t *testing.T) { for _, c := range cases { t.Run(c.Name, func(t *testing.T) { root := expr.RunDSL(t, c.DSL) - services := CreateHTTPServices(root) - fs := ServerFiles(genpkg, services) + plan := linkedHTTPPlanForRoot(t, root) + fs := plan.ServerFiles() require.Len(t, fs, c.FileCount) sections := fs[0].SectionTemplates require.Greater(t, len(sections), c.SectionNum) diff --git a/http/codegen/server_mount_test.go b/http/codegen/server_mount_test.go index 5875b6ef18..4c02cd02ba 100644 --- a/http/codegen/server_mount_test.go +++ b/http/codegen/server_mount_test.go @@ -14,7 +14,6 @@ import ( ) func TestServerMount(t *testing.T) { - const genpkg = "gen" cases := []struct { Name string DSL func() @@ -34,8 +33,8 @@ func TestServerMount(t *testing.T) { for _, c := range cases { t.Run(c.Name, func(t *testing.T) { root := expr.RunDSL(t, c.DSL) - services := CreateHTTPServices(root) - fs := ServerFiles(genpkg, services) + plan := linkedHTTPPlanForRoot(t, root) + fs := plan.ServerFiles() sections := codegentest.Sections(fs, "server.go", c.SectionName) require.Greater(t, len(sections), c.SectionNum) code := codegen.SectionCode(t, sections[c.SectionNum]) diff --git a/http/codegen/server_payload_types_test.go b/http/codegen/server_payload_types_test.go index 0fd2ace2b3..258a229ad2 100644 --- a/http/codegen/server_payload_types_test.go +++ b/http/codegen/server_payload_types_test.go @@ -118,13 +118,17 @@ func TestPayloadConstructor(t *testing.T) { {"body-inline-array-user", testdata.PayloadBodyInlineArrayUserDSL}, {"body-inline-map-user", testdata.PayloadBodyInlineMapUserDSL}, {"body-inline-recursive-user", testdata.PayloadBodyInlineRecursiveUserDSL}, + {"selected-body-defaults", testdata.PayloadSelectedBodyDefaultsDSL}, + {"selected-body-object-default", testdata.PayloadSelectedBodyObjectDefaultDSL}, + {"selected-body-map-default", testdata.PayloadSelectedBodyMapDefaultDSL}, + {"selected-body-bytes-default", testdata.PayloadSelectedBodyBytesDefaultDSL}, } for _, c := range cases { t.Run(c.Name, func(t *testing.T) { root := expr.RunDSL(t, c.DSL) require.Len(t, root.API.HTTP.Services, 1) - services := CreateHTTPServices(root) - fs := typesFile("", root.API.HTTP.Services[0], true, services) + plan := linkedHTTPPlanForRoot(t, root) + fs := plan.ServerTypeFiles()[0] sections := fs.SectionTemplates var section *codegen.SectionTemplate for _, s := range sections { diff --git a/http/codegen/server_types_test.go b/http/codegen/server_types_test.go index e572e03288..f5292af8b7 100644 --- a/http/codegen/server_types_test.go +++ b/http/codegen/server_types_test.go @@ -14,7 +14,6 @@ import ( ) func TestServerTypes(t *testing.T) { - const genpkg = "gen" cases := []struct { Name string DSL func() @@ -23,11 +22,15 @@ func TestServerTypes(t *testing.T) { {"server-multiple-methods", testdata.MultipleMethodsDSL}, {"server-payload-extend-validate", testdata.PayloadExtendedValidateDSL}, {"server-result-type-validate", testdata.ResultTypeValidateDSL}, + {"server-status-tag-required", testdata.ResultStatusTagRequiredDSL}, {"server-with-result-collection", testdata.ResultWithResultCollectionDSL}, {"server-with-result-view", testdata.ResultWithResultViewDSL}, {"server-with-result-sibling-user-type-fields", testdata.ResultTypeSiblingUserTypeFieldsDSL}, {"server-with-result-collection-sibling-user-type-fields", testdata.ResultTypeCollectionSiblingUserTypeFieldsDSL}, {"server-with-result-nested-user-type-fields", testdata.ResultTypeNestedUserTypeFieldsDSL}, + {"server-with-result-required-user-type-array", testdata.ResultTypeRequiredUserTypeArrayDSL}, + {"server-with-result-required-nested-array", testdata.ResultTypeRequiredNestedArrayDSL}, + {"server-with-result-viewed-required-array", testdata.ResultTypeMultipleViewsRequiredArrayDSL}, {"server-empty-error-response-body", testdata.EmptyErrorResponseBodyDSL}, {"server-with-error-custom-pkg", testdata.WithErrorCustomPkgDSL}, {"server-body-custom-name", testdata.PayloadBodyCustomNameDSL}, @@ -36,13 +39,15 @@ func TestServerTypes(t *testing.T) { {"server-header-custom-name", testdata.PayloadHeaderCustomNameDSL}, {"server-cookie-custom-name", testdata.PayloadCookieCustomNameDSL}, {"server-payload-with-validated-alias", testdata.PayloadWithValidatedAliasDSL}, + {"server-required-primitive-arrays", testdata.RequiredPrimitiveArrayDSL}, + {"server-multipart-validation", testdata.PayloadMultipartValidationDSL}, {"server-streaming-payload-required-fields", testdata.StreamingPayloadRequiredFieldsDSL}, } for _, c := range cases { t.Run(c.Name, func(t *testing.T) { root := expr.RunDSL(t, c.DSL) - services := CreateHTTPServices(root) - fs := typesFile(genpkg, root.API.HTTP.Services[0], true, services) + plan := linkedHTTPPlanForRoot(t, root) + fs := plan.ServerTypeFiles()[0] var buf bytes.Buffer for _, s := range fs.SectionTemplates[1:] { require.NoError(t, s.Write(&buf)) diff --git a/http/codegen/service_data.go b/http/codegen/service_data.go index 32d034980a..e94beb3a9b 100644 --- a/http/codegen/service_data.go +++ b/http/codegen/service_data.go @@ -1,16 +1,19 @@ +// This file turns HTTP endpoint designs into the data used to write client, +// server, request, response, validation, and streaming code. package codegen import ( "bytes" "fmt" "net/http" + "path" "slices" "sort" - "strconv" "strings" "text/template" "goa.design/goa/v3/codegen" + "goa.design/goa/v3/codegen/cli" "goa.design/goa/v3/codegen/service" "goa.design/goa/v3/expr" ) @@ -36,10 +39,32 @@ type ( *service.ServicesData Expressions *expr.HTTPExpr HTTPData map[string]*ServiceData - // jsonrpc indicates that the data describes the JSON-RPC - // transport: generated files live under gen/jsonrpc and titles - // use the JSON-RPC label. + // jsonrpc is true when files are written under gen/jsonrpc and their + // headings use the JSON-RPC name. jsonrpc bool + // viewedResultConstructors contains every client result function name + // chosen for the generated client package. + viewedResultConstructors map[viewedConstructorKey]*codegen.NameDeclaration + payloadConstructors map[*expr.HTTPEndpointExpr]*codegen.NameDeclaration + streamConstructors map[*expr.HTTPEndpointExpr]*codegen.NameDeclaration + errorConstructors map[*expr.HTTPErrorExpr]*codegen.NameDeclaration + // plannedWireTypes contains each copied request and response field with + // the Go name used by both its definition and its references. + plannedWireTypes map[*expr.HTTPServiceExpr]*plannedWireTypes + // plannedSymbols contains the Go names used in each client and server package. + plannedSymbols map[*expr.HTTPServiceExpr]*httpSymbols + // cliParsers contains the function names for each command parser file. + cliParsers map[string]*cli.ParserPlan + // linkErr is the first conversion error found while building template data. + // Plan.Link returns it before exposing any generated files. + linkErr error + // fileImports contains the exact design-derived imports collected for each + // generated file before package names were frozen. + fileImports map[string][]*codegen.ImportSpec + // clientServicePackages contains the package name used by client files for + // each generated service. Services with no client reference keep the + // preferred name because no import alias was selected. + clientServicePackages map[string]string } // ServiceData contains the data used to render the code related to a @@ -47,23 +72,66 @@ type ( ServiceData struct { // Service contains the related service data. Service *service.Data + // ClientPkgName is the Go package name written before client types. + ClientPkgName string + // ServerPkgName is the Go package name written before server types. + ServerPkgName string // Endpoints describes the endpoint data for this service. Endpoints []*EndpointData // FileServers lists the file servers for this service. FileServers []*FileServerData - // ServerStruct is the name of the HTTP server struct. + // ServerHandlerWrappers lists the planned wrapper declarations copied into + // every endpoint and file mount helper for this service. + ServerHandlerWrappers []*codegen.NameDeclaration + // ServerMounts lists functions that add routes after the routes defined in + // the design. + ServerMounts []*ServerMount + // ServerStruct is the server type name kept for existing plugins. + // + // Deprecated: Use ServerStructDeclaration.Name() after planning so name collisions are handled. ServerStruct string - // MountPointStruct is the name of the mount point struct. + // ServerStructDeclaration is the generated Go type name used by server definitions and calls. + ServerStructDeclaration *codegen.NameDeclaration + // MountPointStruct is the mount point type name kept for existing plugins. + // + // Deprecated: Use MountPointStructDeclaration.Name() after planning so name collisions are handled. MountPointStruct string - // ServerInit is the name of the constructor of the server - // struct. + // MountPointStructDeclaration is the generated Go type name used by the mount point type. + MountPointStructDeclaration *codegen.NameDeclaration + // ServerInit is the server constructor name kept for existing plugins. + // + // Deprecated: Use ServerInitDeclaration.Name() after planning so name collisions are handled. ServerInit string - // MountServer is the name of the mount function. + // ServerInitDeclaration is the generated Go function name used by the server constructor. + ServerInitDeclaration *codegen.NameDeclaration + // MountServer is the route mount function name kept for existing plugins. + // + // Deprecated: Use MountServerDeclaration.Name() after planning so name collisions are handled. MountServer string + // MountServerDeclaration is the generated Go function name used to mount the service routes. + MountServerDeclaration *codegen.NameDeclaration // ServerService is the name of service function. ServerService string - // ClientStruct is the name of the HTTP client struct. + // ClientStruct is the client type name kept for existing plugins. + // + // Deprecated: Use ClientStructDeclaration.Name() after planning so name collisions are handled. ClientStruct string + // ClientStructDeclaration is the generated Go type name used by the client. + ClientStructDeclaration *codegen.NameDeclaration + // ClientInitDeclaration is the generated Go function name used by the client constructor. + ClientInitDeclaration *codegen.NameDeclaration + // ServerConnConfigurerDeclaration names the server WebSocket configuration type. + ServerConnConfigurerDeclaration *codegen.NameDeclaration + // ServerConnConfigurerInitDeclaration names the server WebSocket configuration constructor. + ServerConnConfigurerInitDeclaration *codegen.NameDeclaration + // ClientConnConfigurerDeclaration names the client WebSocket configuration type. + ClientConnConfigurerDeclaration *codegen.NameDeclaration + // ClientConnConfigurerInitDeclaration names the client WebSocket configuration constructor. + ClientConnConfigurerInitDeclaration *codegen.NameDeclaration + // AppendFSDeclaration names the file system type used for mapped file paths. + AppendFSDeclaration *codegen.NameDeclaration + // AppendPrefixDeclaration names the function that adds a mapped file path prefix. + AppendPrefixDeclaration *codegen.NameDeclaration // ServerBodyAttributeTypes is the list of user types used to // define the request, response and error response type // attributes in the server code. @@ -72,31 +140,26 @@ type ( // define the request, response and error response type // attributes in the client code. ClientBodyAttributeTypes []*TypeData - // ServerTypeNames records the user type names used to define - // the endpoint request and response bodies for server code. It - // is populated once during analysis and acts as a - // deduplication set; file generators must never write to it. - ServerTypeNames map[string]struct{} - // ClientTypeNames records the user type names used to define - // the endpoint request and response bodies for client code. It - // is populated once during analysis and acts as a - // deduplication set; file generators must never write to it. - ClientTypeNames map[string]struct{} // ServerTransformHelpers is the list of transform functions // required by the various server side constructors. ServerTransformHelpers []*codegen.TransformFunctionData // ClientTransformHelpers is the list of transform functions // required by the various client side constructors. ClientTransformHelpers []*codegen.TransformFunctionData - // UnionTypes lists the sum-type unions referenced by the HTTP request and - // response body types. - UnionTypes []*service.UnionTypeData - // Scope initialized with all the server and client types. + // Scope records unique Go names for all server and client types. Scope *codegen.NameScope - // bodies caches the shaped body attributes derived from the - // design expressions during analysis. Shaped bodies are detached - // copies: the analyze pass must never write them back onto the - // design expression tree. + // serverWireTypes stores declarations emitted in the server package. + serverWireTypes *wireTypeCatalog + // clientWireTypes stores declarations emitted in the client package. + clientWireTypes *wireTypeCatalog + // clientBodyConstructors contains planned constructors for unnamed + // request and streamed request body values. + clientBodyConstructors map[clientBodyConstructorKey]*codegen.NameDeclaration + // transforms contains the exact conversions selected while this + // service's HTTP shapes were planned. + transforms plannedWireTransforms + // bodies stores copied request and response fields after applying the HTTP + // mappings. Building service data must never change the input design. bodies shapedBodies } @@ -110,6 +173,12 @@ type ( // method exposed over both plain HTTP and JSON-RPC yields two // endpoints with different values. IsJSONRPC bool + // IsJSONRPCNotification reports whether every call omits the request + // ID and receives no JSON-RPC response. + IsJSONRPCNotification bool + // JSONRPCRequestID contains the complete request-ID plan. It is nil for + // explicit notifications and non-JSON-RPC endpoints. + JSONRPCRequestID *JSONRPCRequestIDData // ServiceName is the name of the service exposing the endpoint. ServiceName string // ServiceVarName is the goified service name (first letter @@ -143,17 +212,45 @@ type ( // server - // MountHandler is the name of the mount handler function. + // MountHandler is the endpoint mount function name kept for existing plugins. + // + // Deprecated: Use MountHandlerDeclaration.Name() after planning so name collisions are handled. MountHandler string - // HandlerInit is the name of the constructor function for the - // http handler function. + // MountHandlerDeclaration is the generated Go function name used to mount this endpoint. + MountHandlerDeclaration *codegen.NameDeclaration + // ServerHandlerWrappers lists functions that surround the handler before + // this endpoint's mount function registers its routes. + ServerHandlerWrappers []*codegen.NameDeclaration + // HandlerInit is the handler constructor name kept for existing plugins. + // + // Deprecated: Use HandlerInitDeclaration.Name() after planning so name collisions are handled. HandlerInit string - // RequestDecoder is the name of the request decoder function. + // HandlerInitDeclaration is the generated Go function name used to create + // this endpoint's handler. + HandlerInitDeclaration *codegen.NameDeclaration + // RequestDecoder is the request decoder name kept for existing plugins. + // + // Deprecated: Use RequestDecoderDeclaration.Name() after planning so name collisions are handled. RequestDecoder string - // ResponseEncoder is the name of the response encoder function. + // RequestDecoderDeclaration is the generated Go function name used to + // decode this endpoint's request. + RequestDecoderDeclaration *codegen.NameDeclaration + // ResponseEncoder is the response encoder name kept for existing plugins. + // + // Deprecated: Use ResponseEncoderDeclaration.Name() after planning so name collisions are handled. ResponseEncoder string - // ErrorEncoder is the name of the error encoder function. + // ResponseEncoderDeclaration is the generated Go function name used to + // encode this endpoint's response. + ResponseEncoderDeclaration *codegen.NameDeclaration + // ErrorEncoder is the error encoder name kept for existing plugins. + // + // Deprecated: Use ErrorEncoderDeclaration.Name() after planning so name collisions are handled. ErrorEncoder string + // ErrorEncoderDeclaration is the generated Go function name used to encode + // this endpoint's errors. + ErrorEncoderDeclaration *codegen.NameDeclaration + // DiscardStreamDeclaration names the no-output stream used by a mixed-result request. + DiscardStreamDeclaration *codegen.NameDeclaration // MultipartRequestDecoder indicates the request decoder for // multipart content type. MultipartRequestDecoder *MultipartData @@ -165,38 +262,65 @@ type ( SSE *SSEData // Redirect defines a redirect for the endpoint. Redirect *RedirectData - // HasMixedResults indicates if the method has both Result and StreamingResult - // defined with different types, enabling content negotiation. + // HasMixedResults indicates that HTTP clients may request one normal result + // or a stream of results. HasMixedResults bool // client - // ClientStruct is the name of the HTTP client struct. + // ClientStruct is the client type name kept for existing plugins. + // + // Deprecated: Use ClientStructDeclaration.Name() after planning so name collisions are handled. ClientStruct string + // ClientStructDeclaration supplies the client type name used by endpoint methods. + ClientStructDeclaration *codegen.NameDeclaration // EndpointInit is the name of the constructor function for the // client endpoint. EndpointInit string // RequestInit is the request builder function. RequestInit *InitData - // RequestEncoder is the name of the request encoder function. + // RequestEncoder is the request encoder name kept for existing plugins. + // + // Deprecated: Use RequestEncoderDeclaration.Name() after planning so name collisions are handled. RequestEncoder string - // ResponseDecoder is the name of the response decoder function. + // RequestEncoderDeclaration is the generated Go function name used to + // encode this endpoint's request. + RequestEncoderDeclaration *codegen.NameDeclaration + // ResponseDecoder is the response decoder name kept for existing plugins. + // + // Deprecated: Use ResponseDecoderDeclaration.Name() after planning so name collisions are handled. ResponseDecoder string + // ResponseDecoderDeclaration is the generated Go function name used to + // decode this endpoint's response. + ResponseDecoderDeclaration *codegen.NameDeclaration // MultipartRequestEncoder indicates the request encoder for // multipart content type. MultipartRequestEncoder *MultipartData // ClientWebSocket holds the data to render the client struct which // implements the client stream interface. ClientWebSocket *WebSocketData - // BuildStreamPayload is the name of the function used to create the - // payload for endpoints that use SkipRequestBodyEncodeDecode. + // BuildStreamPayload is the streamed request helper name kept for existing plugins. + // + // Deprecated: Use BuildStreamPayloadDeclaration.Name() after planning so name collisions are handled. BuildStreamPayload string + // BuildStreamPayloadDeclaration is the generated Go function name used to + // build streamed requests. + BuildStreamPayloadDeclaration *codegen.NameDeclaration + // CLIPayloadDeclaration is the generated Go function name used to build command-line payloads. + CLIPayloadDeclaration *codegen.NameDeclaration } // FileServerData lists the data needed to generate file servers. FileServerData struct { - // MountHandler is the name of the mount handler function. + // MountHandler is the file server mount function name kept for existing plugins. + // + // Deprecated: Use MountHandlerDeclaration.Name() after planning so name collisions are handled. MountHandler string + // MountHandlerDeclaration is the generated Go function name used to mount this file server. + MountHandlerDeclaration *codegen.NameDeclaration + // ServerHandlerWrappers lists functions that surround the handler before + // this file server's mount function registers its routes. + ServerHandlerWrappers []*codegen.NameDeclaration // RequestPaths is the set of HTTP paths to the server. RequestPaths []string // Root is the root server file path. @@ -231,6 +355,8 @@ type ( Name string // Ref is the fully qualified reference to the payload type. Ref string + // CLIPlan describes how command-line text becomes the complete payload. + CLIPlan *cli.FlagPlan // Request contains the data for the corresponding HTTP request. Request *RequestData // DecoderReturnValue is a reference to the decoder return value @@ -239,8 +365,45 @@ type ( // IDAttribute is the name of the attribute where the ID of the // JSON-RPC request is stored. IDAttribute string + // IDAttributeTypeRef is the named string type assigned from the + // JSON-RPC request ID. It is empty for a plain string field. + IDAttributeTypeRef string // IDAttributeRequired is true if the ID attribute is required. IDAttributeRequired bool + // JSONRPCRequestID contains the typed payload field and generated-client + // behavior for the JSON-RPC request ID. + JSONRPCRequestID *JSONRPCRequestIDData + } + + // JSONRPCRequestIDData contains the generation-time choices for one + // JSON-RPC request ID. + JSONRPCRequestIDData struct { + // Attribute is the service payload field that receives the ID. It is + // empty when the generated client creates every ID. + Attribute string + // Variable is the local constructor argument that carries the ID. + Variable string + // ValueTypeRef is the generated Go type for the ID value. + ValueTypeRef string + // Aliased is true when ValueTypeRef is a named string type. + Aliased bool + // Required is true when the design marks the payload field as required. + Required bool + // MustHave is true when the JSON-RPC request must contain an ID because + // the service field has no default. + MustHave bool + // Pointer is true when the service payload stores the ID as a pointer. + Pointer bool + // HasDefault is true when an absent request ID uses the design default. + HasDefault bool + // DefaultValue is the string used when HasDefault is true. + DefaultValue string + // Generate is true when the client creates an ID if the payload does not + // supply one. + Generate bool + // Validate contains the service-field checks emitted after decoding the + // envelope ID. + Validate string } // ResultData contains the result information required to generate the @@ -259,10 +422,13 @@ type ( // Responses contains the data for the corresponding HTTP // responses. Responses []*ResponseData - // IDAttribute is the name of the attribute where the ID of the - // JSON-RPC request is stored. + // IDAttribute is retained for generator compatibility. + // + // Deprecated: JSON-RPC results cannot define an ID field. IDAttribute string - // IDAttributeRequired is true if the ID attribute is required. + // IDAttributeRequired is retained for generator compatibility. + // + // Deprecated: JSON-RPC results cannot define an ID field. IDAttributeRequired bool // View is the view used to render the result. View string @@ -308,13 +474,13 @@ type ( // Cookies contains the HTTP request cookies used to build the // method payload. Cookies []*CookieData - // ServerBody describes the request body type used by server - // code. The type is generated using pointers for all fields so - // that it can be validated. + // ServerBody describes the request body decoded by server code. + // Scalar fields use pointers when validation must distinguish an + // omitted value from zero. Byte slices and Any fields remain values. ServerBody *TypeData - // ClientBody describes the request body type used by client - // code. The type does NOT use pointers for every fields since - // no validation is required. + // ClientBody describes the request body encoded by client code. Its + // fields follow the client transport shape because no body decoding + // validation runs there. ClientBody *TypeData // PayloadInit contains the data required to render the // payload constructor used by server code if any. @@ -327,12 +493,27 @@ type ( PayloadAttr string // MustHaveBody is true if the request body cannot be empty. MustHaveBody bool + // OptionalBody reports whether Body selects an optional payload field. + OptionalBody bool + // BodyIsUnion reports whether the selected optional field is a union whose + // empty kind represents absence. + BodyIsUnion bool + // BodyFieldPointer reports whether the selected service payload field is + // a pointer to a primitive value. + BodyFieldPointer bool + // BodyFieldCanBeAbsent reports whether the selected service payload field + // can represent absence. + BodyFieldCanBeAbsent bool // MustValidate is true if the request body or at least one // parameter or header requires validation. MustValidate bool // Multipart if true indicates the request is a multipart // request. Multipart bool + // JSONRPCParams describes how the request body is carried in JSON-RPC + // params. It is nil for ordinary HTTP requests and requests without a + // body. + JSONRPCParams *JSONRPCParamsData } // ResponseData describes a response. @@ -353,9 +534,9 @@ type ( // ErrorHeader contains the value of the response "goa-error" // header if any. ErrorHeader string - // ServerBody is the type of the response body used by server - // code, nil if body should be empty. The type does NOT use - // pointers for all fields. If the method result is a result + // ServerBody is the response body encoded by server code, or nil when + // the body is empty. Its fields follow the server transport shape. + // If the method result is a result // type and the response data describes a success response, then // this field contains a type for every view in the result type. // The type name is suffixed with the name of the view (except @@ -366,9 +547,10 @@ type ( // describes an error response, then this field contains at most // one item. ServerBody []*TypeData - // ClientBody is the type of the response body used by client - // code, nil if body should be empty. The type uses pointers for - // all fields so they can be validated. + // ClientBody is the response body decoded by client code, or nil when + // the body is empty. Scalar fields use pointers when validation must + // distinguish an omitted value from zero. Byte slices and Any fields + // remain values. Service result fields keep their own pointer rules. ClientBody *TypeData // Init contains the data required to render the result or error // constructor if any. @@ -391,11 +573,47 @@ type ( // ViewedResult indicates whether the response body type is a // result type. ViewedResult *service.ViewedResultTypeData + // ViewedRepresentations lists the body type and constructor used for each + // legal result view. A response that supports several views includes its + // view name so the client can choose the matching entry. + ViewedRepresentations []*ViewedRepresentationData + // SelectClientBodyByView is true when a unary HTTP client must read the + // goa-view response header before choosing the body type and constructor. + SelectClientBodyByView bool + } + + // ViewedRepresentationData describes the HTTP body used for one legal result + // view. The server constructor converts the service result into ServerBody. + // The client decodes ClientBody and ResultInit rebuilds the service result. + ViewedRepresentationData struct { + // View is the exact design view name carried on variable-view messages. + View string + // ResultAttr is the Go field selected by Body("name"). It is empty when + // the response body uses the complete result containing the selected view's + // fields. + ResultAttr string + // ServerBody is the body type encoded by the server for View. + ServerBody *TypeData + // ClientBody is the body type decoded by the client for View. + ClientBody *TypeData + // ClientDataPointer reports whether the SSE data line is assigned to + // a pointer field in ClientBody. + ClientDataPointer bool + // ResultInit rebuilds the result containing the selected view's fields from + // ClientBody. + ResultInit *InitData } // InitData contains the data required to render a constructor. InitData struct { - // Name is the constructor function name. + // Declaration is the generated Go function name used by this constructor. + Declaration *codegen.NameDeclaration + // ClientDeclaration is the generated Go function name written in the client package + // when the same path constructor is also written in the server package. + ClientDeclaration *codegen.NameDeclaration + // Name is the constructor function name kept for existing plugins. + // + // Deprecated: Use Declaration.Name() after planning so name collisions are handled. Name string // Description is the function description. Description string @@ -434,6 +652,17 @@ type ( // ReturnIsPrimitivePointer indicates whether the payload, result or error // type is a primitive pointer. ReturnIsPrimitivePointer bool + // ReturnIsUnion reports whether the selected body field is a union value. + ReturnIsUnion bool + // OptionalBody reports whether body is the value of an optional payload + // field and may be nil. + OptionalBody bool + // ClientBodyOptional reports whether the service field supplied by a + // client can represent absence. + ClientBodyOptional bool + // BodyDefault is the specialized Go value assigned when an optional + // request body is absent. It is nil when the field has no authored default. + BodyDefault *codegen.GoValueCode } // AttributeData contains the information needed to generate the code @@ -453,6 +682,10 @@ type ( TypeName string // TypeRef is the generated attribute type reference. TypeRef string + // ValueTypeRef is TypeRef without the pointer used for optional fields. + ValueTypeRef string + // ElemTypeRef is the generated element type reference for an array. + ElemTypeRef string // Description is the attribute description as defined in the design. Description string // FieldName is the name of the data structure field that should @@ -466,13 +699,18 @@ type ( FieldPointer bool // DefaultValue is the default value of the attribute if any. DefaultValue any + // HasDefault reports whether DefaultValue was authored, including a zero value. + HasDefault bool // Validate contains the validation code for the attribute value if any. Validate string + // CLIPlan describes how command-line text becomes this attribute value and + // how the generated payload builder validates it. + CLIPlan *cli.FlagPlan // Example is an example attribute value Example any - // IsAliased is true if the field type is a user-defined type (alias). + // IsAliased is true when the field uses a user-defined type. IsAliased bool - // ServiceTypeRef is the service-aware type reference for cross-service resolution. + // ServiceTypeRef is the Go type used when the field comes from another service. ServiceTypeRef string // IsTextUnmarshaler is true if the attribute has a struct:field:type meta // whose underlying DSL type is string and the custom type is expected to @@ -529,8 +767,11 @@ type ( // HeaderData describes a HTTP request or response header. HeaderData struct { *Element - // CanonicalName is the canonical header key. + // CanonicalName is the standard HTTP header spelling. CanonicalName string + // PreserveEmpty reports whether an empty header value is different from + // an absent header. + PreserveEmpty bool } // CookieData describes a HTTP request or response cookie. @@ -554,7 +795,13 @@ type ( TypeData struct { // Name is the type name. Name string - // VarName is the Go type name. + // Declaration is the generated Go type name. + Declaration *codegen.NameDeclaration + // VarName is the Go type spelling kept for existing plugins. When + // Declaration is nonnil, it matches Declaration.Name(). Otherwise it is a + // Go expression such as []string that does not declare a named type. + // + // Deprecated: Use Declaration.Name() after planning so name collisions are handled. VarName string // Description is the type human description. Description string @@ -567,21 +814,54 @@ type ( Ref string // ValidateDef contains the validation code. ValidateDef string - // ValidateRef contains the call to the validation code. + // NestedValidateDef contains validation code whose error paths begin with + // the path passed by another generated validator. + NestedValidateDef string + // ValidateRef contains inline validation code when no named validator is called. ValidateRef string + // ValidationTarget is the value passed to ValidatorDeclaration. It is empty + // when this body does not need a named validator call. + ValidationTarget string + // ValidatorDeclaration is the generated Go function name that runs ValidateDef. + ValidatorDeclaration *codegen.NameDeclaration + // ValidatorName is the validator name kept for existing plugins. + // + // Deprecated: Use ValidatorDeclaration.Name() after planning so name collisions are handled. + ValidatorName string + // NestedValidatorDeclaration is the private generated Go function name used + // when this type appears inside another HTTP body value. + NestedValidatorDeclaration *codegen.NameDeclaration + // NestedValidatorName is the nested validator name kept for existing plugins. + // + // Deprecated: Use NestedValidatorDeclaration.Name() after planning so name collisions are handled. + NestedValidatorName string // Example is an example value for the type. Example any // View is the view used to render the (result) type if any. View string + // declaration points to the one request or response type record that the HTTP + // plan uses for this generated type. + declaration *wireTypeRecord + // attribute is the copied HTTP type whose generated names produced Def and + // Ref. Example generators use it to qualify nested request body types. + attribute *expr.AttributeExpr } // MultipartData contains the data needed to render multipart // encoder/decoder. MultipartData struct { - // FuncName is the name used to generate function type. + // FuncName is the multipart function type or helper name kept for existing plugins. + // + // Deprecated: Use FuncDeclaration.Name() after planning so name collisions are handled. FuncName string - // InitName is the name of the constructor. + // FuncDeclaration is the generated Go name used by the multipart function type or helper. + FuncDeclaration *codegen.NameDeclaration + // InitName is the multipart constructor name kept for existing plugins. + // + // Deprecated: Use InitDeclaration.Name() after planning so name collisions are handled. InitName string + // InitDeclaration is the generated Go function name used by the multipart constructor. + InitDeclaration *codegen.NameDeclaration // VarName is the name of the variable referring to the function. VarName string // ServiceName is the name of the service. @@ -599,17 +879,23 @@ type ( // report messages. httpElementKind string - // shapedBodies caches the detached body attributes computed from the - // design expressions: request and response bodies are shaped with - // makeHTTPType while streaming bodies are plain copies. Caching - // guarantees the shaping runs once per expression and that all the - // consumers share the same attribute instances, which keeps the - // example generator call sequence stable. + // shapedBodies stores each HTTP body after it is copied from the design. + // Requests, responses, and streamed results use their HTTP field names; + // streamed requests keep their authored fields. Reusing each copy gives + // every generated file the same body and the same example values. shapedBodies struct { - requests map[*expr.HTTPEndpointExpr]*expr.AttributeExpr - streams map[*expr.HTTPEndpointExpr]*expr.AttributeExpr - responses map[*expr.HTTPResponseExpr]*expr.AttributeExpr - errors map[*expr.HTTPErrorExpr]*expr.AttributeExpr + requests map[*expr.HTTPEndpointExpr]*expr.AttributeExpr + streams map[*expr.HTTPEndpointExpr]*expr.AttributeExpr + streamResults map[*expr.HTTPEndpointExpr]*expr.AttributeExpr + responses map[*expr.HTTPResponseExpr]*expr.AttributeExpr + errors map[*expr.HTTPErrorExpr]*expr.AttributeExpr + } + + // releasedWireTypePair stops recursive response types after their current + // and released names have been paired once. + releasedWireTypePair struct { + current expr.UserType + released expr.UserType } ) @@ -624,8 +910,9 @@ const ( cookieElement httpElementKind = "cookie" ) -// NewServicesData creates a new ServicesData instance for the given service data. -func NewServicesData(services *service.ServicesData, expressions *expr.HTTPExpr) *ServicesData { +// newServicesData creates the HTTP service map that Plan.Link fills before it +// builds any generated file. +func newServicesData(services *service.ServicesData, expressions *expr.HTTPExpr) *ServicesData { return &ServicesData{ ServicesData: services, Expressions: expressions, @@ -633,27 +920,10 @@ func NewServicesData(services *service.ServicesData, expressions *expr.HTTPExpr) } } -// NewJSONRPCServicesData creates a new ServicesData instance for the JSON-RPC -// transport: file constructors write under gen/jsonrpc and use the JSON-RPC -// label in generated file headers. -func NewJSONRPCServicesData(services *service.ServicesData, expressions *expr.HTTPExpr) *ServicesData { - data := NewServicesData(services, expressions) - data.jsonrpc = true - return data -} - -// Get retrieves the transport data for the service with the given name -// computing it if needed. It returns nil if there is no service with the given -// name. +// Get returns the generated HTTP information for the service with the given +// name. A missing entry means the design does not expose that service over the +// protocol handled by this plan. func (sds *ServicesData) Get(name string) *ServiceData { - if data, ok := sds.HTTPData[name]; ok { - return data - } - svc := sds.Expressions.Service(name) - if svc == nil { - return nil - } - sds.HTTPData[name] = sds.analyze(svc) return sds.HTTPData[name] } @@ -690,24 +960,71 @@ func (sds *ServicesData) label() string { // It records the user types needed by the service definition in userTypes. func (sds *ServicesData) analyze(httpSvc *expr.HTTPServiceExpr) *ServiceData { svc := sds.ServicesData.Get(httpSvc.ServiceExpr.Name) + transportService := *svc + transportService.PkgName = sds.clientServicePackages[svc.Name] + svc = &transportService scope := codegen.NewNameScope() scope.Unique("c") // 'c' is reserved as the client's receiver name. scope.Unique("v") // 'v' is reserved as the request builder payload argument name. // Reserve 'websocket' to avoid collision with gorilla/websocket scope.Unique("websocket") - // Reserve the service package name to avoid collision with parameter names in generated code + // Reserve the service package alias to avoid collision with parameter names in generated code. scope.Unique(svc.PkgName) + planned := sds.plannedWireTypes[httpSvc] + if planned == nil { + panic(fmt.Sprintf("HTTP service %q has no planned generated types", httpSvc.Name())) + } + planned.server.Link() + planned.client.Link() + symbols := sds.plannedSymbols[httpSvc] + if symbols == nil { + panic(fmt.Sprintf("HTTP service %q has no package names", httpSvc.Name())) + } + clientPkgName := strings.ToLower(codegen.Goify(svc.Name, false)) + "c" + serverPkgName := strings.ToLower(codegen.Goify(svc.Name, false)) + "svr" + clientOutputPackage := path.Join(sds.GenPkg(), sds.dir(), svc.PathName, "client") + if sds.jsonrpc { + serverPkgName = strings.ToLower(codegen.Goify(svc.Name, false)) + "jssvr" + } + if len(httpSvc.HTTPEndpoints) > 0 { + for _, server := range sds.Root.API.Servers { + if !slices.Contains(server.Services, svc.Name) || sds.cliParsers[server.Name] == nil { + continue + } + serverName := codegen.SnakeCase(codegen.Goify(server.Name, true)) + cliOutputPackage := path.Join(sds.GenPkg(), sds.dir(), "cli", serverName) + clientPkgName = sds.PackageImport(cliOutputPackage, clientOutputPackage).Name + break + } + } sd := &ServiceData{ - Service: svc, - ServerStruct: "Server", - MountPointStruct: "MountPoint", - ServerInit: "New", - MountServer: "Mount", - ServerService: "Service", - ClientStruct: "Client", - ServerTypeNames: make(map[string]struct{}), - ClientTypeNames: make(map[string]struct{}), - Scope: scope, + Service: svc, + ClientPkgName: clientPkgName, + ServerPkgName: serverPkgName, + ServerStruct: symbols.serverStruct.Name(), + ServerStructDeclaration: symbols.serverStruct, + MountPointStruct: symbols.mountPoint.Name(), + MountPointStructDeclaration: symbols.mountPoint, + ServerInit: symbols.serverInit.Name(), + ServerInitDeclaration: symbols.serverInit, + MountServer: symbols.mountServer.Name(), + MountServerDeclaration: symbols.mountServer, + ServerService: "Service", + ClientStruct: symbols.clientStruct.Name(), + ClientStructDeclaration: symbols.clientStruct, + ClientInitDeclaration: symbols.clientInit, + ServerConnConfigurerDeclaration: symbols.serverConfigurer, + ServerConnConfigurerInitDeclaration: symbols.serverConfigurerInit, + ClientConnConfigurerDeclaration: symbols.clientConfigurer, + ClientConnConfigurerInitDeclaration: symbols.clientConfigurerInit, + AppendFSDeclaration: symbols.appendFS, + AppendPrefixDeclaration: symbols.appendPrefix, + Scope: scope, + serverWireTypes: planned.server, + clientWireTypes: planned.client, + clientBodyConstructors: planned.clientBodyConstructors, + transforms: planned.transforms, + bodies: planned.bodies, } for _, s := range httpSvc.FileServers { @@ -735,14 +1052,15 @@ func (sds *ServicesData) analyze(httpSvc *expr.HTTPServiceExpr) *ServiceData { } } data := &FileServerData{ - MountHandler: scope.Unique(fmt.Sprintf("Mount%s", codegen.Goify(s.FilePath, true))), - RequestPaths: paths, - FilePath: s.FilePath, - IsDir: s.IsDir(), - PathParam: pp, - Redirect: redirect, - VarName: scope.Unique(codegen.Goify(s.FilePath, true)), - ArgName: scope.Unique(fmt.Sprintf("fileSystem%s", codegen.Goify(s.FilePath, true))), + MountHandler: symbols.fileServers[s].Name(), + MountHandlerDeclaration: symbols.fileServers[s], + RequestPaths: paths, + FilePath: s.FilePath, + IsDir: s.IsDir(), + PathParam: pp, + Redirect: redirect, + VarName: scope.Unique(codegen.Goify(s.FilePath, true)), + ArgName: scope.Unique(fmt.Sprintf("fileSystem%s", codegen.Goify(s.FilePath, true))), } sd.FileServers = append(sd.FileServers, data) } @@ -755,6 +1073,10 @@ func (sds *ServicesData) analyze(httpSvc *expr.HTTPServiceExpr) *ServiceData { routesCap += len(r.FullPaths()) } routes := make([]*RouteData, 0, routesCap) + endpointSymbols := symbols.endpoints[httpEndpoint] + if endpointSymbols == nil { + panic(fmt.Sprintf("HTTP endpoint %q has no package names", httpEndpoint.Name())) + } pathCount := 0 for _, r := range httpEndpoint.Routes { for _, rpath := range r.FullPaths() { @@ -765,12 +1087,8 @@ func (sds *ServicesData) analyze(httpSvc *expr.HTTPServiceExpr) *ServiceData { { initArgs := make([]*InitArgData, len(params)) pathParamsObj := expr.AsObject(httpEndpoint.PathParams().Type) - suffix := "" - if pathCount > 0 { - suffix = strconv.Itoa(pathCount + 1) - } - pathCount++ - name := fmt.Sprintf("%s%sPath%s", method.VarName, svc.StructName, suffix) + declaration := endpointSymbols.serverPaths[pathCount] + name := declaration.Name() for j, arg := range params { patt := pathParamsObj.Attribute(arg) att := makeHTTPType(patt) @@ -798,7 +1116,7 @@ func (sds *ServicesData) analyze(httpSvc *expr.HTTPServiceExpr) *ServiceData { Type: att.Type, Pointer: pointer, Required: true, - Example: att.Example(sds.Root.API.ExampleGenerator.Field(httpEndpoint.MethodExpr.Payload, arg)), + Example: sds.FieldExample(att, httpEndpoint.MethodExpr.Payload, arg, expr.MethodPayloadExampleIdentity(httpEndpoint.MethodExpr)), Validate: vcode, }, } @@ -827,14 +1145,16 @@ func (sds *ServicesData) analyze(httpSvc *expr.HTTPServiceExpr) *ServiceData { } } init = &InitData{ - Name: name, - Description: fmt.Sprintf("%s returns the URL path to the %s service %s HTTP endpoint. ", name, svc.Name, method.Name), - ServerArgs: initArgs, - ClientArgs: clientArgs, - ReturnTypeName: "string", - ReturnTypeRef: "string", - ServerCode: buffer.String(), - ClientCode: buffer.String(), + Declaration: declaration, + ClientDeclaration: endpointSymbols.clientPaths[pathCount], + Name: name, + Description: fmt.Sprintf("%s returns the URL path to the %s service %s HTTP endpoint. ", name, svc.Name, method.Name), + ServerArgs: initArgs, + ClientArgs: clientArgs, + ReturnTypeName: "string", + ReturnTypeRef: "string", + ServerCode: buffer.String(), + ClientCode: buffer.String(), } } @@ -843,6 +1163,7 @@ func (sds *ServicesData) analyze(httpSvc *expr.HTTPServiceExpr) *ServiceData { Path: rpath, PathInit: init, }) + pathCount++ } } @@ -877,22 +1198,14 @@ func (sds *ServicesData) analyze(httpSvc *expr.HTTPServiceExpr) *ServiceData { reqs = append(reqs, &service.RequirementData{Schemes: rs, Scopes: req.Scopes}) } - var requestEncoder string - if httpEndpoint.IsJSONRPC() || payload.Request.ClientBody != nil || len(payload.Request.Headers) > 0 || len(payload.Request.QueryParams) > 0 || len(payload.Request.Cookies) > 0 || basch != nil { - // JSON-RPC endpoints always need a request encoder to build - // the JSON-RPC envelope, even when the payload is empty. - requestEncoder = fmt.Sprintf("Encode%sRequest", method.VarName) - } - var requestInit *InitData var ( - name string args []*InitArgData payloadRef string pkg string ) { - name = fmt.Sprintf("Build%sRequest", method.VarName) + svcctx := sds.serviceTypeContext(sd, "client").Enter(httpEndpoint.MethodExpr.Payload) s := codegen.NewNameScope() s.Unique("c") // 'c' is reserved as the client's receiver name. for _, ca := range routes[0].PathInit.ClientArgs { @@ -902,14 +1215,19 @@ func (sds *ServicesData) analyze(httpSvc *expr.HTTPServiceExpr) *ServiceData { // Populate service-aware type resolution fields _, ca.IsAliased = ca.FieldType.(expr.UserType) if ca.IsAliased { - ca.ServiceTypeRef = sds.ServicesData.Get(svc.Name).Scope.GoTypeRef(&expr.AttributeExpr{Type: ca.Type}) + attribute := &expr.AttributeExpr{Type: ca.FieldType} + ca.ServiceTypeRef = svcctx.Scope.Ref(attribute, svcctx.Pkg(attribute)) } args = append(args, ca) } } - pkg = method.PayloadLoc.PackageNameOrDefault(svc.PkgName) + pkg = svc.PkgName if len(routes[0].PathInit.ClientArgs) > 0 && httpEndpoint.MethodExpr.Payload.Type != expr.Empty { - payloadRef = svc.Scope.GoFullTypeRef(httpEndpoint.MethodExpr.Payload, pkg) + payloadPkg := "" + if attributeUsesServiceType(httpEndpoint.MethodExpr.Payload, make(map[expr.UserType]struct{})) { + payloadPkg = svcctx.Pkg(httpEndpoint.MethodExpr.Payload) + } + payloadRef = svcctx.Scope.Ref(httpEndpoint.MethodExpr.Payload, payloadPkg) } } data := map[string]any{ @@ -931,66 +1249,107 @@ func (sds *ServicesData) analyze(httpSvc *expr.HTTPServiceExpr) *ServiceData { } clientArgs := []*InitArgData{{Ref: "v", AttributeData: &AttributeData{Name: "payload", VarName: "v", TypeRef: "any"}}} requestInit = &InitData{ - Name: name, - Description: fmt.Sprintf("%s instantiates a HTTP request object with method and path set to call the %q service %q endpoint", name, svc.Name, method.Name), + Declaration: endpointSymbols.requestBuilder, + Name: endpointSymbols.requestBuilder.Name(), + Description: fmt.Sprintf("%s instantiates a HTTP request object with method and path set to call the %q service %q endpoint", endpointSymbols.requestBuilder.Name(), svc.Name, method.Name), ClientCode: buf.String(), ClientArgs: clientArgs, } ed := &EndpointData{ - Method: method, - IsJSONRPC: httpEndpoint.IsJSONRPC(), - ServiceName: svc.Name, - ServiceVarName: svc.VarName, - ServicePkgName: svc.PkgName, - Payload: payload, - Result: sds.buildResultData(httpEndpoint, sd), - Errors: sds.buildErrorsData(httpEndpoint, sd), - HeaderSchemes: hsch, - BodySchemes: bosch, - QuerySchemes: qsch, - BasicScheme: basch, - Routes: routes, - MountHandler: fmt.Sprintf("Mount%sHandler", method.VarName), - HandlerInit: fmt.Sprintf("New%sHandler", method.VarName), - RequestDecoder: fmt.Sprintf("Decode%sRequest", method.VarName), - ResponseEncoder: fmt.Sprintf("Encode%sResponse", method.VarName), - ErrorEncoder: fmt.Sprintf("Encode%sError", method.VarName), - ClientStruct: "Client", - EndpointInit: method.VarName, - RequestInit: requestInit, - HasMixedResults: httpEndpoint.MethodExpr.HasMixedResults(), - RequestEncoder: requestEncoder, - ResponseDecoder: fmt.Sprintf("Decode%sResponse", method.VarName), - Requirements: reqs, + Method: method, + IsJSONRPC: httpEndpoint.IsJSONRPC(), + IsJSONRPCNotification: httpEndpoint.IsJSONRPCNotification(), + JSONRPCRequestID: payload.JSONRPCRequestID, + ServiceName: svc.Name, + ServiceVarName: svc.VarName, + ServicePkgName: svc.PkgName, + Payload: payload, + Result: sds.buildResultData(httpEndpoint, sd), + Errors: sds.buildErrorsData(httpEndpoint, sd), + HeaderSchemes: hsch, + BodySchemes: bosch, + QuerySchemes: qsch, + BasicScheme: basch, + Routes: routes, + MountHandler: endpointSymbols.mountHandler.Name(), + MountHandlerDeclaration: endpointSymbols.mountHandler, + HandlerInit: endpointSymbols.handlerInit.Name(), + HandlerInitDeclaration: endpointSymbols.handlerInit, + RequestDecoderDeclaration: endpointSymbols.requestDecoder, + ResponseEncoderDeclaration: endpointSymbols.responseEncoder, + ErrorEncoderDeclaration: endpointSymbols.errorEncoder, + DiscardStreamDeclaration: endpointSymbols.discardStream, + ClientStruct: symbols.clientStruct.Name(), + ClientStructDeclaration: symbols.clientStruct, + EndpointInit: method.VarName, + RequestInit: requestInit, + HasMixedResults: httpEndpoint.MethodExpr.HasMixedResults(), + RequestEncoderDeclaration: endpointSymbols.requestEncoder, + ResponseDecoder: endpointSymbols.responseDecoder.Name(), + ResponseDecoderDeclaration: endpointSymbols.responseDecoder, + Requirements: reqs, + } + if declaration := endpointSymbols.requestDecoder; declaration != nil { + ed.RequestDecoder = declaration.Name() + } + if declaration := endpointSymbols.responseEncoder; declaration != nil { + ed.ResponseEncoder = declaration.Name() + } + if declaration := endpointSymbols.errorEncoder; declaration != nil { + ed.ErrorEncoder = declaration.Name() + } + if declaration := endpointSymbols.requestEncoder; declaration != nil { + ed.RequestEncoder = declaration.Name() } if httpEndpoint.MethodExpr.IsStreaming() { sds.initWebSocketData(ed, httpEndpoint, sd) - initSSEData(ed, httpEndpoint, sd) + sds.initSSEData(ed, httpEndpoint, sd) + if ed.ServerWebSocket != nil { + ed.ServerWebSocket.VarDeclaration = endpointSymbols.serverStream + ed.ServerWebSocket.VarName = endpointSymbols.serverStream.Name() + } + if ed.ClientWebSocket != nil { + ed.ClientWebSocket.VarDeclaration = endpointSymbols.clientStream + ed.ClientWebSocket.VarName = endpointSymbols.clientStream.Name() + } + if ed.SSE != nil { + ed.SSE.StructName = endpointSymbols.serverStream.Name() + ed.SSE.StructDeclaration = endpointSymbols.serverStream + ed.SSE.ClientInterfaceDeclaration = endpointSymbols.sseClientInterface + ed.SSE.ClientStructDeclaration = endpointSymbols.sseClientStruct + ed.SSE.ClientInitDeclaration = endpointSymbols.sseClientInit + } } if httpEndpoint.MultipartRequest { ed.MultipartRequestDecoder = &MultipartData{ - FuncName: fmt.Sprintf("%s%sDecoderFunc", svc.StructName, method.VarName), - InitName: fmt.Sprintf("New%s%sDecoder", svc.StructName, method.VarName), - VarName: fmt.Sprintf("%s%sDecoderFn", svc.VarName, method.VarName), - ServiceName: svc.Name, - MethodName: method.Name, - Payload: ed.Payload, + FuncName: endpointSymbols.serverMultipart.functionType.Name(), + FuncDeclaration: endpointSymbols.serverMultipart.functionType, + InitName: endpointSymbols.serverMultipart.constructor.Name(), + InitDeclaration: endpointSymbols.serverMultipart.constructor, + VarName: fmt.Sprintf("%s%sDecoderFn", svc.VarName, method.VarName), + ServiceName: svc.Name, + MethodName: method.Name, + Payload: ed.Payload, } ed.MultipartRequestEncoder = &MultipartData{ - FuncName: fmt.Sprintf("%s%sEncoderFunc", svc.StructName, method.VarName), - InitName: fmt.Sprintf("New%s%sEncoder", svc.StructName, method.VarName), - VarName: fmt.Sprintf("%s%sEncoderFn", svc.VarName, method.VarName), - ServiceName: svc.Name, - MethodName: method.Name, - Payload: ed.Payload, + FuncName: endpointSymbols.clientMultipart.functionType.Name(), + FuncDeclaration: endpointSymbols.clientMultipart.functionType, + InitName: endpointSymbols.clientMultipart.constructor.Name(), + InitDeclaration: endpointSymbols.clientMultipart.constructor, + VarName: fmt.Sprintf("%s%sEncoderFn", svc.VarName, method.VarName), + ServiceName: svc.Name, + MethodName: method.Name, + Payload: ed.Payload, } } if httpEndpoint.SkipRequestBodyEncodeDecode { - ed.BuildStreamPayload = scope.Unique("Build" + codegen.Goify(method.Name, true) + "StreamPayload") + ed.BuildStreamPayload = endpointSymbols.buildStreamPayload.Name() + ed.BuildStreamPayloadDeclaration = endpointSymbols.buildStreamPayload } + ed.CLIPayloadDeclaration = endpointSymbols.cliPayload if httpEndpoint.Redirect != nil { ed.Redirect = &RedirectData{ @@ -1003,92 +1362,721 @@ func (sds *ServicesData) analyze(httpSvc *expr.HTTPServiceExpr) *ServiceData { } for _, a := range httpSvc.HTTPEndpoints { - collectUserTypes(sd.bodies.request(a).Type, func(ut expr.UserType) { - if d := sds.attributeTypeData(ut, true, true, true, sd); d != nil { - sd.ServerBodyAttributeTypes = append(sd.ServerBodyAttributeTypes, d) + sds.buildRequestAttributeTypes(sd.bodies.request(a), wireRequestBody, sd) + + if a.MethodExpr.StreamingPayload.Type != expr.Empty { + sds.buildRequestAttributeTypes(sd.bodies.streaming(a), wireStreamPayload, sd) + } + } + + return sd +} + +// buildRequestAttributeTypes builds nested request declarations from separate +// tagged copies because server and client packages apply different pointer and +// default policies to the same authored body graph. +func (sds *ServicesData) buildRequestAttributeTypes(body *expr.AttributeExpr, role wireTypeRole, data *ServiceData) { + for _, side := range []struct { + server bool + pointer bool + }{ + {server: true, pointer: true}, + {server: false, pointer: false}, + } { + body := expr.DupAtt(body) + addMarshalTags(body) + top, _ := body.Type.(expr.UserType) + collectUserTypes(body.Type, func(userType expr.UserType) { + if top != nil && userType.Origin() == top.Origin() { + return } - if d := sds.attributeTypeData(ut, true, false, false, sd); d != nil { - sd.ClientBodyAttributeTypes = append(sd.ClientBodyAttributeTypes, d) + declaration := sds.attributeTypeData(userType, true, side.pointer, side.server, wireUnionUse{role: role}, data) + if declaration == nil { + return + } + if side.server { + data.ServerBodyAttributeTypes = append(data.ServerBodyAttributeTypes, declaration) + } else { + data.ClientBodyAttributeTypes = append(data.ClientBodyAttributeTypes, declaration) } }) + } +} - if a.MethodExpr.StreamingPayload.Type != expr.Empty { - collectUserTypes(sd.bodies.streaming(a).Type, func(ut expr.UserType) { - if d := sds.attributeTypeData(ut, true, true, true, sd); d != nil { - sd.ServerBodyAttributeTypes = append(sd.ServerBodyAttributeTypes, d) +// collectPlannedWireTypes records every request and response type written by +// the generated client and server packages. NewPlans calls it before +// Generation.Freeze chooses Go names, and Link later uses these same copied +// values. +func collectPlannedWireTypes(api string, httpService *expr.HTTPServiceExpr, planned *plannedWireTypes, servicePlan *service.Plan) { + bodies, server, client := &planned.bodies, planned.server, planned.client + for _, endpoint := range httpService.HTTPEndpoints { + request := expr.DupAtt(bodies.request(endpoint)) + addMarshalTags(request) + serverRequestPolicy := jsonBodyPolicy(true, true, true, "") + clientRequestPolicy := jsonBodyPolicy(true, false, false, "") + server.collect(request, wireRequestBody, serverRequestPolicy, api) + server.addValidationRoot(request, serverRequestPolicy) + clientRequest := client.collect(request, wireRequestBody, clientRequestPolicy, api) + if userType, named := request.Type.(expr.UserType); named && userType.Attribute().Validation != nil { + client.addValidationRoot(request, clientRequestPolicy) + } + if clientRequest != nil && needInit(request.Type) { + clientRequest.needsConstructor = true + } else if needInit(request.Type) { + key := clientBodyConstructorKey{endpoint: endpoint, role: wireRequestBody} + planned.clientBodyConstructorNames[key] = anonymousClientBodyConstructorName(request, clientRequestPolicy) + } + requestUse := wireUnionUse{role: wireRequestBody} + server.collectChildren(request, requestUse, jsonBodyPolicy(true, true, true, ""), api) + client.collectChildren(request, requestUse, jsonBodyPolicy(true, false, true, ""), api) + if endpoint.MethodExpr.StreamingPayload.Type != expr.Empty { + streaming := expr.DupAtt(bodies.streaming(endpoint)) + addMarshalTags(streaming) + serverStreamPolicy := jsonBodyPolicy(true, true, true, "") + clientStreamPolicy := jsonBodyPolicy(true, false, false, "") + serverStream := server.collect(streaming, wireStreamPayload, serverStreamPolicy, api) + server.addValidationRoot(streaming, serverStreamPolicy) + if endpoint.UsesWebSocket() && needInit(endpoint.MethodExpr.StreamingPayload.Type) && serverStream != nil { + serverStream.needsConstructor = true + planned.streamPayloads[endpoint] = serverStream + } + clientStream := client.collect(streaming, wireStreamPayload, clientStreamPolicy, api) + if userType, named := streaming.Type.(expr.UserType); !named || userType.Attribute().Validation != nil { + client.addValidationRoot(streaming, clientStreamPolicy) + } + if clientStream != nil && needInit(streaming.Type) { + clientStream.needsConstructor = true + } else if needInit(streaming.Type) { + key := clientBodyConstructorKey{endpoint: endpoint, role: wireStreamPayload} + planned.clientBodyConstructorNames[key] = anonymousClientBodyConstructorName(streaming, clientStreamPolicy) + } + streamUse := wireUnionUse{role: wireStreamPayload} + server.collectChildren(streaming, streamUse, jsonBodyPolicy(true, true, true, ""), api) + client.collectChildren(streaming, streamUse, jsonBodyPolicy(true, false, true, ""), api) + } + if endpoint.UsesSSE() && endpoint.MethodExpr.HasMixedResults() { + body := bodies.streamingResult(endpoint) + collectResponseWireType(api, body, body, endpoint, server, true, nil, "") + collectResponseWireType(api, body, body, endpoint, client, false, nil, "") + } + + resultType, viewed := endpoint.MethodExpr.Result.Type.(*expr.ResultTypeExpr) + for _, response := range endpoint.Responses { + body := bodies.response(response) + if !viewed { + collectResponseWireType(api, body, body, endpoint, server, true, nil, "") + collectResponseWireType(api, body, body, endpoint, client, false, nil, "") + continue + } + origin := "" + if value, ok := body.Meta["origin:attribute"]; ok { + origin = value[0] + } + _, explicitBody := body.Meta["http:body"] + emptyView := "" + switch { + case origin != "": + collectResponseWireType(api, body, body, endpoint, server, true, &emptyView, "") + case endpoint.MethodExpr.Result.Meta != nil: + if view, ok := endpoint.MethodExpr.Result.Meta.Last(expr.ViewMetaKey); ok { + collectResponseWireType(api, body, body, endpoint, server, true, &view, "") + } else { + for _, view := range resultType.Views { + collectResponseWireType(api, body, body, endpoint, server, true, &view.Name, "") + } } - if d := sds.attributeTypeData(ut, true, false, false, sd); d != nil { - sd.ClientBodyAttributeTypes = append(sd.ClientBodyAttributeTypes, d) + default: + for _, view := range resultType.Views { + collectResponseWireType(api, body, body, endpoint, server, true, &view.Name, "") } + } + clientView := clientResponseViewNameExpr(endpoint, resultType) + if origin != "" { + emptyView := "" + collectResponseWireType(api, body, body, endpoint, client, false, &emptyView, "") + continue + } + if clientView == "" && explicitBody { + emptyView := "" + collectResponseWireType(api, body, body, endpoint, client, false, &emptyView, "") + continue + } + if clientView != "" { + clientBody := effectiveClientResponseBodyForView(body, clientView, endpoint) + collectResponseWireType(api, clientBody, body, endpoint, client, false, &clientView, "") + continue + } + for _, view := range resultType.Views { + clientBody := effectiveClientResponseBodyForView(body, view.Name, endpoint) + collectResponseWireType(api, clientBody, body, endpoint, client, false, &view.Name, "") + } + } + for _, transportError := range endpoint.HTTPErrors { + body := bodies.errorResponse(transportError) + collectResponseWireType(api, body, body, endpoint, server, true, nil, transportError.Name) + collectResponseWireType(api, body, body, endpoint, client, false, nil, transportError.Name) + } + collectPlannedTransforms(endpoint, planned, servicePlan) + } +} + +// anonymousClientBodyConstructorName returns the preferred function name for +// a request body that uses a Go expression such as []T instead of declaring a +// package type. +func anonymousClientBodyConstructorName(body *expr.AttributeExpr, policy wireTypePolicy) string { + scope := codegen.NewAttributeScope(codegen.NewNameScope()) + name := scope.Name(body, "", policy.pointer, policy.useDefault) + return "New" + codegen.Goify(name, true) +} + +// collectPlannedTransforms records each request, response, error, and stream +// conversion and stores its handle with the endpoint value that will write it. +// The generated package can then name every helper before Plan.Link. +func collectPlannedTransforms( + endpoint *expr.HTTPEndpointExpr, + planned *plannedWireTypes, + servicePlan *service.Plan, +) { + methodName := endpoint.MethodExpr.Name + bodies := &planned.bodies + server := planned.server + client := planned.client + servicePackage, viewsPackage, err := servicePlan.MethodPackageImports(endpoint.MethodExpr) + if err != nil { + panic(err) + } + request := expr.DupAtt(bodies.request(endpoint)) + addMarshalTags(request) + payload := endpoint.MethodExpr.Payload + if needInit(payload.Type) { + if request.Type != expr.Empty { + target := payload + if origin, ok := request.Meta["origin:attribute"]; ok { + target = expr.AsObject(payload.Type).Attribute(origin[0]) + } + requestTransforms := planned.transforms.request(endpoint, wireRequestBody) + if needInit(request.Type) { + requestTransforms.clientEncode = client.collectTransform(target, request, "marshal", methodName+" request body", wireTransformLayout{ + wireSide: wireTransformTarget, + wirePolicy: jsonBodyPolicy(true, false, false, ""), + wireUse: wireUnionUse{role: wireRequestBody}, + servicePackage: *servicePackage, + }) + } + requestTransforms.serverDecode = server.collectTransform(request, target, "unmarshal", methodName+" server payload", wireTransformLayout{ + wireSide: wireTransformSource, + wirePolicy: jsonBodyPolicy(true, true, false, ""), + wireUse: wireUnionUse{role: wireRequestBody}, + servicePackage: *servicePackage, }) + requestTransforms.clientDecode = client.collectTransform(request, target, "marshal", methodName+" command payload", wireTransformLayout{ + wireSide: wireTransformSource, + wirePolicy: jsonBodyPolicy(true, false, false, ""), + wireUse: wireUnionUse{role: wireRequestBody}, + servicePackage: *servicePackage, + }) + } else if expr.IsArray(payload.Type) || expr.IsMap(payload.Type) { + if params := expr.AsObject(endpoint.Params.Type); len(*params) > 0 { + requestTransforms := planned.transforms.request(endpoint, wireRequestBody) + requestTransforms.serverDecode = server.collectTransform((*params)[0].Attribute, payload, "unmarshal", methodName+" server parameters", wireTransformLayout{ + wireSide: wireTransformSource, + wirePolicy: wireTypePolicy{request: true, pointer: true}, + wireUse: wireUnionUse{role: wireRequestBody}, + servicePackage: *servicePackage, + }) + requestTransforms.clientDecode = client.collectTransform((*params)[0].Attribute, payload, "marshal", methodName+" command parameters", wireTransformLayout{ + wireSide: wireTransformSource, + wirePolicy: wireTypePolicy{request: true, useDefault: true}, + wireUse: wireUnionUse{role: wireRequestBody}, + servicePackage: *servicePackage, + }) + } } + } - md := sd.Service.Method(a.Name()) - for _, v := range a.Responses { - body := effectiveClientResponseBody(sd.bodies.response(v), a, md) - collectUserTypes(body.Type, func(ut expr.UserType) { - // NOTE: ServerBodyAttributeTypes for response body types are - // collected in buildResponseBodyType because we have to generate - // body types for each view in a result type. - if d := sds.attributeTypeData(ut, false, true, false, sd); d != nil { - sd.ClientBodyAttributeTypes = append(sd.ClientBodyAttributeTypes, d) + result := endpoint.MethodExpr.Result + resultType, viewed := result.Type.(*expr.ResultTypeExpr) + resultPackage := *servicePackage + if viewed { + if viewsPackage == nil { + panic(fmt.Sprintf("viewed method %q has no views package", methodName)) + } + resultPackage = *viewsPackage + result, err = servicePlan.ProjectedResult(endpoint.MethodExpr) + if err != nil { + panic(err) + } + } + for _, response := range endpoint.Responses { + body := bodies.response(response) + origin := "" + if value, ok := body.Meta["origin:attribute"]; ok { + origin = value[0] + } + _, explicitBody := body.Meta["http:body"] + resultAttribute := result + if origin != "" { + resultAttribute = expr.AsObject(result.Type).Attribute(origin) + } + var serverViews []*string + switch { + case !viewed: + serverViews = []*string{nil} + case origin != "": + empty := "" + serverViews = []*string{&empty} + case endpoint.MethodExpr.Result.Meta != nil: + if view, ok := endpoint.MethodExpr.Result.Meta.Last(expr.ViewMetaKey); ok { + serverViews = []*string{&view} + } else { + for index := range resultType.Views { + serverViews = append(serverViews, &resultType.Views[index].Name) } - }) + } + default: + for index := range resultType.Views { + serverViews = append(serverViews, &resultType.Views[index].Name) + } + } + for _, view := range serverViews { + prepared, viewName := prepareResponseWireBody(body, view) + if prepared.Type != expr.Empty && resultAttribute.Type != expr.Empty && needInit(prepared.Type) { + responseTransforms := planned.transforms.response(endpoint, response, viewName) + responseTransforms.serverEncode = server.collectTransform(resultAttribute, prepared, "marshal", transformResponseOwner(methodName, response, view, "server"), wireTransformLayout{ + wireSide: wireTransformTarget, + wirePolicy: jsonBodyPolicy(false, true, false, viewName), + wireUse: wireUnionUse{role: wireResponseBody, view: viewName}, + servicePointer: view != nil, + servicePackage: resultPackage, + }) + } } - for _, v := range a.HTTPErrors { - collectUserTypes(sd.bodies.errorResponse(v).Type, func(ut expr.UserType) { - // NOTE: ServerBodyAttributeTypes for error response body types are - // collected in buildResponseBodyType because we have to generate - // body types for each view in a result type. - if d := sds.attributeTypeData(ut, false, true, false, sd); d != nil { - sd.ClientBodyAttributeTypes = append(sd.ClientBodyAttributeTypes, d) + if !needClientResponseInit(result.Type) { + continue + } + clientViewCount := 1 + if viewed { + clientViewCount += len(resultType.Views) + } + clientViews := make([]*string, 0, clientViewCount) + if !viewed { + clientViews = append(clientViews, nil) + } else { + selected := clientResponseViewNameExpr(endpoint, resultType) + switch { + case origin != "": + empty := "" + clientViews = append(clientViews, &empty) + case selected != "": + clientViews = append(clientViews, &selected) + case explicitBody: + empty := "" + clientViews = append(clientViews, &empty) + default: + for index := range resultType.Views { + clientViews = append(clientViews, &resultType.Views[index].Name) } + } + } + for _, view := range clientViews { + clientBody := body + if view != nil && *view != "" { + clientBody = effectiveClientResponseBodyForView(body, *view, endpoint) + } + prepared, viewName := prepareResponseWireBody(clientBody, view) + if prepared.Type != expr.Empty { + responseTransforms := planned.transforms.response(endpoint, response, viewName) + responseTransforms.clientDecode = client.collectTransform(prepared, resultAttribute, "unmarshal", transformResponseOwner(methodName, response, view, "client"), wireTransformLayout{ + wireSide: wireTransformSource, + wirePolicy: jsonBodyPolicy(false, false, true, viewName), + wireUse: wireUnionUse{role: wireResponseBody, view: viewName}, + servicePointer: viewed, + servicePackage: resultPackage, + }) + } + } + if body.Type == expr.Empty && (expr.IsArray(result.Type) || expr.IsMap(result.Type)) { + if params := expr.AsObject(endpoint.QueryParams().Type); len(*params) > 0 { + responseTransforms := planned.transforms.response(endpoint, response, "") + responseTransforms.clientDecode = client.collectTransform((*params)[0].Attribute, result, "unmarshal", transformResponseOwner(methodName, response, nil, "client parameters"), wireTransformLayout{ + wireSide: wireTransformSource, + wirePolicy: wireTypePolicy{pointer: true}, + wireUse: wireUnionUse{role: wireResponseBody}, + servicePointer: viewed, + servicePackage: resultPackage, + }) + } + } + } + + for _, transportError := range endpoint.HTTPErrors { + body, _ := prepareResponseWireBody(bodies.errorResponse(transportError), nil) + target := endpoint.MethodExpr.Error(transportError.Name).AttributeExpr + if origin, ok := body.Meta["origin:attribute"]; ok { + target = expr.AsObject(target.Type).Attribute(origin[0]) + } + if body.Type != expr.Empty && needInit(transportError.Type) { + errorTransforms := planned.transforms.transportError(transportError) + if needInit(body.Type) { + errorTransforms.serverEncode = server.collectTransform(target, body, "marshal", methodName+" server error "+transportError.Name, wireTransformLayout{ + wireSide: wireTransformTarget, + wirePolicy: jsonBodyPolicy(false, true, false, ""), + wireUse: wireUnionUse{role: wireResponseBody}, + servicePackage: *servicePackage, + }) + } + errorTransforms.clientDecode = client.collectTransform(body, target, "unmarshal", methodName+" client error "+transportError.Name, wireTransformLayout{ + wireSide: wireTransformSource, + wirePolicy: jsonBodyPolicy(false, false, true, ""), + wireUse: wireUnionUse{role: wireResponseBody}, + servicePackage: *servicePackage, }) + } else if body.Type == expr.Empty && (expr.IsArray(transportError.Type) || expr.IsMap(transportError.Type)) { + if params := expr.AsObject(endpoint.QueryParams().Type); len(*params) > 0 { + errorTransforms := planned.transforms.transportError(transportError) + errorTransforms.clientDecode = client.collectTransform((*params)[0].Attribute, endpoint.MethodExpr.Error(transportError.Name).AttributeExpr, "unmarshal", methodName+" client error parameters "+transportError.Name, wireTransformLayout{ + wireSide: wireTransformSource, + wirePolicy: wireTypePolicy{pointer: true}, + wireUse: wireUnionUse{role: wireResponseBody}, + servicePackage: *servicePackage, + }) + } } } - unionByHash := make(map[string]*service.UnionTypeData) - seenUnionTypes := make(map[string]struct{}) - for _, a := range httpSvc.HTTPEndpoints { - collectHTTPUnionTypes(sd.bodies.request(a), sd.Scope, unionByHash, seenUnionTypes) + if endpoint.MethodExpr.StreamingPayload.Type != expr.Empty && endpoint.UsesWebSocket() { + body := expr.DupAtt(bodies.streaming(endpoint)) + addMarshalTags(body) + if body.Type != expr.Empty && needInit(endpoint.MethodExpr.StreamingPayload.Type) { + requestTransforms := planned.transforms.request(endpoint, wireStreamPayload) + requestTransforms.serverDecode = server.collectTransform(body, endpoint.MethodExpr.StreamingPayload, "marshal", methodName+" server stream payload", wireTransformLayout{ + wireSide: wireTransformSource, + wirePolicy: jsonBodyPolicy(true, true, false, ""), + wireUse: wireUnionUse{role: wireStreamPayload}, + servicePackage: *servicePackage, + }) + requestTransforms.clientEncode = client.collectTransform(endpoint.MethodExpr.StreamingPayload, body, "marshal", methodName+" client stream body", wireTransformLayout{ + wireSide: wireTransformTarget, + wirePolicy: jsonBodyPolicy(true, false, false, ""), + wireUse: wireUnionUse{role: wireStreamPayload}, + servicePackage: *servicePackage, + }) + } + } + if endpoint.UsesSSE() && endpoint.MethodExpr.HasMixedResults() { + body, _ := prepareResponseWireBody(bodies.streamingResult(endpoint), nil) + result := endpoint.MethodExpr.StreamingResult + streamTransforms := planned.transforms.streamingResult(endpoint) + serviceLayout, err := servicePlan.StreamingResultLayout(endpoint.MethodExpr) + if err != nil { + panic(err) + } + direct, err := sameMixedSSERepresentation(body, serviceLayout) + if err != nil { + panic(err) + } + if body.Type != expr.Empty && direct { + streamTransforms.clientDecodeDirect = true + } + if body.Type != expr.Empty && !streamTransforms.clientDecodeDirect { + if needInit(body.Type) { + streamTransforms.serverEncode = server.collectTransform(result, body, "marshal", methodName+" server streaming result", wireTransformLayout{ + wireSide: wireTransformTarget, + wirePolicy: jsonBodyPolicy(false, true, false, ""), + wireUse: wireUnionUse{role: wireResponseBody}, + servicePackage: *servicePackage, + }) + } + streamTransforms.clientDecode = client.collectTransform(body, result, "unmarshal", methodName+" client streaming result", wireTransformLayout{ + wireSide: wireTransformSource, + wirePolicy: jsonBodyPolicy(false, false, true, ""), + wireUse: wireUnionUse{role: wireResponseBody}, + servicePackage: *servicePackage, + }) + } + } +} - if a.MethodExpr.StreamingPayload.Type != expr.Empty { - collectHTTPUnionTypes(sd.bodies.streaming(a), sd.Scope, unionByHash, seenUnionTypes) +// sameMixedSSERepresentation compares the retained service layout with the +// wire layout decoded by the client. Named values, unions, and structs always +// use a planned conversion; primitive values and their collections are direct +// only when every retained Go type detail matches. +func sameMixedSSERepresentation(wire *expr.AttributeExpr, serviceLayout *codegen.GoTypePlan) (bool, error) { + if !mixedSSEDirectLayout(serviceLayout) { + return false, nil + } + wireLayout, err := codegen.PlanGoType(wire, codegen.GoTypePlanOptions{ + Owner: serviceLayout.Owner(), + Policy: serviceLayout.Policy(), + }) + if err != nil { + return false, err + } + return serviceLayout.Equivalent(wireLayout), nil +} + +// mixedSSEDirectLayout reports whether a layout can be assigned without any +// generated declaration or field-by-field conversion. +func mixedSSEDirectLayout(layout *codegen.GoTypePlan) bool { + switch layout.Kind() { + case codegen.GoPrimitive: + return true + case codegen.GoArray: + return mixedSSEDirectLayout(layout.Elem()) + case codegen.GoMap: + return mixedSSEDirectLayout(layout.Key()) && mixedSSEDirectLayout(layout.Elem()) + default: + return false + } +} + +// request returns the retained conversions for one ordinary or streamed +// request body, creating the record during collection when needed. +func (p *plannedWireTransforms) request( + endpoint *expr.HTTPEndpointExpr, + role wireTypeRole, +) *plannedRequestTransforms { + key := clientBodyConstructorKey{endpoint: endpoint, role: role} + transforms := p.requests[key] + if transforms == nil { + transforms = &plannedRequestTransforms{} + p.requests[key] = transforms + } + return transforms +} + +// response returns the retained conversions for one status, tag, and view +// representation, creating the record during collection when needed. +func (p *plannedWireTransforms) response( + endpoint *expr.HTTPEndpointExpr, + response *expr.HTTPResponseExpr, + view string, +) *plannedResponseTransforms { + key := viewedConstructorKey{endpoint: endpoint, response: response, view: view} + transforms := p.responses[key] + if transforms == nil { + transforms = &plannedResponseTransforms{} + p.responses[key] = transforms + } + return transforms +} + +// transportError returns the retained conversions for one designed error, +// creating the record during collection when needed. +func (p *plannedWireTransforms) transportError( + transportError *expr.HTTPErrorExpr, +) *plannedResponseTransforms { + transforms := p.errors[transportError] + if transforms == nil { + transforms = &plannedResponseTransforms{} + p.errors[transportError] = transforms + } + return transforms +} + +// streamingResult returns the retained conversions for a mixed method's +// streamed result, creating the record during collection when needed. +func (p *plannedWireTransforms) streamingResult( + endpoint *expr.HTTPEndpointExpr, +) *plannedResponseTransforms { + transforms := p.streamingResults[endpoint] + if transforms == nil { + transforms = &plannedResponseTransforms{} + p.streamingResults[endpoint] = transforms + } + return transforms +} + +// transformResponseOwner returns the method, transport side, status, tag, and +// view values that distinguish helper functions for two responses with the +// same generated Go types. +func transformResponseOwner(method string, response *expr.HTTPResponseExpr, view *string, side string) string { + viewName := "" + if view != nil { + viewName = *view + } + return fmt.Sprintf("%s %s response %d %s %s %s", method, side, response.StatusCode, response.Tag[0], response.Tag[1], viewName) +} + +// collectResponseWireType applies the selected view and records response body +// declarations using the same policy later consumed by buildResponseBodyType. +func collectResponseWireType( + api string, + body *expr.AttributeExpr, + releasedBody *expr.AttributeExpr, + endpoint *expr.HTTPEndpointExpr, + catalog *wireTypeCatalog, + server bool, + view *string, + errorName string, +) { + body, viewName := prepareResponseWireBody(body, view) + releasedNames := releasedResponseWireNames(releasedBody, body, view, endpoint) + policy := jsonBodyPolicy(false, server, !server, viewName) + preferred := "" + if server && !expr.IsPrimitive(body.Type) && needInit(body.Type) { + if _, userType := body.Type.(expr.UserType); !userType { + preferred = codegen.Goify(endpoint.Name(), true) + "ResponseBody" } + } + record := catalog.collectWithReleasedNames(body, wireResponseBody, policy, preferred, releasedNames, api) + if record != nil && errorName != "" { + record.addErrorUse(wireErrorUse{ + service: endpoint.Service.Name(), + method: endpoint.Name(), + name: errorName, + }) + } + if policy.validate { + catalog.addValidationRoot(body, policy) + } + if server && record != nil && needInit(body.Type) { + record.needsConstructor = true + } + attributePolicy := jsonBodyPolicy(false, server, !server, "") + responseUse := wireUnionUse{role: wireResponseBody, view: viewName} + catalog.collectChildrenWithReleasedNames(body, responseUse, attributePolicy, releasedNames) +} - md := sd.Service.Method(a.Name()) - for _, v := range a.Responses { - collectHTTPUnionTypes(effectiveClientResponseBody(sd.bodies.response(v), a, md), sd.Scope, unionByHash, seenUnionTypes) +// prepareResponseWireBody copies the response body, selects the requested view +// fields, and adds JSON tags. Collection, declaration generation, and client +// response conversion all use the returned shape. +func prepareResponseWireBody(body *expr.AttributeExpr, view *string) (*expr.AttributeExpr, string) { + body = expr.DupAtt(body) + viewName := "" + if view != nil && *view != "" { + viewName = *view + if resultType, ok := body.Type.(*expr.ResultTypeExpr); ok { + projected, err := expr.Project(resultType, *view) + if err != nil { + panic(err) + } + body.Type = projected } + } + addMarshalTags(body) + return body, viewName +} - for _, v := range a.HTTPErrors { - collectHTTPUnionTypes(sd.bodies.errorResponse(v), sd.Scope, unionByHash, seenUnionTypes) +// releasedResponseWireNames returns the response type names produced when Goa +// added transport suffixes before selecting a result view. +func releasedResponseWireNames(original, prepared *expr.AttributeExpr, view *string, endpoint *expr.HTTPEndpointExpr) map[expr.UserType]string { + released := expr.DupAtt(original) + suffix := releasedWireTypeSuffix(released, wireResponseBody) + if userType, ok := released.Type.(expr.UserType); ok { + appendReleasedWireSuffix(userType.Attribute().Type, suffix, make(map[expr.UserType]struct{})) + } else { + appendReleasedWireSuffix(released.Type, suffix, make(map[expr.UserType]struct{})) + } + released, _ = prepareResponseWireBody(released, view) + addJSONRPCSSEViewFields(released.Type, original.Type, endpoint) + names := make(map[expr.UserType]string) + collectReleasedWireNames(prepared.Type, released.Type, names, make(map[releasedWireTypePair]struct{})) + if collection, ok := prepared.Type.(*expr.ResultTypeExpr); ok { + if array := expr.AsArray(collection.Attribute().Type); array != nil { + if element, ok := array.ElemType.Type.(expr.UserType); ok { + names[collection] = names[element] + "Collection" + } } } + return names +} - unions := make([]*service.UnionTypeData, 0, len(unionByHash)) - for _, u := range unionByHash { - unions = append(unions, u) +// appendReleasedWireSuffix reproduces the order used by released Goa versions +// on a private copy of the response type. +func appendReleasedWireSuffix(dataType expr.DataType, suffix string, seen map[expr.UserType]struct{}) { + switch actual := dataType.(type) { + case expr.UserType: + if _, ok := seen[actual]; ok { + return + } + seen[actual] = struct{}{} + actual.Rename(actual.Name() + suffix) + appendReleasedWireSuffix(actual.Attribute().Type, suffix, seen) + case *expr.Object: + for _, named := range *actual { + appendReleasedWireSuffix(named.Attribute.Type, suffix, seen) + } + case *expr.Array: + appendReleasedWireSuffix(actual.ElemType.Type, suffix, seen) + case *expr.Map: + appendReleasedWireSuffix(actual.KeyType.Type, suffix, seen) + appendReleasedWireSuffix(actual.ElemType.Type, suffix, seen) + case *expr.Union: + for _, named := range actual.Values { + appendReleasedWireSuffix(named.Attribute.Type, suffix, seen) + } } - sort.Slice(unions, func(i, j int) bool { - return unions[i].Name < unions[j].Name - }) - sd.UnionTypes = unions +} - return sd +// collectReleasedWireNames pairs the current response types with the names +// from the earlier suffix-before-view order. +func collectReleasedWireNames(current, released expr.DataType, names map[expr.UserType]string, seen map[releasedWireTypePair]struct{}) { + currentUser, currentNamed := current.(expr.UserType) + releasedUser, releasedNamed := released.(expr.UserType) + if currentNamed || releasedNamed { + if !currentNamed || !releasedNamed { + panic("response view changed whether a generated type is named") + } + pair := releasedWireTypePair{current: currentUser, released: releasedUser} + if _, ok := seen[pair]; ok { + return + } + seen[pair] = struct{}{} + names[currentUser] = codegen.Goify(releasedUser.Name(), true) + collectReleasedWireNames(currentUser.Attribute().Type, releasedUser.Attribute().Type, names, seen) + return + } + + switch currentType := current.(type) { + case *expr.Object: + releasedType, ok := released.(*expr.Object) + if !ok { + panic("response view changed the generated object shape") + } + for _, named := range *currentType { + other := releasedType.Attribute(named.Name) + if other == nil { + panic("response view changed a generated field name") + } + collectReleasedWireNames(named.Attribute.Type, other.Type, names, seen) + } + case *expr.Array: + releasedType, ok := released.(*expr.Array) + if !ok { + panic("response view changed the generated array shape") + } + collectReleasedWireNames(currentType.ElemType.Type, releasedType.ElemType.Type, names, seen) + case *expr.Map: + releasedType, ok := released.(*expr.Map) + if !ok { + panic("response view changed the generated map shape") + } + collectReleasedWireNames(currentType.KeyType.Type, releasedType.KeyType.Type, names, seen) + collectReleasedWireNames(currentType.ElemType.Type, releasedType.ElemType.Type, names, seen) + case *expr.Union: + releasedType, ok := released.(*expr.Union) + if !ok || len(currentType.Values) != len(releasedType.Values) { + panic("response view changed the generated union shape") + } + for index, named := range currentType.Values { + collectReleasedWireNames(named.Attribute.Type, releasedType.Values[index].Attribute.Type, names, seen) + } + } } // makeHTTPType traverses the attribute recursively and performs these actions: // // * removes aliased user type by replacing them with the underlying type. -// * changes unions into structs with Type and Value fields. +// * keeps unions as generated values with one selected branch. func makeHTTPType(att *expr.AttributeExpr) *expr.AttributeExpr { att = expr.DupAtt(att) - return makeHTTPTypeRecursive(att, make(map[string]struct{})) + return makeHTTPTypeRecursive(att, make(map[expr.UserType]struct{})) } -func makeHTTPTypeRecursive(att *expr.AttributeExpr, seen map[string]struct{}) *expr.AttributeExpr { +func makeHTTPTypeRecursive(att *expr.AttributeExpr, seen map[expr.UserType]struct{}) *expr.AttributeExpr { + delete(att.Meta, "struct:pkg:path") switch dt := att.Type.(type) { case expr.UserType: if dt == expr.Empty { @@ -1111,10 +2099,11 @@ func makeHTTPTypeRecursive(att *expr.AttributeExpr, seen map[string]struct{}) *e att.DefaultValue = dt.Attribute().DefaultValue att.UserExamples = dt.Attribute().UserExamples } - if _, ok := seen[dt.ID()]; ok { + origin := dt.Origin() + if _, ok := seen[origin]; ok { return att } - seen[dt.ID()] = struct{}{} + seen[origin] = struct{}{} dt.SetAttribute(makeHTTPTypeRecursive(dt.Attribute(), seen)) case *expr.Array: dt.ElemType = makeHTTPTypeRecursive(dt.ElemType, seen) @@ -1128,8 +2117,11 @@ func makeHTTPTypeRecursive(att *expr.AttributeExpr, seen map[string]struct{}) *e } att.Type = &obj case *expr.Union: - // Unions are represented as first-class sum types; HTTP uses the same - // type for request and response bodies. + // Prepare every branch before the HTTP package catalog assigns the union's + // request, streaming request, response, or response-view name. + for _, branch := range dt.Values { + branch.Attribute = makeHTTPTypeRecursive(branch.Attribute, seen) + } } return att } @@ -1150,11 +2142,10 @@ func (b *shapedBodies) request(e *expr.HTTPEndpointExpr) *expr.AttributeExpr { return att } -// streaming returns the streaming request body for the given endpoint. The -// returned attribute is a detached copy of the design body so that marshal -// tag meta may be added to it without affecting the design expression tree. -// Streaming bodies are not shaped with makeHTTPType: aliased user types have -// never been flattened in streaming bodies. +// streaming returns a copy of the endpoint's streaming request body. Generated +// JSON field information may be added to the copy without changing the authored +// design. Streaming requests keep named user types instead of replacing them +// with their fields. func (b *shapedBodies) streaming(e *expr.HTTPEndpointExpr) *expr.AttributeExpr { if att, ok := b.streams[e]; ok { return att @@ -1163,10 +2154,26 @@ func (b *shapedBodies) streaming(e *expr.HTTPEndpointExpr) *expr.AttributeExpr { b.streams = make(map[*expr.HTTPEndpointExpr]*expr.AttributeExpr) } att := expr.DupAtt(e.StreamingBody) + expr.RemovePkgPath(att) b.streams[e] = att return att } +// streamingResult returns the JSON body written for each result in a mixed SSE +// stream. It copies the service result before applying HTTP field names so +// generation never changes the authored service type. +func (b *shapedBodies) streamingResult(e *expr.HTTPEndpointExpr) *expr.AttributeExpr { + if att, ok := b.streamResults[e]; ok { + return att + } + if b.streamResults == nil { + b.streamResults = make(map[*expr.HTTPEndpointExpr]*expr.AttributeExpr) + } + att := makeHTTPType(e.MethodExpr.StreamingResult) + b.streamResults[e] = att + return att +} + // response returns the shaped HTTP body for the given success response, see // request. func (b *shapedBodies) response(resp *expr.HTTPResponseExpr) *expr.AttributeExpr { @@ -1200,31 +2207,76 @@ func (b *shapedBodies) errorResponse(v *expr.HTTPErrorExpr) *expr.AttributeExpr // used by the request body type recursively if any. func (sds *ServicesData) buildPayloadData(e *expr.HTTPEndpointExpr, sd *ServiceData) *PayloadData { httpBody := sd.bodies.request(e) + serverHTTPBody := expr.DupAtt(httpBody) + clientHTTPBody := expr.DupAtt(httpBody) + if httpBody.Type != expr.Empty { + addMarshalTags(serverHTTPBody) + addMarshalTags(clientHTTPBody) + serverPolicy := jsonBodyPolicy(true, true, true, "") + clientPolicy := jsonBodyPolicy(true, false, true, "") + sd.serverWireTypes.applyNames(serverHTTPBody, wireRequestBody, serverPolicy) + sd.clientWireTypes.applyNames(clientHTTPBody, wireRequestBody, clientPolicy) + } var ( - payload = e.MethodExpr.Payload - svc = sd.Service - body = httpBody.Type - ep = svc.Method(e.MethodExpr.Name) - httpsvrctx = httpContext(sd.Scope, true, true) - httpclictx = httpContext(sd.Scope, true, false) - pkg = ep.PayloadLoc.PackageNameOrDefault(svc.PkgName) - svcctx = serviceContext(pkg, sd.Service.Scope) - - request *RequestData - mapQueryParam *ParamData + payload = e.MethodExpr.Payload + svc = sd.Service + body = httpBody.Type + ep = svc.Method(e.MethodExpr.Name) + httpsvrctx = jsonBodyContext(sd.serverWireTypes, sd.serverWireTypes.scope, true, true) + httpclictx = jsonBodyContext(sd.clientWireTypes, sd.clientWireTypes.scope, true, false) + svcsvrctx = sds.serviceTypeContext(sd, "server").Enter(payload) + svcclictx = sds.serviceTypeContext(sd, "client").Enter(payload) + payloadOwner = expr.MethodPayloadExampleIdentity(e.MethodExpr) + bodyOwner = expr.RequestBodyExampleIdentity(e) + + request *RequestData + mapQueryParam *ParamData + origin string + originAttribute *expr.AttributeExpr + bodyDefault any + ) + httpsvrctx.Scope = sd.serverWireTypes.resolverForUse( + sd.serverWireTypes.scope, + jsonBodyPolicy(true, true, true, ""), + wireUnionUse{role: wireRequestBody}, + ) + httpclictx.Scope = sd.clientWireTypes.resolverForUse( + sd.clientWireTypes.scope, + jsonBodyPolicy(true, false, true, ""), + wireUnionUse{role: wireRequestBody}, ) + idPolicy := jsonRPCRequestIDPolicyFor(e) + var idData *JSONRPCRequestIDData + if idPolicy != nil { + idData = &JSONRPCRequestIDData{Generate: idPolicy.generates()} + if idPolicy.attribute != nil { + field := idPolicy.attribute + attribute := field.Attribute + variable := sd.Scope.Name("requestID") + idData.Attribute = codegen.Goify(field.Name, true) + idData.Variable = variable + idData.ValueTypeRef = svcsvrctx.Scope.Ref(attribute, svcsvrctx.Pkg(attribute)) + idData.Aliased = expr.IsAlias(attribute.Type) + idData.Required = idPolicy.required + idData.HasDefault = idPolicy.defaultValue != nil + idData.MustHave = idPolicy.required && !idData.HasDefault + idData.Pointer = idPolicy.pointer + if idData.HasDefault { + idData.DefaultValue = idPolicy.defaultValue.(string) + } + idData.Validate = codegen.AttributeValidationCode(attribute, nil, svcsvrctx, idPolicy.required, idData.Aliased, variable, field.Name) + } + } { var ( - serverBodyData = sds.buildRequestBodyType(httpBody, payload, e, true, sd) - clientBodyData = sds.buildRequestBodyType(httpBody, payload, e, false, sd) - paramsData = sds.extractPathParams(e.PathParams(), payload, sd.Scope) - queryData = sds.extractQueryParams(e.QueryParams(), payload, sd.Scope) - headersData = sds.extractHeaders(e.Headers, payload, svcctx, sd.Scope) - cookiesData = sds.extractCookies(e.Cookies, payload, svcctx, sd.Scope) - origin string - - mustValidate bool - mustHaveBody = true + serverBodyData = sds.buildRequestBodyType(httpBody, payload, e, wireRequestBody, true, sd, payloadOwner, bodyOwner) + clientBodyData = sds.buildRequestBodyType(httpBody, payload, e, wireRequestBody, false, sd, payloadOwner, bodyOwner) + paramsData = sds.extractPathParams(e.PathParams(), payload, sd, payloadOwner) + queryData = sds.extractQueryParams(e.QueryParams(), payload, sd, payloadOwner) + headersData = sds.extractHeaders(e.Headers, payload, svcsvrctx, sd.Scope, payloadOwner) + cookiesData = sds.extractCookies(e.Cookies, payload, svcsvrctx, sd.Scope, payloadOwner) + mustValidate bool + mustHaveBody = true ) if e.MapQueryParams != nil { var ( @@ -1240,32 +2292,36 @@ func (sds *ServicesData) buildPayloadData(e *expr.HTTPEndpointExpr, sd *ServiceD fieldName = codegen.Goify(name, true) } varn := codegen.Goify(name, false) + typeName := sd.Scope.GoTypeName(pAtt) + typeRef := sd.Scope.GoTypeRef(pAtt) + validate := codegen.AttributeValidationCode(pAtt, nil, httpsvrctx, required, expr.IsAlias(pAtt.Type), varn, name) mapQueryParam = &ParamData{ MapQueryParams: e.MapQueryParams, - Map: expr.AsMap(payload.Type) != nil, Element: &Element{ HTTPName: name, AttributeData: &AttributeData{ - Name: name, - VarName: varn, - FieldName: fieldName, - FieldType: pAtt.Type, - Required: required, - Type: pAtt.Type, - TypeName: sd.Scope.GoTypeName(pAtt), - TypeRef: sd.Scope.GoTypeRef(pAtt), - Validate: codegen.AttributeValidationCode(pAtt, nil, httpsvrctx, required, expr.IsAlias(pAtt.Type), varn, name), + Name: name, + VarName: varn, + FieldName: fieldName, + FieldType: pAtt.Type, + Required: required, + Type: pAtt.Type, + TypeName: typeName, + TypeRef: typeRef, + Validate: validate, + CLIPlan: cli.NewFlagPlan( + pAtt, + typeName, + typeRef, + cliValidationRenderer(validate != "", pAtt, httpsvrctx, name), + ), DefaultValue: pAtt.DefaultValue, - Example: pAtt.Example(sds.Root.API.ExampleGenerator.Field(e.MethodExpr.Payload, name)), + Example: sds.FieldExample(pAtt, e.MethodExpr.Payload, name, payloadOwner), }, }, } queryData = append(queryData, mapQueryParam) } - if serverBodyData != nil { - sd.ServerTypeNames[serverBodyData.Name] = struct{}{} - sd.ClientTypeNames[serverBodyData.Name] = struct{}{} - } for _, p := range cookiesData { if p.Required || p.Validate != "" || needConversion(p.Type) { mustValidate = true @@ -1290,7 +2346,7 @@ func (sds *ServicesData) buildPayloadData(e *expr.HTTPEndpointExpr, sd *ServiceD } if !mustValidate { for _, h := range headersData { - if h.Validate != "" || h.Required || needConversion(h.Type) { + if h.PreserveEmpty || h.Validate != "" || h.Required || needConversion(h.Type) { mustValidate = true break } @@ -1300,25 +2356,56 @@ func (sds *ServicesData) buildPayloadData(e *expr.HTTPEndpointExpr, sd *ServiceD // If design uses Body("name") syntax we need to use the // corresponding attribute in the result type for body // transformation. - if o, ok := httpBody.Meta["origin:attribute"]; ok { + if o, ok := serverHTTPBody.Meta["origin:attribute"]; ok { origin = o[0] + originAttribute = expr.AsObject(payload.Type).Attribute(origin) + bodyDefault = payload.GetDefault(origin) if !payload.IsRequired(o[0]) { mustHaveBody = false } } } + bodyIsUnion := originAttribute != nil && expr.AsUnion(originAttribute.Type) != nil + bodyFieldPointer := origin != "" && payload.IsPrimitivePointer(origin, true) + bodyFieldCanBeAbsent := originAttribute != nil && !payload.IsRequired(origin) && + (bodyFieldPointer || codegen.IsNilable(originAttribute.Type) || bodyIsUnion) request = &RequestData{ - PathParams: paramsData, - QueryParams: queryData, - Headers: headersData, - Cookies: cookiesData, - ServerBody: serverBodyData, - ClientBody: clientBodyData, - PayloadAttr: codegen.Goify(origin, true), - PayloadType: e.MethodExpr.Payload.Type, - MustHaveBody: mustHaveBody, - MustValidate: mustValidate, - Multipart: e.MultipartRequest, + PathParams: paramsData, + QueryParams: queryData, + Headers: headersData, + Cookies: cookiesData, + ServerBody: serverBodyData, + ClientBody: clientBodyData, + PayloadAttr: codegen.Goify(origin, true), + PayloadType: e.MethodExpr.Payload.Type, + MustHaveBody: mustHaveBody, + OptionalBody: origin != "" && !payload.IsRequired(origin), + BodyIsUnion: bodyIsUnion, + BodyFieldPointer: bodyFieldPointer, + BodyFieldCanBeAbsent: bodyFieldCanBeAbsent, + MustValidate: mustValidate, + Multipart: e.MultipartRequest, + } + if e.IsJSONRPC() && clientBodyData != nil { + paramsTypeRef := clientBodyData.Ref + if clientBodyData.Init == nil { + paramsAttribute := payload + if origin != "" { + paramsAttribute = expr.AsObject(payload.Type).Attribute(origin) + } + paramsContext := svcclictx.Enter(paramsAttribute) + paramsLayout, layoutErr := paramsContext.Scope.(codegen.GoTypeLayoutResolver).GoTypeLayout(paramsAttribute, paramsContext.LayoutPolicy()) + if layoutErr != nil { + sds.recordLinkError(layoutErr) + return nil + } + if bodyFieldPointer { + paramsTypeRef = paramsLayout.RefWithPointer(true) + } else { + paramsTypeRef = paramsLayout.Ref() + } + } + request.JSONRPCParams = jsonRPCParams(httpBody.Type, paramsTypeRef, mustHaveBody, !mustHaveBody) } } @@ -1327,23 +2414,20 @@ func (sds *ServicesData) buildPayloadData(e *expr.HTTPEndpointExpr, sd *ServiceD // generate constructor function to transform request body, // params, headers and cookies into the method payload type var ( - name string - desc string - isObject bool - clientArgs []*InitArgData - serverArgs []*InitArgData + name string + desc string + isObject bool + clientArgs []*InitArgData + serverArgs []*InitArgData + serverBodyDereference bool + clientBodyDereference bool ) argsCap := len(request.PathParams) + len(request.QueryParams) + len(request.Headers) + len(request.Cookies) - n := codegen.Goify(ep.Name, true) - p := codegen.Goify(ep.Payload, true) - // Raw payload object has type name prefixed with endpoint name. No need to - // prefix the type name again. - if strings.HasPrefix(p, n) { - p = svc.Scope.HashedUnique(payload.Type, p) - name = fmt.Sprintf("New%s", p) - } else { - name = fmt.Sprintf("New%s%s", n, p) + declaration := sds.payloadConstructors[e] + if declaration == nil { + panic(fmt.Sprintf("payload constructor for %s.%s was not submitted", svc.Name, e.Name())) } + name = declaration.Name() desc = fmt.Sprintf("%s builds a %s service %s endpoint payload.", name, svc.Name, e.Name()) isObject = expr.IsObject(payload.Type) @@ -1351,39 +2435,81 @@ func (sds *ServicesData) buildPayloadData(e *expr.HTTPEndpointExpr, sd *ServiceD clientArgs = make([]*InitArgData, 0, argsCap+1) if body != expr.Empty { var ( - svcode string - cvcode string + svcode string + cvcode string + serverTypeName string + serverTypeRef string + clientTypeName string + clientTypeRef string + clientBodyPointer bool ) - if ut, ok := body.(expr.UserType); ok { + serverLayout, layoutErr := httpsvrctx.Scope.(codegen.GoTypeLayoutResolver).GoTypeLayout(serverHTTPBody, httpsvrctx.LayoutPolicy()) + if layoutErr != nil { + sds.recordLinkError(layoutErr) + } else { + serverTypeName = serverLayout.Name() + serverTypeRef, _, serverBodyDereference = optionalWireTypeRef(serverLayout, request.OptionalBody) + } + clientLayout, layoutErr := httpclictx.Scope.(codegen.GoTypeLayoutResolver).GoTypeLayout(clientHTTPBody, httpclictx.LayoutPolicy()) + if layoutErr != nil { + sds.recordLinkError(layoutErr) + } else { + clientTypeName = clientLayout.Name() + clientTypeRef, clientBodyPointer, clientBodyDereference = optionalWireTypeRef(clientLayout, request.BodyFieldCanBeAbsent) + } + if ut, ok := serverHTTPBody.Type.(expr.UserType); ok { if val := ut.Attribute().Validation; val != nil { svcode = codegen.ValidationCode(ut.Attribute(), ut, httpsvrctx, true, expr.IsAlias(ut), false, "body") + } + } + if ut, ok := clientHTTPBody.Type.(expr.UserType); ok { + if val := ut.Attribute().Validation; val != nil { cvcode = codegen.ValidationCode(ut.Attribute(), ut, httpclictx, true, expr.IsAlias(ut), false, "body") } } + cliValidation := cliValidationRenderer(cvcode != "", clientHTTPBody, httpclictx, "body") + clientDefault := clientBodyDefault(httpBody, bodyDefault) + serverBodyRef := sd.serverWireTypes.scope.GoVar("body", serverHTTPBody.Type) + if request.OptionalBody { + serverBodyRef = "body" + } serverArgs = append(serverArgs, &InitArgData{ - Ref: sd.Scope.GoVar("body", body), + Ref: serverBodyRef, AttributeData: &AttributeData{ - Name: "body", - VarName: "body", - TypeName: sd.Scope.GoTypeName(httpBody), - TypeRef: sd.Scope.GoTypeRef(httpBody), - Type: body, - Required: true, - Example: httpBody.Example(sds.Root.API.ExampleGenerator), - Validate: svcode, + Name: "body", + VarName: "body", + TypeName: serverTypeName, + TypeRef: serverTypeRef, + Type: serverHTTPBody.Type, + Required: !request.OptionalBody, + DefaultValue: bodyDefault, + Example: sds.Example(httpBody, bodyOwner), + Validate: svcode, }, }) + clientBodyArgRef := sd.clientWireTypes.scope.GoVar("body", clientHTTPBody.Type) + if request.OptionalBody { + clientBodyArgRef = "body" + } clientArgs = append(clientArgs, &InitArgData{ - Ref: sd.Scope.GoVar("body", body), + Ref: clientBodyArgRef, AttributeData: &AttributeData{ - Name: "body", - VarName: "body", - TypeName: sd.Scope.GoTypeNameWithDefaults(httpBody), - TypeRef: sd.Scope.GoTypeRefWithDefaults(httpBody), - Type: body, - Required: true, - Example: httpBody.Example(sds.Root.API.ExampleGenerator), - Validate: cvcode, + Name: "body", + VarName: "body", + TypeName: clientTypeName, + TypeRef: clientTypeRef, + Type: clientHTTPBody.Type, + Required: !request.OptionalBody, + Pointer: clientBodyPointer, + DefaultValue: clientDefault, + Example: sds.Example(httpBody, bodyOwner), + Validate: cvcode, + CLIPlan: cli.NewFlagPlan( + clientHTTPBody, + clientTypeName, + clientTypeName, + cliValidation, + ), }, }) } @@ -1414,6 +2540,10 @@ func (sds *ServicesData) buildPayloadData(e *expr.HTTPEndpointExpr, sd *ServiceD } serverArgs = append(serverArgs, args...) clientArgs = append(clientArgs, args...) + if idPolicy != nil && idPolicy.attribute != nil { + serverArgs = append(serverArgs, sds.jsonRPCRequestIDInitArg(idPolicy, idData.Variable, payload, svcsvrctx, payloadOwner, false)) + clientArgs = append(clientArgs, sds.jsonRPCRequestIDInitArg(idPolicy, idData.Variable, payload, svcclictx, payloadOwner, true)) + } var ( cliArgs []*InitArgData @@ -1423,9 +2553,17 @@ func (sds *ServicesData) buildPayloadData(e *expr.HTTPEndpointExpr, sd *ServiceD for _, sc := range r.Schemes { if sc.Type == "Basic" { uatt := e.MethodExpr.Payload.Find(sc.UsernameAttr) - uref := svc.Scope.GoTypeRef(uatt) + uctx := svcclictx.Enter(uatt) + ulayout, layoutErr := uctx.Scope.(codegen.GoTypeLayoutResolver).GoTypeLayout(uatt, uctx.LayoutPolicy()) + if layoutErr != nil { + sds.recordLinkError(layoutErr) + return nil + } + uref := ulayout.Ref() + uvalueRef := uref + uvalidate := codegen.ValidationCode(uatt, nil, httpsvrctx, sc.UsernameRequired, expr.IsAlias(uatt.Type), false, sc.UsernameAttr) if sc.UsernamePointer { - uref = "*" + uref + uref = ulayout.RefWithPointer(true) } uarg := &InitArgData{ Ref: sc.UsernameAttr, @@ -1437,18 +2575,32 @@ func (sds *ServicesData) buildPayloadData(e *expr.HTTPEndpointExpr, sd *ServiceD FieldType: uatt.Type, Description: uatt.Description, Required: sc.UsernameRequired, - TypeName: svc.Scope.GoTypeName(uatt), + TypeName: uctx.Scope.Name(uatt, uctx.Pkg(uatt), false, true), TypeRef: uref, Type: uatt.Type, Pointer: sc.UsernamePointer, - Validate: codegen.ValidationCode(uatt, nil, httpsvrctx, sc.UsernameRequired, expr.IsAlias(uatt.Type), false, sc.UsernameAttr), - Example: uatt.Example(sds.Root.API.ExampleGenerator.Field(e.MethodExpr.Payload, sc.UsernameAttr)), + Validate: uvalidate, + CLIPlan: cli.NewFlagPlan( + uatt, + uctx.Scope.Name(uatt, uctx.Pkg(uatt), false, true), + uvalueRef, + cliValidationRenderer(uvalidate != "", uatt, uctx, sc.UsernameAttr), + ), + Example: sds.FieldExample(uatt, e.MethodExpr.Payload, sc.UsernameAttr, payloadOwner), }, } patt := e.MethodExpr.Payload.Find(sc.PasswordAttr) - pref := svc.Scope.GoTypeRef(patt) + pctx := svcclictx.Enter(patt) + playout, layoutErr := pctx.Scope.(codegen.GoTypeLayoutResolver).GoTypeLayout(patt, pctx.LayoutPolicy()) + if layoutErr != nil { + sds.recordLinkError(layoutErr) + return nil + } + pref := playout.Ref() + pvalueRef := pref + pvalidate := codegen.ValidationCode(patt, nil, httpsvrctx, sc.PasswordRequired, expr.IsAlias(patt.Type), false, sc.PasswordAttr) if sc.PasswordPointer { - pref = "*" + pref + pref = playout.RefWithPointer(true) } parg := &InitArgData{ Ref: sc.PasswordAttr, @@ -1460,12 +2612,18 @@ func (sds *ServicesData) buildPayloadData(e *expr.HTTPEndpointExpr, sd *ServiceD FieldType: patt.Type, Description: patt.Description, Required: sc.PasswordRequired, - TypeName: svc.Scope.GoTypeName(patt), + TypeName: pctx.Scope.Name(patt, pctx.Pkg(patt), false, true), TypeRef: pref, Type: patt.Type, Pointer: sc.PasswordPointer, - Validate: codegen.ValidationCode(patt, nil, httpsvrctx, sc.PasswordRequired, expr.IsAlias(patt.Type), false, sc.PasswordAttr), - Example: patt.Example(sds.Root.API.ExampleGenerator.Field(e.MethodExpr.Payload, sc.PasswordAttr)), + Validate: pvalidate, + CLIPlan: cli.NewFlagPlan( + patt, + pctx.Scope.Name(patt, pctx.Pkg(patt), false, true), + pvalueRef, + cliValidationRenderer(pvalidate != "", patt, pctx, sc.PasswordAttr), + ), + Example: sds.FieldExample(patt, e.MethodExpr.Payload, sc.PasswordAttr, payloadOwner), }, } cliArgs = []*InitArgData{uarg, parg} @@ -1477,70 +2635,155 @@ func (sds *ServicesData) buildPayloadData(e *expr.HTTPEndpointExpr, sd *ServiceD break } } + if len(cliArgs) > 0 { + for index, arg := range cliArgs { + field := e.MethodExpr.Payload.Find(arg.Name) + ctx := svcsvrctx.Enter(field) + layout, layoutErr := ctx.Scope.(codegen.GoTypeLayoutResolver).GoTypeLayout(field, ctx.LayoutPolicy()) + if layoutErr != nil { + sds.recordLinkError(layoutErr) + return nil + } + ref := "user" + if index == 1 { + ref = "pass" + } + if arg.FieldPointer { + ref += "Ptr" + } + typeRef := "string" + if arg.FieldPointer { + typeRef = "*string" + } + serverArgs = append(serverArgs, &InitArgData{ + Ref: ref, + AttributeData: &AttributeData{ + Name: arg.Name, + VarName: arg.VarName, + TypeRef: typeRef, + Type: expr.String, + Pointer: arg.FieldPointer, + FieldName: arg.FieldName, + FieldType: arg.FieldType, + FieldPointer: arg.FieldPointer, + ServiceTypeRef: layout.Ref(), + }, + }) + } + } var ( serverCode string clientCode string err error - origin string pointer bool - - pAtt = payload ) + requestTransforms := sd.transforms.requests[clientBodyConstructorKey{endpoint: e, role: wireRequestBody}] if body != expr.Empty { - // If design uses Body("name") syntax then need to use payload - // attribute to transform. - if o, ok := httpBody.Meta["origin:attribute"]; ok { - origin = o[0] - pAtt = expr.AsObject(payload.Type).Attribute(origin) - pointer = !payload.IsRequired(o[0]) && expr.IsPrimitive(pAtt.Type) + if origin != "" { + pointer = payload.IsPrimitivePointer(origin, true) } var ( helpers []*codegen.TransformFunctionData ) - serverCode, helpers, err = unmarshal(httpBody, pAtt, "body", httpsvrctx, svcctx) + transformctx := jsonBodyContext(sd.serverWireTypes, sd.serverWireTypes.scope, true, true) + serverBodyRef := "body" + if serverBodyDereference { + serverBodyRef = "*body" + } + serverCode, helpers, err = sd.serverWireTypes.renderTransform(requestTransforms.serverDecode, serverHTTPBody, serverBodyRef, "v", transformctx, svcsvrctx) if err == nil { sd.ServerTransformHelpers = codegen.AppendHelpers(sd.ServerTransformHelpers, helpers) + } else { + sds.recordLinkError(err) } // The client code for building the method payload from a request // body is used by the CLI tool to build the payload given to the - // client endpoint. It differs because the body type there does not - // use pointers for all fields (no need to validate). - clientCode, helpers, err = marshal(httpBody, pAtt, "body", "v", httpclictx, svcctx) + // client endpoint. It follows the encoded request shape because this + // path does not validate a decoded body. + transformctx = jsonBodyContext(sd.clientWireTypes, sd.clientWireTypes.scope, true, false) + clientBodyRef := "body" + if clientBodyDereference { + clientBodyRef = "*body" + } + clientCode, helpers, err = sd.clientWireTypes.renderTransform(requestTransforms.clientDecode, clientHTTPBody, clientBodyRef, "v", transformctx, svcclictx) if err == nil { sd.ClientTransformHelpers = codegen.AppendHelpers(sd.ClientTransformHelpers, helpers) + } else { + sds.recordLinkError(err) } } else if expr.IsArray(payload.Type) || expr.IsMap(payload.Type) { if params := expr.AsObject(e.Params.Type); len(*params) > 0 { var helpers []*codegen.TransformFunctionData - serverCode, helpers, err = unmarshal((*params)[0].Attribute, payload, codegen.Goify((*params)[0].Name, false), httpsvrctx, svcctx) + transformctx := wireHTTPContext(sd.serverWireTypes, sd.serverWireTypes.scope, true, true) + serverCode, helpers, err = sd.serverWireTypes.renderTransform(requestTransforms.serverDecode, (*params)[0].Attribute, codegen.Goify((*params)[0].Name, false), "v", transformctx, svcsvrctx) if err == nil { sd.ServerTransformHelpers = codegen.AppendHelpers(sd.ServerTransformHelpers, helpers) + } else { + sds.recordLinkError(err) } - clientCode, helpers, err = marshal((*params)[0].Attribute, payload, codegen.Goify((*params)[0].Name, false), "v", httpclictx, svcctx) + transformctx = wireHTTPContext(sd.clientWireTypes, sd.clientWireTypes.scope, true, false) + clientCode, helpers, err = sd.clientWireTypes.renderTransform(requestTransforms.clientDecode, (*params)[0].Attribute, codegen.Goify((*params)[0].Name, false), "v", transformctx, svcclictx) if err == nil { sd.ClientTransformHelpers = codegen.AppendHelpers(sd.ClientTransformHelpers, helpers) + } else { + sds.recordLinkError(err) } } } if err != nil { - panic(err) // bug + sds.recordLinkError(err) + } + var renderedBodyDefault *codegen.GoValueCode + if bodyDefault != nil { + var resolveUnion codegen.UnionConstructorResolver + if resolver, ok := svcsvrctx.Scope.(interface { + UnionConstructor(*expr.AttributeExpr, string) (string, error) + }); ok { + resolveUnion = resolver.UnionConstructor + } + fieldPointer := origin != "" && svcsvrctx.IsFieldPointer(origin, payload) + layoutResolver, ok := svcsvrctx.Scope.(codegen.GoTypeLayoutResolver) + if !ok { + sds.recordLinkError(fmt.Errorf("service package cannot resolve the Go layout for %s", originAttribute.Type.Name())) + } else if layout, layoutErr := layoutResolver.GoTypeLayout(originAttribute, svcsvrctx.LayoutPolicy()); layoutErr != nil { + sds.recordLinkError(layoutErr) + } else { + rendered, renderErr := codegen.RenderGoValue( + originAttribute, + bodyDefault, + layout, + fieldPointer, + resolveUnion, + "bodyDefault", + ) + if renderErr != nil { + sds.recordLinkError(renderErr) + } else { + renderedBodyDefault = &rendered + } + } } init = &InitData{ + Declaration: declaration, Name: name, Description: desc, ServerArgs: serverArgs, ClientArgs: clientArgs, CLIArgs: cliArgs, - ReturnTypeName: svc.Scope.GoFullTypeName(payload, pkg), - ReturnTypeRef: svc.Scope.GoFullTypeRef(payload, pkg), + ReturnTypeName: svcsvrctx.Scope.Name(payload, svcsvrctx.Pkg(payload), false, true), + ReturnTypeRef: svcsvrctx.Scope.Ref(payload, svcsvrctx.Pkg(payload)), ReturnIsStruct: isObject, ReturnTypeAttribute: codegen.Goify(origin, true), - ReturnTypePkg: pkg, + ReturnTypePkg: svcsvrctx.Pkg(payload), ServerCode: serverCode, ClientCode: clientCode, ReturnIsPrimitivePointer: pointer, + ReturnIsUnion: request.BodyIsUnion, + OptionalBody: request.OptionalBody, + ClientBodyOptional: request.BodyFieldCanBeAbsent, + BodyDefault: renderedBodyDefault, } } request.PayloadInit = init @@ -1549,10 +2792,12 @@ func (sds *ServicesData) buildPayloadData(e *expr.HTTPEndpointExpr, sd *ServiceD returnValue string name string ref string + cliPlan *cli.FlagPlan ) if payload.Type != expr.Empty { - name = svc.Scope.GoFullTypeName(payload, pkg) - ref = svc.Scope.GoFullTypeRef(payload, pkg) + name = svcsvrctx.Scope.Name(payload, svcsvrctx.Pkg(payload), false, true) + ref = svcsvrctx.Scope.Ref(payload, svcsvrctx.Pkg(payload)) + cliPlan = cli.NewFlagPlan(payload, name, ref, nil) } if init == nil { if o := expr.AsObject(e.Params.Type); o != nil && len(*o) > 0 { @@ -1568,8 +2813,10 @@ func (sds *ServicesData) buildPayloadData(e *expr.HTTPEndpointExpr, sd *ServiceD data := &PayloadData{ Name: name, Ref: ref, + CLIPlan: cliPlan, Request: request, DecoderReturnValue: returnValue, + JSONRPCRequestID: idData, } if e.IsJSONRPC() { obj := expr.AsObject(e.MethodExpr.Payload.Type) @@ -1577,6 +2824,9 @@ func (sds *ServicesData) buildPayloadData(e *expr.HTTPEndpointExpr, sd *ServiceD for _, att := range *obj { if _, ok := att.Attribute.Meta["jsonrpc:id"]; ok { data.IDAttribute = codegen.Goify(att.Name, true) + if expr.IsAlias(att.Attribute.Type) { + data.IDAttributeTypeRef = svcsvrctx.Scope.Ref(att.Attribute, svcsvrctx.Pkg(att.Attribute)) + } data.IDAttributeRequired = e.MethodExpr.Payload.IsRequired(att.Name) break } @@ -1586,13 +2836,72 @@ func (sds *ServicesData) buildPayloadData(e *expr.HTTPEndpointExpr, sd *ServiceD return data } +// jsonRPCParams returns the exact JSON-RPC params shape for one service value. +// Primitive values use one positional item. Nil optional objects, arrays, and +// maps omit params because JSON-RPC does not allow params to be null. +func jsonRPCParams(dataType expr.DataType, typeRef string, rejectNull, allowAbsent bool) *JSONRPCParamsData { + union := expr.AsUnion(dataType) != nil + return &JSONRPCParamsData{ + Positional: expr.IsPrimitive(dataType), + TypeRef: typeRef, + RejectNull: rejectNull, + AllowAbsent: allowAbsent, + OmitAbsent: allowAbsent && !expr.IsPrimitive(dataType) && (codegen.IsNilable(dataType) || union), + } +} + +// jsonRPCRequestIDInitArg builds the payload-constructor argument that carries +// the envelope ID separately from params. Client arguments also describe the +// command-line flag used to supply a caller-chosen ID. +func (sds *ServicesData) jsonRPCRequestIDInitArg(policy *jsonRPCRequestIDPolicy, variable string, payload *expr.AttributeExpr, context *codegen.AttributeContext, owner expr.ExampleIdentity, client bool) *InitArgData { + field := policy.attribute + attribute := field.Attribute + required := policy.required + pointer := policy.pointer + layout, err := context.Scope.(codegen.GoTypeLayoutResolver).GoTypeLayout(attribute, context.LayoutPolicy()) + if err != nil { + sds.recordLinkError(err) + return nil + } + valueTypeRef := layout.Ref() + typeRef := valueTypeRef + if pointer { + typeRef = layout.RefWithPointer(true) + } + data := &AttributeData{ + Name: field.Name, + VarName: variable, + Pointer: pointer, + Required: required, + Type: attribute.Type, + TypeName: context.Scope.Name(attribute, context.Pkg(attribute), false, true), + TypeRef: typeRef, + Description: attribute.Description, + FieldName: codegen.Goify(field.Name, true), + FieldType: attribute.Type, + ServiceTypeRef: valueTypeRef, + FieldPointer: pointer, + DefaultValue: policy.defaultValue, + Example: sds.FieldExample(attribute, payload, field.Name, owner), + } + if client { + validate := codegen.AttributeValidationCode(attribute, nil, context, required, expr.IsAlias(attribute.Type), variable, field.Name) + data.CLIPlan = cli.NewFlagPlan( + attribute, + data.TypeName, + valueTypeRef, + cliValidationRenderer(validate != "", attribute, context, field.Name), + ) + } + return &InitArgData{AttributeData: data, Ref: variable} +} + // buildResultData builds the result data for the given service endpoint. func (sds *ServicesData) buildResultData(e *expr.HTTPEndpointExpr, sd *ServiceData) *ResultData { var ( - svc = sd.Service - ep = svc.Method(e.MethodExpr.Name) - pkg = ep.ResultLoc.PackageNameOrDefault(svc.PkgName) result = e.MethodExpr.Result + method = sd.Service.Method(e.MethodExpr.Name) + svcctx = sds.serviceTypeContext(sd, "server").Enter(result) name string ref string @@ -1604,8 +2913,8 @@ func (sds *ServicesData) buildResultData(e *expr.HTTPEndpointExpr, sd *ServiceDa view = v } if result.Type != expr.Empty { - name = svc.Scope.GoFullTypeName(result, pkg) - ref = svc.Scope.GoFullTypeRef(result, pkg) + name = svcctx.Scope.Name(result, svcctx.Pkg(result), false, true) + ref = svcctx.Scope.Ref(result, svcctx.Pkg(result)) } var ( @@ -1614,8 +2923,8 @@ func (sds *ServicesData) buildResultData(e *expr.HTTPEndpointExpr, sd *ServiceDa ) { viewed := false - if ep.ViewedResult != nil { - result = expr.AsObject(ep.ViewedResult.Type).Attribute("projected") + if method.ViewedResult != nil { + result = expr.AsObject(method.ViewedResult.Type).Attribute("projected") viewed = true } responses = sds.buildResponses(e, result, viewed, sd) @@ -1626,29 +2935,13 @@ func (sds *ServicesData) buildResultData(e *expr.HTTPEndpointExpr, sd *ServiceDa } } } - idAtt := "" - idAttRequired := false - if e.IsJSONRPC() && result.Type != expr.Empty { - obj := expr.AsObject(result.Type) - if obj != nil { - for _, att := range *obj { - if _, ok := att.Attribute.Meta["jsonrpc:id"]; ok { - idAtt = codegen.Goify(att.Name, true) - idAttRequired = result.IsRequired(att.Name) - break - } - } - } - } return &ResultData{ - IsStruct: expr.IsObject(result.Type), - Name: name, - Ref: ref, - IDAttribute: idAtt, - IDAttributeRequired: idAttRequired, - Responses: responses, - View: view, - MustInit: mustInit, + IsStruct: expr.IsObject(result.Type), + Name: name, + Ref: ref, + Responses: responses, + View: view, + MustInit: mustInit, } } @@ -1661,26 +2954,23 @@ func (sds *ServicesData) buildResponses(e *expr.HTTPEndpointExpr, result *expr.A var ( responses []*ResponseData - svc = sd.Service - md = svc.Method(e.Name()) - pkg = md.ResultLoc.PackageNameOrDefault(svc.PkgName) - httpclictx = httpContext(sd.Scope, false, false) - scope = svc.Scope - svcctx = serviceContext(pkg, sd.Service.Scope) + svc = sd.Service + md = svc.Method(e.Name()) + scope = svc.Scope + svcctx = sds.serviceTypeContext(sd, "client").Enter(result) ) { if viewed { scope = svc.ViewScope - svcctx = viewContext(sd.Service.ViewsPkg, sd.Service.ViewScope) + svcctx = sds.viewTypeContext(sd, "client").Enter(result) } - notag := -1 - for i, resp := range e.Responses { + defaultResponseIndex := -1 + for _, resp := range e.Responses { respBody := sd.bodies.response(resp) + resultOwner := expr.MethodResultExampleIdentity(e.MethodExpr) + bodyOwner := expr.ResponseBodyExampleIdentity(e, resp) if resp.Tag[0] == "" { - if notag > -1 { - continue // we don't want more than one response with no tag - } - notag = i + defaultResponseIndex = len(responses) } var ( headersData []*HeaderData @@ -1691,12 +2981,13 @@ func (sds *ServicesData) buildResponses(e *expr.HTTPEndpointExpr, result *expr.A origin string mustValidate bool clientRespBody = respBody + clientBodyView *string resAttr = result ) { - headersData = sds.extractHeaders(resp.Headers, result, svcctx, scope) - cookiesData = sds.extractCookies(resp.Cookies, result, svcctx, scope) + headersData = sds.extractHeaders(resp.Headers, result, svcctx, scope, resultOwner) + cookiesData = sds.extractCookies(resp.Cookies, result, svcctx, scope, resultOwner) if respBody.Type != expr.Empty { // If design uses Body("name") syntax we need to use the // corresponding attribute in the result type for body @@ -1706,20 +2997,23 @@ func (sds *ServicesData) buildResponses(e *expr.HTTPEndpointExpr, result *expr.A resAttr = expr.AsObject(resAttr.Type).Attribute(origin) } } + _, explicitBody := respBody.Meta["http:body"] if viewed { vname := "" clientView := clientResponseViewName(e, md) if origin != "" { // Response body is explicitly set to an attribute in the method // result type. No need to do any view-based projections server side. - if sbd := sds.buildResponseBodyType(respBody, result, md.ResultLoc, e, true, &vname, sd); sbd != nil { + transforms := sd.transforms.responses[viewedConstructorKey{endpoint: e, response: resp, view: vname}] + if sbd := sds.buildResponseBodyType(respBody, result, e, true, &vname, sd, transforms, resultOwner, bodyOwner); sbd != nil { serverBodyData = append(serverBodyData, sbd) } } else if v, ok := e.MethodExpr.Result.Meta.Last(expr.ViewMetaKey); ok { // Design explicitly sets the view to render the result. // We generate only one server body type which will be rendered // using the specified view. - if sbd := sds.buildResponseBodyType(respBody, result, md.ResultLoc, e, true, &v, sd); sbd != nil { + transforms := sd.transforms.responses[viewedConstructorKey{endpoint: e, response: resp, view: v}] + if sbd := sds.buildResponseBodyType(respBody, result, e, true, &v, sd, transforms, resultOwner, bodyOwner); sbd != nil { serverBodyData = append(serverBodyData, sbd) } } else { @@ -1732,25 +3026,38 @@ func (sds *ServicesData) buildResponses(e *expr.HTTPEndpointExpr, result *expr.A // attributes defined in the view in the response (NOTE: a required // attribute in the result type may not be present in all its views) for _, view := range md.ViewedResult.Views { - if sbd := sds.buildResponseBodyType(respBody, result, md.ResultLoc, e, true, &view.Name, sd); sbd != nil { + transforms := sd.transforms.responses[viewedConstructorKey{endpoint: e, response: resp, view: view.Name}] + if sbd := sds.buildResponseBodyType(respBody, result, e, true, &view.Name, sd, transforms, resultOwner, bodyOwner); sbd != nil { serverBodyData = append(serverBodyData, sbd) } } } - if clientView != "" { - clientRespBody = effectiveClientResponseBody(respBody, e, md) - clientBodyData = sds.buildResponseBodyType(respBody, result, md.ResultLoc, e, false, &clientView, sd) - } else { - clientBodyData = sds.buildResponseBodyType(respBody, result, md.ResultLoc, e, false, &vname, sd) + switch { + case origin != "": + clientBodyData = sds.buildResponseBodyType(respBody, result, e, false, &vname, sd, nil, resultOwner, bodyOwner) + clientBodyView = &vname + case clientView != "": + clientRespBody = effectiveClientResponseBodyForView(respBody, clientView, e) + clientBodyData = sds.buildResponseBodyType(clientRespBody, result, e, false, &clientView, sd, nil, resultOwner, bodyOwner) + clientBodyView = &clientView + case explicitBody: + clientBodyData = sds.buildResponseBodyType(respBody, result, e, false, &vname, sd, nil, resultOwner, bodyOwner) + clientBodyView = &vname + default: + clientRespBody = &expr.AttributeExpr{Type: expr.Empty} } } else { - if sbd := sds.buildResponseBodyType(respBody, result, md.ResultLoc, e, true, nil, sd); sbd != nil { + transforms := sd.transforms.responses[viewedConstructorKey{endpoint: e, response: resp}] + if sbd := sds.buildResponseBodyType(respBody, result, e, true, nil, sd, transforms, resultOwner, bodyOwner); sbd != nil { serverBodyData = append(serverBodyData, sbd) } - clientBodyData = sds.buildResponseBodyType(respBody, result, md.ResultLoc, e, false, nil, sd) + clientBodyData = sds.buildResponseBodyType(respBody, result, e, false, nil, sd, nil, resultOwner, bodyOwner) } - if clientBodyData != nil && clientBodyData.Def != "" { - sd.ClientTypeNames[clientBodyData.Name] = struct{}{} + if clientBodyData != nil && clientRespBody.Type != expr.Empty { + var viewName string + clientRespBody, viewName = prepareResponseWireBody(clientRespBody, clientBodyView) + policy := jsonBodyPolicy(false, false, true, viewName) + sd.clientWireTypes.applyNames(clientRespBody, wireResponseBody, policy) } for _, h := range headersData { if h.Validate != "" || h.Required || needConversion(h.Type) { @@ -1764,106 +3071,82 @@ func (sds *ServicesData) buildResponses(e *expr.HTTPEndpointExpr, result *expr.A break } } - if needInit(result.Type) { - // generate constructor function to transform response body, - // headers and cookies into the method result type - var ( - name string - desc string - code string - tname string - tref string - err error - pointer bool - clientArgs []*InitArgData - helpers []*codegen.TransformFunctionData + variableView := viewed && origin == "" && clientResponseViewName(e, md) == "" + variableWire := variableView && !explicitBody + selectClientBodyByView := variableWire && + !e.IsJSONRPC() && + (!e.MethodExpr.IsStreaming() || e.MethodExpr.HasMixedResults()) + if needClientResponseInit(result.Type) && !variableWire { + init = sds.buildResponseResultInit( + e, resp, result, clientRespBody, origin, + headersData, cookiesData, sd, "", clientBodyData, ) - { - tname = svc.Scope.GoFullTypeName(result, pkg) - tref = svc.Scope.GoFullTypeRef(result, pkg) - if viewed { - tname = svc.ViewScope.GoFullTypeName(result, svc.ViewsPkg) - tref = svc.ViewScope.GoFullTypeRef(result, svc.ViewsPkg) + } + + var representations []*ViewedRepresentationData + if viewed && (e.UsesSSE() || e.IsJSONRPC() || (e.UsesWebSocket() && variableWire) || selectClientBodyByView) { + clientView := clientResponseViewName(e, md) + if explicitBody { + views := md.ViewedResult.Views + if clientView != "" { + views = []*service.ViewData{{Name: clientView}} } - status := codegen.Goify(http.StatusText(resp.StatusCode), true) - n := codegen.Goify(md.Name, true) - r := codegen.Goify(md.Result, true) - // Raw result object has type name prefixed with endpoint name. No need to - // prefix the type name again. - if strings.HasPrefix(r, n) { - r = scope.HashedUnique(result.Type, r) - name = fmt.Sprintf("New%s%s", r, status) - } else { - name = fmt.Sprintf("New%s%s%s", n, r, status) + for _, view := range views { + representation := &ViewedRepresentationData{ + View: view.Name, + ResultAttr: codegen.Goify(origin, true), + ClientBody: clientBodyData, + ClientDataPointer: clientSSEDataPointer(e, clientRespBody), + ResultInit: init, + } + if len(serverBodyData) > 0 { + representation.ServerBody = serverBodyData[0] + } + representations = append(representations, representation) } - desc = fmt.Sprintf("%s builds a %q service %q endpoint result from a HTTP %q response.", name, svc.Name, e.Name(), status) - if clientRespBody.Type != expr.Empty { - if origin != "" { - pointer = result.IsPrimitivePointer(origin, true) + } else { + if clientView != "" { + representation := &ViewedRepresentationData{ + View: clientView, + ResultAttr: codegen.Goify(origin, true), + ClientBody: clientBodyData, + ClientDataPointer: clientSSEDataPointer(e, clientRespBody), + ResultInit: init, } - ref := "body" - if expr.IsObject(clientRespBody.Type) { - ref = "&body" - pointer = false + if len(serverBodyData) > 0 { + representation.ServerBody = viewedServerBody(serverBodyData, clientView) } - var vcode string - if ut, ok := clientRespBody.Type.(expr.UserType); ok { - if val := ut.Attribute().Validation; val != nil { - vcode = codegen.ValidationCode(ut.Attribute(), ut, httpclictx, true, expr.IsAlias(ut), false, "body") - } + representations = append(representations, representation) + } + for _, view := range md.ViewedResult.Views { + if clientView != "" { + break } - clientArgs = []*InitArgData{{ - Ref: ref, - AttributeData: &AttributeData{ - Name: "body", - VarName: "body", - TypeRef: sd.Scope.GoTypeRef(clientRespBody), - Validate: vcode, - }, - }} - // If the method result is a - // * result type - we unmarshal the client response body to the - // corresponding type in the views package so that view-specific - // validation logic can be applied. - // * user type - we unmarshal the client response body to the - // corresponding type in the service package after validating the - // response body. Here, the transformation code must - // rely on the fact that the required attributes are - // set in the response body (otherwise validation - // would fail). - code, helpers, err = unmarshal(clientRespBody, resAttr, "body", httpclictx, svcctx) - if err == nil { - sd.ClientTransformHelpers = codegen.AppendHelpers(sd.ClientTransformHelpers, helpers) + viewName := view.Name + body := effectiveClientResponseBodyForView(respBody, viewName, e) + clientBody := sds.buildResponseBodyType( + body, result, e, false, &viewName, sd, nil, resultOwner, bodyOwner, + ) + if body.Type != expr.Empty { + policy := jsonBodyPolicy(false, false, true, viewName) + sd.clientWireTypes.applyNames(body, wireResponseBody, policy) } - } else if expr.IsArray(result.Type) || expr.IsMap(result.Type) { - if params := expr.AsObject(e.QueryParams().Type); len(*params) > 0 { - code, helpers, err = unmarshal((*params)[0].Attribute, result, codegen.Goify((*params)[0].Name, false), httpclictx, svcctx) - if err == nil { - sd.ClientTransformHelpers = codegen.AppendHelpers(sd.ClientTransformHelpers, helpers) - } + resultInit := sds.buildResponseResultInit( + e, resp, result, body, origin, + headersData, cookiesData, sd, viewName, clientBody, + ) + representation := &ViewedRepresentationData{ + View: viewName, + ResultAttr: codegen.Goify(origin, true), + ClientBody: clientBody, + ClientDataPointer: clientSSEDataPointer(e, body), + ResultInit: resultInit, } + if len(serverBodyData) > 0 { + representation.ServerBody = viewedServerBody(serverBodyData, viewName) + } + representations = append(representations, representation) } - if err != nil { - panic(err) // bug - } - for _, h := range headersData { - clientArgs = append(clientArgs, resultInitArg(h.Element)) - } - for _, c := range cookiesData { - clientArgs = append(clientArgs, resultInitArg(c.Element)) - } - } - init = &InitData{ - Name: name, - Description: desc, - ClientArgs: clientArgs, - ReturnTypeName: tname, - ReturnTypeRef: tref, - ReturnIsStruct: expr.IsObject(result.Type), - ReturnTypeAttribute: codegen.Goify(origin, true), - ReturnTypePkg: pkg, - ReturnIsPrimitivePointer: pointer, - ClientCode: code, } } @@ -1878,52 +3161,162 @@ func (sds *ServicesData) buildResponses(e *expr.HTTPEndpointExpr, result *expr.A tagPtr = viewed || result.IsPrimitivePointer(resp.Tag[0], true) } responses = append(responses, &ResponseData{ - StatusCode: statusCodeToHTTPConst(resp.StatusCode), - Description: resp.Description, - Headers: headersData, - Cookies: cookiesData, - ContentType: resp.ContentType, - ServerBody: serverBodyData, - ClientBody: clientBodyData, - ResultInit: init, - TagName: tagName, - TagValue: tagVal, - TagPointer: tagPtr, - MustValidate: mustValidate, - ResultAttr: codegen.Goify(origin, true), - ViewedResult: md.ViewedResult, + StatusCode: statusCodeToHTTPConst(resp.StatusCode), + Description: resp.Description, + Headers: headersData, + Cookies: cookiesData, + ContentType: resp.ContentType, + ServerBody: serverBodyData, + ClientBody: clientBodyData, + ResultInit: init, + TagName: tagName, + TagValue: tagVal, + TagPointer: tagPtr, + MustValidate: mustValidate, + ResultAttr: codegen.Goify(origin, true), + ViewedResult: md.ViewedResult, + ViewedRepresentations: representations, + SelectClientBodyByView: selectClientBodyByView, }) } } count := len(responses) - if notag >= 0 && notag < count-1 { - // Make sure tagless response is last - responses[notag], responses[count-1] = responses[count-1], responses[notag] + if defaultResponseIndex < count-1 { + // Put the default response last so generated encoders test tagged + // responses in the order written in the design. + defaultResponse := responses[defaultResponseIndex] + copy(responses[defaultResponseIndex:], responses[defaultResponseIndex+1:]) + responses[count-1] = defaultResponse } } return responses } +// buildResponseResultInit builds the data used to write one client result +// function. It uses the name chosen by NewPlans and converts the decoded HTTP +// body, headers, and cookies into the method result. +func (sds *ServicesData) buildResponseResultInit(e *expr.HTTPEndpointExpr, resp *expr.HTTPResponseExpr, result, clientBody *expr.AttributeExpr, origin string, headers []*HeaderData, cookies []*CookieData, sd *ServiceData, view string, bodyType *TypeData) *InitData { + var ( + svc = sd.Service + md = svc.Method(e.Name()) + svcctx = sds.serviceTypeContext(sd, "client").Enter(result) + ) + if md.ViewedResult != nil { + svcctx = sds.viewTypeContext(sd, "client").Enter(result) + } + tname := svcctx.Scope.Name(result, svcctx.Pkg(result), false, true) + tref := svcctx.Scope.Ref(result, svcctx.Pkg(result)) + status := codegen.Goify(http.StatusText(resp.StatusCode), true) + declaration := sds.viewedResultConstructors[viewedConstructorKey{endpoint: e, response: resp, view: view}] + if declaration == nil { + panic(fmt.Sprintf("result constructor for %s.%s view %q was not submitted", svc.Name, e.Name(), view)) + } + name := declaration.Name() + desc := fmt.Sprintf("%s builds a %q service %q endpoint result from a HTTP %q response.", name, svc.Name, e.Name(), status) + + var ( + code string + pointer bool + clientArgs = make([]*InitArgData, 0, len(headers)+len(cookies)+1) + ) + if clientBody.Type != expr.Empty { + if origin != "" { + pointer = svcctx.IsPrimitivePointer(origin, result) + } + ref := "body" + if expr.IsObject(clientBody.Type) { + ref = "&body" + pointer = false + } + bodyTypeRef := bodyType.Ref + if bodyTypeRef == "" { + bodyTypeRef = bodyType.VarName + } + clientArgs = append(clientArgs, &InitArgData{ + Ref: ref, + AttributeData: &AttributeData{ + Name: "body", + VarName: "body", + TypeRef: bodyTypeRef, + }, + }) + transformctx := jsonBodyContext(sd.clientWireTypes, sd.clientWireTypes.scope, false, false) + bodyPolicy := wireTypePolicy{ + pointer: transformctx.Pointer, + arrayElementPointer: transformctx.ArrayElementPointer, + view: bodyType.View, + } + responseUse := wireUnionUse{role: wireResponseBody, view: bodyType.View} + transformctx.Scope = sd.clientWireTypes.resolverForUse(sd.clientWireTypes.scope, bodyPolicy, responseUse) + if bodyPolicy.view != "" { + transformctx.Scope = sd.clientWireTypes.rootResolver(sd.clientWireTypes.scope, bodyPolicy, responseUse, bodyType.declaration) + } + transforms := sd.transforms.responses[viewedConstructorKey{endpoint: e, response: resp, view: bodyType.View}] + converted, helpers, err := sd.clientWireTypes.renderTransform(transforms.clientDecode, clientBody, "body", "v", transformctx, svcctx) + if err != nil { + sds.recordLinkError(err) + } + code = converted + sd.ClientTransformHelpers = codegen.AppendHelpers(sd.ClientTransformHelpers, helpers) + } else if expr.IsArray(result.Type) || expr.IsMap(result.Type) { + if params := expr.AsObject(e.QueryParams().Type); len(*params) > 0 { + queryctx := wireHTTPContext(sd.clientWireTypes, sd.clientWireTypes.scope, false, false) + transforms := sd.transforms.responses[viewedConstructorKey{endpoint: e, response: resp, view: view}] + converted, helpers, err := sd.clientWireTypes.renderTransform(transforms.clientDecode, (*params)[0].Attribute, codegen.Goify((*params)[0].Name, false), "v", queryctx, svcctx) + if err != nil { + sds.recordLinkError(err) + } + code = converted + sd.ClientTransformHelpers = codegen.AppendHelpers(sd.ClientTransformHelpers, helpers) + } + } + for _, header := range headers { + clientArgs = append(clientArgs, resultInitArg(header.Element)) + } + for _, cookie := range cookies { + clientArgs = append(clientArgs, resultInitArg(cookie.Element)) + } + return &InitData{ + Declaration: declaration, + Name: name, + Description: desc, + ClientArgs: clientArgs, + ReturnTypeName: tname, + ReturnTypeRef: tref, + ReturnIsStruct: expr.IsObject(result.Type), + ReturnTypeAttribute: codegen.Goify(origin, true), + ReturnTypePkg: svcctx.Pkg(result), + ReturnIsPrimitivePointer: pointer, + ClientCode: code, + } +} + // buildErrorsData builds the error data for all the error responses in the // endpoint expression. The response headers, cookies and body for each response // are inferred from the method's error expression if not specified explicitly. func (sds *ServicesData) buildErrorsData(e *expr.HTTPEndpointExpr, sd *ServiceData) []*ErrorGroupData { var ( svc = sd.Service - ep = svc.Method(e.MethodExpr.Name) - httpclictx = httpContext(sd.Scope, false, false) + httpclictx = jsonBodyContext(sd.clientWireTypes, sd.clientWireTypes.scope, false, false) ) data := make(map[string][]*ErrorData) for _, v := range e.HTTPErrors { - respBody := sd.bodies.errorResponse(v) + respBody := expr.DupAtt(sd.bodies.errorResponse(v)) + addMarshalTags(respBody) + errorAttribute := e.MethodExpr.Error(v.Name).AttributeExpr + errorOwner := expr.MethodErrorExampleIdentity(e.MethodExpr, v.ErrorExpr) + bodyOwner := expr.ErrorResponseBodyExampleIdentity(e, v) var ( - init *InitData - body = respBody.Type + init *InitData + body = respBody.Type + origin string ) + if values, ok := respBody.Meta["origin:attribute"]; ok { + origin = values[0] + } - pkg := ep.ErrorLocs[v.Name].PackageNameOrDefault(svc.PkgName) - errctx := serviceContext(pkg, sd.Service.Scope) + errctx := sds.serviceTypeContext(sd, "client").Enter(errorAttribute) if needInit(v.Type) { var ( @@ -1932,11 +3325,15 @@ func (sds *ServicesData) buildErrorsData(e *expr.HTTPEndpointExpr, sd *ServiceDa isObject bool args []*InitArgData ) - name = fmt.Sprintf("New%s%s", codegen.Goify(ep.Name, true), codegen.Goify(v.ErrorExpr.Name, true)) + declaration := sds.errorConstructors[v] + if declaration == nil { + panic(fmt.Sprintf("error constructor for %s.%s error %q was not submitted", svc.Name, e.Name(), v.Name)) + } + name = declaration.Name() desc = fmt.Sprintf("%s builds a %s service %s endpoint %s error.", name, svc.Name, e.Name(), v.ErrorExpr.Name) - headers := sds.extractHeaders(v.Response.Headers, v.AttributeExpr, errctx, sd.Scope) - cookies := sds.extractCookies(v.Response.Cookies, v.AttributeExpr, errctx, sd.Scope) + headers := sds.extractHeaders(v.Response.Headers, errorAttribute, errctx, sd.Scope, errorOwner) + cookies := sds.extractCookies(v.Response.Cookies, errorAttribute, errctx, sd.Scope, errorOwner) argsCap := len(headers) + len(cookies) if body != expr.Empty { argsCap++ @@ -1948,9 +3345,18 @@ func (sds *ServicesData) buildErrorsData(e *expr.HTTPEndpointExpr, sd *ServiceDa if isObject { ref = "&body" } + policy := jsonBodyPolicy(false, false, true, "") + bodyRecord := sd.clientWireTypes.lookupUser(respBody, wireResponseBody, policy) + sd.clientWireTypes.applyNames(respBody, wireResponseBody, policy) + var bodyTypeRef string + if bodyRecord != nil { + bodyTypeRef = bodyRecord.ref + } else { + bodyTypeRef = httpclictx.Scope.Ref(respBody, "") + } args = append(args, &InitArgData{ Ref: ref, - AttributeData: &AttributeData{Name: "body", VarName: "body", TypeRef: sd.Scope.GoTypeRef(respBody)}, + AttributeData: &AttributeData{Name: "body", VarName: "body", TypeRef: bodyTypeRef}, }) } for _, h := range headers { @@ -1961,46 +3367,42 @@ func (sds *ServicesData) buildErrorsData(e *expr.HTTPEndpointExpr, sd *ServiceDa } var ( - code string - origin string - err error + code string + err error ) if body != expr.Empty { - eAtt := v.AttributeExpr - // If design uses Body("name") syntax then need to use payload - // attribute to transform. - if o, ok := respBody.Meta["origin:attribute"]; ok { - origin = o[0] - eAtt = expr.AsObject(v.ErrorExpr.Type).Attribute(origin) - } - var helpers []*codegen.TransformFunctionData - code, helpers, err = unmarshal(respBody, eAtt, "body", httpclictx, errctx) + transformctx := jsonBodyContext(sd.clientWireTypes, sd.clientWireTypes.scope, false, false) + transforms := sd.transforms.errors[v] + code, helpers, err = sd.clientWireTypes.renderTransform(transforms.clientDecode, respBody, "body", "v", transformctx, errctx) if err == nil { sd.ClientTransformHelpers = codegen.AppendHelpers(sd.ClientTransformHelpers, helpers) } } else if expr.IsArray(v.Type) || expr.IsMap(v.Type) { if params := expr.AsObject(e.QueryParams().Type); len(*params) > 0 { var helpers []*codegen.TransformFunctionData - code, helpers, err = unmarshal((*params)[0].Attribute, v.AttributeExpr, codegen.Goify((*params)[0].Name, false), httpclictx, errctx) + queryctx := wireHTTPContext(sd.clientWireTypes, sd.clientWireTypes.scope, false, false) + transforms := sd.transforms.errors[v] + code, helpers, err = sd.clientWireTypes.renderTransform(transforms.clientDecode, (*params)[0].Attribute, codegen.Goify((*params)[0].Name, false), "v", queryctx, errctx) if err == nil { sd.ClientTransformHelpers = codegen.AppendHelpers(sd.ClientTransformHelpers, helpers) } } } if err != nil { - panic(err) // bug + sds.recordLinkError(err) } init = &InitData{ + Declaration: declaration, Name: name, Description: desc, ClientArgs: args, - ReturnTypeName: svc.Scope.GoFullTypeName(v.AttributeExpr, pkg), - ReturnTypeRef: svc.Scope.GoFullTypeRef(v.AttributeExpr, pkg), + ReturnTypeName: errctx.Scope.Name(errorAttribute, errctx.Pkg(errorAttribute), false, true), + ReturnTypeRef: errctx.Scope.Ref(errorAttribute, errctx.Pkg(errorAttribute)), ReturnIsStruct: expr.IsObject(v.Type), ReturnTypeAttribute: codegen.Goify(origin, true), - ReturnTypePkg: pkg, + ReturnTypePkg: errctx.Pkg(errorAttribute), ClientCode: code, } } @@ -2014,24 +3416,15 @@ func (sds *ServicesData) buildErrorsData(e *expr.HTTPEndpointExpr, sd *ServiceDa clientBodyData *TypeData ) { - errorLoc := ep.ErrorLocs[v.ErrorExpr.Name] - if sbd := sds.buildResponseBodyType(respBody, v.AttributeExpr, errorLoc, e, true, nil, sd); sbd != nil { + transforms := sd.transforms.errors[v] + if sbd := sds.buildResponseBodyType(respBody, errorAttribute, e, true, nil, sd, transforms, errorOwner, bodyOwner); sbd != nil { serverBodyData = append(serverBodyData, sbd) } - clientBodyData = sds.buildResponseBodyType(respBody, v.AttributeExpr, errorLoc, e, false, nil, sd) - if clientBodyData != nil { - if clientBodyData.Def != "" { - sd.ClientTypeNames[clientBodyData.Name] = struct{}{} - } - clientBodyData.Description = fmt.Sprintf("%s is the type of the %q service %q endpoint HTTP response body for the %q error.", - clientBodyData.VarName, svc.Name, e.Name(), v.Name) - serverBodyData[0].Description = fmt.Sprintf("%s is the type of the %q service %q endpoint HTTP response body for the %q error.", - serverBodyData[0].VarName, svc.Name, e.Name(), v.Name) - } + clientBodyData = sds.buildResponseBodyType(respBody, errorAttribute, e, false, nil, sd, nil, errorOwner, bodyOwner) } - headers := sds.extractHeaders(v.Response.Headers, v.AttributeExpr, errctx, sd.Scope) - cookies := sds.extractCookies(v.Response.Cookies, v.AttributeExpr, errctx, sd.Scope) + headers := sds.extractHeaders(v.Response.Headers, errorAttribute, errctx, sd.Scope, errorOwner) + cookies := sds.extractCookies(v.Response.Cookies, errorAttribute, errctx, sd.Scope, errorOwner) var mustValidate bool for _, h := range headers { if h.Validate != "" || h.Required || needConversion(h.Type) { @@ -2060,10 +3453,11 @@ func (sds *ServicesData) buildErrorsData(e *expr.HTTPEndpointExpr, sd *ServiceDa ClientBody: clientBodyData, ResultInit: init, MustValidate: mustValidate, + ResultAttr: codegen.Goify(origin, true), } } - ref := svc.Scope.GoFullTypeRef(v.AttributeExpr, pkg) + ref := errctx.Scope.Ref(errorAttribute, errctx.Pkg(errorAttribute)) data[ref] = append(data[ref], &ErrorData{ Name: v.Name, Response: responseData, @@ -2114,86 +3508,124 @@ func (sds *ServicesData) buildErrorsData(e *expr.HTTPEndpointExpr, sd *ServiceDa // svr is true if the function is generated for server side code. // // sd is the service data -func (sds *ServicesData) buildRequestBodyType(body, att *expr.AttributeExpr, e *expr.HTTPEndpointExpr, svr bool, sd *ServiceData) *TypeData { +func (sds *ServicesData) buildRequestBodyType(body, att *expr.AttributeExpr, e *expr.HTTPEndpointExpr, role wireTypeRole, svr bool, sd *ServiceData, sourceOwner, bodyOwner expr.ExampleIdentity) *TypeData { if body.Type == expr.Empty { return nil } + body = expr.DupAtt(body) var ( - name string - varname string - desc string - def string - ref string - validateDef string - validateRef string + name string + varname string + desc string + def string + ref string + validateDef string + nestedValidateDef string + validateRef string + validationTarget string svc = sd.Service - httpctx = httpContext(sd.Scope, true, svr) - ep = sd.Service.Method(e.Name()) - pkg = ep.PayloadLoc.PackageNameOrDefault(sd.Service.PkgName) - svcctx = serviceContext(pkg, sd.Service.Scope) + catalog = sd.wireTypes(svr) + policy = jsonBodyPolicy(true, svr, true, "") + httpctx = jsonBodyContext(catalog, catalog.scope, true, svr) + side = "client" ) + if svr { + side = "server" + } + svcctx := sds.serviceTypeContext(sd, side).Enter(att) + addMarshalTags(body) + record := catalog.lookupUser(body, role, policy) + catalog.applyNames(body, role, policy) + httpctx.Scope = catalog.resolverForUse(catalog.scope, policy, wireUnionUse{role: role}) + layoutResolver := httpctx.Scope.(codegen.GoTypeLayoutResolver) + bodyLayout, layoutErr := layoutResolver.GoTypeLayout(body, httpctx.LayoutPolicy()) + if layoutErr != nil { + sds.recordLinkError(layoutErr) + return nil + } name = body.Type.Name() - ref = sd.Scope.GoTypeRef(body) - - addMarshalTags(body, make(map[string]struct{})) + if record != nil { + name = record.name + } + ref = bodyLayout.Ref() if ut, ok := body.Type.(expr.UserType); ok { - varname = codegen.Goify(ut.Name(), true) - def = goTypeDef(sd.Scope, ut.Attribute(), svr, !svr) + varname = record.name + def = goTypeDefForContext(ut.Attribute(), httpctx) desc = fmt.Sprintf("%s is the type of the %q service %q endpoint HTTP request body.", varname, svc.Name, e.Name()) if svr { // generate validation code for unmarshaled type (server-side). validateDef = codegen.ValidationCode(ut.Attribute(), ut, httpctx, true, expr.IsAlias(ut), false, "body") + if record.needsNestedCall { + nestedValidateDef = codegen.ValidationCodeWithPathParameter(ut.Attribute(), ut, httpctx, true, expr.IsAlias(ut), false, "body", "path") + } if validateDef != "" { - validateRef = fmt.Sprintf("err = Validate%s(&body)", varname) + validationTarget = "&body" } } } else { - // Generate validation code first because inline struct validation is removed. - ctx := codegen.NewAttributeContext(!expr.IsPrimitive(body.Type), false, !svr, "", sd.Scope) - validateRef = codegen.ValidationCode(body, nil, ctx, true, expr.IsAlias(body.Type), false, "body") + if svr { + // Generate validation code first because inline struct validation is removed. + ctx := jsonBodyContext(catalog, catalog.scope, true, true) + ctx.Pointer = !expr.IsPrimitive(body.Type) + ctx.Scope = catalog.resolverForUse(catalog.scope, policy, wireUnionUse{role: role}) + validationSource := "body" + if origin, ok := body.Meta["origin:attribute"]; ok && !att.IsRequired(origin[0]) && !bodyLayout.ReferenceCanBeNil() { + validationSource = "*body" + } + validateRef = codegen.AttributeValidationCode(body, nil, ctx, true, expr.IsAlias(body.Type), validationSource, "body") + } if svr && expr.IsObject(body.Type) { // Body is an explicit object described in the design and in - // this case the GoTypeRef is an inline struct definition. We - // want to force all attributes to be pointers because we are - // generating the server body type pre-validation. + // this case the GoTypeRef is an inline struct definition. Keep + // scalar fields pointer-backed until server validation checks + // whether each value was present. body.Validation = nil } - varname = sd.Scope.GoTypeRef(body) + varname = httpctx.Scope.Ref(body, "") desc = body.Description } var init *InitData if !svr && att.Type != expr.Empty && needInit(body.Type) { var ( - name string - desc string - code string - origin string - err error - helpers []*codegen.TransformFunctionData + name string + desc string + code string + origin string + err error + helpers []*codegen.TransformFunctionData + declaration *codegen.NameDeclaration sourceVar = "p" svc = sd.Service ) { - name = fmt.Sprintf("New%s", codegen.Goify(sd.Scope.GoTypeName(body), true)) + if record != nil { + declaration = record.constructor + } else { + declaration = sd.clientBodyConstructors[clientBodyConstructorKey{endpoint: e, role: role}] + } + if declaration == nil { + panic(fmt.Sprintf("client body constructor for %s.%s was not submitted", svc.Name, e.Name())) + } + name = declaration.Name() desc = fmt.Sprintf("%s builds the HTTP request body from the payload of the %q endpoint of the %q service.", name, e.Name(), svc.Name) src := sourceVar - srcAtt := att // If design uses Body("name") syntax then need to use payload attribute // to transform. if o, ok := body.Meta["origin:attribute"]; ok { - srcObj := expr.AsObject(att.Type) origin = o[0] - srcAtt = srcObj.Attribute(origin) src += "." + codegen.Goify(origin, true) } - code, helpers, err = marshal(srcAtt, body, src, "body", svcctx, httpctx) + transformctx := jsonBodyContext(catalog, catalog.scope, true, svr) + transformctx.Scope = catalog.resolverForUse(catalog.scope, policy, wireUnionUse{role: role}) + transforms := sd.transforms.requests[clientBodyConstructorKey{endpoint: e, role: role}] + code, helpers, err = catalog.renderTransform(transforms.clientEncode, body, src, "body", svcctx, transformctx) if err != nil { - panic(err) // bug + sds.recordLinkError(err) } sd.ClientTransformHelpers = codegen.AppendHelpers(sd.ClientTransformHelpers, helpers) } @@ -2202,32 +3634,40 @@ func (sds *ServicesData) buildRequestBodyType(body, att *expr.AttributeExpr, e * AttributeData: &AttributeData{ Name: "payload", VarName: sourceVar, - TypeRef: svc.Scope.GoFullTypeRef(att, pkg), + TypeRef: svcctx.Scope.Ref(att, svcctx.Pkg(att)), Type: att.Type, Validate: validateDef, - Example: att.Example(sds.Root.API.ExampleGenerator), + Example: sds.Example(att, sourceOwner), }, } init = &InitData{ + Declaration: declaration, Name: name, Description: desc, - ReturnTypeRef: sd.Scope.GoTypeRef(body), + ReturnTypeRef: ref, ReturnTypeAttribute: codegen.Goify(origin, true), ClientCode: code, ClientArgs: []*InitArgData{&arg}, } } - return &TypeData{ - Name: name, - VarName: varname, - Description: desc, - Def: def, - Ref: ref, - Init: init, - ValidateDef: validateDef, - ValidateRef: validateRef, - Example: body.Example(sds.Root.API.ExampleGenerator), + data := &TypeData{ + Name: name, + VarName: varname, + Description: desc, + Def: def, + Ref: ref, + Init: init, + ValidateDef: validateDef, + NestedValidateDef: nestedValidateDef, + ValidateRef: validateRef, + ValidationTarget: validationTarget, + Example: sds.Example(body, bodyOwner), + attribute: body, } + if record == nil || data.Def == "" && data.ValidateDef == "" { + return data + } + return catalog.bind(record, data) } // buildResponseBodyType builds the TypeData for a response body. The data @@ -2241,68 +3681,90 @@ func (sds *ServicesData) buildRequestBodyType(body, att *expr.AttributeExpr, e * // svr is true if the function is generated for server side code // // view is the view name to add as a suffix to the type name. -func (sds *ServicesData) buildResponseBodyType(body, att *expr.AttributeExpr, loc *codegen.Location, e *expr.HTTPEndpointExpr, svr bool, view *string, sd *ServiceData) *TypeData { +func (sds *ServicesData) buildResponseBodyType( + body, att *expr.AttributeExpr, + e *expr.HTTPEndpointExpr, + svr bool, + view *string, + sd *ServiceData, + transforms *plannedResponseTransforms, + sourceOwner, bodyOwner expr.ExampleIdentity, +) *TypeData { if body.Type == expr.Empty { return nil } + body, viewName := prepareResponseWireBody(body, view) var ( - name string - varname string - desc string - def string - ref string - validateDef string - validateRef string - viewName string - mustInit bool - - svc = sd.Service - httpctx = httpContext(sd.Scope, false, svr) - pkg = loc.PackageNameOrDefault(sd.Service.PkgName) - svcctx = serviceContext(pkg, sd.Service.Scope) + name string + varname string + desc string + def string + ref string + validateDef string + nestedValidateDef string + validateRef string + validationTarget string + mustInit bool + + svc = sd.Service + side = "client" ) - // Project the response body when the design fixes the response to a single - // view so the generated transport code uses the effective wire shape. - if view != nil && *view != "" { - viewName = *view - body = expr.DupAtt(body) - if rt, ok := body.Type.(*expr.ResultTypeExpr); ok { - var err error - rt, err = expr.Project(rt, *view) - if err != nil { - panic(err) - } - body.Type = rt + if svr { + side = "server" + } + svcctx := sds.serviceTypeContext(sd, side).Enter(att) + catalog := sd.wireTypes(svr) + policy := jsonBodyPolicy(false, svr, !svr, viewName) + // Add each nested named field before body receives its chosen Go names. This + // keeps each copied request or response field tied to its own definition. + topLevel, _ := body.Type.(expr.UserType) + collectUserTypes(body.Type, func(ut expr.UserType) { + if topLevel != nil && ut == topLevel { + return + } + if d := sds.attributeTypeData(ut, false, !svr, svr, wireUnionUse{role: wireResponseBody, view: viewName}, sd); d != nil { if svr { - sd.ServerTypeNames[rt.Name()] = struct{}{} + sd.ServerBodyAttributeTypes = append(sd.ServerBodyAttributeTypes, d) } else { - sd.ClientTypeNames[rt.Name()] = struct{}{} + sd.ClientBodyAttributeTypes = append(sd.ClientBodyAttributeTypes, d) } } - } - + }) + record := catalog.lookupUser(body, wireResponseBody, policy) + catalog.applyNames(body, wireResponseBody, policy) + httpctx := jsonBodyContext(catalog, catalog.scope, false, svr) + responseUse := wireUnionUse{role: wireResponseBody, view: viewName} + httpctxPolicy := policy + httpctxPolicy.view = "" + httpctx.Scope = catalog.resolverForUse(catalog.scope, httpctxPolicy, responseUse) name = body.Type.Name() - ref = sd.Scope.GoTypeRef(body) - mustInit = att.Type != expr.Empty && needInit(body.Type) - - addMarshalTags(body, make(map[string]struct{})) + if record != nil { + name = record.name + ref = record.ref + } else { + ref = httpctx.Scope.Ref(body, "") + } + mustInit = att.Type != expr.Empty && (needInit(body.Type) || !svr && needClientResponseInit(att.Type)) if ut, ok := body.Type.(expr.UserType); ok { // response body is a user type. - varname = codegen.Goify(ut.Name(), true) - def = goTypeDef(sd.Scope, ut.Attribute(), !svr, svr) + varname = record.name + def = goTypeDefForContext(ut.Attribute(), httpctx) desc = fmt.Sprintf("%s is the type of the %q service %q endpoint HTTP response body.", varname, svc.Name, e.Name()) - if !svr && view == nil { + if !svr { // generate validation code for unmarshaled type (client-side). validateDef = codegen.ValidationCode(body, ut, httpctx, true, expr.IsAlias(body.Type), false, "body") + if record.needsNestedCall { + nestedValidateDef = codegen.ValidationCodeWithPathParameter(body, ut, httpctx, true, expr.IsAlias(body.Type), false, "body", "path") + } if validateDef != "" { target := "&body" if expr.IsArray(ut) { // result type collection target = "body" } - validateRef = fmt.Sprintf("err = Validate%s(%s)", varname, target) + validationTarget = target } } } else if !expr.IsPrimitive(body.Type) && mustInit { @@ -2318,38 +3780,30 @@ func (sds *ServicesData) buildResponseBodyType(body, att *expr.AttributeExpr, lo // may be deduplicated away in client/types.go. if svr { name = codegen.Goify(e.Name(), true) + "ResponseBody" - varname = name + record = catalog.lookup(body, wireResponseBody, policy, name) + varname = record.name + name = record.name desc = fmt.Sprintf("%s is the type of the %q service %q endpoint HTTP response body.", varname, svc.Name, e.Name()) - def = goTypeDef(sd.Scope, body, !svr, svr) + def = goTypeDefForContext(body, httpctx) } else { - varname = sd.Scope.GoTypeRef(body) + varname = httpctx.Scope.Ref(body, "") desc = body.Description def = "" } - validateRef = codegen.ValidationCode(body, nil, httpctx, true, expr.IsAlias(body.Type), false, "body") + if !svr { + validateRef = codegen.ValidationCode(body, nil, httpctx, true, expr.IsAlias(body.Type), false, "body") + } } else { // response body is a primitive type. They are used as non-pointers when // encoding/decoding responses. - httpctx = httpContext(sd.Scope, false, true) - validateRef = codegen.ValidationCode(body, nil, httpctx, true, expr.IsAlias(body.Type), false, "body") - varname = sd.Scope.GoTypeRef(body) + httpctx = jsonBodyContext(catalog, catalog.scope, false, true) + if !svr { + validateRef = codegen.ValidationCode(body, nil, httpctx, true, expr.IsAlias(body.Type), false, "body") + } + varname = httpctx.Scope.Ref(body, "") desc = body.Description } - if svr { - sd.ServerTypeNames[name] = struct{}{} - // We collect the server body types need to generate a response body type - // here because the response body type would be different from the actual - // type in the HTTPResponseExpr since we projected the body type above. - // For client side, response body types are collected in "analyze" using - // the effective client response body. - collectUserTypes(body.Type, func(ut expr.UserType) { - if d := sds.attributeTypeData(ut, false, false, true, sd); d != nil { - sd.ServerBodyAttributeTypes = append(sd.ServerBodyAttributeTypes, d) - } - }) - } - var init *InitData if svr && mustInit { var ( @@ -2370,28 +3824,30 @@ func (sds *ServicesData) buildResponseBodyType(body, att *expr.AttributeExpr, lo rtname = codegen.Goify(e.Name(), true) + "ResponseBody" rtref = rtname } else { - rtname = codegen.Goify(sd.Scope.GoTypeName(body), true) - rtref = sd.Scope.GoTypeRef(body) + rtname = record.name + rtref = ref } name = fmt.Sprintf("New%s", rtname) desc = fmt.Sprintf("%s builds the HTTP response body from the result of the %q endpoint of the %q service.", name, e.Name(), svc.Name) if view != nil { - svcctx = viewContext(sd.Service.ViewsPkg, sd.Service.ViewScope) + svcctx = sds.viewTypeContext(sd, "server").Enter(att) } src := sourceVar - srcAtt := att // If design uses Body("name") syntax then need to use result attribute // to transform. if o, ok := body.Meta["origin:attribute"]; ok { - srcObj := expr.AsObject(att.Type) origin = o[0] - srcAtt = srcObj.Attribute(origin) src += "." + codegen.Goify(origin, true) } - code, helpers, err = marshal(srcAtt, body, src, "body", svcctx, httpctx) + transformctx := jsonBodyContext(catalog, catalog.scope, false, svr) + transformctx.Scope = catalog.resolverForUse(catalog.scope, policy, responseUse) + if policy.view != "" { + transformctx.Scope = catalog.rootResolver(catalog.scope, policy, responseUse, record) + } + code, helpers, err = catalog.renderTransform(transforms.serverEncode, body, src, "body", svcctx, transformctx) if err != nil { - panic(err) // bug + sds.recordLinkError(err) } sd.ServerTransformHelpers = codegen.AppendHelpers(sd.ServerTransformHelpers, helpers) } @@ -2399,10 +3855,7 @@ func (sds *ServicesData) buildResponseBodyType(body, att *expr.AttributeExpr, lo if view != nil { ref += ".Projected" } - tref := svc.Scope.GoFullTypeRef(att, pkg) - if view != nil { - tref = svc.ViewScope.GoFullTypeRef(att, svc.ViewsPkg) - } + tref := svcctx.Scope.Ref(att, svcctx.Pkg(att)) arg := InitArgData{ Ref: ref, AttributeData: &AttributeData{ @@ -2411,7 +3864,7 @@ func (sds *ServicesData) buildResponseBodyType(body, att *expr.AttributeExpr, lo TypeRef: tref, Type: att.Type, Validate: validateDef, - Example: att.Example(sds.Root.API.ExampleGenerator), + Example: sds.Example(att, sourceOwner), }, } init = &InitData{ @@ -2424,23 +3877,29 @@ func (sds *ServicesData) buildResponseBodyType(body, att *expr.AttributeExpr, lo } } td := &TypeData{ - Name: name, - VarName: varname, - Description: desc, - Def: def, - Ref: ref, - Init: init, - ValidateDef: validateDef, - ValidateRef: validateRef, - Example: body.Example(sds.Root.API.ExampleGenerator), - View: viewName, + Name: name, + VarName: varname, + Description: desc, + Def: def, + Ref: ref, + Init: init, + ValidateDef: validateDef, + NestedValidateDef: nestedValidateDef, + ValidateRef: validateRef, + ValidationTarget: validationTarget, + Example: sds.Example(body, bodyOwner), + View: viewName, + } + if record == nil || td.Def == "" && td.ValidateDef == "" { + return td } - return td + return catalog.bind(record, td) } -func (sds *ServicesData) extractPathParams(a *expr.MappedAttributeExpr, service *expr.AttributeExpr, scope *codegen.NameScope) []*ParamData { +func (sds *ServicesData) extractPathParams(a *expr.MappedAttributeExpr, service *expr.AttributeExpr, sd *ServiceData, owner expr.ExampleIdentity) []*ParamData { var params []*ParamData - sds.extractElements(pathElement, a, service, serviceContext("", scope), scope, func(el *Element, _ *expr.AttributeExpr) { + svcctx := sds.serviceTypeContext(sd, "server").Enter(service) + sds.extractElements(pathElement, a, service, svcctx, sd.Scope, owner, func(el *Element, _ *expr.AttributeExpr) { params = append(params, &ParamData{ Map: false, MapStringSlice: false, @@ -2450,9 +3909,10 @@ func (sds *ServicesData) extractPathParams(a *expr.MappedAttributeExpr, service return params } -func (sds *ServicesData) extractQueryParams(a *expr.MappedAttributeExpr, service *expr.AttributeExpr, scope *codegen.NameScope) []*ParamData { +func (sds *ServicesData) extractQueryParams(a *expr.MappedAttributeExpr, service *expr.AttributeExpr, sd *ServiceData, owner expr.ExampleIdentity) []*ParamData { var params []*ParamData - sds.extractElements(queryElement, a, service, serviceContext("", scope), scope, func(el *Element, att *expr.AttributeExpr) { + svcctx := sds.serviceTypeContext(sd, "server").Enter(service) + sds.extractElements(queryElement, a, service, svcctx, sd.Scope, owner, func(el *Element, att *expr.AttributeExpr) { mp := expr.AsMap(att.Type) params = append(params, &ParamData{ Map: mp != nil, @@ -2466,20 +3926,22 @@ func (sds *ServicesData) extractQueryParams(a *expr.MappedAttributeExpr, service return params } -func (sds *ServicesData) extractHeaders(a *expr.MappedAttributeExpr, svcAtt *expr.AttributeExpr, svcCtx *codegen.AttributeContext, scope *codegen.NameScope) []*HeaderData { +func (sds *ServicesData) extractHeaders(a *expr.MappedAttributeExpr, svcAtt *expr.AttributeExpr, svcCtx *codegen.AttributeContext, scope *codegen.NameScope, owner expr.ExampleIdentity) []*HeaderData { var headers []*HeaderData - sds.extractElements(headerElement, a, svcAtt, svcCtx, scope, func(el *Element, _ *expr.AttributeExpr) { + sds.extractElements(headerElement, a, svcAtt, svcCtx, scope, owner, func(el *Element, _ *expr.AttributeExpr) { + _, preserveEmpty := a.Find(el.Name).Meta["sse:last-event-id"] headers = append(headers, &HeaderData{ CanonicalName: http.CanonicalHeaderKey(el.HTTPName), + PreserveEmpty: preserveEmpty, Element: el, }) }) return headers } -func (sds *ServicesData) extractCookies(a *expr.MappedAttributeExpr, svcAtt *expr.AttributeExpr, svcCtx *codegen.AttributeContext, scope *codegen.NameScope) []*CookieData { +func (sds *ServicesData) extractCookies(a *expr.MappedAttributeExpr, svcAtt *expr.AttributeExpr, svcCtx *codegen.AttributeContext, scope *codegen.NameScope, owner expr.ExampleIdentity) []*CookieData { var cookies []*CookieData - sds.extractElements(cookieElement, a, svcAtt, svcCtx, scope, func(el *Element, _ *expr.AttributeExpr) { + sds.extractElements(cookieElement, a, svcAtt, svcCtx, scope, owner, func(el *Element, _ *expr.AttributeExpr) { c := &CookieData{Element: el} for n, v := range a.Meta { switch n { @@ -2536,7 +3998,7 @@ func (sds *ServicesData) extractCookies(a *expr.MappedAttributeExpr, svcAtt *exp // the service expression to compute field pointer semantics, // // - cookies do not track slice information (cookie values are scalars). -func (sds *ServicesData) extractElements(kind httpElementKind, a *expr.MappedAttributeExpr, svcAtt *expr.AttributeExpr, svcCtx *codegen.AttributeContext, scope *codegen.NameScope, add func(el *Element, att *expr.AttributeExpr)) { +func (sds *ServicesData) extractElements(kind httpElementKind, a *expr.MappedAttributeExpr, svcAtt *expr.AttributeExpr, svcCtx *codegen.AttributeContext, scope *codegen.NameScope, owner expr.ExampleIdentity, add func(el *Element, att *expr.AttributeExpr)) { codegen.WalkMappedAttr(a, func(name, elem string, required bool, c *expr.AttributeExpr) error { // nolint: errcheck if kind == pathElement { required = true @@ -2561,35 +4023,55 @@ func (sds *ServicesData) extractElements(kind httpElementKind, a *expr.MappedAtt } } att := makeHTTPType(attr) + layout, err := svcCtx.Scope.(codegen.GoTypeLayoutResolver).GoTypeLayout(att, svcCtx.LayoutPolicy()) + if err != nil { + sds.recordLinkError(err) + return err + } var ( - varn = scope.Name(codegen.Goify(name, false)) - typeRef = scope.GoTypeRef(att) - ft = svcAtt.Type + varn = scope.Name(codegen.Goify(name, false)) + typeRef = layout.Ref() + elemTypeRef string + ft = svcAtt.Type + fieldAtt = svcAtt slice bool pointer bool fptr bool ) + if arr := expr.AsArray(att.Type); arr != nil { + elemCtx := svcCtx.Enter(arr.ElemType) + elemTypeRef = elemCtx.Scope.Ref(arr.ElemType, elemCtx.Pkg(arr.ElemType)) + } if kind != cookieElement { slice = expr.AsArray(att.Type) != nil } if kind != pathElement { pointer = a.IsPrimitivePointer(name, true) } + valueTypeRef := typeRef if pointer { - typeRef = "*" + typeRef + typeRef = layout.RefWithPointer(true) } fieldName := codegen.GoifyAtt(att, name, true) if !expr.IsObject(svcAtt.Type) { fieldName = "" } else { - ft = svcAtt.Find(name).Type + fieldAtt = svcAtt.Find(name) + ft = fieldAtt.Type if kind == pathElement || kind == queryElement { fptr = svcAtt.IsPrimitivePointer(name, true) } else { fptr = svcCtx.IsPrimitivePointer(name, svcAtt) } } + fieldPackage := "" + if _, userType := fieldAtt.Type.(expr.UserType); userType { + fieldPackage = svcCtx.Pkg(fieldAtt) + } + fieldTypeRef := svcCtx.Scope.Ref(fieldAtt, fieldPackage) + validationAttribute := att + defaultValue := requestElementDefault(svcAtt, name, att) validate := codegen.AttributeValidationCode(att, nil, svcCtx, required, expr.IsAlias(att.Type), varn, name) isText := (kind == pathElement || kind == queryElement) && isStringMetaType(att) if isText { @@ -2601,28 +4083,40 @@ func (sds *ServicesData) extractElements(kind httpElementKind, a *expr.MappedAtt v.Format = "" attNoFmt.Validation = &v } - validate = codegen.AttributeValidationCode(&attNoFmt, nil, svcCtx, required, expr.IsAlias(att.Type), varn+"Raw", name) + validationAttribute = &attNoFmt + validate = codegen.AttributeValidationCode(validationAttribute, nil, svcCtx, required, expr.IsAlias(att.Type), varn+"Raw", name) } add(&Element{ HTTPName: elem, Slice: slice, StringSlice: stringSlice, AttributeData: &AttributeData{ - Name: name, - Description: att.Description, - FieldName: fieldName, - FieldPointer: fptr, - FieldType: ft, - VarName: varn, - Required: required, - Type: att.Type, - TypeName: scope.GoTypeName(att), - TypeRef: typeRef, - Pointer: pointer, - Validate: validate, + Name: name, + Description: att.Description, + FieldName: fieldName, + FieldPointer: fptr, + FieldType: ft, + ServiceTypeRef: fieldTypeRef, + VarName: varn, + Required: required, + Type: att.Type, + TypeName: scope.GoTypeName(att), + TypeRef: typeRef, + ValueTypeRef: valueTypeRef, + ElemTypeRef: elemTypeRef, + Pointer: pointer, + Validate: validate, + CLIPlan: cli.NewFlagPlan( + validationAttribute, + scope.GoTypeName(validationAttribute), + valueTypeRef, + cliValidationRenderer(validate != "", validationAttribute, svcCtx, name), + ), IsTextUnmarshaler: isText, - DefaultValue: att.DefaultValue, - Example: att.Example(sds.Root.API.ExampleGenerator.Field(svcAtt, name)), + DefaultValue: defaultValue, + HasDefault: defaultValue != nil, + IsAliased: expr.IsAlias(att.Type), + Example: sds.FieldExample(att, svcAtt, name, owner), }, }, att) return nil @@ -2638,6 +4132,19 @@ func elementInitArg(el *Element) *InitArgData { return &InitArgData{Ref: att.VarName, AttributeData: &att} } +// cliValidationRenderer returns nil when the transport plan has no checks. A +// non-nil function writes checks for the concrete value parsed from a flag. +func cliValidationRenderer(enabled bool, attribute *expr.AttributeExpr, context *codegen.AttributeContext, name string) func(string) string { + if !enabled { + return nil + } + valueContext := context.Dup() + valueContext.Pointer = false + return func(target string) string { + return codegen.AttributeValidationCode(attribute, nil, valueContext, true, expr.IsAlias(attribute.Type), target, name) + } +} + // resultInitArg returns a result constructor argument backed by a copy of the // element attribute data. Result constructor arguments carry no description, // type name or default value: the constructor templates do not read them. @@ -2663,80 +4170,45 @@ func errorInitArg(el *Element) *InitArgData { // collectUserTypes traverses the given data type recursively and calls back the // given function for each attribute using a user type. -func collectUserTypes(dt expr.DataType, cb func(expr.UserType), seen ...map[string]struct{}) { +func collectUserTypes(dt expr.DataType, cb func(expr.UserType)) { + collectUserTypesRecursive(dt, cb, make(map[expr.UserType]struct{})) +} + +// collectUserTypesRecursive follows nested declarations once per authored +// origin so recursive copies terminate without hiding unrelated declarations. +func collectUserTypesRecursive(dt expr.DataType, cb func(expr.UserType), seen map[expr.UserType]struct{}) { if dt == expr.Empty { return } - var s map[string]struct{} - if len(seen) > 0 { - s = seen[0] - } else { - s = make(map[string]struct{}) - } switch actual := dt.(type) { case *expr.Object: for _, nat := range *actual { - collectUserTypes(nat.Attribute.Type, cb, seen...) + collectUserTypesRecursive(nat.Attribute.Type, cb, seen) } case *expr.Union: for _, nat := range actual.Values { - collectUserTypes(nat.Attribute.Type, cb, seen...) + collectUserTypesRecursive(nat.Attribute.Type, cb, seen) } case *expr.Array: - collectUserTypes(actual.ElemType.Type, cb, seen...) + collectUserTypesRecursive(actual.ElemType.Type, cb, seen) case *expr.Map: - collectUserTypes(actual.KeyType.Type, cb, seen...) - collectUserTypes(actual.ElemType.Type, cb, seen...) + collectUserTypesRecursive(actual.KeyType.Type, cb, seen) + collectUserTypesRecursive(actual.ElemType.Type, cb, seen) case expr.UserType: - if _, ok := s[actual.ID()]; ok { + origin := actual.Origin() + if _, ok := seen[origin]; ok { return } - s[actual.ID()] = struct{}{} + seen[origin] = struct{}{} cb(actual) - collectUserTypes(actual.Attribute().Type, cb, s) - } -} - -func collectHTTPUnionTypes(att *expr.AttributeExpr, scope *codegen.NameScope, unions map[string]*service.UnionTypeData, seen map[string]struct{}) { - if att == nil || att.Type == expr.Empty { - return - } - switch dt := att.Type.(type) { - case expr.UserType: - if _, ok := seen[dt.ID()]; ok { - return - } - seen[dt.ID()] = struct{}{} - collectHTTPUnionTypes(dt.Attribute(), scope, unions, seen) - case *expr.Object: - for _, nat := range sortedNamedAttributes(*dt) { - collectHTTPUnionTypes(nat.Attribute, scope, unions, seen) - } - case *expr.Array: - collectHTTPUnionTypes(dt.ElemType, scope, unions, seen) - case *expr.Map: - collectHTTPUnionTypes(dt.KeyType, scope, unions, seen) - collectHTTPUnionTypes(dt.ElemType, scope, unions, seen) - case *expr.Union: - hash := dt.Hash() - if _, ok := unions[hash]; !ok { - unions[hash] = buildHTTPUnionTypeData(dt, scope) - } - for _, nat := range dt.Values { - collectHTTPUnionTypes(nat.Attribute, scope, unions, seen) - } + collectUserTypesRecursive(actual.Attribute().Type, cb, seen) } } -// effectiveClientResponseBody returns the response body shape used by client -// code generation. When the design fixes the response to a single view, the -// returned attribute uses that projected ResultType so type collection, union -// collection, and client decode/init all agree on one transport body. -func effectiveClientResponseBody(body *expr.AttributeExpr, e *expr.HTTPEndpointExpr, md *service.MethodData) *expr.AttributeExpr { - view := clientResponseViewName(e, md) - if view == "" { - return body - } +// effectiveClientResponseBodyForView returns a copied response body containing +// the fields visible in one selected view. Type naming and client decoding both +// use this copy so they cannot disagree about its fields. +func effectiveClientResponseBodyForView(body *expr.AttributeExpr, view string, endpoint *expr.HTTPEndpointExpr) *expr.AttributeExpr { body = expr.DupAtt(body) rt, ok := body.Type.(*expr.ResultTypeExpr) if !ok { @@ -2746,14 +4218,71 @@ func effectiveClientResponseBody(body *expr.AttributeExpr, e *expr.HTTPEndpointE if err != nil { panic(err) // bug } + addJSONRPCSSEViewFields(projected, rt, endpoint) body.Type = projected return body } +// addJSONRPCSSEViewFields adds values carried by outer SSE lines to a client-only view body. +func addJSONRPCSSEViewFields(projected, result expr.DataType, endpoint *expr.HTTPEndpointExpr) { + if !endpoint.IsJSONRPC() || endpoint.SSE == nil { + return + } + if endpoint.SSE.IDField == "" && endpoint.SSE.EventField == "" && endpoint.SSE.RetryField == "" { + return + } + resultObject := expr.AsObject(result) + projectedResult, ok := projected.(*expr.ResultTypeExpr) + resultType, resultOK := result.(expr.UserType) + if !ok || !resultOK || resultObject == nil { + return + } + addFields := func(attribute *expr.AttributeExpr) { + object := expr.AsObject(attribute.Type) + if object == nil { + return + } + for _, name := range []string{endpoint.SSE.IDField, endpoint.SSE.EventField, endpoint.SSE.RetryField} { + if name == "" || object.Attribute(name) != nil { + continue + } + object.Set(name, expr.DupAtt(resultObject.Attribute(name))) + if resultType.Attribute().IsRequired(name) { + if attribute.Validation == nil { + attribute.Validation = &expr.ValidationExpr{} + } + attribute.Validation.AddRequired(name) + } + } + } + addFields(projectedResult.Attribute()) + for _, view := range projectedResult.Views { + addFields(view.AttributeExpr) + } +} + +// clientSSEDataPointer reports whether a configured SSE data field uses the +// pointer layout required by client response validation. Complete primitive +// response bodies remain values. +func clientSSEDataPointer(endpoint *expr.HTTPEndpointExpr, body *expr.AttributeExpr) bool { + if endpoint.SSE == nil || endpoint.SSE.DataField == "" { + return false + } + object := expr.AsObject(body.Type) + if object == nil { + return false + } + attribute := object.Attribute(endpoint.SSE.DataField) + if attribute == nil { + panic(fmt.Sprintf("SSE data field %q is missing from the client response body", endpoint.SSE.DataField)) + } + return sseBodyFieldPointer(attribute) +} + // clientResponseViewName returns the response view used by client code -// generation when the design fixes the response to a single view. An empty -// string means the client must keep the unprojected transport body because the -// server may render multiple views. +// generation when the design fixes a viewed result to one view. It also +// returns an empty string for unviewed methods and for responses that choose +// among the authored views at runtime. func clientResponseViewName(e *expr.HTTPEndpointExpr, md *service.MethodData) string { if md.ViewedResult == nil { return "" @@ -2767,73 +4296,72 @@ func clientResponseViewName(e *expr.HTTPEndpointExpr, md *service.MethodData) st return "" } -func buildHTTPUnionTypeData(u *expr.Union, scope *codegen.NameScope) *service.UnionTypeData { - att := &expr.AttributeExpr{Type: u} - name := scope.GoTypeName(att) - kindName := scope.Unique(name + "Kind") +// clientResponseViewNameExpr returns the one view selected by the HTTP design. +// An empty result means each streamed response may name any allowed view. +func clientResponseViewNameExpr(e *expr.HTTPEndpointExpr, result *expr.ResultTypeExpr) string { + if view, ok := e.MethodExpr.Result.Meta.Last(expr.ViewMetaKey); ok { + return view + } + if len(result.Views) == 1 { + return result.Views[0].Name + } + return "" +} +func buildHTTPUnionTypeData(u *expr.Union, scope codegen.Attributor, record *wireUnionRecord) *service.UnionTypeData { fields := make([]*service.UnionFieldData, len(u.Values)) for i, nat := range u.Values { fieldName := codegen.Goify(nat.Name, true) - fieldType := scope.GoTypeRef(nat.Attribute) - kindConst := kindName + fieldName + fieldType := scope.Ref(nat.Attribute, scope.Package(nat.Attribute)) fields[i] = &service.UnionFieldData{ - Name: nat.Name, - KindConst: kindConst, - FieldName: fieldName, - FieldType: fieldType, - Nilable: codegen.IsNilable(nat.Attribute.Type), - TypeTag: nat.Name, + Name: nat.Name, + KindConst: record.kindConsts[i], + Constructor: record.constructors[i], + KindDeclaration: record.kindDecls[i], + ConstructorDeclaration: record.ctorDecls[i], + FieldName: fieldName, + StorageName: record.storageNames[i], + FieldType: fieldType, + Nilable: codegen.IsNilable(nat.Attribute.Type), + TypeTag: nat.Name, } } return &service.UnionTypeData{ - Name: name, - KindName: kindName, - Fields: fields, - TypeKey: u.GetTypeKey(), - ValueKey: u.GetValueKey(), - } -} - -// sortedNamedAttributes returns object fields sorted by attribute name. -// Union naming uses NameScope uniqueness, so callers that discover unions while -// traversing objects must use a deterministic field order to avoid oscillating -// generated identifiers across runs. -func sortedNamedAttributes(attrs []*expr.NamedAttributeExpr) []*expr.NamedAttributeExpr { - if len(attrs) < 2 { - return attrs + Name: record.name, + KindName: record.kindName, + TypeDeclaration: record.declaration, + KindDeclaration: record.kind, + Fields: fields, + TypeKey: u.GetTypeKey(), + ValueKey: u.GetValueKey(), } - sorted := slices.Clone(attrs) - sort.Slice(sorted, func(i, j int) bool { - return sorted[i].Name < sorted[j].Name - }) - return sorted } -func (sds *ServicesData) attributeTypeData(ut expr.UserType, req, ptr, server bool, rd *ServiceData) *TypeData { +// attributeTypeData builds a nested declaration and keeps the HTTP body use +// that owns any union below it. +func (sds *ServicesData) attributeTypeData(ut expr.UserType, req, ptr, server bool, use wireUnionUse, rd *ServiceData) *TypeData { if ut == expr.Empty { return nil } - seen := rd.ServerTypeNames - if !server { - seen = rd.ClientTypeNames - } - if _, ok := seen[ut.Name()]; ok { - return nil - } - seen[ut.Name()] = struct{}{} var ( - name string - desc string - validate string - validateRef string - - att = &expr.AttributeExpr{Type: ut} - hctx = httpContext(rd.Scope, req, server) + name string + desc string + validate string + nestedValidate string + validateRef string + + att = expr.DupAtt(&expr.AttributeExpr{Type: ut}) + catalog = rd.wireTypes(server) + policy = wireTypePolicy{request: req, pointer: ptr, useDefault: hctxUseDefault(req, server), validate: req || !server, arrayElementPointer: req == server} ) - name = rd.Scope.GoTypeName(att) + ut = att.Type.(expr.UserType) + record := catalog.lookupUser(att, wireAttribute, policy) + catalog.applyNamesRecursive(att, wireAttribute, policy, use, make(map[expr.UserType]struct{})) + hctx := jsonBodyContext(catalog, catalog.scope, req, server) + hctx.Scope = catalog.resolverForUse(catalog.scope, policy, use) + name = record.name ctx := "request" if !req { ctx = "response" @@ -2844,22 +4372,65 @@ func (sds *ServicesData) attributeTypeData(ut expr.UserType, req, ptr, server bo // requests server-side and CLI. // Alias types are validated inline in the parent type validate = codegen.ValidationCode(ut.Attribute(), ut, hctx, true, expr.IsAlias(ut), false, "body") + if record.needsNestedCall { + nestedValidate = codegen.ValidationCodeWithPathParameter(ut.Attribute(), ut, hctx, true, expr.IsAlias(ut), false, "body", "path") + } } + validationTarget := "" if validate != "" { - validateRef = fmt.Sprintf("err = Validate%s(v)", name) + validationTarget = "v" + } + return catalog.bind(record, &TypeData{ + Name: ut.Name(), + VarName: name, + Description: desc, + Def: goTypeDefForContext(ut.Attribute(), hctx), + Ref: record.ref, + ValidateDef: validate, + NestedValidateDef: nestedValidate, + ValidateRef: validateRef, + ValidationTarget: validationTarget, + Example: sds.Example(att, expr.UserTypeExampleIdentity(ut)), + }) +} + +// recordLinkError keeps the first failed conversion so Plan.Link can return it +// before callers receive files built from incomplete template data. +func (sds *ServicesData) recordLinkError(err error) { + if sds.linkErr == nil { + sds.linkErr = err } - return &TypeData{ - Name: ut.Name(), - VarName: name, - Description: desc, - Def: goTypeDef(rd.Scope, ut.Attribute(), ptr, hctx.UseDefault), - Ref: rd.Scope.GoTypeRef(att), - ValidateDef: validate, - ValidateRef: validateRef, - Example: att.Example(sds.Root.API.ExampleGenerator), +} + +// wireTypes returns the request and response types for the server or client package. +func (sd *ServiceData) wireTypes(server bool) *wireTypeCatalog { + if server { + return sd.serverWireTypes + } + return sd.clientWireTypes +} + +// jsonBodyPolicy describes one generated JSON body. Bodies being decoded keep +// required primitive array elements as pointers until validation rejects null. +func jsonBodyPolicy(request, server, validate bool, view string) wireTypePolicy { + // A server decodes a request, and a client decodes a response. + decode := request == server + return wireTypePolicy{ + request: request, + pointer: decode, + useDefault: !decode, + validate: validate, + arrayElementPointer: decode, + view: view, } } +// hctxUseDefault reports whether missing HTTP values receive their design +// defaults for the selected request or response side. +func hctxUseDefault(request, server bool) bool { + return !request && server || request && !server +} + // httpContext returns a context for attributes of types used to marshal and // unmarshal HTTP requests and responses. // @@ -2878,44 +4449,53 @@ func httpContext(scope *codegen.NameScope, request, svr bool) *codegen.Attribute return ctx } -// serviceContext returns an attribute context for service types. -func serviceContext(pkg string, scope *codegen.NameScope) *codegen.AttributeContext { - return codegen.NewAttributeContext(false, false, true, pkg, scope) +// wireHTTPContext returns the pointer and default-value rules for one generated +// HTTP package. It maps each copied field to the Go type name chosen for +// that particular request or response. +func wireHTTPContext(catalog *wireTypeCatalog, scope *codegen.NameScope, request, server bool) *codegen.AttributeContext { + context := httpContext(scope, request, server) + context.Scope = catalog.resolver(scope, wireTypePolicy{ + request: request, + pointer: context.Pointer, + useDefault: context.UseDefault, + }) + return context } -// viewContext returns an attribute context for projected types. -func viewContext(pkg string, scope *codegen.NameScope) *codegen.AttributeContext { - return codegen.NewAttributeContext(true, false, true, pkg, scope) +// jsonBodyContext uses pointer elements only while decoding a JSON body. This +// lets generated validation reject null before conversion to service values. +func jsonBodyContext(catalog *wireTypeCatalog, scope *codegen.NameScope, request, server bool) *codegen.AttributeContext { + context := wireHTTPContext(catalog, scope, request, server) + decode := request == server + context.ArrayElementPointer = decode + context.Scope = catalog.resolver(scope, wireTypePolicy{ + request: request, + pointer: context.Pointer, + useDefault: context.UseDefault, + arrayElementPointer: context.ArrayElementPointer, + }) + return context } -// unmarshal initializes a data structure defined by target type from a data -// structure defined by source type. The attributes in the source data -// structure are pointers and the attributes in the target data structure that -// have default values are non-pointers. Fields in target type are initialized -// with their default values (if any). -// -// source, target are the attributes used in the transformation -// -// sourceVar, targetVar are the variable names for source and target used in -// the transformation code -// -// sourceCtx, targetCtx are the source and target attribute contexts -func unmarshal(source, target *expr.AttributeExpr, sourceVar string, sourceCtx, targetCtx *codegen.AttributeContext) (string, []*codegen.TransformFunctionData, error) { - return codegen.GoTransform(source, target, sourceVar, "v", sourceCtx, targetCtx, "unmarshal", true) +// serviceTypeContext returns the service type names as referenced from the +// generated client or server package named by side. +func (sds *ServicesData) serviceTypeContext(sd *ServiceData, side string) *codegen.AttributeContext { + outputPackage := path.Join(sds.GenPkg(), sds.dir(), sd.Service.PathName, side) + return &codegen.AttributeContext{ + UseDefault: true, + Scope: sds.ServiceAttributor(sd.Service.Name, outputPackage), + } } -// marshal initializes a data structure defined by target type from a data -// structure defined by source type. The fields in the source and target -// data structure use non-pointers for attributes with default values. -// -// source, target are the attributes used in the transformation -// -// sourceVar, targetVar are the variable names for source and target used in -// the transformation code -// -// sourceCtx, targetCtx are the source and target attribute contexts -func marshal(source, target *expr.AttributeExpr, sourceVar, targetVar string, sourceCtx, targetCtx *codegen.AttributeContext) (string, []*codegen.TransformFunctionData, error) { - return codegen.GoTransform(source, target, sourceVar, targetVar, sourceCtx, targetCtx, "marshal", true) +// viewTypeContext returns the result-view type names as referenced from the +// generated client or server package named by side. +func (sds *ServicesData) viewTypeContext(sd *ServiceData, side string) *codegen.AttributeContext { + outputPackage := path.Join(sds.GenPkg(), sds.dir(), sd.Service.PathName, side) + return &codegen.AttributeContext{ + Pointer: true, + UseDefault: true, + Scope: sds.ViewAttributor(sd.Service.Name, outputPackage), + } } // needConversion returns true if the type needs to be converted from a string. @@ -2953,26 +4533,33 @@ func isStringMetaType(c *expr.AttributeExpr) bool { } // addMarshalTags adds JSON, XML and Form tags to all inline object attributes recursively. -func addMarshalTags(att *expr.AttributeExpr, seen map[string]struct{}) { +func addMarshalTags(att *expr.AttributeExpr) { + addMarshalTagsRecursive(att, make(map[expr.UserType]struct{})) +} + +// addMarshalTagsRecursive annotates every inline object reachable through one +// declaration origin and stops when recursive copies return to that origin. +func addMarshalTagsRecursive(att *expr.AttributeExpr, seen map[expr.UserType]struct{}) { if ut, ok := att.Type.(expr.UserType); ok { - if _, ok := seen[ut.Hash()]; ok { + origin := ut.Origin() + if _, ok := seen[origin]; ok { return // avoid infinite recursions } - seen[ut.Hash()] = struct{}{} + seen[origin] = struct{}{} if expr.IsObject(ut.Attribute().Type) { for _, att := range *(expr.AsObject(att.Type)) { - addMarshalTags(att.Attribute, seen) + addMarshalTagsRecursive(att.Attribute, seen) } } return } if expr.IsArray(att.Type) { - addMarshalTags(expr.AsArray(att.Type).ElemType, seen) + addMarshalTagsRecursive(expr.AsArray(att.Type).ElemType, seen) return } if expr.IsMap(att.Type) { - addMarshalTags(expr.AsMap(att.Type).KeyType, seen) - addMarshalTags(expr.AsMap(att.Type).ElemType, seen) + addMarshalTagsRecursive(expr.AsMap(att.Type).KeyType, seen) + addMarshalTagsRecursive(expr.AsMap(att.Type).ElemType, seen) return } if !expr.IsObject(att.Type) { @@ -3011,6 +4598,8 @@ func needInit(dt expr.DataType) bool { } } return false + case *expr.Union: + return true case expr.UserType: return true default: @@ -3018,6 +4607,29 @@ func needInit(dt expr.DataType) bool { } } +// needClientResponseInit reports whether decoding a response requires a +// separate transport value before Goa can return the service result. In +// addition to named types, required primitive array elements need pointers so +// validation can distinguish JSON null from the primitive zero value. +func needClientResponseInit(dt expr.DataType) bool { + if needInit(dt) { + return true + } + switch actual := dt.(type) { + case *expr.Array: + if actual.NonNullableElems && expr.IsPrimitive(actual.ElemType.Type) && + !codegen.IsNilable(actual.ElemType.Type) { + return true + } + return needClientResponseInit(actual.ElemType.Type) + case *expr.Map: + return needClientResponseInit(actual.KeyType.Type) || + needClientResponseInit(actual.ElemType.Type) + default: + return false + } +} + // upgradeParams returns the data required to render the websocket_upgrade // template. func upgradeParams(e *EndpointData, fn string) map[string]any { diff --git a/http/codegen/service_data_purity_test.go b/http/codegen/service_data_purity_test.go index cceb97d271..0ac13dfaf6 100644 --- a/http/codegen/service_data_purity_test.go +++ b/http/codegen/service_data_purity_test.go @@ -82,9 +82,9 @@ func TestAnalyzeLeavesDesignExpressionsUnchanged(t *testing.T) { } } - services := CreateHTTPServices(root) + plan := linkedHTTPPlanForRoot(t, root) for _, svc := range root.API.HTTP.Services { - require.NotNil(t, services.Get(svc.Name())) + require.NotNil(t, plan.services.Get(svc.Name())) } for _, svc := range root.API.HTTP.Services { diff --git a/http/codegen/service_data_traversal_test.go b/http/codegen/service_data_traversal_test.go new file mode 100644 index 0000000000..17b04059a1 --- /dev/null +++ b/http/codegen/service_data_traversal_test.go @@ -0,0 +1,121 @@ +// This file verifies that HTTP body shaping and traversal visit unrelated +// declarations even when their semantic identifiers or structures match. +package codegen + +import ( + "testing" + + "github.com/stretchr/testify/require" + + "goa.design/goa/v3/expr" +) + +func TestMakeHTTPTypeDistinguishesEqualUIDOrigins(t *testing.T) { + first := locatedHTTPTraversalType("First", "shared", "first/types") + second := locatedHTTPTraversalType("Second", "shared", "second/types") + body := &expr.AttributeExpr{Type: &expr.Object{ + {Name: "first", Attribute: &expr.AttributeExpr{Type: first}}, + {Name: "second", Attribute: &expr.AttributeExpr{Type: second}}, + }} + + wire := makeHTTPType(body) + object := expr.AsObject(wire.Type) + wireFirst := object.Attribute("first").Type.(expr.UserType) + wireSecond := object.Attribute("second").Type.(expr.UserType) + require.NotContains(t, wireFirst.Attribute().Meta, "struct:pkg:path") + require.NotContains(t, wireSecond.Attribute().Meta, "struct:pkg:path") +} + +func TestMakeHTTPTypeStopsRecursiveUnionBranches(t *testing.T) { + node := &expr.UserTypeExpr{TypeName: "Node", UID: "node"} + next := &expr.Union{ + TypeName: "Next", + Values: []*expr.NamedAttributeExpr{ + {Name: "end", Attribute: &expr.AttributeExpr{Type: &expr.Object{}}}, + {Name: "node", Attribute: &expr.AttributeExpr{Type: node}}, + }, + } + node.AttributeExpr = &expr.AttributeExpr{ + Type: &expr.Object{ + {Name: "next", Attribute: &expr.AttributeExpr{Type: next}}, + }, + Meta: expr.MetaExpr{"struct:pkg:path": {"service/types"}}, + } + + wire := makeHTTPType(&expr.AttributeExpr{Type: node}) + wireNode := wire.Type.(expr.UserType) + wireNext := expr.AsObject(wireNode).Attribute("next").Type.(*expr.Union) + wireRecursiveNode := wireNext.Values[1].Attribute.Type.(expr.UserType) + require.Equal(t, "Node", wireRecursiveNode.Name()) + require.NotContains(t, wireNode.Attribute().Meta, "struct:pkg:path") +} + +func TestCollectUserTypesDistinguishesEqualUIDOriginsAndStopsRecursion(t *testing.T) { + first := &expr.UserTypeExpr{TypeName: "First", UID: "shared"} + firstObject := &expr.Object{} + first.AttributeExpr = &expr.AttributeExpr{Type: firstObject} + firstObject.Set("self", &expr.AttributeExpr{Type: first}) + second := &expr.UserTypeExpr{ + AttributeExpr: &expr.AttributeExpr{Type: &expr.Object{}}, + TypeName: "Second", + UID: "shared", + } + outer := &expr.UserTypeExpr{ + AttributeExpr: &expr.AttributeExpr{Type: &expr.Object{ + {Name: "first", Attribute: &expr.AttributeExpr{Type: first}}, + {Name: "second", Attribute: &expr.AttributeExpr{Type: second}}, + }}, + TypeName: "Outer", + } + + var names []string + collectUserTypes(outer, func(userType expr.UserType) { + names = append(names, userType.Name()) + }) + require.Equal(t, []string{"Outer", "First", "Second"}, names) +} + +func TestAddMarshalTagsDistinguishesEqualStructuralOrigins(t *testing.T) { + first := marshalTagTraversalType() + second := marshalTagTraversalType() + outer := &expr.UserTypeExpr{ + AttributeExpr: &expr.AttributeExpr{Type: &expr.Object{ + {Name: "first", Attribute: &expr.AttributeExpr{Type: first}}, + {Name: "second", Attribute: &expr.AttributeExpr{Type: second}}, + }}, + TypeName: "Outer", + } + + addMarshalTags(&expr.AttributeExpr{Type: outer}) + firstValue := expr.AsObject(expr.AsObject(first).Attribute("nested").Type).Attribute("value") + secondValue := expr.AsObject(expr.AsObject(second).Attribute("nested").Type).Attribute("value") + require.Equal(t, []string{"value"}, firstValue.Meta["struct:tag:json"]) + require.Equal(t, []string{"value"}, secondValue.Meta["struct:tag:json"]) +} + +// locatedHTTPTraversalType builds an authored declaration whose package path +// must be removed when Goa derives its transport type. +func locatedHTTPTraversalType(name, uid, packagePath string) *expr.UserTypeExpr { + return &expr.UserTypeExpr{ + AttributeExpr: &expr.AttributeExpr{ + Type: &expr.Object{}, + Meta: expr.MetaExpr{"struct:pkg:path": {packagePath}}, + }, + TypeName: name, + UID: uid, + } +} + +// marshalTagTraversalType builds a declaration with an inline object whose +// fields must receive transport serialization tags. +func marshalTagTraversalType() *expr.UserTypeExpr { + return &expr.UserTypeExpr{ + AttributeExpr: &expr.AttributeExpr{Type: &expr.Object{ + {Name: "nested", Attribute: &expr.AttributeExpr{Type: &expr.Object{ + {Name: "value", Attribute: &expr.AttributeExpr{Type: expr.String}}, + }}}, + }}, + TypeName: "Shared", + UID: "shared", + } +} diff --git a/http/codegen/service_data_union_nilability_test.go b/http/codegen/service_data_union_nilability_test.go index 3b49baf22f..bf32aac970 100644 --- a/http/codegen/service_data_union_nilability_test.go +++ b/http/codegen/service_data_union_nilability_test.go @@ -1,3 +1,5 @@ +// This file verifies HTTP union records preserve the nilability of every +// branch when rendering a value that holds one selected branch. package codegen import ( @@ -11,7 +13,26 @@ import ( func TestBuildHTTPUnionTypeDataMarksNilableBranches(t *testing.T) { union := unionWithBranchTypes() - data := buildHTTPUnionTypeData(union, codegen.NewNameScope()) + kindNames := []string{"ValueKindArray", "ValueKindBool", "ValueKindBytes", "ValueKindMap", "ValueKindObject", "ValueKindString"} + constructorNames := []string{"NewValueArray", "NewValueBool", "NewValueBytes", "NewValueMap", "NewValueObject", "NewValueString"} + kindDeclarations := make([]*codegen.NameDeclaration, len(kindNames)) + constructorDeclarations := make([]*codegen.NameDeclaration, len(constructorNames)) + for index := range kindNames { + kindDeclarations[index] = codegen.NewExactName(codegen.NameConstant, kindNames[index]) + constructorDeclarations[index] = codegen.NewExactName(codegen.NameFunction, constructorNames[index]) + } + record := &wireUnionRecord{ + declaration: codegen.NewExactName(codegen.NameType, "Value"), + kind: codegen.NewExactName(codegen.NameType, "ValueKind"), + kindDecls: kindDeclarations, + ctorDecls: constructorDeclarations, + name: "Value", + kindName: "ValueKind", + kindConsts: kindNames, + constructors: constructorNames, + storageNames: []string{"array", "bool_", "bytes", "map_", "object", "string_"}, + } + data := buildHTTPUnionTypeData(union, codegen.NewAttributeScope(codegen.NewNameScope()), record) nilable := make(map[string]bool, len(data.Fields)) for _, field := range data.Fields { @@ -25,6 +46,19 @@ func TestBuildHTTPUnionTypeDataMarksNilableBranches(t *testing.T) { "object": true, "string": false, }, nilable) + + storageNames := make(map[string]string, len(data.Fields)) + for _, field := range data.Fields { + storageNames[field.Name] = field.StorageName + } + assert.Equal(t, map[string]string{ + "array": "array", + "bool": "bool_", + "bytes": "bytes", + "map": "map_", + "object": "object", + "string": "string_", + }, storageNames) } func unionWithBranchTypes() *expr.Union { diff --git a/http/codegen/service_data_union_order_test.go b/http/codegen/service_data_union_order_test.go index b4fadfcc48..a890f17034 100644 --- a/http/codegen/service_data_union_order_test.go +++ b/http/codegen/service_data_union_order_test.go @@ -1,3 +1,5 @@ +// This file verifies deterministic HTTP wire union identity and confirms that +// detached wire expressions do not retain service package ownership. package codegen import ( @@ -5,8 +7,8 @@ import ( "github.com/stretchr/testify/require" - cg "goa.design/goa/v3/codegen" - svc "goa.design/goa/v3/codegen/service" + "goa.design/goa/v3/codegen/testutil" + "goa.design/goa/v3/dsl" "goa.design/goa/v3/expr" ) @@ -15,10 +17,12 @@ func TestCollectHTTPUnionTypesDeterministicAcrossObjectOrder(t *testing.T) { "physical_point", "synthetic_series", ) + sourceFromSignals.TypeName = "SignalSource" sourceFromInputs := makeUnionForOrderTest("source", "time_series", "energy_rates", ) + sourceFromInputs.TypeName = "InputSource" forward := &expr.AttributeExpr{ Type: &expr.Object{ @@ -53,22 +57,324 @@ func TestCollectHTTPUnionTypesDeterministicAcrossObjectOrder(t *testing.T) { }, } - forwardNames := collectHTTPUnionTypeNames(forward) - reverseNames := collectHTTPUnionTypeNames(reverse) + forwardNames := collectHTTPUnionTypeNames(t, forward) + reverseNames := collectHTTPUnionTypeNames(t, reverse) require.Len(t, forwardNames, 2) require.Equal(t, forwardNames, reverseNames) } -func collectHTTPUnionTypeNames(att *expr.AttributeExpr) map[string]string { - scope := cg.NewNameScope() - seen := make(map[string]struct{}) - unionByHash := make(map[string]*svc.UnionTypeData) - collectHTTPUnionTypes(att, scope, unionByHash, seen) +func TestCollectHTTPUnionTypesRejectsUnrelatedSameShapedDeclarations(t *testing.T) { + first := makeUnionForOrderTest("Value", "bool", "number") + second := makeUnionForOrderTest("Value", "bool", "number") + bodies := &expr.AttributeExpr{ + Type: &expr.Object{ + { + Name: "first", + Attribute: &expr.AttributeExpr{Type: first}, + }, + { + Name: "second", + Attribute: &expr.AttributeExpr{Type: second}, + }, + }, + } + + catalog, _ := testWireTypeCatalog(t) + catalog.collect(bodies, wireAttribute, wireTypePolicy{}) + err := catalog.Declare() + require.ErrorContains(t, err, `declare HTTP OneOf "Value" for direct attribute`) + require.ErrorContains(t, err, `cannot declare exact type "Value"`) + require.ErrorContains(t, err, "set TypeName on the OneOf to a unique name") +} + +func TestCollectHTTPUnionTypesRejectsBranchesWithTheSameGoName(t *testing.T) { + union := makeUnionForOrderTest("Value", "foo-bar", "foo_bar") + catalog, _ := testWireTypeCatalog(t) + catalog.collect(&expr.AttributeExpr{Type: union}, wireAttribute, wireTypePolicy{}) + + err := catalog.Declare() + + require.ErrorContains(t, err, `OneOf "Value" branches "foo-bar" and "foo_bar" both generate Go name "FooBar"`) + require.ErrorContains(t, err, "rename one of the branches") + require.NotContains(t, err.Error(), "TypeName") +} + +func TestCollectHTTPUnionTypesUsesExactRootRoleNames(t *testing.T) { + authored := &expr.AttributeExpr{Type: makeUnionForOrderTest("Scope", "site_set", "all_sites")} + catalog, generation := testWireTypeCatalog(t) + + catalog.collect(authored, wireAttribute, wireTypePolicy{}) + catalog.collect(expr.DupAtt(authored), wireRequestBody, wireTypePolicy{request: true}) + catalog.collect(expr.DupAtt(authored), wireRequestBody, wireTypePolicy{request: true}) + catalog.collect(expr.DupAtt(authored), wireStreamPayload, wireTypePolicy{request: true}) + catalog.collect(expr.DupAtt(authored), wireResponseBody, wireTypePolicy{}) + catalog.collect(expr.DupAtt(authored), wireResponseBody, wireTypePolicy{view: "detailed"}) + linkTestWireTypeCatalog(t, generation, catalog) + + names := make([]string, 0, len(catalog.unions)) + for _, union := range catalog.unionTypes() { + names = append(names, union.Name) + } + require.Equal(t, []string{ + "Scope", + "ScopeDetailedResponseBody", + "ScopeRequestBody", + "ScopeResponseBody", + "ScopeStreamingBody", + }, names) +} + +func TestCollectHTTPUnionTypesRejectsConflictingShapesInOneRootRole(t *testing.T) { + authored := &expr.AttributeExpr{Type: makeUnionForOrderTest("Scope", "site_set", "all_sites")} + first := expr.DupAtt(authored) + second := expr.DupAtt(authored) + second.Type.(*expr.Union).ValueKey = "data" + catalog, _ := testWireTypeCatalog(t) + + catalog.collect(first, wireRequestBody, wireTypePolicy{request: true}) + catalog.collect(second, wireRequestBody, wireTypePolicy{request: true}) + err := catalog.Declare() + require.ErrorContains(t, err, "OneOf \"Scope\" produces different RequestBody definitions") + require.ErrorContains(t, err, "use separate OneOf declarations") +} + +func TestHTTPServiceDataReusesOneAuthoredUnionAcrossMethods(t *testing.T) { + root := expr.RunDSL(t, func() { + payload := dsl.Type("SharedPayload", func() { + dsl.OneOf("Value", sameShapedValueUnionDSL) + }) + dsl.Service("values", func() { + dsl.Method("first", func() { + dsl.Payload(payload) + dsl.HTTP(func() { + dsl.POST("/first") + }) + }) + dsl.Method("second", func() { + dsl.Payload(payload) + dsl.HTTP(func() { + dsl.POST("/second") + }) + }) + }) + }) - names := make(map[string]string, len(unionByHash)) - for hash, data := range unionByHash { - names[hash] = data.Name + data := linkedHTTPPlanForRoot(t, root).services.Get("values") + require.NotNil(t, data) + for _, catalog := range []*wireTypeCatalog{data.serverWireTypes, data.clientWireTypes} { + unions := catalog.unionTypes() + emitted := make([]string, len(unions)) + for i, union := range unions { + emitted[i] = union.Name + } + require.Equal(t, []string{"ValueRequestBody"}, emitted) + } + require.Contains(t, data.Endpoint("first").Payload.Request.ServerBody.Def, "Value *ValueRequestBody ") + require.Contains(t, data.Endpoint("second").Payload.Request.ServerBody.Def, "Value *ValueRequestBody ") +} + +func TestHTTPServiceDataReusesOneAuthoredUnionAcrossExplicitRequestBodies(t *testing.T) { + root := expr.RunDSL(t, func() { + payload := dsl.Type("SharedPayload", func() { + dsl.OneOf("Choice", sameShapedValueUnionDSL) + dsl.Attribute("note", dsl.String) + }) + dsl.Service("values", func() { + for _, method := range []string{"first", "second"} { + dsl.Method(method, func() { + dsl.Payload(payload) + dsl.HTTP(func() { + dsl.POST("/" + method) + dsl.Body(func() { + if method == "first" { + dsl.Attribute("Choice") + dsl.Attribute("note") + return + } + dsl.Attribute("note") + dsl.Attribute("Choice") + }) + }) + }) + } + }) + }) + + data := linkedHTTPPlanForRoot(t, root).services.Get("values") + for _, catalog := range []*wireTypeCatalog{data.serverWireTypes, data.clientWireTypes} { + require.Len(t, catalog.unionTypes(), 1) + require.Equal(t, "ChoiceRequestBody", catalog.unionTypes()[0].Name) + } +} + +func TestHTTPServiceDataReusesOneAuthoredUnionAcrossErrors(t *testing.T) { + root := expr.RunDSL(t, func() { + failure := dsl.Type("SharedFailure", func() { + dsl.OneOf("Cause", sameShapedValueUnionDSL) + }) + dsl.Service("values", func() { + for _, method := range []string{"first", "second"} { + dsl.Method(method, func() { + dsl.Error("failed", failure) + dsl.HTTP(func() { + dsl.GET("/" + method) + dsl.Response("failed", dsl.StatusBadRequest) + }) + }) + } + }) + }) + + data := linkedHTTPPlanForRoot(t, root).services.Get("values") + for _, catalog := range []*wireTypeCatalog{data.serverWireTypes, data.clientWireTypes} { + require.Len(t, catalog.unionTypes(), 1) + require.Equal(t, "CauseResponseBody", catalog.unionTypes()[0].Name) + } +} + +func TestHTTPServiceDataReusesUnionWhenBranchTypeAppearsEarlier(t *testing.T) { + root := expr.RunDSL(t, unionBranchOrderDSL) + + data := linkedHTTPPlanForRoot(t, root).services.Get("values") + for _, catalog := range []*wireTypeCatalog{data.serverWireTypes, data.clientWireTypes} { + require.Len(t, catalog.unionTypes(), 1) + require.Equal(t, "RelationshipResponseBody", catalog.unionTypes()[0].Name) + } +} + +func TestHTTPUnionBranchTypesDoNotDependOnSurroundingFieldOrder(t *testing.T) { + code := renderClientTypesCode(t, unionBranchOrderDSL) + testutil.AssertGo(t, "testdata/golden/http_union_branch_order.go.golden", code) +} + +func TestMakeHTTPTypeRemovesServicePackageOwnershipFromWireCopy(t *testing.T) { + nested := &expr.UserTypeExpr{ + TypeName: "Nested", + AttributeExpr: &expr.AttributeExpr{ + Type: &expr.Object{ + {Name: "choice", Attribute: &expr.AttributeExpr{Type: makeUnionForOrderTest("Choice", "text", "number")}}, + }, + Meta: expr.MetaExpr{"struct:pkg:path": {"service/types"}}, + }, + } + outer := &expr.UserTypeExpr{ + TypeName: "Envelope", + AttributeExpr: &expr.AttributeExpr{ + Type: &expr.Object{ + {Name: "nested", Attribute: &expr.AttributeExpr{Type: nested}}, + }, + Meta: expr.MetaExpr{"struct:pkg:path": {"service/types"}}, + }, + } + + wire := makeHTTPType(&expr.AttributeExpr{Type: outer}) + wireOuter := wire.Type.(expr.UserType) + wireNested := expr.AsObject(wireOuter.Attribute().Type).Attribute("nested").Type.(expr.UserType) + + require.NotContains(t, wireOuter.Attribute().Meta, "struct:pkg:path") + require.NotContains(t, wireNested.Attribute().Meta, "struct:pkg:path") + require.Contains(t, outer.Attribute().Meta, "struct:pkg:path") + require.Contains(t, nested.Attribute().Meta, "struct:pkg:path") +} + +func TestStreamingHTTPTypeRemovesServicePackageOwnershipFromWireCopy(t *testing.T) { + nested := &expr.UserTypeExpr{ + TypeName: "Nested", + AttributeExpr: &expr.AttributeExpr{ + Type: &expr.Object{ + {Name: "value", Attribute: &expr.AttributeExpr{Type: expr.String}}, + }, + Meta: expr.MetaExpr{"struct:pkg:path": {"service/types"}}, + }, + } + outer := &expr.UserTypeExpr{ + TypeName: "Envelope", + AttributeExpr: &expr.AttributeExpr{ + Type: &expr.Object{ + {Name: "nested", Attribute: &expr.AttributeExpr{Type: nested}}, + }, + Meta: expr.MetaExpr{"struct:pkg:path": {"service/types"}}, + }, + } + body := &expr.AttributeExpr{Type: outer} + endpoint := &expr.HTTPEndpointExpr{StreamingBody: body} + + wire := new(shapedBodies).streaming(endpoint) + wireOuter := wire.Type.(expr.UserType) + wireNested := expr.AsObject(wireOuter.Attribute().Type).Attribute("nested").Type.(expr.UserType) + + require.NotContains(t, wireOuter.Attribute().Meta, "struct:pkg:path") + require.NotContains(t, wireNested.Attribute().Meta, "struct:pkg:path") + require.Contains(t, outer.Attribute().Meta, "struct:pkg:path") + require.Contains(t, nested.Attribute().Meta, "struct:pkg:path") +} + +func sameShapedValueUnionDSL() { + dsl.Attribute("bool", dsl.Boolean) + dsl.Attribute("number", dsl.Float64) +} + +// unionBranchOrderDSL returns the same authored union through response objects +// that reach its nested branch type in different orders. +func unionBranchOrderDSL() { + identifier := dsl.Type("Identifier", dsl.String) + label := dsl.Type("Label", func() { + dsl.Attribute("value", dsl.String) + dsl.Required("value") + }) + snapshot := dsl.Type("Snapshot", func() { + dsl.Attribute("id", identifier) + dsl.Attribute("labels", dsl.ArrayOf(label)) + dsl.Required("id", "labels") + }) + noEdit := dsl.Type("NoEdit", func() {}) + editable := dsl.Type("Editable", func() { + dsl.Attribute("snapshot", snapshot) + dsl.Required("snapshot") + }) + edit := dsl.Type("Edit", func() { + dsl.OneOf("relationship", func() { + dsl.Attribute("none", noEdit) + dsl.Attribute("editable", editable) + }) + dsl.Required("relationship") + }) + withEarlierSnapshot := dsl.Type("WithEarlierSnapshot", func() { + dsl.Attribute("snapshot", snapshot) + dsl.Attribute("edit", edit) + dsl.Required("snapshot", "edit") + }) + withEditOnly := dsl.Type("WithEditOnly", func() { + dsl.Attribute("edit", edit) + dsl.Required("edit") + }) + + dsl.Service("values", func() { + dsl.Method("with_earlier_snapshot", func() { + dsl.Result(withEarlierSnapshot) + dsl.HTTP(func() { + dsl.GET("/with-earlier-snapshot") + }) + }) + dsl.Method("with_edit_only", func() { + dsl.Result(withEditOnly) + dsl.HTTP(func() { + dsl.GET("/with-edit-only") + }) + }) + }) +} + +func collectHTTPUnionTypeNames(t *testing.T, att *expr.AttributeExpr) map[string]string { + t.Helper() + catalog, generation := testWireTypeCatalog(t) + catalog.collect(att, wireAttribute, wireTypePolicy{}) + linkTestWireTypeCatalog(t, generation, catalog) + + names := make(map[string]string, len(catalog.unions)) + for _, record := range catalog.unions { + names[record.attribute.Type.Name()] = record.data.Name } return names } @@ -83,8 +389,5 @@ func makeUnionForOrderTest(typeName string, variants ...string) *expr.Union { }, } } - return &expr.Union{ - TypeName: typeName, - Values: values, - } + return &expr.Union{TypeName: typeName, Values: values} } diff --git a/http/codegen/service_imports.go b/http/codegen/service_imports.go new file mode 100644 index 0000000000..5596d32e5f --- /dev/null +++ b/http/codegen/service_imports.go @@ -0,0 +1,1107 @@ +// This file derives imports from the HTTP endpoints rendered into one +// generated file. Streaming-only files pass only their streaming endpoints. +package codegen + +import ( + "path" + "sort" + "strings" + + "goa.design/goa/v3/codegen" + "goa.design/goa/v3/codegen/cli" + "goa.design/goa/v3/expr" +) + +type ( + // httpFileKind identifies one generated file whose sections have one fixed + // import contract. + httpFileKind uint8 + + // httpGeneratedFile pairs one import contract with its generated file name. + httpGeneratedFile struct { + kind httpFileKind + name string + } + + // httpCLIFlag contains the input facts that decide one payload-builder + // flag's conversion and omission checks. + httpCLIFlag struct { + attribute *expr.AttributeExpr + required bool + hasDefault bool + } +) + +const ( + httpTransportFile httpFileKind = iota + 1 + httpCodecFile + httpTypesFile + httpPathsFile + httpPayloadBuilderFile + httpWebSocketFile + httpSSEFile +) + +// httpFixedFileImports returns packages named directly by templates in one +// generated HTTP file. The same list is reserved before names are chosen and +// later written into the file header. +func httpFixedFileImports(service *expr.HTTPServiceExpr, client bool, kind httpFileKind) []*codegen.ImportSpec { + var paths []string + switch kind { + case httpTransportFile: + if client { + paths = []string{"context", "net/http", codegen.GoaImport("").Path, codegen.GoaNamedImport("http", "goahttp").Path} + if httpServiceUsesSSE(service) { + paths = append(paths, "errors", "fmt", "mime") + } + if httpServiceSkipsRequestBody(service) || httpServiceSkipsResponseBody(service) { + paths = append(paths, "io") + } + if httpServiceSkipsResponseBody(service) { + paths = append(paths, "errors") + } + if serviceHasMultipartRequest(service) { + paths = append(paths, "mime/multipart") + } + if len(httpWebSocketEndpoints(service)) > 0 { + paths = append(paths, "github.com/gorilla/websocket", "time") + } + } else { + paths = []string{"context", "net/http", codegen.GoaImport("").Path, codegen.GoaNamedImport("http", "goahttp").Path} + if httpServiceHasMixedResults(service) { + paths = append(paths, "strings") + } + if httpServiceSkipsResponseBody(service) { + paths = append(paths, "bufio", "io") + } + if serviceHasMultipartRequest(service) { + paths = append(paths, "mime/multipart") + } + if len(httpWebSocketEndpoints(service)) > 0 { + paths = append(paths, "bufio", "github.com/gorilla/websocket") + } + if len(service.FileServers) > 0 { + paths = append(paths, "fmt", "io", "path") + } + } + case httpCodecFile: + paths = []string{"net/http", codegen.GoaNamedImport("http", "goahttp").Path} + if client || httpServerCodecUsesContext(service) { + paths = append(paths, "context") + } + if client { + paths = append(paths, "net/url") + if httpClientCodecRestoresResponseBody(service) { + paths = append(paths, "bytes") + } + if httpClientCodecUsesIO(service) { + paths = append(paths, "io") + } + } else if httpServiceHasRequestBody(service) { + paths = append(paths, "io") + } + if httpServiceUsesJSONRPC(service) { + paths = append(paths, codegen.GoaImport("jsonrpc").Path) + if !client { + paths = append(paths, "bytes") + if httpServiceDecodesJSONRPCParams(service) { + paths = append(paths, "io") + } + } + } + if client && httpServiceGeneratesJSONRPCRequestID(service) { + paths = append(paths, "github.com/google/uuid") + } + if httpCodecUsesGoa(service, client) { + paths = append(paths, codegen.GoaImport("").Path) + } + if httpCodecUsesFmt(service) { + paths = append(paths, "fmt") + } + if client && httpServiceSkipsRequestBody(service) { + paths = append(paths, "os") + } + if httpCodecUsesErrors(service, client) { + paths = append(paths, "errors") + } + if httpCodecUsesStrings(service, client) { + paths = append(paths, "strings") + } + if httpCodecUsesStrconv(service) { + paths = append(paths, "strconv") + } + if serviceHasMultipartRequest(service) { + paths = append(paths, "mime/multipart") + } + case httpTypesFile: + if httpServiceHasUnion(service) { + paths = append(paths, "bytes", "encoding/json", "fmt", codegen.GoaImport("").Path) + } + case httpPathsFile: + paths = httpPathImportPaths(service) + case httpPayloadBuilderFile: + return httpCLIPayloadBuilderFixedImports(service) + case httpWebSocketFile: + paths = []string{ + "context", "io", "net/http", "sync", "time", "github.com/gorilla/websocket", + codegen.GoaImport("").Path, codegen.GoaNamedImport("http", "goahttp").Path, + } + case httpSSEFile: + if client { + paths = []string{ + "bytes", "context", "encoding/json", "errors", "fmt", "io", "net/http", "strconv", "strings", "sync", + codegen.GoaNamedImport("http", "goahttp").Path, + } + } else { + paths = []string{"context", "encoding/json", "fmt", "io", "net/http", "sync", "time"} + } + if httpServiceHasViewedSSE(service) { + paths = append(paths, codegen.GoaImport("").Path) + } + } + imports := make([]*codegen.ImportSpec, 0, len(paths)) + for _, importPath := range paths { + switch importPath { + case codegen.GoaImport("").Path: + imports = append(imports, codegen.GoaImport("")) + case codegen.GoaNamedImport("http", "goahttp").Path: + imports = append(imports, codegen.GoaNamedImport("http", "goahttp")) + default: + imports = append(imports, codegen.SimpleImport(importPath)) + } + } + return imports +} + +// httpCodecValidationImports returns the Go packages used by validation checks +// written directly into one client or server codec file. +func httpCodecValidationImports(service *expr.HTTPServiceExpr, client bool) []*codegen.ImportSpec { + seen := make(map[string]struct{}) + var imports []*codegen.ImportSpec + add := func(attribute *expr.AttributeExpr, policy codegen.GoLayoutPolicy) { + for _, runtimeImport := range codegen.ValidationRuntimeImports(attribute, policy) { + if _, ok := seen[runtimeImport.Path]; ok { + continue + } + seen[runtimeImport.Path] = struct{}{} + imports = append(imports, codegen.NewImport(runtimeImport.Name, runtimeImport.Path)) + } + } + addMapped := func(mapped *expr.MappedAttributeExpr) { + if mapped == nil || mapped.IsEmpty() { + return + } + if err := codegen.WalkMappedAttr(mapped, func(_ string, _ string, _ bool, attribute *expr.AttributeExpr) error { + add(attribute, codegen.GoLayoutPolicy{}) + return nil + }); err != nil { + panic(err) // The callback above cannot return an error. + } + } + for _, endpoint := range service.HTTPEndpoints { + if client { + for _, response := range endpoint.Responses { + if response.Body != nil && response.Body.Type != expr.Empty { + if _, named := response.Body.Type.(expr.UserType); !named { + add(response.Body, codegen.GoLayoutPolicy{ArrayElementPointer: true}) + } + } + addMapped(response.Headers) + addMapped(response.Cookies) + } + continue + } + if endpoint.Body != nil && endpoint.Body.Type != expr.Empty { + if _, named := endpoint.Body.Type.(expr.UserType); !named { + add(endpoint.Body, codegen.GoLayoutPolicy{ + Pointer: !expr.IsPrimitive(endpoint.Body.Type), + UnionPointer: true, + ArrayElementPointer: true, + SumType: true, + }) + } + } + addMapped(endpoint.PathParams()) + addMapped(endpoint.QueryParams()) + addMapped(endpoint.Headers) + addMapped(endpoint.Cookies) + if policy := jsonRPCRequestIDPolicyFor(endpoint); policy != nil && policy.attribute != nil { + add(policy.attribute.Attribute, codegen.GoLayoutPolicy{Pointer: policy.pointer}) + } + } + return imports +} + +// httpCLIPayloadBuilderFixedImports returns the conversion and validation +// packages used by request flags written in one client payload-builder file. +func httpCLIPayloadBuilderFixedImports(service *expr.HTTPServiceExpr) []*codegen.ImportSpec { + seen := make(map[string]struct{}) + var imports []*codegen.ImportSpec + for _, endpoint := range service.HTTPEndpoints { + if !needInit(endpoint.MethodExpr.Payload.Type) { + continue + } + for _, flag := range httpCLIRequestFlags(endpoint) { + validation := codegen.NeedsValidation(flag.attribute, codegen.GoLayoutPolicy{}) + for _, preference := range cli.FlagFieldImportPreferences(flag.attribute, validation, flag.required, flag.hasDefault) { + if _, ok := seen[preference.Path]; ok { + continue + } + seen[preference.Path] = struct{}{} + imports = append(imports, preference) + } + } + } + return imports +} + +// httpCLIRequestFlags returns the request values that become command-line +// flags for one method payload constructor. +func httpCLIRequestFlags(endpoint *expr.HTTPEndpointExpr) []httpCLIFlag { + var flags []httpCLIFlag + if endpoint.Body != nil && endpoint.Body.Type != expr.Empty { + flag := httpCLIFlag{attribute: endpoint.Body, required: true, hasDefault: endpoint.Body.DefaultValue != nil} + if origin := endpoint.Body.Meta["origin:attribute"]; len(origin) > 0 { + flag.required = endpoint.MethodExpr.Payload.IsRequired(origin[0]) + flag.hasDefault = endpoint.MethodExpr.Payload.GetDefault(origin[0]) != nil + } + flags = append(flags, flag) + } + for _, mapping := range []struct { + mapped *expr.MappedAttributeExpr + pathRequired bool + }{ + {mapped: endpoint.PathParams(), pathRequired: true}, + {mapped: endpoint.QueryParams()}, + {mapped: endpoint.Headers}, + {mapped: endpoint.Cookies}, + } { + mapped := mapping.mapped + if mapped == nil || mapped.IsEmpty() { + continue + } + _ = codegen.WalkMappedAttr(mapped, func(name, _ string, required bool, attribute *expr.AttributeExpr) error { + hasDefault := requestElementDefault(endpoint.MethodExpr.Payload, name, attribute) != nil + if mapping.pathRequired { + required = true + hasDefault = false + } + flags = append(flags, httpCLIFlag{ + attribute: attribute, + required: required, + hasDefault: hasDefault, + }) + return nil + }) + } + if policy := jsonRPCRequestIDPolicyFor(endpoint); policy != nil && policy.attribute != nil { + flags = append(flags, httpCLIFlag{ + attribute: policy.attribute.Attribute, + required: policy.required, + hasDefault: policy.defaultValue != nil, + }) + } + flags = append(flags, httpBasicAuthFlags(endpoint)...) + return flags +} + +// httpBasicAuthFlags returns the username and password fields added to the +// generated client command for a method that uses Basic authentication. +func httpBasicAuthFlags(endpoint *expr.HTTPEndpointExpr) []httpCLIFlag { + if !httpEndpointUsesBasicAuth(endpoint) { + return nil + } + payload := endpoint.MethodExpr.Payload + flags := make([]httpCLIFlag, 0, 2) + for _, tag := range []string{"security:username", "security:password"} { + name := expr.TaggedAttribute(payload, tag) + flags = append(flags, httpCLIFlag{ + attribute: payload.Find(name), + required: payload.IsRequired(name), + hasDefault: payload.GetDefault(name) != nil, + }) + } + return flags +} + +// httpServerCodecUsesContext reports whether a normal HTTP response or error +// encoder receives a request context. +func httpServerCodecUsesContext(service *expr.HTTPServiceExpr) bool { + for _, endpoint := range service.HTTPEndpoints { + if endpoint.IsJSONRPC() || endpoint.UsesWebSocket() { + continue + } + if endpoint.Redirect == nil || len(endpoint.HTTPErrors) > 0 { + return true + } + } + return false +} + +// httpClientCodecRestoresResponseBody reports whether a shared response +// decoder can read and then replace an ordinary HTTP response body. +func httpClientCodecRestoresResponseBody(service *expr.HTTPServiceExpr) bool { + for _, endpoint := range service.HTTPEndpoints { + if !endpoint.IsJSONRPC() || !endpoint.UsesSSE() { + return true + } + } + return false +} + +// httpClientCodecUsesIO reports whether a response decoder, streamed request, +// or multipart request names an io type or helper. +func httpClientCodecUsesIO(service *expr.HTTPServiceExpr) bool { + return httpClientCodecRestoresResponseBody(service) || + httpServiceSkipsRequestBody(service) || serviceHasMultipartRequest(service) +} + +// httpGeneratedImportPlan returns generated service packages referenced by +// one HTTP file. Path files contain only standard Go values and need none. +func httpGeneratedImportPlan(service *expr.HTTPServiceExpr, client bool, kind httpFileKind, servicePackage, viewsPackage *codegen.ImportSpec) []*codegen.ImportSpec { + if kind == httpPathsFile { + return nil + } + if kind == httpTransportFile && client && !httpServiceSkipsResponseBody(service) && !serviceHasMultipartRequest(service) { + return nil + } + if kind == httpCodecFile && !httpCodecUsesService(service) { + return nil + } + if kind == httpTypesFile && !httpTypesUseService(service) { + return nil + } + imports := []*codegen.ImportSpec{servicePackage} + if viewsPackage == nil { + return imports + } + switch kind { + case httpCodecFile, httpTypesFile: + return append(imports, viewsPackage) + case httpWebSocketFile, httpSSEFile: + if client { + return append(imports, viewsPackage) + } + } + return imports +} + +// httpTypesUseService reports whether copied transport types or their +// constructors name a generated service type. +func httpTypesUseService(service *expr.HTTPServiceExpr) bool { + for _, endpoint := range service.HTTPEndpoints { + method := endpoint.MethodExpr + if len(method.Errors) > 0 { + return true + } + for _, attribute := range []*expr.AttributeExpr{method.Payload, method.StreamingPayload, method.Result, method.StreamingResult} { + if attributeUsesServiceType(attribute, make(map[expr.UserType]struct{})) { + return true + } + } + } + return false +} + +// httpCodecUsesService reports whether one codec names a generated payload, +// result, stream wrapper, or designed error type. +func httpCodecUsesService(service *expr.HTTPServiceExpr) bool { + for _, endpoint := range service.HTTPEndpoints { + method := endpoint.MethodExpr + for _, attribute := range []*expr.AttributeExpr{method.Payload, method.StreamingPayload, method.Result, method.StreamingResult} { + if attributeUsesServiceType(attribute, make(map[expr.UserType]struct{})) { + return true + } + } + _, viewed := method.Result.Type.(*expr.ResultTypeExpr) + if len(method.Errors) > 0 || viewed || + endpoint.SkipRequestBodyEncodeDecode || endpoint.SkipResponseBodyEncodeDecode { + return true + } + } + return false +} + +// attributeUsesServiceType reports whether a generated reference reaches a +// user type declared in the service package. +func attributeUsesServiceType(attribute *expr.AttributeExpr, seen map[expr.UserType]struct{}) bool { + if attribute == nil || attribute.Type == expr.Empty { + return false + } + switch actual := attribute.Type.(type) { + case expr.UserType: + origin := actual.Origin() + if _, ok := seen[origin]; ok { + return false + } + seen[origin] = struct{}{} + return true + case *expr.Array: + return attributeUsesServiceType(actual.ElemType, seen) + case *expr.Map: + return attributeUsesServiceType(actual.KeyType, seen) || attributeUsesServiceType(actual.ElemType, seen) + case *expr.Object: + for _, named := range *actual { + if attributeUsesServiceType(named.Attribute, seen) { + return true + } + } + } + return false +} + +// httpCLIParserFixedImports returns packages named directly by the shared CLI +// parser and HTTP endpoint-selection templates. +func httpCLIParserFixedImports(services ...*expr.HTTPServiceExpr) []*codegen.ImportSpec { + imports := []*codegen.ImportSpec{ + codegen.SimpleImport("flag"), + codegen.SimpleImport("fmt"), + codegen.SimpleImport("net/http"), + codegen.SimpleImport("os"), + codegen.GoaImport(""), + codegen.GoaNamedImport("http", "goahttp"), + } + seen := make(map[string]struct{}, len(imports)) + for _, spec := range imports { + seen[spec.Path] = struct{}{} + } + for _, service := range services { + for _, endpoint := range service.HTTPEndpoints { + payload := endpoint.MethodExpr.Payload + if payload.Type == expr.Empty || needInit(payload.Type) { + continue + } + for _, preference := range cli.FlagImportPreferences(payload, false) { + if _, ok := seen[preference.Path]; ok { + continue + } + seen[preference.Path] = struct{}{} + imports = append(imports, preference) + } + } + } + return imports +} + +// httpServiceHasPayloadBuilder reports whether the client CLI writes a +// constructor that combines request values into a service payload. +func httpServiceHasPayloadBuilder(service *expr.HTTPServiceExpr) bool { + for _, endpoint := range service.HTTPEndpoints { + if needInit(endpoint.MethodExpr.Payload.Type) { + return true + } + } + return false +} + +// httpServiceHasUnion reports whether a generated transport type file writes +// JSON methods for a union used by the selected side. +func httpServiceHasUnion(service *expr.HTTPServiceExpr) bool { + for _, endpoint := range service.HTTPEndpoints { + attributes := []*expr.AttributeExpr{ + endpoint.MethodExpr.Payload, + endpoint.MethodExpr.StreamingPayload, + endpoint.MethodExpr.Result, + endpoint.MethodExpr.StreamingResult, + } + for _, attribute := range attributes { + if attributeContainsUnion(attribute, make(map[expr.UserType]struct{})) { + return true + } + } + } + return false +} + +// attributeContainsUnion reports whether attribute reaches a union definition. +func attributeContainsUnion(attribute *expr.AttributeExpr, seen map[expr.UserType]struct{}) bool { + if attribute == nil { + return false + } + switch actual := attribute.Type.(type) { + case *expr.Union: + return true + case expr.UserType: + origin := actual.Origin() + if _, ok := seen[origin]; ok { + return false + } + seen[origin] = struct{}{} + return attributeContainsUnion(actual.Attribute(), seen) + case *expr.Object: + for _, named := range *actual { + if attributeContainsUnion(named.Attribute, seen) { + return true + } + } + case *expr.Array: + return attributeContainsUnion(actual.ElemType, seen) + case *expr.Map: + return attributeContainsUnion(actual.KeyType, seen) || attributeContainsUnion(actual.ElemType, seen) + } + return false +} + +// httpServiceUsesSSE reports whether the client transport checks an SSE +// response content type. +func httpServiceUsesSSE(service *expr.HTTPServiceExpr) bool { + return len(httpSSEEndpoints(service)) > 0 +} + +// httpServiceHasViewedSSE reports whether an SSE stream emits code that checks +// or reconstructs a selected result view. +func httpServiceHasViewedSSE(service *expr.HTTPServiceExpr) bool { + for _, endpoint := range httpSSEEndpoints(service) { + if _, ok := endpoint.MethodExpr.Result.Type.(*expr.ResultTypeExpr); ok { + return true + } + } + return false +} + +// httpServiceHasMixedResults reports whether one handler selects between a +// normal response and an SSE response from the Accept header. +func httpServiceHasMixedResults(service *expr.HTTPServiceExpr) bool { + for _, endpoint := range service.HTTPEndpoints { + if endpoint.MethodExpr.HasMixedResults() { + return true + } + } + return false +} + +// httpServiceSkipsRequestBody reports whether one client or server copies a +// request stream instead of encoding it. +func httpServiceSkipsRequestBody(service *expr.HTTPServiceExpr) bool { + for _, endpoint := range service.HTTPEndpoints { + if endpoint.SkipRequestBodyEncodeDecode { + return true + } + } + return false +} + +// httpServiceSkipsResponseBody reports whether one client or server copies a +// response stream instead of decoding it. +func httpServiceSkipsResponseBody(service *expr.HTTPServiceExpr) bool { + for _, endpoint := range service.HTTPEndpoints { + if endpoint.SkipResponseBodyEncodeDecode { + return true + } + } + return false +} + +// httpServiceHasRequestBody reports whether one server codec decodes a request +// body through an io.Reader. +func httpServiceHasRequestBody(service *expr.HTTPServiceExpr) bool { + for _, endpoint := range service.HTTPEndpoints { + if endpoint.Body != nil && endpoint.Body.Type != expr.Empty { + return true + } + } + return false +} + +// httpServiceUsesJSONRPC reports whether shared codec templates write a +// JSON-RPC request or response envelope. +func httpServiceUsesJSONRPC(service *expr.HTTPServiceExpr) bool { + for _, endpoint := range service.HTTPEndpoints { + if endpoint.IsJSONRPC() { + return true + } + } + return false +} + +// httpServiceDecodesJSONRPCParams reports whether a server gives JSON-RPC +// request parameters to a generated payload decoder. +func httpServiceDecodesJSONRPCParams(service *expr.HTTPServiceExpr) bool { + for _, endpoint := range service.HTTPEndpoints { + if endpoint.IsJSONRPC() && endpoint.MethodExpr.Payload.Type != expr.Empty { + return true + } + } + return false +} + +// httpServiceGeneratesJSONRPCRequestID reports whether a client codec creates +// a UUID for a request whose design does not supply an ID. +func httpServiceGeneratesJSONRPCRequestID(service *expr.HTTPServiceExpr) bool { + for _, endpoint := range service.HTTPEndpoints { + policy := jsonRPCRequestIDPolicyFor(endpoint) + if policy != nil && policy.generates() { + return true + } + } + return false +} + +// httpCodecUsesGoa reports whether request, response, or validation code calls +// Goa's generated-error helpers. +func httpCodecUsesGoa(service *expr.HTTPServiceExpr, client bool) bool { + for _, endpoint := range service.HTTPEndpoints { + if client { + if endpoint.SSE != nil && endpoint.SSE.RequestIDField != "" { + return true + } + if attributeHasNonRequiredValidation(endpoint.MethodExpr.Payload, make(map[expr.UserType]struct{})) { + return true + } + if policy := jsonRPCRequestIDPolicyFor(endpoint); policy != nil && policy.attribute != nil && + attributeHasNonRequiredValidation(policy.attribute.Attribute, make(map[expr.UserType]struct{})) { + return true + } + for _, response := range endpoint.Responses { + if !response.Headers.IsEmpty() || !response.Cookies.IsEmpty() { + return true + } + } + continue + } + if endpoint.Body != nil && endpoint.Body.Type != expr.Empty || + !endpoint.Params.IsEmpty() || !endpoint.Headers.IsEmpty() || !endpoint.Cookies.IsEmpty() || + len(endpoint.HTTPErrors) > 0 { + return true + } + for _, flag := range httpBasicAuthFlags(endpoint) { + if flag.required { + return true + } + } + } + return false +} + +// attributeHasNonRequiredValidation reports whether request conversion writes +// an enum, format, pattern, range, or length check. Required-field checks are +// handled while the request values are assembled. +func attributeHasNonRequiredValidation(attribute *expr.AttributeExpr, seen map[expr.UserType]struct{}) bool { + if attribute == nil { + return false + } + if validation := expr.EffectiveValidation(attribute); validation != nil && !validation.HasRequiredOnly() { + return true + } + switch actual := attribute.Type.(type) { + case expr.UserType: + origin := actual.Origin() + if _, ok := seen[origin]; ok { + return false + } + seen[origin] = struct{}{} + return attributeHasNonRequiredValidation(actual.Attribute(), seen) + case *expr.Object: + for _, named := range *actual { + if attributeHasNonRequiredValidation(named.Attribute, seen) { + return true + } + } + case *expr.Array: + return attributeHasNonRequiredValidation(actual.ElemType, seen) + case *expr.Map: + return attributeHasNonRequiredValidation(actual.KeyType, seen) || attributeHasNonRequiredValidation(actual.ElemType, seen) + } + return false +} + +// httpCodecUsesFmt reports whether a generated conversion formats a value +// whose HTTP text form is not a direct primitive conversion. +func httpCodecUsesFmt(service *expr.HTTPServiceExpr) bool { + for _, endpoint := range service.HTTPEndpoints { + if endpoint.MapQueryParams != nil { + return true + } + } + return false +} + +// httpCodecUsesErrors reports whether a generated codec compares, joins, or +// unwraps errors. +func httpCodecUsesErrors(service *expr.HTTPServiceExpr, client bool) bool { + if client { + return httpClientCodecRestoresResponseBody(service) + } + for _, endpoint := range service.HTTPEndpoints { + if endpoint.Body != nil && endpoint.Body.Type != expr.Empty || + len(endpoint.HTTPErrors) > 0 || !endpoint.Cookies.IsEmpty() { + return true + } + } + return false +} + +// httpCodecUsesStrings reports whether a generated codec splits, joins, or +// searches request or response text. +func httpCodecUsesStrings(service *expr.HTTPServiceExpr, client bool) bool { + for _, endpoint := range service.HTTPEndpoints { + if endpoint.MapQueryParams != nil || mappedAttributeHasArray(endpoint.PathParams()) || mappedAttributeHasArray(endpoint.QueryParams()) { + return true + } + if client && httpEndpointUsesBearerAuthorizationHeader(endpoint) { + return true + } + if !client && httpEndpointUsesHeaderSecurity(endpoint) { + return true + } + if mappedAttributeHasArray(endpoint.Headers) || mappedAttributeHasArray(endpoint.Cookies) { + return true + } + for _, response := range endpoint.Responses { + if mappedAttributeHasArray(response.Headers) || mappedAttributeHasArray(response.Cookies) { + return true + } + } + } + return false +} + +// httpEndpointUsesBasicAuth reports whether the method reads a username and +// password from the HTTP Authorization header. +func httpEndpointUsesBasicAuth(endpoint *expr.HTTPEndpointExpr) bool { + for _, requirement := range endpoint.Requirements { + for _, scheme := range requirement.Schemes { + if scheme.Kind == expr.BasicAuthKind { + return true + } + } + } + return false +} + +// httpEndpointUsesBearerAuthorizationHeader reports whether the client adds a +// Bearer prefix to the Authorization header when the supplied token has none. +func httpEndpointUsesBearerAuthorizationHeader(endpoint *expr.HTTPEndpointExpr) bool { + for _, requirement := range endpoint.Requirements { + for _, scheme := range requirement.Schemes { + if scheme.In != "header" || scheme.Name != "Authorization" { + continue + } + switch scheme.Kind { + case expr.BearerKind, expr.JWTKind, expr.OAuth2Kind: + return true + } + } + } + return false +} + +// httpEndpointUsesHeaderSecurity reports whether the server removes an +// authentication prefix from a credential read from an HTTP header. +func httpEndpointUsesHeaderSecurity(endpoint *expr.HTTPEndpointExpr) bool { + for _, requirement := range endpoint.Requirements { + for _, scheme := range requirement.Schemes { + if scheme.Kind != expr.BasicAuthKind && scheme.In == "header" { + return true + } + } + } + return false +} + +// httpCodecUsesStrconv reports whether a generated codec converts numeric or +// boolean values to or from HTTP text. +func httpCodecUsesStrconv(service *expr.HTTPServiceExpr) bool { + for _, endpoint := range service.HTTPEndpoints { + if endpoint.MapQueryParams != nil { + attribute := endpoint.MethodExpr.Payload + if name := *endpoint.MapQueryParams; name != "" { + attribute = attribute.Find(name) + } + if dataTypeHasTextConvertedValue(attribute.Type) { + return true + } + } + attributes := make([]*expr.MappedAttributeExpr, 0, 4+2*len(endpoint.Responses)) + attributes = append(attributes, + endpoint.PathParams(), endpoint.QueryParams(), endpoint.Headers, endpoint.Cookies, + ) + for _, response := range endpoint.Responses { + attributes = append(attributes, response.Headers, response.Cookies) + } + for _, attribute := range attributes { + if mappedAttributeHasTextConvertedValue(attribute) { + return true + } + } + } + return false +} + +// wireCatalogValidationImports returns the packages used by every validator +// emitted in one HTTP types file. +func wireCatalogValidationImports(catalog *wireTypeCatalog) []*codegen.ImportSpec { + seen := make(map[string]struct{}) + var imports []*codegen.ImportSpec + for _, record := range catalog.records { + if !record.needsValidator { + continue + } + imports = appendWireValidationImports(imports, seen, record.identity.attribute, record.identity.policy) + } + for _, root := range catalog.validationRoots { + imports = appendWireValidationImports(imports, seen, root.attribute, root.policy) + } + return imports +} + +// appendWireValidationImports adds each runtime package once for one validator +// emitted in the current HTTP types file. +func appendWireValidationImports(imports []*codegen.ImportSpec, seen map[string]struct{}, attribute *expr.AttributeExpr, policy wireTypePolicy) []*codegen.ImportSpec { + for _, preference := range codegen.ValidationRuntimeImports(attribute, wireGoLayoutPolicy(policy)) { + if _, ok := seen[preference.Path]; ok { + continue + } + seen[preference.Path] = struct{}{} + imports = append(imports, codegen.NewImport(preference.Name, preference.Path)) + } + return imports +} + +// httpPathImportPaths returns packages used by request path constructors. +func httpPathImportPaths(service *expr.HTTPServiceExpr) []string { + seen := make(map[string]struct{}) + for _, endpoint := range service.HTTPEndpoints { + pathParameters := endpoint.PathParams() + if pathParameters.IsEmpty() { + continue + } + seen["fmt"] = struct{}{} + for _, named := range *expr.AsObject(pathParameters.Attribute().Type) { + array, ok := underlyingDataType(named.Attribute.Type).(*expr.Array) + if !ok { + continue + } + seen["strings"] = struct{}{} + switch underlyingDataType(array.ElemType.Type).Kind() { + case expr.StringKind, expr.BytesKind, expr.AnyKind: + seen["net/url"] = struct{}{} + default: + seen["strconv"] = struct{}{} + } + } + } + paths := make([]string, 0, len(seen)) + for importPath := range seen { + paths = append(paths, importPath) + } + sort.Strings(paths) + return paths +} + +// mappedAttributeHasArray reports whether one HTTP element stores an array. +func mappedAttributeHasArray(attribute *expr.MappedAttributeExpr) bool { + if attribute == nil || attribute.IsEmpty() { + return false + } + for _, named := range *expr.AsObject(attribute.Attribute().Type) { + if _, ok := underlyingDataType(named.Attribute.Type).(*expr.Array); ok { + return true + } + } + return false +} + +// mappedAttributeHasTextConvertedValue reports whether one HTTP element +// contains a numeric or boolean value converted through strconv. +func mappedAttributeHasTextConvertedValue(attribute *expr.MappedAttributeExpr) bool { + if attribute == nil || attribute.IsEmpty() { + return false + } + return dataTypeHasTextConvertedValue(attribute.Attribute().Type) +} + +// dataTypeHasTextConvertedValue reports whether one mapped HTTP value contains +// a number or boolean that generated code converts with strconv. +func dataTypeHasTextConvertedValue(dataType expr.DataType) bool { + dataType = underlyingDataType(dataType) + switch actual := dataType.(type) { + case *expr.Object: + for _, field := range *actual { + if dataTypeHasTextConvertedValue(field.Attribute.Type) { + return true + } + } + case *expr.Array: + return dataTypeHasTextConvertedValue(actual.ElemType.Type) + case *expr.Map: + return dataTypeHasTextConvertedValue(actual.KeyType.Type) || + dataTypeHasTextConvertedValue(actual.ElemType.Type) + default: + switch dataType.Kind() { + case expr.BooleanKind, expr.IntKind, expr.Int32Kind, expr.Int64Kind, + expr.UIntKind, expr.UInt32Kind, expr.UInt64Kind, expr.Float32Kind, expr.Float64Kind: + return true + } + } + return false +} + +// underlyingDataType returns the first non-user type in a named type chain. +func underlyingDataType(dataType expr.DataType) expr.DataType { + for { + userType, ok := dataType.(expr.UserType) + if !ok { + return dataType + } + dataType = userType.Attribute().Type + } +} + +// plannedFileHeader returns a source header with the imports recorded before +// generation names were frozen. +func plannedFileHeader(title, packageName, filePath string, services *ServicesData) *codegen.SectionTemplate { + return codegen.Header(title, packageName, services.fileImports[filepathKey(filePath)]) +} + +// generatedFileOutputPackage returns the import path of the package that owns +// a generated file. Files below gen use the generated package as their base. +// Root files such as cmd/server/http.go use the module containing gen. +func generatedFileOutputPackage(services *ServicesData, filePath string) string { + outputPath := strings.TrimPrefix(strings.ReplaceAll(filePath, "\\", "/"), "./") + if strings.HasPrefix(outputPath, codegen.Gendir+"/") { + outputPath = strings.TrimPrefix(outputPath, codegen.Gendir+"/") + return path.Join(services.GenPkg(), path.Dir(outputPath)) + } + return path.Join(path.Dir(services.GenPkg()), path.Dir(outputPath)) +} + +// serviceDataForOutput copies the package-name fields that a template writes +// so they match the imports selected by its actual output package. +func serviceDataForOutput(data *ServiceData, services *ServicesData, outputPackage string) *ServiceData { + serviceCopy := *data.Service + servicePath := path.Join(services.GenPkg(), serviceCopy.PathName) + for filePath, imports := range services.fileImports { + if generatedFileOutputPackage(services, filePath) != outputPackage { + continue + } + for _, spec := range imports { + if spec.Path == servicePath { + serviceCopy.PkgName = spec.Name + break + } + } + } + copy := *data + copy.Service = &serviceCopy + copy.Endpoints = make([]*EndpointData, len(data.Endpoints)) + for index, endpoint := range data.Endpoints { + endpointCopy := *endpoint + endpointCopy.ServicePkgName = serviceCopy.PkgName + copy.Endpoints[index] = &endpointCopy + } + return © +} + +// exampleServiceDataForOutput gives local variables the unique service package +// path chosen for this generation. Example files may use several services at +// once, and two service names can produce the same Go name. +func exampleServiceDataForOutput(data *ServiceData, services *ServicesData, outputPackage string) *ServiceData { + copy := serviceDataForOutput(data, services, outputPackage) + copy.Service.VarName = codegen.Goify(copy.Service.PathName, false) + return copy +} + +// serviceReferenceAttributes returns the named service attributes referenced +// by generated HTTP or JSON-RPC endpoint sections, including the nested result +// field selected as SSE event data. +func serviceReferenceAttributes(endpoints ...*expr.HTTPEndpointExpr) []*expr.AttributeExpr { + var attributes []*expr.AttributeExpr + for _, endpoint := range endpoints { + method := endpoint.MethodExpr + attributes = append(attributes, method.Payload, method.StreamingPayload, method.Result, method.StreamingResult) + if endpoint.SSE != nil && endpoint.SSE.DataField != "" { + event := method.Result + if method.HasMixedResults() { + event = method.StreamingResult + } + if object := expr.AsObject(event.Type); object != nil { + attributes = append(attributes, object.Attribute(endpoint.SSE.DataField)) + } + } + for _, methodError := range method.Errors { + attributes = append(attributes, methodError.AttributeExpr) + } + } + return attributes +} + +// wireCatalogImportAttributes returns the type shapes and generated function +// inputs written by one transport types file. +func wireCatalogImportAttributes(catalog *wireTypeCatalog) (definitions, references []*expr.AttributeExpr) { + for _, record := range catalog.records { + definitions = append(definitions, record.identity.attribute) + if record.needsValidator || record.needsNestedCall || record.needsConstructor { + references = append(references, record.identity.attribute) + } + } + for _, root := range catalog.validationRoots { + references = append(references, root.attribute) + } + return definitions, references +} + +// httpCLIPayloadBuilderReferenceAttributes returns the values converted or +// validated by one generated HTTP payload-builder file. +func httpCLIPayloadBuilderReferenceAttributes(service *expr.HTTPServiceExpr) []*expr.AttributeExpr { + var attributes []*expr.AttributeExpr + for _, endpoint := range service.HTTPEndpoints { + if !needInit(endpoint.MethodExpr.Payload.Type) { + continue + } + attributes = append(attributes, + endpoint.MethodExpr.Payload, + endpoint.Body, + endpoint.PathParams().AttributeExpr, + endpoint.QueryParams().AttributeExpr, + endpoint.Headers.AttributeExpr, + endpoint.Cookies.AttributeExpr, + ) + for _, flag := range httpCLIRequestFlags(endpoint) { + attributes = append(attributes, flag.attribute) + } + } + return attributes +} + +// httpTransportReferenceAttributes returns values named directly by raw-body +// client and server sections instead of by generated codecs. +func httpTransportReferenceAttributes(service *expr.HTTPServiceExpr) []*expr.AttributeExpr { + var endpoints []*expr.HTTPEndpointExpr + for _, endpoint := range service.HTTPEndpoints { + if endpoint.SkipRequestBodyEncodeDecode || endpoint.SkipResponseBodyEncodeDecode { + endpoints = append(endpoints, endpoint) + } + } + return serviceReferenceAttributes(endpoints...) +} + +// filepathKey normalizes generated paths so file writers on every platform +// read the same planned import record. +func filepathKey(filePath string) string { + return strings.ReplaceAll(filePath, "\\", "/") +} + +// httpWebSocketEndpoints returns only the endpoints whose stream sections are +// rendered into WebSocket files. +func httpWebSocketEndpoints(svc *expr.HTTPServiceExpr) []*expr.HTTPEndpointExpr { + var endpoints []*expr.HTTPEndpointExpr + for _, endpoint := range svc.HTTPEndpoints { + if endpoint.UsesWebSocket() { + endpoints = append(endpoints, endpoint) + } + } + return endpoints +} + +// httpSSEEndpoints returns only the endpoints whose stream sections are +// rendered into Server-Sent Events files. +func httpSSEEndpoints(svc *expr.HTTPServiceExpr) []*expr.HTTPEndpointExpr { + var endpoints []*expr.HTTPEndpointExpr + for _, endpoint := range svc.HTTPEndpoints { + if endpoint.UsesSSE() { + endpoints = append(endpoints, endpoint) + } + } + return endpoints +} diff --git a/http/codegen/sse.go b/http/codegen/sse.go index d1a7ffc7c9..0eed108620 100644 --- a/http/codegen/sse.go +++ b/http/codegen/sse.go @@ -1,3 +1,6 @@ +// This file builds the values used to write HTTP server-sent event code. +// Service event types keep the names chosen earlier, while the HTTP package +// defines the request and response body types. package codegen import ( @@ -11,12 +14,46 @@ import ( ) type ( + // SSEValueData describes one value written to or read from an SSE line. + // Kind selects its generated conversion. TypeRef keeps a declared Go type + // when the client rebuilds the service result. + SSEValueData struct { + // Kind is the designed kind of the value. + Kind expr.Kind + // TypeRef is the Go type assigned by the generated client. + TypeRef string + // ClientTypeRef is the Go type stored in the generated HTTP response body. + ClientTypeRef string + // Named reports whether TypeRef is a declared service type. + Named bool + // Pointer reports whether the service field stores a primitive pointer. + Pointer bool + // ClientPointer reports whether the validated HTTP body stores this + // primitive as a pointer before conversion to the service event. + ClientPointer bool + // DefaultValue is used when the corresponding SSE line is absent. + DefaultValue any + // HasDefault reports whether DefaultValue was authored, including zero values. + HasDefault bool + // Union reports whether an empty selected branch represents absence. + Union bool + } + // SSEData contains the data needed to render struct type that // implements the server and client stream interface for SSE. SSEData struct { - // StructName is the name of the generated struct which encapsulates the - // server implementation. + // StructName is the server stream type name kept for existing plugins. + // + // Deprecated: Use StructDeclaration.Name() after planning so name collisions are handled. StructName string + // StructDeclaration is the generated Go type name used by the server stream. + StructDeclaration *codegen.NameDeclaration + // ClientInterfaceDeclaration is the generated Go type name used by the client stream interface. + ClientInterfaceDeclaration *codegen.NameDeclaration + // ClientStructDeclaration is the generated Go type name used by the client stream implementation. + ClientStructDeclaration *codegen.NameDeclaration + // ClientInitDeclaration is the generated Go function name used by the client stream constructor. + ClientInitDeclaration *codegen.NameDeclaration // Interface is the fully qualified name of the interface that // the struct implements. Interface string @@ -30,24 +67,41 @@ type ( SendWithContextDesc string // EventTypeRef is the fully qualified type ref for the event type. EventTypeRef string - // EventTypeName is the name of the event type without package qualifier. + // EventTypeName is the fully qualified non-pointer type used to allocate an event. EventTypeName string // EventIsStruct indicates whether the SSE method return type is a struct. EventIsStruct bool - // DataFieldTypeRef is the fully qualified type ref for the data field if any. + // DataFieldTypeRef is the final Go type of the mapped data field kept for + // existing plugins. It is empty when the whole event is data. + // + // Deprecated: Use Data.TypeRef. DataFieldTypeRef string // DataField is the name of the result type event data attribute if any. // If empty, the entire result type is used as the data field. DataField string + // Data describes the exact value carried by each data line. + Data SSEValueData // IDField is the name of the result type event ID attribute if any. // If empty, no id field is included in the event. IDField string + // ID describes the exact string value carried by the id line. + ID *SSEValueData + // ClientIDPointer reports whether the validated HTTP body stores IDField + // as a pointer before conversion to the service event. + ClientIDPointer bool // EventField is the name of the result type event field if any. // If empty, no event field is included in the event. EventField string + // Event describes the exact string value carried by the event line. + Event *SSEValueData + // ClientEventPointer reports whether the validated HTTP body stores + // EventField as a pointer before conversion to the service event. + ClientEventPointer bool // RetryField is the name of the result type event retry field if any. // If empty, no retry field is included in the event. RetryField string + // Retry describes the exact integer type carried by the retry line. + Retry *SSEValueData // RequestIDField is the name of the payload field that maps to the Last-Event-ID header if any. // If empty, no last event id is included in the request. RequestIDField string @@ -55,11 +109,26 @@ type ( RequestIDPointer bool // HasResponseBody indicates whether an HTTP response body converter exists for this endpoint. HasResponseBody bool + // Response is the successful HTTP response whose body types encode and + // decode stream events. + Response *ResponseData + // ClientEventCode converts the validated HTTP event body into the service + // event returned by Recv. It is present for methods with different ordinary + // and streaming result types. + ClientEventCode string + // VariableView reports whether SetView selects the result body used by all + // events sent for one HTTP request. + VariableView bool + // DefaultView is used when SetView receives an empty string. + DefaultView string + // JSONRPCParams describes how the streamed result is carried in a + // JSON-RPC notification. It is nil for ordinary HTTP streams. + JSONRPCParams *JSONRPCParamsData } ) // initSSEData initializes the SSE related data in ed. -func initSSEData(ed *EndpointData, e *expr.HTTPEndpointExpr, sd *ServiceData) { +func (sds *ServicesData) initSSEData(ed *EndpointData, e *expr.HTTPEndpointExpr, sd *ServiceData) { if !e.UsesSSE() { return } @@ -73,9 +142,13 @@ func initSSEData(ed *EndpointData, e *expr.HTTPEndpointExpr, sd *ServiceData) { if e.MethodExpr.HasMixedResults() && e.MethodExpr.StreamingResult != nil { // For mixed results, use StreamingResult for SSE events eventAttr = e.MethodExpr.StreamingResult + if eventAttr.Type == expr.Empty { + eventAttr = e.MethodExpr.Result + } + svcctx := sds.serviceTypeContext(sd, "server").Enter(eventAttr) eventType = &ResultData{ - Name: md.StreamingResult, - Ref: sd.Service.Scope.GoFullTypeRef(eventAttr, svc.PkgName), + Name: svcctx.Scope.Name(eventAttr, svcctx.Pkg(eventAttr), false, true), + Ref: svcctx.Scope.Ref(eventAttr, svcctx.Pkg(eventAttr)), IsStruct: expr.IsObject(eventAttr.Type), } } else { @@ -88,31 +161,60 @@ func initSSEData(ed *EndpointData, e *expr.HTTPEndpointExpr, sd *ServiceData) { sendWithContextDesc := fmt.Sprintf("%s streams instances of %q to the %q endpoint SSE connection with context.", md.ServerStream.SendWithContextName, eventType.Name, md.Name) // Convert attribute names to Go field names - var dataFieldVar, dataFieldTypeRef, idFieldVar, eventFieldVar, retryFieldVar string + var ( + dataFieldVar string + dataFieldTypeRef string + dataFieldParamsTypeRef string + dataField *expr.AttributeExpr + idFieldVar string + idField *expr.AttributeExpr + eventFieldVar string + eventField *expr.AttributeExpr + retryFieldVar string + retryField *expr.AttributeExpr + ) + svcctx := sds.serviceTypeContext(sd, "server").Enter(eventAttr) if obj := expr.AsObject(eventAttr.Type); obj != nil { for _, nat := range *obj { switch nat.Name { case e.SSE.IDField: idFieldVar = codegen.GoifyAtt(nat.Attribute, nat.Name, true) + idField = nat.Attribute case e.SSE.EventField: eventFieldVar = codegen.GoifyAtt(nat.Attribute, nat.Name, true) + eventField = nat.Attribute case e.SSE.RetryField: retryFieldVar = codegen.GoifyAtt(nat.Attribute, nat.Name, true) + retryField = nat.Attribute case e.SSE.DataField: dataFieldVar = codegen.GoifyAtt(nat.Attribute, nat.Name, true) - dataFieldTypeRef = sd.Service.Scope.GoFullTypeRef(nat.Attribute, svc.PkgName) + dataField = nat.Attribute + fieldctx := svcctx.Enter(nat.Attribute) + layout, err := fieldctx.Scope.(codegen.GoTypeLayoutResolver).GoTypeLayout(nat.Attribute, fieldctx.LayoutPolicy()) + if err != nil { + sds.recordLinkError(err) + return + } + dataFieldTypeRef = layout.Ref() + dataFieldParamsTypeRef = dataFieldTypeRef + if eventAttr.IsPrimitivePointer(nat.Name, true) { + dataFieldParamsTypeRef = layout.RefWithPointer(true) + } } } } - // Determine if the Last-Event-ID mapped payload attribute is a primitive pointer + // Record the exact service field that receives Last-Event-ID and whether it + // uses a pointer. + ridField := "" ridPtr := false if e.SSE.RequestIDField != "" { + attribute := e.MethodExpr.Payload.Find(e.SSE.RequestIDField) + ridField = codegen.GoifyAtt(attribute, e.SSE.RequestIDField, true) ridPtr = e.MethodExpr.Payload.IsPrimitivePointer(e.SSE.RequestIDField, true) } ed.SSE = &SSEData{ - StructName: md.ServerStream.VarName, Interface: fmt.Sprintf("%s.%s", svc.PkgName, md.ServerStream.Interface), SendName: md.ServerStream.SendName, SendDesc: sendDesc, @@ -126,17 +228,108 @@ func initSSEData(ed *EndpointData, e *expr.HTTPEndpointExpr, sd *ServiceData) { IDField: idFieldVar, EventField: eventFieldVar, RetryField: retryFieldVar, - RequestIDField: e.SSE.RequestIDField, + RequestIDField: ridField, RequestIDPointer: ridPtr, + VariableView: md.ViewedResult != nil && md.ViewedResult.ViewName == "", } - - // Mixed results SSE uses the streaming result type for events, not the unary - // HTTP response body type. Disable HTTP response body conversion in the SSE - // stream implementation and marshal the event value directly. + if retryField != nil { + fieldctx := svcctx.Enter(retryField) + value := sseValueData(eventAttr, retryField, fieldctx.Scope.Ref(retryField, fieldctx.Pkg(retryField)), e.SSE.RetryField) + ed.SSE.Retry = &value + } + if idField != nil { + fieldctx := svcctx.Enter(idField) + value := sseValueData(eventAttr, idField, fieldctx.Scope.Ref(idField, fieldctx.Pkg(idField)), e.SSE.IDField) + ed.SSE.ID = &value + } + if eventField != nil { + fieldctx := svcctx.Enter(eventField) + value := sseValueData(eventAttr, eventField, fieldctx.Scope.Ref(eventField, fieldctx.Pkg(eventField)), e.SSE.EventField) + ed.SSE.Event = &value + } + if ed.SSE.VariableView { + for _, view := range md.ViewedResult.Views { + if view.Name == expr.DefaultView { + ed.SSE.DefaultView = view.Name + break + } + } + if ed.SSE.DefaultView == "" { + panic(fmt.Sprintf("viewed SSE method %q has no default view", md.Name)) + } + } + // A mixed method has one ordinary result and a different streamed result. + // Build the streamed result's own JSON body instead of reusing the ordinary + // response body or encoding the service struct directly. if ed.HasMixedResults { - ed.SSE.HasResponseBody = false + body := sd.bodies.streamingResult(e) + owner := expr.MethodStreamingResultExampleIdentity(e.MethodExpr) + transforms := sd.transforms.streamingResults[e] + serverBody := sds.buildResponseBodyType(body, eventAttr, e, true, nil, sd, transforms, owner, owner) + clientBody := sds.buildResponseBodyType(body, eventAttr, e, false, nil, sd, nil, owner, owner) + clientObject := expr.AsObject(body.Type) + setSSEClientField(ed.SSE.ID, clientObject, e.SSE.IDField) + setSSEClientField(ed.SSE.Event, clientObject, e.SSE.EventField) + setSSEClientField(ed.SSE.Retry, clientObject, e.SSE.RetryField) + ed.SSE.ClientIDPointer = ed.SSE.ID != nil && ed.SSE.ID.ClientPointer + ed.SSE.ClientEventPointer = ed.SSE.Event != nil && ed.SSE.Event.ClientPointer + clientCode := "" + switch { + case body.Type == expr.Empty: + case transforms.clientDecodeDirect: + clientCode = "result := body" + case transforms.clientDecode.record != nil: + transformContext := jsonBodyContext(sd.clientWireTypes, sd.clientWireTypes.scope, false, false) + transformContext.Scope = sd.clientWireTypes.resolver(sd.clientWireTypes.scope, jsonBodyPolicy(false, false, true, "")) + serviceContext := sds.serviceTypeContext(sd, "client").Enter(eventAttr) + var helpers []*codegen.TransformFunctionData + var err error + clientCode, helpers, err = sd.clientWireTypes.renderTransform( + transforms.clientDecode, + body, + "body", + "result", + transformContext, + serviceContext, + ) + if err != nil { + sds.recordLinkError(err) + } else { + sd.ClientTransformHelpers = codegen.AppendHelpers(sd.ClientTransformHelpers, helpers) + } + default: + sds.recordLinkError(fmt.Errorf("mixed SSE client result for %q has no planned conversion", e.Name())) + } + ed.SSE.Response = &ResponseData{ClientBody: clientBody} + if serverBody != nil { + ed.SSE.Response.ServerBody = []*TypeData{serverBody} + } + ed.SSE.ClientEventCode = clientCode + ed.SSE.HasResponseBody = serverBody != nil + if dataField == nil { + dataField = body + dataFieldTypeRef = eventType.Ref + if serverBody != nil { + dataFieldTypeRef = serverBody.Ref + } + dataFieldParamsTypeRef = dataFieldTypeRef + } + ed.SSE.Data = sseValueData(eventAttr, dataField, dataFieldTypeRef, e.SSE.DataField) + setSSEClientField(&ed.SSE.Data, clientObject, e.SSE.DataField) + setJSONRPCSSEParams(ed, e, eventAttr, dataField, dataFieldParamsTypeRef) return } + if len(ed.Result.Responses) > 0 { + ed.SSE.Response = ed.Result.Responses[0] + } + if len(e.Responses) > 0 { + clientObject := expr.AsObject(sd.bodies.response(e.Responses[0]).Type) + setSSEClientField(ed.SSE.ID, clientObject, e.SSE.IDField) + setSSEClientField(ed.SSE.Event, clientObject, e.SSE.EventField) + setSSEClientField(ed.SSE.Retry, clientObject, e.SSE.RetryField) + ed.SSE.ClientIDPointer = ed.SSE.ID != nil && ed.SSE.ID.ClientPointer + ed.SSE.ClientEventPointer = ed.SSE.Event != nil && ed.SSE.Event.ClientPointer + } for _, resp := range ed.Result.Responses { if len(resp.ServerBody) > 0 { @@ -144,34 +337,176 @@ func initSSEData(ed *EndpointData, e *expr.HTTPEndpointExpr, sd *ServiceData) { break } } + dataAttribute := dataField + dataTypeRef := dataFieldTypeRef + if dataAttribute == nil { + dataAttribute = eventAttr + dataTypeRef = eventType.Ref + if ed.SSE.HasResponseBody && len(e.Responses) > 0 { + dataAttribute = sd.bodies.response(e.Responses[0]) + } + } + ed.SSE.Data = sseValueData(eventAttr, dataAttribute, dataTypeRef, e.SSE.DataField) + if len(e.Responses) > 0 { + setSSEClientField(&ed.SSE.Data, expr.AsObject(sd.bodies.response(e.Responses[0]).Type), e.SSE.DataField) + } + setJSONRPCSSEParams(ed, e, eventAttr, dataField, dataFieldParamsTypeRef) +} + +// setJSONRPCSSEParams records the one-element params array used for a string, +// number, boolean, byte slice, or Any result. Objects, arrays, maps, and unions +// keep their JSON shape. +func setJSONRPCSSEParams(endpoint *EndpointData, expression *expr.HTTPEndpointExpr, event, data *expr.AttributeExpr, dataTypeRef string) { + if !expression.IsJSONRPC() { + return + } + params := event + typeRef := endpoint.SSE.EventTypeRef + if data != nil { + params = data + typeRef = dataTypeRef + } else if endpoint.SSE.HasResponseBody && endpoint.SSE.Response != nil && len(endpoint.SSE.Response.ServerBody) > 0 { + body := endpoint.SSE.Response.ServerBody[0] + if body.Init != nil { + typeRef = body.Ref + } + } + allowAbsent := data != nil && !event.IsRequired(expression.SSE.DataField) + endpoint.SSE.JSONRPCParams = jsonRPCParams(params.Type, typeRef, false, allowAbsent) +} + +// sseValueData records the exact conversion selected for one SSE value. +func sseValueData(event, value *expr.AttributeExpr, typeRef, field string) SSEValueData { + pointer := field != "" && event.IsPrimitivePointer(field, true) + if field == "" && value != event { + if origin, ok := value.Meta["origin:attribute"]; ok && len(origin) > 0 { + pointer = event.IsPrimitivePointer(origin[0], true) + } + } + kind := sseValueKind(value.Type) + named := false + if expr.IsPrimitive(value.Type) { + named = typeRef != codegen.GoNativeTypeName(expr.Primitive(kind)) + } + var defaultValue any + if field != "" { + defaultValue = event.GetDefault(field) + } + return SSEValueData{ + Kind: kind, + TypeRef: typeRef, + ClientTypeRef: typeRef, + Named: named, + Pointer: pointer, + DefaultValue: defaultValue, + HasDefault: defaultValue != nil, + Union: expr.AsUnion(value.Type) != nil, + } +} + +// setSSEClientField records how one SSE value is stored in the generated HTTP body. +func setSSEClientField(value *SSEValueData, object *expr.Object, field string) { + if value == nil || object == nil || field == "" { + return + } + attribute := object.Attribute(field) + if attribute == nil { + return + } + kind := sseValueKind(attribute.Type) + value.ClientPointer = sseBodyFieldPointer(attribute) + if expr.IsPrimitive(attribute.Type) { + value.ClientTypeRef = codegen.GoNativeTypeName(expr.Primitive(kind)) + } +} + +// sseBodyFieldPointer reports whether client validation keeps one scalar +// field as a pointer so it can distinguish a missing value from zero. +func sseBodyFieldPointer(attribute *expr.AttributeExpr) bool { + if attribute == nil || !expr.IsPrimitive(attribute.Type) { + return false + } + kind := sseValueKind(attribute.Type) + return kind != expr.BytesKind && kind != expr.AnyKind +} + +// sseValueKind returns the primitive or structured kind beneath a declared +// type name. Generated assignments still use the declared Go type in TypeRef. +func sseValueKind(dataType expr.DataType) expr.Kind { + switch actual := dataType.(type) { + case *expr.UserTypeExpr: + return sseValueKind(actual.Type) + case *expr.ResultTypeExpr: + return sseValueKind(actual.Type) + default: + return actual.Kind() + } +} + +// sseTemplateFuncs returns the generation-time type tests used by SSE +// templates. Each test removes every other conversion from generated code. +func sseTemplateFuncs() map[string]any { + return map[string]any{ + "ssePrimitive": func(value SSEValueData) bool { + switch value.Kind { + case expr.BooleanKind, expr.IntKind, expr.Int32Kind, expr.Int64Kind, + expr.UIntKind, expr.UInt32Kind, expr.UInt64Kind, + expr.Float32Kind, expr.Float64Kind, expr.StringKind, expr.BytesKind: + return true + default: + return false + } + }, + "sseString": func(value SSEValueData) bool { + return value.Kind == expr.StringKind + }, + "sseBytes": func(value SSEValueData) bool { + return value.Kind == expr.BytesKind + }, + "sseBoolean": func(value SSEValueData) bool { + return value.Kind == expr.BooleanKind + }, + "sseSignedInteger": func(value SSEValueData) bool { + return value.Kind == expr.IntKind || value.Kind == expr.Int32Kind || value.Kind == expr.Int64Kind + }, + "sseUnsignedInteger": func(value SSEValueData) bool { + return value.Kind == expr.UIntKind || value.Kind == expr.UInt32Kind || value.Kind == expr.UInt64Kind + }, + "sseFloat": func(value SSEValueData) bool { + return value.Kind == expr.Float32Kind || value.Kind == expr.Float64Kind + }, + "sseBitSize": func(value SSEValueData) int { + switch value.Kind { + case expr.Int32Kind, expr.UInt32Kind, expr.Float32Kind: + return 32 + case expr.Int64Kind, expr.UInt64Kind, expr.Float64Kind: + return 64 + default: + return 0 + } + }, + } } // sseServerFile returns the file implementing the SSE server // streaming implementation if any. -func sseServerFile(genpkg string, svc *expr.HTTPServiceExpr, services *ServicesData) *codegen.File { +func sseServerFile(svc *expr.HTTPServiceExpr, services *ServicesData) *codegen.File { data := services.Get(svc.Name()) if !HasSSE(data) { return nil } - path := filepath.Join(codegen.Gendir, "http", codegen.SnakeCase(svc.Name()), "server", "sse.go") + path := filepath.Join(codegen.Gendir, "http", data.Service.PathName, "server", "sse.go") + outputPackage := generatedFileOutputPackage(services, path) + data = serviceDataForOutput(data, services, outputPackage) tmplSections := sseTemplateSections(data) sections := make([]*codegen.SectionTemplate, 0, 1+len(tmplSections)) sections = append(sections, - codegen.Header( + plannedFileHeader( "sse", "server", - []*codegen.ImportSpec{ - {Path: "context"}, - {Path: "io"}, - {Path: "net/http"}, - {Path: "sync"}, - {Path: "time"}, - {Path: "encoding/json"}, - {Path: "fmt"}, - {Path: genpkg + "/" + codegen.SnakeCase(svc.Name()), Name: data.Service.PkgName}, - {Path: genpkg + "/" + codegen.SnakeCase(svc.Name()) + "/views", Name: data.Service.ViewsPkg}, - }, + path, + services, ), ) sections = append(sections, tmplSections...) @@ -185,14 +520,14 @@ func sseTemplateSections(data *ServiceData) []*codegen.SectionTemplate { if ed.SSE == nil { continue } + funcs := sseTemplateFuncs() + funcs["dict"] = dict + funcs["goify"] = codegen.Goify sections = append(sections, &codegen.SectionTemplate{ - Name: "server-sse", - Source: httpTemplates.Read(serverSseT, sseFormatP), - Data: ed, - FuncMap: map[string]any{ - "dict": dict, - "goify": codegen.Goify, - }, + Name: "server-sse", + Source: httpTemplates.Read(serverSseT, sseFormatP), + Data: ed, + FuncMap: funcs, }) } return sections diff --git a/http/codegen/sse_client.go b/http/codegen/sse_client.go index 1296bb46be..46e24c3163 100644 --- a/http/codegen/sse_client.go +++ b/http/codegen/sse_client.go @@ -2,7 +2,6 @@ package codegen import ( "path/filepath" - "strings" "goa.design/goa/v3/codegen" "goa.design/goa/v3/expr" @@ -10,33 +9,22 @@ import ( // sseClientFile returns the file implementing the SSE client code for SSE endpoints if any. // Relies on SSEData (ed.SSE) for all codegen needs. -func sseClientFile(genpkg string, svc *expr.HTTPServiceExpr, services *ServicesData) *codegen.File { +func sseClientFile(svc *expr.HTTPServiceExpr, services *ServicesData) *codegen.File { data := services.Get(svc.Name()) if !HasSSE(data) { return nil } - path := filepath.Join(codegen.Gendir, "http", codegen.SnakeCase(svc.Name()), "client", "sse.go") + path := filepath.Join(codegen.Gendir, "http", data.Service.PathName, "client", "sse.go") + outputPackage := generatedFileOutputPackage(services, path) + data = serviceDataForOutput(data, services, outputPackage) tmplSections := sseClientTemplateSections(data) sections := make([]*codegen.SectionTemplate, 0, 1+len(tmplSections)) sections = append(sections, - codegen.Header( + plannedFileHeader( "sse-client", "client", - []*codegen.ImportSpec{ - {Path: "bytes"}, - {Path: "context"}, - {Path: "encoding/json"}, - {Path: "errors"}, - {Path: "io"}, - {Path: "net/http"}, - {Path: "fmt"}, - {Path: "strings"}, - {Path: "strconv"}, - {Path: "sync"}, - {Path: genpkg + "/" + codegen.SnakeCase(svc.Name()), Name: data.Service.PkgName}, - {Path: genpkg + "/" + codegen.SnakeCase(svc.Name()) + "/views", Name: data.Service.ViewsPkg}, - {Path: "goa.design/goa/v3/http", Name: "goahttp"}, - }, + path, + services, ), ) sections = append(sections, tmplSections...) // add SSE client methods @@ -50,16 +38,16 @@ func sseClientTemplateSections(data *ServiceData) []*codegen.SectionTemplate { if ed.SSE == nil { continue } + funcs := sseTemplateFuncs() + funcs["dict"] = dict + funcs["goTypeRef"] = func(dataType expr.DataType) string { + return data.Scope.GoTypeRef(&expr.AttributeExpr{Type: dataType}) + } sections = append(sections, &codegen.SectionTemplate{ - Name: "client-sse", - Source: httpTemplates.Read(clientSseT, sseParseP), - Data: ed, - FuncMap: map[string]any{ - "dict": dict, - "deref": func(ref string) string { - return strings.TrimPrefix(ref, "*") - }, - }, + Name: "client-sse", + Source: httpTemplates.Read(clientSseT, sseParseP, queryTypeConversionP, elementSliceConversionP, sliceItemConversionP), + Data: ed, + FuncMap: funcs, }) } return sections diff --git a/http/codegen/sse_client_test.go b/http/codegen/sse_client_test.go index 7c52f71827..a7967f5f07 100644 --- a/http/codegen/sse_client_test.go +++ b/http/codegen/sse_client_test.go @@ -30,8 +30,8 @@ func TestSSEClient(t *testing.T) { for _, c := range cases { t.Run(c.Name, func(t *testing.T) { root := expr.RunDSL(t, c.DSL) - services := CreateHTTPServices(root) - fs := ClientFiles("", services) + plan := linkedHTTPPlanForRoot(t, root) + fs := plan.ClientFiles() require.Len(t, fs, 3) sections := fs[1].SectionTemplates require.Greater(t, len(sections), 1) @@ -41,3 +41,66 @@ func TestSSEClient(t *testing.T) { }) } } + +// TestSSEClientSpecializesDataAndRetryParsing checks that generated clients +// parse each designed field into its exact Go type. +func TestSSEClientSpecializesDataAndRetryParsing(t *testing.T) { + tests := []struct { + name string + design func() + contains []string + }{ + { + name: "string alias", + design: ssePrimitiveAliasDSL, + contains: []string{ + "body = dataContent", + "event = NewWatchEventTextOK(body)", + }, + }, + { + name: "optional data field", + design: testdata.SSEDataFieldDSL, + contains: []string{ + "value := dataContent", + "body.Data = &value", + "event = NewSSEDataFieldMethodResultOK(&body)", + }, + }, + { + name: "viewed data field", + design: viewedSSEDataFieldDSL, + contains: []string{ + "value := dataContent", + "body.Data = &value", + }, + }, + { + name: "viewed alias data field", + design: viewedSSEPrimitiveAliasDataFieldDSL, + contains: []string{ + "value := dataContent", + "body.Data = &value", + }, + }, + { + name: "retry", + design: testdata.SSEAllFieldsDSL, + contains: []string{ + `retryContent := string(value)`, + `strconv.ParseInt(retryContent, 10, 0)`, + "body.Retry = &value", + }, + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + root := expr.RunDSL(t, test.design) + code := renderedFile(t, linkedHTTPPlanForRoot(t, root).ClientFiles()) + for _, expected := range test.contains { + require.Contains(t, code, expected) + } + require.NotContains(t, code, "retry value parsing depends on the field type") + }) + } +} diff --git a/http/codegen/sse_client_validation_runtime_test.go b/http/codegen/sse_client_validation_runtime_test.go new file mode 100644 index 0000000000..47d7928d6a --- /dev/null +++ b/http/codegen/sse_client_validation_runtime_test.go @@ -0,0 +1,179 @@ +// This file renders an ordinary HTTP SSE client into a temporary module. The +// generated test proves required JSON fields are checked before an event is +// returned through the service interface. +package codegen + +import ( + "context" + "os" + "os/exec" + "path/filepath" + "slices" + "testing" + "time" + + "github.com/stretchr/testify/require" + + "goa.design/goa/v3/codegen" + "goa.design/goa/v3/codegen/service" + "goa.design/goa/v3/dsl" + "goa.design/goa/v3/eval" + "goa.design/goa/v3/expr" +) + +// TestGeneratedSSEClientValidatesResponseBody checks that a required zero-valued +// field is accepted when present and rejected when missing. +func TestGeneratedSSEClientValidatesResponseBody(t *testing.T) { + root := expr.RunDSL(t, sseRequiredPrimitiveDSL) + generation, err := codegen.NewGeneration("generated.local/gen", []eval.Root{root}) + require.NoError(t, err) + servicePlan, err := service.NewPlan(root, generation, expr.NewExampleGenerator(root.API.RandomizerFactory)) + require.NoError(t, err) + httpPlans, err := NewPlans(generation, PlanInput{Root: root, Service: servicePlan}) + require.NoError(t, err) + require.NoError(t, generation.Freeze()) + require.NoError(t, servicePlan.Link()) + require.NoError(t, httpPlans[0].Link()) + + serviceFiles, err := service.Files(servicePlan) + require.NoError(t, err) + files := slices.Clone(serviceFiles) + files = append(files, httpPlans[0].ClientFiles()...) + files = append(files, httpPlans[0].ClientTypeFiles()...) + files = append(files, httpPlans[0].PathFiles()...) + runGeneratedSSEClientValidationTest(t, files) +} + +// sseRequiredPrimitiveDSL defines a required primitive whose zero value must +// remain different from an absent JSON property. +func sseRequiredPrimitiveDSL() { + viewed := dsl.ResultType("application/vnd.viewed-sse-validation", func() { + dsl.TypeName("ViewedEvent") + dsl.Attribute("id", dsl.String) + dsl.Attribute("kind", dsl.String, func() { + dsl.Default("default-kind") + }) + dsl.Attribute("retry", dsl.Int, func() { + dsl.Default(5) + }) + dsl.Attribute("data", dsl.String) + dsl.Required("id", "data") + dsl.View("default", func() { + dsl.Attribute("id") + dsl.Attribute("kind") + dsl.Attribute("retry") + dsl.Attribute("data") + }) + }) + dsl.Service("SSE Validation", func() { + dsl.Method("watch", func() { + dsl.StreamingResult(func() { + dsl.Attribute("count", dsl.Int) + dsl.Required("count") + }) + dsl.HTTP(func() { + dsl.GET("/watch") + dsl.ServerSentEvents() + }) + }) + }) + dsl.Service("Viewed SSE Validation", func() { + dsl.Method("watch", func() { + dsl.StreamingResult(viewed) + dsl.HTTP(func() { + dsl.GET("/viewed") + dsl.ServerSentEvents("data", func() { + dsl.SSEEventID("id") + dsl.SSEEventType("kind") + dsl.SSEEventRetry("retry") + }) + }) + }) + }) +} + +// runGeneratedSSEClientValidationTest renders the generated packages and runs +// the generated client's event parser against valid and incomplete JSON. +func runGeneratedSSEClientValidationTest(t *testing.T, files []*codegen.File) { + t.Helper() + directory := t.TempDir() + repository, err := filepath.Abs(filepath.Join("..", "..")) + require.NoError(t, err) + module := "module generated.local\n\ngo 1.25\n\n" + + "require goa.design/goa/v3 v3.0.0\n\n" + + "replace goa.design/goa/v3 => " + filepath.ToSlash(repository) + "\n" + require.NoError(t, os.WriteFile(filepath.Join(directory, "go.mod"), []byte(module), 0o600)) + for _, file := range files { + _, err := file.Render(directory) + require.NoError(t, err) + } + + testPath := filepath.Join(directory, "gen", "http", "sse_validation", "client", "sse_validation_test.go") + require.NoError(t, os.WriteFile(testPath, []byte(generatedSSEClientValidationTest), 0o600)) + viewedTestPath := filepath.Join(directory, "gen", "http", "viewed_sse_validation", "client", "viewed_sse_validation_test.go") + require.NoError(t, os.WriteFile(viewedTestPath, []byte(generatedViewedSSEClientValidationTest), 0o600)) + + ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) + defer cancel() + command := exec.CommandContext(ctx, "go", "test", "-mod=mod", "./gen/http/sse_validation/client", "./gen/http/viewed_sse_validation/client") + command.Dir = directory + command.Env = append(os.Environ(), "GOWORK=off") + output, err := command.CombinedOutput() + require.NoError(t, err, "run generated SSE client test:\n%s", output) +} + +const generatedSSEClientValidationTest = `package client + +import ( + "testing" + + "github.com/stretchr/testify/require" + goahttp "goa.design/goa/v3/http" +) + +func TestRequiredPrimitivePresence(t *testing.T) { + stream := &WatchStreamImpl{decoder: goahttp.ResponseDecoder} + + event, hasData, err := stream.processEvent([]byte("data: {\"count\":0}\n\n")) + require.NoError(t, err) + require.True(t, hasData) + require.Equal(t, 0, event.Count) + + _, _, err = stream.processEvent([]byte("data: {}\n\n")) + require.Error(t, err) + require.Contains(t, err.Error(), "count") +} +` + +const generatedViewedSSEClientValidationTest = `package client + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestOuterFieldsAreValidatedWithTheBody(t *testing.T) { + stream := &WatchStreamImpl{} + + event, hasData, err := stream.processEvent([]byte("id:\nevent:\nretry: 0\ndata: ready\n\n")) + require.NoError(t, err) + require.True(t, hasData) + require.Empty(t, event.ID) + require.Empty(t, event.Kind) + require.Zero(t, event.Retry) + require.Equal(t, "ready", event.Data) + + event, hasData, err = stream.processEvent([]byte("id: event-1\ndata: ready\n\n")) + require.NoError(t, err) + require.True(t, hasData) + require.Equal(t, "event-1", event.ID) + require.Equal(t, "default-kind", event.Kind) + require.Equal(t, 5, event.Retry) + + stream = &WatchStreamImpl{} + _, _, err = stream.processEvent([]byte("data: ready\n\n")) + require.Error(t, err) + require.Contains(t, err.Error(), "id") +} +` diff --git a/http/codegen/sse_mixed_result_runtime_test.go b/http/codegen/sse_mixed_result_runtime_test.go new file mode 100644 index 0000000000..83ae109297 --- /dev/null +++ b/http/codegen/sse_mixed_result_runtime_test.go @@ -0,0 +1,202 @@ +// This file renders a mixed HTTP/SSE client into a temporary module. The +// generated test proves mapped SSE fields are validated before the wire event +// becomes the service event returned by Recv. +package codegen + +import ( + "context" + "os" + "os/exec" + "path/filepath" + "slices" + "testing" + "time" + + "github.com/stretchr/testify/require" + + "goa.design/goa/v3/codegen" + "goa.design/goa/v3/codegen/service" + "goa.design/goa/v3/dsl" + "goa.design/goa/v3/eval" + "goa.design/goa/v3/expr" +) + +// TestGeneratedMixedSSEClientValidatesMappedWireBody catches clients decoding +// event data directly into a service value and skipping transport validation. +func TestGeneratedMixedSSEClientValidatesMappedWireBody(t *testing.T) { + root := expr.RunDSL(t, mixedSSEMappedFieldDSL) + generation, err := codegen.NewGeneration("generated.local/gen", []eval.Root{root}) + require.NoError(t, err) + servicePlan, err := service.NewPlan(root, generation, expr.NewExampleGenerator(root.API.RandomizerFactory)) + require.NoError(t, err) + httpPlans, err := NewPlans(generation, PlanInput{Root: root, Service: servicePlan}) + require.NoError(t, err) + require.NoError(t, generation.Freeze()) + require.NoError(t, servicePlan.Link()) + require.NoError(t, httpPlans[0].Link()) + + serviceFiles, err := service.Files(servicePlan) + require.NoError(t, err) + files := slices.Clone(serviceFiles) + files = append(files, httpPlans[0].ClientFiles()...) + files = append(files, httpPlans[0].ClientTypeFiles()...) + files = append(files, httpPlans[0].PathFiles()...) + runGeneratedMixedSSEClientTest(t, files) +} + +// TestGeneratedMixedSSEResultShapesCompile verifies each generation-time +// conversion branch produces complete client and server packages: direct +// primitive values, direct primitive collections, converted anonymous objects, +// and empty events. +func TestGeneratedMixedSSEResultShapesCompile(t *testing.T) { + root := expr.RunDSL(t, mixedSSEResultShapesDSL) + generation, err := codegen.NewGeneration("generated.local/gen", []eval.Root{root}) + require.NoError(t, err) + servicePlan, err := service.NewPlan(root, generation, expr.NewExampleGenerator(root.API.RandomizerFactory)) + require.NoError(t, err) + httpPlans, err := NewPlans(generation, PlanInput{Root: root, Service: servicePlan}) + require.NoError(t, err) + require.NoError(t, generation.Freeze()) + require.NoError(t, servicePlan.Link()) + require.NoError(t, httpPlans[0].Link()) + + serviceFiles, err := service.Files(servicePlan) + require.NoError(t, err) + files := slices.Clone(serviceFiles) + files = append(files, httpPlans[0].ClientFiles()...) + files = append(files, httpPlans[0].ClientTypeFiles()...) + files = append(files, httpPlans[0].ServerFiles()...) + files = append(files, httpPlans[0].ServerTypeFiles()...) + files = append(files, httpPlans[0].PathFiles()...) + runGeneratedMixedSSECompile(t, files, "./gen/...") +} + +// mixedSSEResultShapesDSL puts every optional-transform shape in one generated +// client package so one compile checks their declarations and return paths. +func mixedSSEResultShapesDSL() { + dsl.Service("Mixed SSE Shapes", func() { + shapes := []struct { + name string + result any + }{ + {"int", dsl.Int}, + {"ints", dsl.ArrayOf(dsl.Int)}, + {"inline", func() { dsl.Attribute("value", dsl.Int) }}, + {"empty", func() {}}, + } + for _, shape := range shapes { + dsl.Method("watch_"+shape.name, func() { + dsl.Result(dsl.String) + dsl.StreamingResult(shape.result) + dsl.HTTP(func() { + dsl.GET("/" + shape.name) + dsl.ServerSentEvents() + }) + }) + } + }) +} + +// mixedSSEMappedFieldDSL makes event_id required and maps it to the SSE id +// line while message is carried by the data line. +func mixedSSEMappedFieldDSL() { + result := dsl.Type("Result", func() { + dsl.Attribute("event_id", dsl.String) + dsl.Attribute("message", dsl.String) + dsl.Required("event_id", "message") + }) + event := dsl.Type("Event", func() { + dsl.Attribute("event_id", dsl.String) + dsl.Attribute("message", dsl.String) + dsl.Required("event_id", "message") + }) + dsl.Service("Mixed SSE Wire", func() { + dsl.Method("watch", func() { + dsl.Result(result) + dsl.StreamingResult(event) + dsl.HTTP(func() { + dsl.GET("/watch") + dsl.ServerSentEvents("message", func() { + dsl.SSEEventID("event_id") + }) + }) + }) + }) +} + +// runGeneratedMixedSSEClientTest writes generated packages and runs the +// generated client's private event parser against complete and incomplete +// frames. +func runGeneratedMixedSSEClientTest(t *testing.T, files []*codegen.File) { + t.Helper() + directory := t.TempDir() + repository, err := filepath.Abs(filepath.Join("..", "..")) + require.NoError(t, err) + module := "module generated.local\n\ngo 1.25\n\n" + + "require goa.design/goa/v3 v3.0.0\n\n" + + "replace goa.design/goa/v3 => " + filepath.ToSlash(repository) + "\n" + require.NoError(t, os.WriteFile(filepath.Join(directory, "go.mod"), []byte(module), 0o600)) + for _, file := range files { + _, err := file.Render(directory) + require.NoError(t, err) + } + + testPath := filepath.Join(directory, "gen", "http", "mixed_sse_wire", "client", "mixed_sse_test.go") + require.NoError(t, os.WriteFile(testPath, []byte(generatedMixedSSEClientTest), 0o600)) + + ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) + defer cancel() + command := exec.CommandContext(ctx, "go", "test", "-mod=mod", "./gen/http/mixed_sse_wire/client") + command.Dir = directory + command.Env = append(os.Environ(), "GOWORK=off") + output, err := command.CombinedOutput() + require.NoError(t, err, "run generated mixed SSE client test:\n%s", output) +} + +// runGeneratedMixedSSECompile renders files in an isolated module and compiles +// the requested generated package. +func runGeneratedMixedSSECompile(t *testing.T, files []*codegen.File, packagePath string) { + t.Helper() + directory := t.TempDir() + repository, err := filepath.Abs(filepath.Join("..", "..")) + require.NoError(t, err) + module := "module generated.local\n\ngo 1.25\n\n" + + "require goa.design/goa/v3 v3.0.0\n\n" + + "replace goa.design/goa/v3 => " + filepath.ToSlash(repository) + "\n" + require.NoError(t, os.WriteFile(filepath.Join(directory, "go.mod"), []byte(module), 0o600)) + for _, file := range files { + _, err := file.Render(directory) + require.NoError(t, err) + } + + ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) + defer cancel() + command := exec.CommandContext(ctx, "go", "test", "-mod=mod", packagePath) + command.Dir = directory + command.Env = append(os.Environ(), "GOWORK=off") + output, err := command.CombinedOutput() + require.NoError(t, err, "compile generated mixed SSE clients:\n%s", output) +} + +const generatedMixedSSEClientTest = `package client + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestMappedEventBodyIsValidated(t *testing.T) { + stream := &WatchStreamImpl{} + + event, hasData, err := stream.processEvent([]byte("id: event-1\ndata: ready\n\n")) + require.NoError(t, err) + require.True(t, hasData) + require.Equal(t, "event-1", event.EventID) + require.Equal(t, "ready", event.Message) + + stream = &WatchStreamImpl{} + _, _, err = stream.processEvent([]byte("data: ready\n\n")) + require.Error(t, err) +} +` diff --git a/http/codegen/sse_mixed_results_test.go b/http/codegen/sse_mixed_results_test.go index 09422ccc85..a117a46250 100644 --- a/http/codegen/sse_mixed_results_test.go +++ b/http/codegen/sse_mixed_results_test.go @@ -8,16 +8,17 @@ import ( "github.com/stretchr/testify/require" "goa.design/goa/v3/codegen" + "goa.design/goa/v3/dsl" "goa.design/goa/v3/expr" "goa.design/goa/v3/http/codegen/testdata" ) func TestSSE_MixedResults(t *testing.T) { root := expr.RunDSL(t, testdata.MixedResultsDSL) - services := CreateHTTPServices(root) + plan := linkedHTTPPlanForRoot(t, root) t.Run("server", func(t *testing.T) { - files := ServerFiles("", services) + files := plan.ServerFiles() var sseFile *codegen.File for _, f := range files { if strings.HasSuffix(f.Path, filepath.Join("server", "sse.go")) { @@ -31,12 +32,14 @@ func TestSSE_MixedResults(t *testing.T) { require.NotEmpty(t, sections) code := codegen.SectionCode(t, sections[0]) - require.Contains(t, code, "payload = res") - require.NotContains(t, code, "NewCreateResponseBody") + require.Contains(t, code, "body := NewEvent(res)") + require.Contains(t, code, "json.Marshal(body)") + require.NotContains(t, code, "var payload any") + require.NotContains(t, code, "json.Marshal(res)") }) t.Run("client", func(t *testing.T) { - files := ClientFiles("", services) + files := plan.ClientFiles() var sseFile *codegen.File for _, f := range files { if strings.HasSuffix(f.Path, filepath.Join("client", "sse.go")) { @@ -50,6 +53,68 @@ func TestSSE_MixedResults(t *testing.T) { require.NotEmpty(t, sections) code := codegen.SectionCode(t, sections[0]) - require.Contains(t, code, "event = new(") + require.Contains(t, code, "var body Event") + require.Contains(t, code, "err = ValidateEvent(&body)") + require.Contains(t, code, "result := &mixedresultsservice.Event{") + require.Contains(t, code, "return result, true, nil") }) } + +// TestSSE_MixedResultConversionSelection verifies that mixed SSE clients use a +// direct assignment only for wire values that already have the service type. +// Anonymous objects need a planned conversion, while an empty streamed result +// returns the method's zero event without declaring an HTTP body. +func TestSSE_MixedResultConversionSelection(t *testing.T) { + tests := []struct { + name string + streaming any + contains string + notContain string + }{ + {"primitive", dsl.Int, "result := body", "result := &"}, + {"primitive collection", dsl.ArrayOf(dsl.Int), "result := body", "result := &"}, + {"inline object", func() { dsl.Attribute("value", dsl.Int) }, "result := &", "result := body"}, + {"empty body", func() {}, "return event, true, nil", "var body"}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + root := expr.RunDSL(t, mixedSSEResultShapeDSL(test.streaming)) + plan := linkedHTTPPlanForRoot(t, root) + code := mixedSSEClientCode(t, plan) + require.Contains(t, code, test.contains) + require.NotContains(t, code, test.notContain) + }) + } +} + +// mixedSSEResultShapeDSL defines one ordinary string result and a separately +// streamed result so each test exercises the mixed SSE client path. +func mixedSSEResultShapeDSL(streaming any) func() { + return func() { + dsl.Service("Mixed Shape", func() { + dsl.Method("watch", func() { + dsl.Result(dsl.String) + dsl.StreamingResult(streaming) + dsl.HTTP(func() { + dsl.GET("/watch") + dsl.ServerSentEvents() + }) + }) + }) + } +} + +// mixedSSEClientCode renders the mixed endpoint's client stream implementation. +func mixedSSEClientCode(t *testing.T, plan *Plan) string { + t.Helper() + for _, file := range plan.ClientFiles() { + if !strings.HasSuffix(file.Path, filepath.Join("client", "sse.go")) { + continue + } + sections := file.Section("client-sse") + require.NotEmpty(t, sections) + return codegen.SectionCode(t, sections[0]) + } + t.Fatal("mixed SSE client file was not generated") + return "" +} diff --git a/http/codegen/sse_primitive_wire_runtime_test.go b/http/codegen/sse_primitive_wire_runtime_test.go new file mode 100644 index 0000000000..090cf1df7b --- /dev/null +++ b/http/codegen/sse_primitive_wire_runtime_test.go @@ -0,0 +1,607 @@ +// This file renders an HTTP SSE service into a temporary module. The generated +// server and client tests check the exact text used for primitive fields and +// the JSON used for structured fields. +package codegen + +import ( + "context" + "os" + "os/exec" + "path/filepath" + "slices" + "testing" + "time" + + "github.com/stretchr/testify/require" + + "goa.design/goa/v3/codegen" + "goa.design/goa/v3/codegen/service" + "goa.design/goa/v3/dsl" + "goa.design/goa/v3/eval" + "goa.design/goa/v3/expr" +) + +// TestSSEClientFieldPlan checks the HTTP body type and presence shape chosen +// before templates render mapped SSE values. +func TestSSEClientFieldPlan(t *testing.T) { + eventText := &expr.UserTypeExpr{ + AttributeExpr: &expr.AttributeExpr{Type: expr.String}, + TypeName: "EventText", + UID: "EventText", + } + tests := []struct { + name string + field *expr.AttributeExpr + required bool + typeRef string + wantTypeRef string + wantPointer bool + }{ + {name: "required alias", field: &expr.AttributeExpr{Type: eventText}, required: true, typeRef: "service.EventText", wantTypeRef: "string", wantPointer: true}, + {name: "optional alias", field: &expr.AttributeExpr{Type: eventText}, typeRef: "service.EventText", wantTypeRef: "string", wantPointer: true}, + {name: "bytes", field: &expr.AttributeExpr{Type: expr.Bytes}, typeRef: "[]byte", wantTypeRef: "[]byte"}, + {name: "any", field: &expr.AttributeExpr{Type: expr.Any}, typeRef: "any", wantTypeRef: "any"}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + object := expr.Object{&expr.NamedAttributeExpr{Name: "value", Attribute: test.field}} + event := &expr.AttributeExpr{Type: &object} + if test.required { + event.Validation = &expr.ValidationExpr{} + event.Validation.AddRequired("value") + } + value := SSEValueData{TypeRef: test.typeRef, ClientTypeRef: test.typeRef} + + setSSEClientField(&value, &object, "value") + + require.Equal(t, test.required, event.IsRequired("value")) + require.Equal(t, test.wantTypeRef, value.ClientTypeRef) + require.Equal(t, test.wantPointer, value.ClientPointer) + }) + } +} + +// TestJSONRPCSSEParamsUsesPlannedDataReference checks that JSON-RPC rendering +// receives the complete service field type chosen during SSE planning. +func TestJSONRPCSSEParamsUsesPlannedDataReference(t *testing.T) { + data := &expr.AttributeExpr{Type: expr.String} + eventObject := expr.Object{{Name: "data", Attribute: data}} + event := &expr.AttributeExpr{Type: &eventObject} + endpoint := &EndpointData{SSE: &SSEData{ + EventTypeRef: "*service.Event", + Data: SSEValueData{TypeRef: "service.EventText", Pointer: true}, + }} + expression := &expr.HTTPEndpointExpr{ + SSE: &expr.HTTPSSEExpr{DataField: "data"}, + Meta: expr.MetaExpr{"jsonrpc": {}}, + } + + setJSONRPCSSEParams(endpoint, expression, event, data, "*service.PlannedEventText") + + require.NotNil(t, endpoint.SSE.JSONRPCParams) + require.Equal(t, "*service.PlannedEventText", endpoint.SSE.JSONRPCParams.TypeRef) + require.True(t, endpoint.SSE.JSONRPCParams.Positional) +} + +// TestGeneratedSSEFieldWireFormat checks both sides of the generated SSE +// connection for primitive, declared primitive, object, and array fields. +func TestGeneratedSSEFieldWireFormat(t *testing.T) { + root := expr.RunDSL(t, sseFieldWireDSL) + generation, err := codegen.NewGeneration("generated.local/gen", []eval.Root{root}) + require.NoError(t, err) + servicePlan, err := service.NewPlan(root, generation, expr.NewExampleGenerator(root.API.RandomizerFactory)) + require.NoError(t, err) + httpPlans, err := NewPlans(generation, PlanInput{Root: root, Service: servicePlan}) + require.NoError(t, err) + require.NoError(t, generation.Freeze()) + require.NoError(t, servicePlan.Link()) + require.NoError(t, httpPlans[0].Link()) + + serviceFiles, err := service.Files(servicePlan) + require.NoError(t, err) + files := slices.Clone(serviceFiles) + files = append(files, httpPlans[0].ServerFiles()...) + files = append(files, httpPlans[0].ClientFiles()...) + files = append(files, httpPlans[0].ServerTypeFiles()...) + files = append(files, httpPlans[0].ClientTypeFiles()...) + files = append(files, httpPlans[0].PathFiles()...) + runGeneratedSSEFieldWireTests(t, files) +} + +// sseFieldWireDSL maps each representative field type to the SSE data line. +func sseFieldWireDSL() { + eventText := dsl.Type("EventText", dsl.String) + requiredText := dsl.Type("RequiredText", func() { + dsl.Attribute("value", dsl.String) + dsl.Required("value") + }) + aliasText := dsl.Type("AliasText", func() { + dsl.Attribute("value", eventText) + dsl.Required("value") + }) + optionalText := dsl.Type("OptionalText", func() { + dsl.Attribute("value", dsl.String) + }) + wireObject := dsl.Type("WireObject", func() { + dsl.Attribute("label", dsl.String) + dsl.Required("label") + }) + structured := dsl.Type("Structured", func() { + dsl.Attribute("object", wireObject) + dsl.Attribute("values", dsl.ArrayOf(dsl.String)) + dsl.Required("object", "values") + }) + framingEvent := dsl.Type("FramingEvent", func() { + dsl.Attribute("id", dsl.String) + dsl.Attribute("event", dsl.String) + dsl.Attribute("retry", dsl.Int) + dsl.Attribute("data", dsl.Bytes) + dsl.Required("id", "event", "retry", "data") + }) + presenceEvent := dsl.Type("PresenceEvent", func() { + dsl.Attribute("id", dsl.String) + dsl.Attribute("event", dsl.String) + dsl.Attribute("retry", dsl.Int) + dsl.Attribute("data", dsl.Bytes) + dsl.Required("data") + }) + + dsl.Service("SSE Wire", func() { + dsl.Method("required", func() { + dsl.StreamingResult(requiredText) + dsl.HTTP(func() { + dsl.GET("/required") + dsl.ServerSentEvents("value") + }) + }) + dsl.Method("alias", func() { + dsl.StreamingResult(aliasText) + dsl.HTTP(func() { + dsl.GET("/alias") + dsl.ServerSentEvents("value") + }) + }) + dsl.Method("optional", func() { + dsl.StreamingResult(optionalText) + dsl.HTTP(func() { + dsl.GET("/optional") + dsl.ServerSentEvents("value") + }) + }) + dsl.Method("object", func() { + dsl.StreamingResult(structured) + dsl.HTTP(func() { + dsl.GET("/object") + dsl.ServerSentEvents("object") + }) + }) + dsl.Method("array", func() { + dsl.StreamingResult(structured) + dsl.HTTP(func() { + dsl.GET("/array") + dsl.ServerSentEvents("values") + }) + }) + dsl.Method("framing", func() { + dsl.StreamingResult(framingEvent) + dsl.HTTP(func() { + dsl.GET("/framing") + dsl.ServerSentEvents("data", func() { + dsl.SSEEventID("id") + dsl.SSEEventType("event") + dsl.SSEEventRetry("retry") + }) + }) + }) + dsl.Method("presence", func() { + dsl.StreamingResult(presenceEvent) + dsl.HTTP(func() { + dsl.GET("/presence") + dsl.ServerSentEvents("data", func() { + dsl.SSEEventID("id") + dsl.SSEEventType("event") + dsl.SSEEventRetry("retry") + }) + }) + }) + }) +} + +// runGeneratedSSEFieldWireTests writes the generated packages and executes the +// server and client tests inside them. +func runGeneratedSSEFieldWireTests(t *testing.T, files []*codegen.File) { + t.Helper() + directory := t.TempDir() + repository, err := filepath.Abs(filepath.Join("..", "..")) + require.NoError(t, err) + module := "module generated.local\n\ngo 1.25\n\n" + + "require goa.design/goa/v3 v3.0.0\n\n" + + "replace goa.design/goa/v3 => " + filepath.ToSlash(repository) + "\n" + require.NoError(t, os.WriteFile(filepath.Join(directory, "go.mod"), []byte(module), 0o600)) + for _, file := range files { + _, err := file.Render(directory) + require.NoError(t, err) + } + + serverTest := filepath.Join(directory, "gen", "http", "sse_wire", "server", "wire_test.go") + clientTest := filepath.Join(directory, "gen", "http", "sse_wire", "client", "wire_test.go") + require.NoError(t, os.WriteFile(serverTest, []byte(generatedSSEServerWireTest), 0o600)) + require.NoError(t, os.WriteFile(clientTest, []byte(generatedSSEClientWireTest), 0o600)) + + ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) + defer cancel() + command := exec.CommandContext(ctx, "go", "test", "-mod=mod", "./gen/http/sse_wire/server", "./gen/http/sse_wire/client") + command.Dir = directory + command.Env = append(os.Environ(), "GOWORK=off") + output, err := command.CombinedOutput() + require.NoError(t, err, "run generated SSE wire tests:\n%s", output) +} + +const generatedSSEServerWireTest = `package server + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/require" + + service "generated.local/gen/sse_wire" +) + +func TestPrimitiveFieldsUseRawSSEData(t *testing.T) { + tests := []struct { + name string + send func() string + want string + }{ + { + name: "string", + send: func() string { + recorder := httptest.NewRecorder() + stream := &RequiredServerStream{w: recorder} + require.NoError(t, stream.Send(&service.RequiredText{Value: "event"})) + return recorder.Body.String() + }, + want: "data: event\n\n", + }, + { + name: "string alias", + send: func() string { + recorder := httptest.NewRecorder() + stream := &AliasServerStream{w: recorder} + require.NoError(t, stream.Send(&service.AliasText{Value: service.EventText("event")})) + return recorder.Body.String() + }, + want: "data: event\n\n", + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + require.Equal(t, test.want, test.send()) + }) + } +} + +func TestEmptyStreamWritesSuccessfulSSEResponse(t *testing.T) { + recorder := httptest.NewRecorder() + stream := &RequiredServerStream{w: recorder} + + require.NoError(t, stream.finish()) + + response := recorder.Result() + require.Equal(t, http.StatusOK, response.StatusCode) + require.Equal(t, "text/event-stream", response.Header.Get("Content-Type")) + require.Equal(t, "no-cache", response.Header.Get("Cache-Control")) + require.Equal(t, "keep-alive", response.Header.Get("Connection")) +} + +func TestOptionalPrimitiveFieldPreservesPresence(t *testing.T) { + tests := []struct { + name string + value *string + want string + }{ + {name: "value", value: stringPointer("event"), want: "data: event\n\n"}, + {name: "empty", value: stringPointer(""), want: "data: \n\n"}, + {name: "absent", want: "\n"}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + recorder := httptest.NewRecorder() + stream := &OptionalServerStream{w: recorder} + require.NoError(t, stream.Send(&service.OptionalText{Value: test.value})) + require.Equal(t, test.want, recorder.Body.String()) + }) + } +} + +func TestStructuredFieldsUseJSON(t *testing.T) { + objectRecorder := httptest.NewRecorder() + objectStream := &ObjectServerStream{w: objectRecorder} + value := &service.WireObject{Label: "event"} + require.NoError(t, objectStream.Send(&service.Structured{Object: value, Values: []string{"one", "two"}})) + require.Equal(t, "data: {\"label\":\"event\"}\n\n", objectRecorder.Body.String()) + + arrayRecorder := httptest.NewRecorder() + arrayStream := &ArrayServerStream{w: arrayRecorder} + require.NoError(t, arrayStream.Send(&service.Structured{Object: value, Values: []string{"one", "two"}})) + require.Equal(t, "data: [\"one\",\"two\"]\n\n", arrayRecorder.Body.String()) +} + +func TestMappedFieldsCannotChangeFrame(t *testing.T) { + tests := []struct { + name string + id string + event string + }{ + {name: "id newline", id: "one\ntwo", event: "update"}, + {name: "id carriage return", id: "one\rtwo", event: "update"}, + {name: "id null", id: "one\x00two", event: "update"}, + {name: "event newline", id: "event-1", event: "one\ntwo"}, + {name: "event carriage return", id: "event-1", event: "one\rtwo"}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + recorder := httptest.NewRecorder() + stream := &FramingServerStream{w: recorder} + err := stream.Send(&service.FramingEvent{ + ID: test.id, + Event: test.event, + Retry: 1, + Data: []byte("ready"), + }) + require.Error(t, err) + require.Empty(t, recorder.Body.String()) + }) + } +} + +func TestEveryPhysicalDataLineHasPrefix(t *testing.T) { + recorder := httptest.NewRecorder() + stream := &FramingServerStream{w: recorder} + err := stream.Send(&service.FramingEvent{ + ID: "event-1", + Event: "update", + Retry: 1, + Data: []byte("one\r\ntwo\rthree\nfour"), + }) + require.NoError(t, err) + require.Equal(t, "id: event-1\nevent: update\nretry: 1\ndata: one\ndata: two\ndata: three\ndata: four\n\n", recorder.Body.String()) +} + +func TestMappedFieldPresenceIsPreserved(t *testing.T) { + tests := []struct { + name string + value *string + retry *int + want string + }{ + {name: "absent", want: "data: ready\n\n"}, + { + name: "selected zero values", + value: stringPointer(""), + retry: intPointer(0), + want: "id: \nevent: \nretry: 0\ndata: ready\n\n", + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + recorder := httptest.NewRecorder() + stream := &PresenceServerStream{w: recorder} + err := stream.Send(&service.PresenceEvent{ + ID: test.value, + Event: test.value, + Retry: test.retry, + Data: []byte("ready"), + }) + require.NoError(t, err) + require.Equal(t, test.want, recorder.Body.String()) + }) + } +} + +func TestNegativeRetryIsRejectedBeforeHeaders(t *testing.T) { + recorder := httptest.NewRecorder() + stream := &FramingServerStream{w: recorder} + err := stream.Send(&service.FramingEvent{ + ID: "event-1", + Event: "update", + Retry: -1, + Data: []byte("ready"), + }) + require.Error(t, err) + require.Empty(t, recorder.Body.String()) +} + +func stringPointer(value string) *string { + return &value +} + +func intPointer(value int) *int { + return &value +} +` + +const generatedSSEClientWireTest = `package client + +import ( + "context" + "io" + "net/http" + "strings" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestOptionalPrimitiveDataAllocatesOnlyWhenPresent(t *testing.T) { + stream := &OptionalStreamImpl{} + + value, hasData, err := stream.processEvent([]byte("data: event\n\n")) + require.NoError(t, err) + require.True(t, hasData) + require.NotNil(t, value.Value) + require.Equal(t, "event", *value.Value) + + empty, hasData, err := stream.processEvent([]byte("data: \n\n")) + require.NoError(t, err) + require.True(t, hasData) + require.NotNil(t, empty.Value) + require.Empty(t, *empty.Value) + + absent, hasData, err := stream.processEvent([]byte("\n\n")) + require.NoError(t, err) + require.False(t, hasData) + require.Nil(t, absent) +} + +func TestUnterminatedEventIsDiscarded(t *testing.T) { + for _, frame := range []string{"data: event\n", "data: event\r"} { + stream := &OptionalStreamImpl{ + resp: &http.Response{Body: io.NopCloser(strings.NewReader(frame))}, + } + event, err := stream.readEvent(context.Background()) + require.ErrorIs(t, err, io.EOF) + require.Nil(t, event) + } +} + +func TestEverySSELineEndingIsAccepted(t *testing.T) { + for _, ending := range []string{"\n\n", "\r\r", "\r\n\r\n", "\r\n\n"} { + stream := &OptionalStreamImpl{ + resp: &http.Response{Body: io.NopCloser(strings.NewReader("data: event" + ending))}, + } + frame, err := stream.readEvent(context.Background()) + require.NoError(t, err) + event, hasData, err := stream.processEvent(frame) + require.NoError(t, err) + require.True(t, hasData) + require.NotNil(t, event.Value) + require.Equal(t, "event", *event.Value) + } +} + +func TestCRLFMaySpanReads(t *testing.T) { + stream := &OptionalStreamImpl{ + resp: &http.Response{Body: io.NopCloser(&chunkReader{chunks: [][]byte{ + []byte("data: event\r"), + []byte("\n\r"), + []byte("\n"), + }})}, + } + frame, err := stream.readEvent(context.Background()) + require.NoError(t, err) + require.Equal(t, "data: event\r\n\r\n", string(frame)) + event, hasData, err := stream.processEvent(frame) + require.NoError(t, err) + require.True(t, hasData) + require.NotNil(t, event.Value) + require.Equal(t, "event", *event.Value) +} + +func TestDataWithoutColonSelectsAnEmptyValue(t *testing.T) { + stream := &OptionalStreamImpl{} + event, hasData, err := stream.processEvent([]byte("data\n\n")) + require.NoError(t, err) + require.True(t, hasData) + require.NotNil(t, event.Value) + require.Empty(t, *event.Value) +} + +func TestRecvSkipsEventsWithoutData(t *testing.T) { + stream := &OptionalStreamImpl{ + resp: &http.Response{Body: io.NopCloser(strings.NewReader("event: ignored\n\ndata: ready\n\n"))}, + } + event, err := stream.Recv() + require.NoError(t, err) + require.NotNil(t, event.Value) + require.Equal(t, "ready", *event.Value) +} + +func TestRetryUsesOnlyValidDigitFields(t *testing.T) { + valid := &FramingStreamImpl{} + event, hasData, err := valid.processEvent([]byte("id: event-1\nevent: update\nretry: 0\ndata: ready\n\n")) + require.NoError(t, err) + require.True(t, hasData) + require.Equal(t, 0, event.Retry) + + for _, retry := range []string{"", "+1", "-0", "١"} { + stream := &PresenceStreamImpl{} + event, hasData, err := stream.processEvent([]byte("retry: " + retry + "\ndata: ready\n\n")) + require.NoError(t, err) + require.True(t, hasData) + require.Nil(t, event.Retry) + require.Equal(t, []byte("ready"), event.Data) + } + + for _, fields := range []string{ + "retry: later\nretry: 7\n", + "retry: 7\nretry: later\n", + } { + stream := &FramingStreamImpl{} + event, hasData, err := stream.processEvent([]byte("id: event-1\nevent: update\n" + fields + "data: ready\n\n")) + require.NoError(t, err) + require.True(t, hasData) + require.Equal(t, 7, event.Retry) + } +} + +func TestOnlyTheFirstStreamLineMayStartWithBOM(t *testing.T) { + stream := &OptionalStreamImpl{} + event, hasData, err := stream.processEvent([]byte("\uFEFFdata: first\n\n")) + require.NoError(t, err) + require.True(t, hasData) + require.Equal(t, "first", *event.Value) + + _, hasData, err = stream.processEvent([]byte("\uFEFFdata: second\n\n")) + require.NoError(t, err) + require.False(t, hasData) +} + +func TestEventIDPersistsUntilAnEmptyValidIDResetsIt(t *testing.T) { + body := strings.Join([]string{ + "\uFEFFid: item-1\ndata: first\n\n", + "data: second\n\n", + "\uFEFFid: later\ndata: third\n\n", + "id: invalid\x00id\ndata: fourth\n\n", + "id:\ndata: fifth\n\n", + "data: sixth\n\n", + }, "") + stream := &PresenceStreamImpl{ + resp: &http.Response{Body: io.NopCloser(strings.NewReader(body))}, + } + + tests := []struct { + id string + data string + }{ + {id: "item-1", data: "first"}, + {id: "item-1", data: "second"}, + {id: "item-1", data: "third"}, + {id: "item-1", data: "fourth"}, + {id: "", data: "fifth"}, + {id: "", data: "sixth"}, + } + for _, test := range tests { + event, err := stream.Recv() + require.NoError(t, err) + require.NotNil(t, event.ID) + require.Equal(t, test.id, *event.ID) + require.Equal(t, []byte(test.data), event.Data) + } +} + +type chunkReader struct { + chunks [][]byte +} + +func (r *chunkReader) Read(buffer []byte) (int, error) { + if len(r.chunks) == 0 { + return 0, io.EOF + } + n := copy(buffer, r.chunks[0]) + r.chunks = r.chunks[1:] + return n, nil +} +` diff --git a/http/codegen/sse_server_test.go b/http/codegen/sse_server_test.go index 7a32e2a084..c31fae83d0 100644 --- a/http/codegen/sse_server_test.go +++ b/http/codegen/sse_server_test.go @@ -8,6 +8,7 @@ import ( "goa.design/goa/v3/codegen" "goa.design/goa/v3/codegen/testutil" + "goa.design/goa/v3/dsl" "goa.design/goa/v3/expr" "goa.design/goa/v3/http/codegen/testdata" ) @@ -30,8 +31,8 @@ func TestSSE(t *testing.T) { for _, c := range cases { t.Run(c.Name, func(t *testing.T) { root := expr.RunDSL(t, c.DSL) - services := CreateHTTPServices(root) - fs := ServerFiles("", services) + plan := linkedHTTPPlanForRoot(t, root) + fs := plan.ServerFiles() require.Len(t, fs, 3) sections := fs[1].SectionTemplates require.Greater(t, len(sections), 1) @@ -42,10 +43,50 @@ func TestSSE(t *testing.T) { } } +// TestSSEServerSpecializesDataEncoding checks that generated send methods use +// the designed data type directly, including named primitive types. +func TestSSEServerSpecializesDataEncoding(t *testing.T) { + tests := []struct { + name string + design func() + contains string + }{ + {name: "string", design: testdata.SSEStringDSL, contains: "data = string(body)"}, + {name: "string alias", design: ssePrimitiveAliasDSL, contains: "data = string(body)"}, + {name: "object", design: testdata.SSEObjectDSL, contains: "json.Marshal(body)"}, + {name: "optional data field", design: testdata.SSEDataFieldDSL, contains: "data = string(*body.Data)"}, + {name: "viewed data field", design: viewedSSEDataFieldDSL, contains: "data = string(body.Data)"}, + {name: "viewed alias data field", design: viewedSSEPrimitiveAliasDataFieldDSL, contains: "data = string(body.Data)"}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + root := expr.RunDSL(t, test.design) + code := renderedFile(t, linkedHTTPPlanForRoot(t, root).ServerFiles()) + + require.Contains(t, code, test.contains) + require.NotContains(t, code, "var payload any") + require.NotContains(t, code, "payload.(type)") + if test.name == "optional data field" { + require.Contains(t, code, "if body.Data != nil") + require.NotContains(t, code, "json.Marshal(body.Data)") + } + }) + } +} + +// TestSSEServerWritesOptionalRetryValue checks that a selected zero retry is +// written while an absent value is omitted. +func TestSSEServerWritesOptionalRetryValue(t *testing.T) { + root := expr.RunDSL(t, testdata.SSEAllFieldsDSL) + code := renderedFile(t, linkedHTTPPlanForRoot(t, root).ServerFiles()) + require.Contains(t, code, "if retry != nil {") + require.Contains(t, code, `fmt.Fprintf(s.w, "retry: %d\n", *retry)`) +} + func TestSSETransportDefaultsToStatusOK(t *testing.T) { root := expr.RunDSL(t, testdata.SSEStringDSL) - services := CreateHTTPServices(root) - fs := ServerFiles("", services) + plan := linkedHTTPPlanForRoot(t, root) + fs := plan.ServerFiles() require.Len(t, fs, 3) sections := fs[1].SectionTemplates @@ -54,3 +95,18 @@ func TestSSETransportDefaultsToStatusOK(t *testing.T) { require.Contains(t, code, "s.w.WriteHeader(http.StatusOK)") require.NotContains(t, code, "http.StatusSwitchingProtocols") } + +// ssePrimitiveAliasDSL streams a named string so generated SSE code must use +// its known underlying string representation. +func ssePrimitiveAliasDSL() { + text := dsl.Type("EventText", dsl.String) + dsl.Service("SSE Primitive Alias", func() { + dsl.Method("Watch", func() { + dsl.StreamingResult(text) + dsl.HTTP(func() { + dsl.GET("/watch") + dsl.ServerSentEvents() + }) + }) + }) +} diff --git a/http/codegen/streaming_test.go b/http/codegen/streaming_test.go index 996ef20587..11215c8ac3 100644 --- a/http/codegen/streaming_test.go +++ b/http/codegen/streaming_test.go @@ -1,6 +1,7 @@ package codegen import ( + "strings" "testing" "github.com/stretchr/testify/assert" @@ -104,22 +105,11 @@ func TestServerStreaming(t *testing.T) { {"server-websocket-close", &testdata.StreamingPayloadNoResultServerStreamCloseCode}, {"server-websocket-set-view", nil}, }}, - {"streaming-payload-result-with-views", testdata.StreamingPayloadResultWithViewsDSL, []*sectionExpectation{ - {"server-websocket-send", &testdata.StreamingPayloadResultWithViewsServerStreamSendCode}, - {"server-websocket-recv", &testdata.StreamingPayloadResultWithViewsServerStreamRecvCode}, - {"server-websocket-close", nil}, - {"server-websocket-set-view", &testdata.StreamingPayloadResultWithViewsServerStreamSetViewCode}, - }}, {"streaming-payload-result-with-explicit-view", testdata.StreamingPayloadResultWithExplicitViewDSL, []*sectionExpectation{ {"server-websocket-send", &testdata.StreamingPayloadResultWithExplicitViewServerStreamSendCode}, {"server-websocket-recv", &testdata.StreamingPayloadResultWithExplicitViewServerStreamRecvCode}, {"server-websocket-set-view", nil}, }}, - {"streaming-payload-result-collection-with-views", testdata.StreamingPayloadResultCollectionWithViewsDSL, []*sectionExpectation{ - {"server-websocket-send", &testdata.StreamingPayloadResultCollectionWithViewsServerStreamSendCode}, - {"server-websocket-recv", &testdata.StreamingPayloadResultCollectionWithViewsServerStreamRecvCode}, - {"server-websocket-set-view", &testdata.StreamingPayloadResultCollectionWithViewsServerStreamSetViewCode}, - }}, {"streaming-payload-result-collection-with-explicit-view", testdata.StreamingPayloadResultCollectionWithExplicitViewDSL, []*sectionExpectation{ {"server-websocket-send", &testdata.StreamingPayloadResultCollectionWithExplicitViewServerStreamSendCode}, {"server-websocket-recv", &testdata.StreamingPayloadResultCollectionWithExplicitViewServerStreamRecvCode}, @@ -160,22 +150,11 @@ func TestServerStreaming(t *testing.T) { {"server-handler-init", &testdata.BidirectionalStreamingNoPayloadServerHandlerInitCode}, {"server-websocket-close", &testdata.BidirectionalStreamingNoPayloadServerStreamCloseCode}, }}, - {"bidirectional-streaming-result-with-views", testdata.BidirectionalStreamingResultWithViewsDSL, []*sectionExpectation{ - {"server-websocket-send", &testdata.BidirectionalStreamingResultWithViewsServerStreamSendCode}, - {"server-websocket-recv", &testdata.BidirectionalStreamingResultWithViewsServerStreamRecvCode}, - {"server-websocket-close", &testdata.BidirectionalStreamingResultWithViewsServerStreamCloseCode}, - {"server-websocket-set-view", &testdata.BidirectionalStreamingResultWithViewsServerStreamSetViewCode}, - }}, {"bidirectional-streaming-result-with-explicit-view", testdata.BidirectionalStreamingResultWithExplicitViewDSL, []*sectionExpectation{ {"server-websocket-send", &testdata.BidirectionalStreamingResultWithExplicitViewServerStreamSendCode}, {"server-websocket-recv", &testdata.BidirectionalStreamingResultWithExplicitViewServerStreamRecvCode}, {"server-websocket-set-view", nil}, }}, - {"bidirectional-streaming-result-collection-with-views", testdata.BidirectionalStreamingResultCollectionWithViewsDSL, []*sectionExpectation{ - {"server-websocket-send", &testdata.BidirectionalStreamingResultCollectionWithViewsServerStreamSendCode}, - {"server-websocket-recv", &testdata.BidirectionalStreamingResultCollectionWithViewsServerStreamRecvCode}, - {"server-websocket-set-view", &testdata.BidirectionalStreamingResultCollectionWithViewsServerStreamSetViewCode}, - }}, {"bidirectional-streaming-result-collection-with-explicit-view", testdata.BidirectionalStreamingResultCollectionWithExplicitViewDSL, []*sectionExpectation{ {"server-websocket-send", &testdata.BidirectionalStreamingResultCollectionWithExplicitViewServerStreamSendCode}, {"server-websocket-recv", &testdata.BidirectionalStreamingResultCollectionWithExplicitViewServerStreamRecvCode}, @@ -205,12 +184,41 @@ func TestServerStreaming(t *testing.T) { } filesFn := func(root *expr.RootExpr) []*codegen.File { - services := CreateHTTPServices(root) - return ServerFiles("", services) + return linkedHTTPPlanForRoot(t, root).ServerFiles() } runTests(t, cases, filesFn) } +// TestVariableViewWebSocketSendWritesTypedBranches checks that the selected +// view is the only runtime choice and that an unknown view writes nothing. +func TestVariableViewWebSocketSendWritesTypedBranches(t *testing.T) { + root := expr.RunDSL(t, testdata.StreamingResultWithViewsDSL) + files := linkedHTTPPlanForRoot(t, root).ServerFiles() + var code string + for _, file := range files { + for _, section := range file.SectionTemplates { + if section.Name == "server-websocket-send" { + code = codegen.SectionCode(t, section) + } + } + } + require.NotEmpty(t, code) + require.NotContains(t, code, "var body any") + require.Contains(t, code, `if view == "" {`) + require.Contains(t, code, `view = "default"`) + require.Contains(t, code, `if s.sentView != "" && view != s.sentView`) + require.Contains(t, code, `respHdr.Add("goa-view", view)`) + require.Contains(t, code, `case "tiny":`) + require.Contains(t, code, `res := streamingresultwithviewsservice.NewViewedUsertype(v, "tiny")`) + require.Contains(t, code, "return s.conn.WriteJSON(NewStreamingResultWithViewsMethodResponseBodyTiny(res.Projected))") + require.Contains(t, code, `default:`) + require.Contains(t, code, `return goa.InvalidEnumValueError("view", view`) + require.Less(t, + strings.Index(code, `InvalidEnumValueError("view", view`), + strings.Index(code, "s.once.Do"), + ) +} + func TestClientStreaming(t *testing.T) { cases := []*testCase{ {"client-mixed-endpoints", testdata.StreamingResultDSL, []*sectionExpectation{ @@ -227,19 +235,13 @@ func TestClientStreaming(t *testing.T) { }}, {"client-streaming-result-with-views", testdata.StreamingResultWithViewsDSL, []*sectionExpectation{ {"client-endpoint-init", &testdata.StreamingResultWithViewsClientEndpointCode}, - {"client-websocket-recv", &testdata.StreamingResultWithViewsClientStreamRecvCode}, {"client-websocket-close", nil}, - {"client-websocket-set-view", &testdata.StreamingResultWithViewsClientStreamSetViewCode}, }}, {"client-streaming-result-with-explicit-view", testdata.StreamingResultWithExplicitViewDSL, []*sectionExpectation{ {"client-endpoint-init", &testdata.StreamingResultWithExplicitViewClientEndpointCode}, {"client-websocket-recv", &testdata.StreamingResultWithExplicitViewClientStreamRecvCode}, {"client-websocket-set-view", nil}, }}, - {"client-streaming-result-collection-with-views", testdata.StreamingResultCollectionWithViewsDSL, []*sectionExpectation{ - {"client-websocket-recv", &testdata.StreamingResultCollectionWithViewsClientStreamRecvCode}, - {"client-websocket-set-view", &testdata.StreamingResultCollectionWithViewsClientStreamSetViewCode}, - }}, {"client-streaming-result-collection-with-explicit-view", testdata.StreamingResultCollectionWithExplicitViewDSL, []*sectionExpectation{ {"client-endpoint-init", &testdata.StreamingResultCollectionWithExplicitViewClientEndpointCode}, {"client-websocket-recv", &testdata.StreamingResultCollectionWithExplicitViewClientStreamRecvCode}, @@ -286,22 +288,11 @@ func TestClientStreaming(t *testing.T) { {"client-websocket-close", &testdata.StreamingPayloadNoResultClientStreamCloseCode}, {"client-websocket-set-view", nil}, }}, - {"client-streaming-payload-result-with-views", testdata.StreamingPayloadResultWithViewsDSL, []*sectionExpectation{ - {"client-websocket-send", &testdata.StreamingPayloadResultWithViewsClientStreamSendCode}, - {"client-websocket-recv", &testdata.StreamingPayloadResultWithViewsClientStreamRecvCode}, - {"client-websocket-close", nil}, - {"client-websocket-set-view", &testdata.StreamingPayloadResultWithViewsClientStreamSetViewCode}, - }}, {"client-streaming-payload-result-with-explicit-view", testdata.StreamingPayloadResultWithExplicitViewDSL, []*sectionExpectation{ {"client-websocket-send", &testdata.StreamingPayloadResultWithExplicitViewClientStreamSendCode}, {"client-websocket-recv", &testdata.StreamingPayloadResultWithExplicitViewClientStreamRecvCode}, {"client-websocket-set-view", nil}, }}, - {"client-streaming-payload-result-collection-with-views", testdata.StreamingPayloadResultCollectionWithViewsDSL, []*sectionExpectation{ - {"client-websocket-send", &testdata.StreamingPayloadResultCollectionWithViewsClientStreamSendCode}, - {"client-websocket-recv", &testdata.StreamingPayloadResultCollectionWithViewsClientStreamRecvCode}, - {"client-websocket-set-view", &testdata.StreamingPayloadResultCollectionWithViewsClientStreamSetViewCode}, - }}, {"client-streaming-payload-result-collection-with-explicit-view", testdata.StreamingPayloadResultCollectionWithExplicitViewDSL, []*sectionExpectation{ {"client-websocket-send", &testdata.StreamingPayloadResultCollectionWithExplicitViewClientStreamSendCode}, {"client-websocket-recv", &testdata.StreamingPayloadResultCollectionWithExplicitViewClientStreamRecvCode}, @@ -344,22 +335,11 @@ func TestClientStreaming(t *testing.T) { {"client-websocket-recv", &testdata.BidirectionalStreamingNoPayloadClientStreamRecvCode}, {"client-websocket-close", &testdata.BidirectionalStreamingNoPayloadClientStreamCloseCode}, }}, - {"client-bidirectional-streaming-result-with-views", testdata.BidirectionalStreamingResultWithViewsDSL, []*sectionExpectation{ - {"client-websocket-send", &testdata.BidirectionalStreamingResultWithViewsClientStreamSendCode}, - {"client-websocket-recv", &testdata.BidirectionalStreamingResultWithViewsClientStreamRecvCode}, - {"client-websocket-close", &testdata.BidirectionalStreamingResultWithViewsClientStreamCloseCode}, - {"client-websocket-set-view", &testdata.BidirectionalStreamingResultWithViewsClientStreamSetViewCode}, - }}, {"client-bidirectional-streaming-result-with-explicit-view", testdata.BidirectionalStreamingResultWithExplicitViewDSL, []*sectionExpectation{ {"client-websocket-send", &testdata.BidirectionalStreamingResultWithExplicitViewClientStreamSendCode}, {"client-websocket-recv", &testdata.BidirectionalStreamingResultWithExplicitViewClientStreamRecvCode}, {"client-websocket-set-view", nil}, }}, - {"client-bidirectional-streaming-result-collection-with-views", testdata.BidirectionalStreamingResultCollectionWithViewsDSL, []*sectionExpectation{ - {"client-websocket-send", &testdata.BidirectionalStreamingResultCollectionWithViewsClientStreamSendCode}, - {"client-websocket-recv", &testdata.BidirectionalStreamingResultCollectionWithViewsClientStreamRecvCode}, - {"client-websocket-set-view", &testdata.BidirectionalStreamingResultCollectionWithViewsClientStreamSetViewCode}, - }}, {"client-bidirectional-streaming-result-collection-with-explicit-view", testdata.BidirectionalStreamingResultCollectionWithExplicitViewDSL, []*sectionExpectation{ {"client-websocket-send", &testdata.BidirectionalStreamingResultCollectionWithExplicitViewClientStreamSendCode}, {"client-websocket-recv", &testdata.BidirectionalStreamingResultCollectionWithExplicitViewClientStreamRecvCode}, @@ -388,8 +368,7 @@ func TestClientStreaming(t *testing.T) { }}, } filesFn := func(root *expr.RootExpr) []*codegen.File { - services := CreateHTTPServices(root) - return ClientFiles("", services) + return linkedHTTPPlanForRoot(t, root).ClientFiles() } runTests(t, cases, filesFn) } diff --git a/http/codegen/symbols.go b/http/codegen/symbols.go new file mode 100644 index 0000000000..19a5be127e --- /dev/null +++ b/http/codegen/symbols.go @@ -0,0 +1,402 @@ +// This file requests the Go names written by generated HTTP client and server +// files. NewPlans calls it before names are assigned, and Link later gives the +// same records to every definition and use. +package codegen + +import ( + "cmp" + "strconv" + + "goa.design/goa/v3/codegen" + "goa.design/goa/v3/expr" +) + +type ( + // httpSymbols contains every package-level name emitted for one HTTP service. + httpSymbols struct { + serverStruct *codegen.NameDeclaration + mountPoint *codegen.NameDeclaration + serverInit *codegen.NameDeclaration + mountServer *codegen.NameDeclaration + clientStruct *codegen.NameDeclaration + clientInit *codegen.NameDeclaration + serverConfigurer *codegen.NameDeclaration + serverConfigurerInit *codegen.NameDeclaration + clientConfigurer *codegen.NameDeclaration + clientConfigurerInit *codegen.NameDeclaration + appendFS *codegen.NameDeclaration + appendPrefix *codegen.NameDeclaration + endpoints map[*expr.HTTPEndpointExpr]*httpEndpointSymbols + fileServers map[*expr.HTTPFileServerExpr]*codegen.NameDeclaration + } + + // httpEndpointSymbols contains the package-level names emitted for one endpoint. + httpEndpointSymbols struct { + mountHandler *codegen.NameDeclaration + handlerInit *codegen.NameDeclaration + requestDecoder *codegen.NameDeclaration + responseEncoder *codegen.NameDeclaration + errorEncoder *codegen.NameDeclaration + discardStream *codegen.NameDeclaration + requestEncoder *codegen.NameDeclaration + responseDecoder *codegen.NameDeclaration + requestBuilder *codegen.NameDeclaration + buildStreamPayload *codegen.NameDeclaration + cliPayload *codegen.NameDeclaration + serverMultipart *httpMultipartSymbols + clientMultipart *httpMultipartSymbols + serverStream *codegen.NameDeclaration + clientStream *codegen.NameDeclaration + sseClientInterface *codegen.NameDeclaration + sseClientStruct *codegen.NameDeclaration + sseClientInit *codegen.NameDeclaration + serverPaths []*codegen.NameDeclaration + clientPaths []*codegen.NameDeclaration + } + + // httpMultipartSymbols contains the type and constructor names for one side + // of a multipart endpoint. + httpMultipartSymbols struct { + functionType *codegen.NameDeclaration + constructor *codegen.NameDeclaration + } + + // httpSymbolID identifies one emitted declaration without encoding fields in + // a string. The output package is supplied separately by the caller. + httpSymbolID struct { + transport transportKind + role httpSymbolRole + api string + service string + method string + subject string + index int + } + + // httpSymbolOrder gives colliding declarations the same result regardless of + // the order in which design roots are passed to NewPlans. + httpSymbolOrder httpSymbolID + + // httpSymbolRole lists each package declaration emitted outside HTTP body + // type and constructor files. + httpSymbolRole uint8 +) + +const ( + httpServerStructRole httpSymbolRole = iota + 1 + httpMountPointRole + httpServerInitRole + httpMountServerRole + httpClientStructRole + httpClientInitRole + httpConnConfigurerRole + httpConnConfigurerInitRole + httpAppendFSRole + httpAppendPrefixRole + httpMountHandlerRole + httpHandlerInitRole + httpRequestDecoderRole + httpResponseEncoderRole + httpErrorEncoderRole + httpDiscardStreamRole + httpRequestEncoderRole + httpResponseDecoderRole + httpRequestBuilderRole + httpBuildStreamPayloadRole + httpCLIPayloadRole + httpMultipartTypeRole + httpMultipartInitRole + httpServerStreamRole + httpClientStreamRole + httpSSEClientInterfaceRole + httpSSEClientStructRole + httpSSEClientInitRole + httpPathRole + httpFileMountRole +) + +// collectHTTPSymbols requests each client and server name needed by service. It +// returns records that Link gives to definitions and calls after Goa assigns +// the names. +func collectHTTPSymbols(plan *Plan, service *expr.HTTPServiceExpr, clientPackage, serverPackage *codegen.GeneratedPackage) (*httpSymbols, error) { + symbols := &httpSymbols{ + endpoints: make(map[*expr.HTTPEndpointExpr]*httpEndpointSymbols), + fileServers: make(map[*expr.HTTPFileServerExpr]*codegen.NameDeclaration), + } + declare := func(pkg *codegen.GeneratedPackage, kind codegen.PackageNameKind, preferred string, exported codegen.PackageNameVisibility, id httpSymbolID) (*codegen.NameDeclaration, error) { + declaration := codegen.NewPreferredName(kind, preferred, exported, httpSymbolOrder(id)) + if err := pkg.DeclareName(declaration); err != nil { + return nil, err + } + return declaration, nil + } + serviceID := httpSymbolID{transport: plan.transport, api: plan.root.API.Name, service: service.Name()} + var err error + if symbols.serverStruct, err = declare(serverPackage, codegen.NameType, "Server", codegen.ExportedName, serviceID.withRole(httpServerStructRole)); err != nil { + return nil, err + } + if symbols.mountPoint, err = declare(serverPackage, codegen.NameType, "MountPoint", codegen.ExportedName, serviceID.withRole(httpMountPointRole)); err != nil { + return nil, err + } + if symbols.serverInit, err = declare(serverPackage, codegen.NameFunction, "New", codegen.ExportedName, serviceID.withRole(httpServerInitRole)); err != nil { + return nil, err + } + if symbols.mountServer, err = declare(serverPackage, codegen.NameFunction, "Mount", codegen.ExportedName, serviceID.withRole(httpMountServerRole)); err != nil { + return nil, err + } + if symbols.clientStruct, err = declare(clientPackage, codegen.NameType, "Client", codegen.ExportedName, serviceID.withRole(httpClientStructRole)); err != nil { + return nil, err + } + if symbols.clientInit, err = declare(clientPackage, codegen.NameFunction, "NewClient", codegen.ExportedName, serviceID.withRole(httpClientInitRole)); err != nil { + return nil, err + } + + hasWebSocket := false + for _, endpoint := range service.HTTPEndpoints { + if endpoint.UsesWebSocket() { + hasWebSocket = true + break + } + } + if hasWebSocket { + if symbols.serverConfigurer, err = declare(serverPackage, codegen.NameType, "ConnConfigurer", codegen.ExportedName, serviceID.withRole(httpConnConfigurerRole).withSubject("server")); err != nil { + return nil, err + } + if symbols.serverConfigurerInit, err = declare(serverPackage, codegen.NameFunction, "NewConnConfigurer", codegen.ExportedName, serviceID.withRole(httpConnConfigurerInitRole).withSubject("server")); err != nil { + return nil, err + } + if symbols.clientConfigurer, err = declare(clientPackage, codegen.NameType, "ConnConfigurer", codegen.ExportedName, serviceID.withRole(httpConnConfigurerRole).withSubject("client")); err != nil { + return nil, err + } + if symbols.clientConfigurerInit, err = declare(clientPackage, codegen.NameFunction, "NewConnConfigurer", codegen.ExportedName, serviceID.withRole(httpConnConfigurerInitRole).withSubject("client")); err != nil { + return nil, err + } + } + if len(service.FileServers) > 0 { + if symbols.appendFS, err = declare(serverPackage, codegen.NameType, "appendFS", codegen.UnexportedName, serviceID.withRole(httpAppendFSRole)); err != nil { + return nil, err + } + if symbols.appendPrefix, err = declare(serverPackage, codegen.NameFunction, "appendPrefix", codegen.UnexportedName, serviceID.withRole(httpAppendPrefixRole)); err != nil { + return nil, err + } + } + for index, fileServer := range service.FileServers { + id := serviceID.withRole(httpFileMountRole).withSubject(fileServer.FilePath).withIndex(index) + declaration, err := declare(serverPackage, codegen.NameFunction, "Mount"+codegen.Goify(fileServer.FilePath, true), codegen.ExportedName, id) + if err != nil { + return nil, err + } + symbols.fileServers[fileServer] = declaration + } + for _, endpoint := range service.HTTPEndpoints { + names, err := plan.servicePlan.HTTPMethodNames(endpoint.MethodExpr) + if err != nil { + return nil, err + } + id := serviceID.withMethod(endpoint.MethodExpr.Name) + endpointSymbols := &httpEndpointSymbols{} + endpointSymbols.mountHandler, err = declare(serverPackage, codegen.NameFunction, "Mount"+names.Method+"Handler", codegen.ExportedName, id.withRole(httpMountHandlerRole)) + if err != nil { + return nil, err + } + endpointSymbols.handlerInit, err = declare(serverPackage, codegen.NameFunction, "New"+names.Method+"Handler", codegen.ExportedName, id.withRole(httpHandlerInitRole)) + if err != nil { + return nil, err + } + if endpoint.MethodExpr.Payload.Type != expr.Empty { + endpointSymbols.requestDecoder, err = declare(serverPackage, codegen.NameFunction, "Decode"+names.Method+"Request", codegen.ExportedName, id.withRole(httpRequestDecoderRole)) + if err != nil { + return nil, err + } + } + if endpoint.Redirect == nil && !endpoint.UsesWebSocket() && !endpoint.IsJSONRPC() { + endpointSymbols.responseEncoder, err = declare(serverPackage, codegen.NameFunction, "Encode"+names.Method+"Response", codegen.ExportedName, id.withRole(httpResponseEncoderRole)) + if err != nil { + return nil, err + } + } + if len(endpoint.HTTPErrors) > 0 && !endpoint.IsJSONRPC() { + endpointSymbols.errorEncoder, err = declare(serverPackage, codegen.NameFunction, "Encode"+names.Method+"Error", codegen.ExportedName, id.withRole(httpErrorEncoderRole)) + if err != nil { + return nil, err + } + } + if endpoint.MethodExpr.HasMixedResults() { + endpointSymbols.discardStream, err = declare(serverPackage, codegen.NameType, "discard"+names.Method+"ServerStream", codegen.UnexportedName, id.withRole(httpDiscardStreamRole)) + if err != nil { + return nil, err + } + } + if clientRequestEncoderSelected(endpoint) { + endpointSymbols.requestEncoder, err = declare(clientPackage, codegen.NameFunction, "Encode"+names.Method+"Request", codegen.ExportedName, id.withRole(httpRequestEncoderRole)) + if err != nil { + return nil, err + } + } + endpointSymbols.responseDecoder, err = declare(clientPackage, codegen.NameFunction, "Decode"+names.Method+"Response", codegen.ExportedName, id.withRole(httpResponseDecoderRole)) + if err != nil { + return nil, err + } + endpointSymbols.requestBuilder, err = declare(clientPackage, codegen.NameFunction, "Build"+names.Method+"Request", codegen.ExportedName, id.withRole(httpRequestBuilderRole)) + if err != nil { + return nil, err + } + if endpoint.SkipRequestBodyEncodeDecode { + endpointSymbols.buildStreamPayload, err = declare(clientPackage, codegen.NameFunction, "Build"+names.Method+"StreamPayload", codegen.ExportedName, id.withRole(httpBuildStreamPayloadRole)) + if err != nil { + return nil, err + } + } + if needInit(endpoint.MethodExpr.Payload.Type) { + endpointSymbols.cliPayload, err = declare(clientPackage, codegen.NameFunction, "Build"+names.Method+"Payload", codegen.ExportedName, id.withRole(httpCLIPayloadRole)) + if err != nil { + return nil, err + } + } + if endpoint.MultipartRequest { + serviceName := codegen.Goify(service.Name(), true) + endpointSymbols.serverMultipart, err = declareHTTPMultipart(declare, serverPackage, serviceName+names.Method+"DecoderFunc", "New"+serviceName+names.Method+"Decoder", id.withSubject("server")) + if err != nil { + return nil, err + } + endpointSymbols.clientMultipart, err = declareHTTPMultipart(declare, clientPackage, serviceName+names.Method+"EncoderFunc", "New"+serviceName+names.Method+"Encoder", id.withSubject("client")) + if err != nil { + return nil, err + } + } + if endpoint.UsesWebSocket() { + endpointSymbols.serverStream, err = declare(serverPackage, codegen.NameType, names.ServerStream, codegen.ExportedName, id.withRole(httpServerStreamRole)) + if err != nil { + return nil, err + } + endpointSymbols.clientStream, err = declare(clientPackage, codegen.NameType, names.ClientStream, codegen.ExportedName, id.withRole(httpClientStreamRole)) + if err != nil { + return nil, err + } + } + if endpoint.UsesSSE() { + endpointSymbols.serverStream, err = declare(serverPackage, codegen.NameType, names.ServerStream, codegen.ExportedName, id.withRole(httpServerStreamRole)) + if err != nil { + return nil, err + } + endpointSymbols.sseClientInterface, err = declare(clientPackage, codegen.NameType, names.Method+"ClientStream", codegen.ExportedName, id.withRole(httpSSEClientInterfaceRole)) + if err != nil { + return nil, err + } + endpointSymbols.sseClientStruct, err = declare(clientPackage, codegen.NameType, names.Method+"StreamImpl", codegen.ExportedName, id.withRole(httpSSEClientStructRole)) + if err != nil { + return nil, err + } + endpointSymbols.sseClientInit, err = declare(clientPackage, codegen.NameFunction, "New"+names.Method+"Stream", codegen.ExportedName, id.withRole(httpSSEClientInitRole)) + if err != nil { + return nil, err + } + } + pathCount := 0 + for _, route := range endpoint.Routes { + for range route.FullPaths() { + suffix := "" + if pathCount > 0 { + suffix = strconv.Itoa(pathCount + 1) + } + preferred := names.Method + codegen.Goify(service.Name(), true) + "Path" + suffix + pathID := id.withRole(httpPathRole).withIndex(pathCount) + serverPath, err := declare(serverPackage, codegen.NameFunction, preferred, codegen.ExportedName, pathID.withSubject("server")) + if err != nil { + return nil, err + } + clientPath, err := declare(clientPackage, codegen.NameFunction, preferred, codegen.ExportedName, pathID.withSubject("client")) + if err != nil { + return nil, err + } + endpointSymbols.serverPaths = append(endpointSymbols.serverPaths, serverPath) + endpointSymbols.clientPaths = append(endpointSymbols.clientPaths, clientPath) + pathCount++ + } + } + symbols.endpoints[endpoint] = endpointSymbols + } + return symbols, nil +} + +// declareHTTPMultipart requests the type and constructor emitted for one +// multipart endpoint side. +func declareHTTPMultipart(declare func(*codegen.GeneratedPackage, codegen.PackageNameKind, string, codegen.PackageNameVisibility, httpSymbolID) (*codegen.NameDeclaration, error), pkg *codegen.GeneratedPackage, typeName, initName string, id httpSymbolID) (*httpMultipartSymbols, error) { + functionType, err := declare(pkg, codegen.NameType, typeName, codegen.ExportedName, id.withRole(httpMultipartTypeRole)) + if err != nil { + return nil, err + } + constructor, err := declare(pkg, codegen.NameFunction, initName, codegen.ExportedName, id.withRole(httpMultipartInitRole)) + if err != nil { + return nil, err + } + return &httpMultipartSymbols{functionType: functionType, constructor: constructor}, nil +} + +// clientRequestEncoderSelected reports whether the client codec file writes a +// request encoder for endpoint. +func clientRequestEncoderSelected(endpoint *expr.HTTPEndpointExpr) bool { + if endpoint.IsJSONRPC() { + return true + } + if (!endpoint.SkipRequestBodyEncodeDecode && endpoint.Body.Type != expr.Empty) || + endpoint.MapQueryParams != nil || + len(*expr.AsObject(endpoint.QueryParams().Type)) > 0 || + len(*expr.AsObject(endpoint.Headers.Type)) > 0 || + len(*expr.AsObject(endpoint.Cookies.Type)) > 0 { + return true + } + for _, requirement := range endpoint.Requirements { + for _, scheme := range requirement.Schemes { + if scheme.Kind == expr.BasicAuthKind { + return true + } + } + } + return false +} + +// withRole returns id with the declaration role used by one template. +func (id httpSymbolID) withRole(role httpSymbolRole) httpSymbolID { + id.role = role + return id +} + +// withMethod returns id with the design method that emits the declaration. +func (id httpSymbolID) withMethod(method string) httpSymbolID { + id.method = method + return id +} + +// withSubject returns id with the route side or file path that distinguishes +// otherwise identical declarations. +func (id httpSymbolID) withSubject(subject string) httpSymbolID { + id.subject = subject + return id +} + +// withIndex returns id with the route or file position in its design list. +func (id httpSymbolID) withIndex(index int) httpSymbolID { + id.index = index + return id +} + +// ComparePackageName orders HTTP declarations by stable design values. +func (order httpSymbolOrder) ComparePackageName(other codegen.PackageNameOrder) int { + left := httpSymbolID(order) + right := httpSymbolID(other.(httpSymbolOrder)) + for _, compared := range []int{ + cmp.Compare(left.transport, right.transport), + cmp.Compare(left.api, right.api), + cmp.Compare(left.service, right.service), + cmp.Compare(left.method, right.method), + cmp.Compare(left.role, right.role), + cmp.Compare(left.subject, right.subject), + cmp.Compare(left.index, right.index), + } { + if compared != 0 { + return compared + } + } + return 0 +} diff --git a/http/codegen/templates.go b/http/codegen/templates.go index f625416473..60abee76e5 100644 --- a/http/codegen/templates.go +++ b/http/codegen/templates.go @@ -96,6 +96,7 @@ const ( sseParseP = "sse_parse" websocketUpgradeP = "websocket_upgrade" clientTypeConversionP = "client_type_conversion" + clientTypeExpressionP = "client_type_expression" clientMapConversionP = "client_map_conversion" singleResponseP = "single_response" queryTypeConversionP = "query_type_conversion" diff --git a/http/codegen/templates/append_fs.go.tpl b/http/codegen/templates/append_fs.go.tpl index 0ae3643c9a..ce1b0caf7d 100644 --- a/http/codegen/templates/append_fs.go.tpl +++ b/http/codegen/templates/append_fs.go.tpl @@ -1,15 +1,14 @@ -// appendFS is a custom implementation of fs.FS that appends a specified prefix -// to the file paths before delegating the Open call to the underlying fs.FS. -type appendFS struct { +{{ printf "%s adds a fixed directory to file paths before opening them." .AppendFSDeclaration.Name | comment }} +type {{ .AppendFSDeclaration.Name }} struct { prefix string fs http.FileSystem } // Open opens the named file, appending the prefix to the file path before -// passing it to the underlying fs.FS. -func (s appendFS) Open(name string) (http.File, error) { +// passing it to the underlying file system. +func (s {{ .AppendFSDeclaration.Name }}) Open(name string) (http.File, error) { switch name { - {{- range $requested, $embedded := . }} + {{- range $requested, $embedded := .Mappings }} case {{ printf "%q" $requested }}: name = {{ printf "%q" $embedded }} {{- end }} @@ -17,8 +16,7 @@ func (s appendFS) Open(name string) (http.File, error) { return s.fs.Open(path.Join(s.prefix, name)) } -// appendPrefix returns a new fs.FS that appends the specified prefix to file paths -// before delegating to the provided embed.FS. -func appendPrefix(fsys http.FileSystem, prefix string) http.FileSystem { - return appendFS{prefix: prefix, fs: fsys} +{{ printf "%s returns a file system that adds prefix before opening each path." .AppendPrefixDeclaration.Name | comment }} +func {{ .AppendPrefixDeclaration.Name }}(fsys http.FileSystem, prefix string) http.FileSystem { + return {{ .AppendFSDeclaration.Name }}{prefix: prefix, fs: fsys} } diff --git a/http/codegen/templates/build_stream_request.go.tpl b/http/codegen/templates/build_stream_request.go.tpl index 2cab16afc5..2101f434c3 100644 --- a/http/codegen/templates/build_stream_request.go.tpl +++ b/http/codegen/templates/build_stream_request.go.tpl @@ -1,10 +1,10 @@ -// {{ printf "%s creates a streaming endpoint request payload from the method payload and the path to the file to be streamed" .BuildStreamPayload | comment }} -func {{ .BuildStreamPayload }}({{ if .Payload.Ref }}payload any, {{ end }}fpath string) (*{{ requestStructPkg .Method .ServicePkgName }}.{{ .Method.RequestStruct }}, error) { +// {{ printf "%s creates a streaming endpoint request payload from the method payload and the path to the file to be streamed" .BuildStreamPayloadDeclaration.Name | comment }} +func {{ .BuildStreamPayloadDeclaration.Name }}({{ if .Payload.Ref }}payload any, {{ end }}fpath string) (*{{ .ServicePkgName }}.{{ .Method.RequestStruct }}, error) { f, err := os.Open(fpath) if err != nil { return nil, err } - return &{{ requestStructPkg .Method .ServicePkgName }}.{{ .Method.RequestStruct }}{ + return &{{ .ServicePkgName }}.{{ .Method.RequestStruct }}{ {{- if .Payload.Ref }} Payload: payload.({{ .Payload.Ref }}), {{- end }} diff --git a/http/codegen/templates/cli_end.go.tpl b/http/codegen/templates/cli_end.go.tpl index 808f04b9fc..f2abdcf65f 100644 --- a/http/codegen/templates/cli_end.go.tpl +++ b/http/codegen/templates/cli_end.go.tpl @@ -1,4 +1,25 @@ -endpoint, payload, err := cli.ParseEndpoint( +{{- if hasAnyInputStreams .Services }} + switch flag.Arg(0) { + {{- range .Services }} + {{- if hasInputStreams . }} + case {{ printf "%q" (kebab .Service.PathName) }}: + switch flag.Arg(1) { + {{- range .Endpoints }} + {{- if streamsInput .Method }} + case {{ printf "%q" (kebab .Method.Name) }}: + return errors.New({{ printf "%q" (printf "example client does not support streamed input for service %q method %q" .ServiceName .Method.Name) }}) + {{- end }} + {{- end }} + } + {{- end }} + {{- end }} + } +{{- end }} +{{- if hasRunnable .Services }} + endpoint, payload, err := {{ .CLIPkg }}.{{ .Parser.ParseEndpoint.Name }}( +{{- else }} + _, _, err := {{ .CLIPkg }}.{{ .Parser.ParseEndpoint.Name }}( +{{- end }} scheme, host, doer, @@ -16,7 +37,7 @@ endpoint, payload, err := cli.ParseEndpoint( {{- range .Services }} {{- range .Endpoints }} {{- if .MultipartRequestDecoder }} - {{ $.APIPkg }}.{{ .MultipartRequestEncoder.FuncName }}, + {{ $.APIPkg }}.{{ .MultipartRequestEncoder.FuncDeclaration.Name }}, {{- end }} {{- end }} {{- end }} @@ -27,7 +48,34 @@ endpoint, payload, err := cli.ParseEndpoint( {{- end }} ) if err != nil { - return nil, nil, fmt.Errorf("parse endpoint: %w", err) + return fmt.Errorf("parse endpoint: %w", err) } - return endpoint, payload, nil + +{{ if hasRunnable .Services }} + switch flag.Arg(0) { + {{- range .Services }} + {{- if hasRunnableService . }} + case {{ printf "%q" (kebab .Service.PathName) }}: + switch flag.Arg(1) { + {{- range .Endpoints }} + {{- if not (streamsInput .Method) }} + case {{ printf "%q" (kebab .Method.Name) }}: + {{- if and (streamsOutput .Method) (not .HasMixedResults) }} + data, err := endpoint(ctx, payload) + if err != nil { + return err + } + stream := data.({{ .ServicePkgName }}.{{ .Method.ClientStream.Interface }}) + return writeStreamResults(ctx, stdout, stream.{{ .Method.ClientStream.RecvWithContextName }}) + {{- else }} + return writeEndpointResult(ctx, stdout, endpoint, payload) + {{- end }} + {{- end }} + {{- end }} + } + {{- end }} + {{- end }} + } + {{- end }} + panic({{ printf "%q" (printf "parsed %s command has no generated result writer" .Transport) }}) } diff --git a/http/codegen/templates/cli_start.go.tpl b/http/codegen/templates/cli_start.go.tpl index 6054afbdf6..8a3e7640fa 100644 --- a/http/codegen/templates/cli_start.go.tpl +++ b/http/codegen/templates/cli_start.go.tpl @@ -1,9 +1,9 @@ -func do{{ .FuncSuffix }}(scheme, host string, timeout int, debug bool) (goa.Endpoint, any, error) { +func do{{ .FuncSuffix }}(ctx context.Context, scheme, host string, timeout int, debug bool, stdout io.Writer) error { var ( doer goahttp.Doer {{- range .Services }} {{- if .Service.ClientInterceptors }} - {{ .Service.VarName }}Interceptors {{ .Service.PkgName }}.ClientInterceptors + {{ .Service.VarName }}Interceptors {{ .Service.PkgName }}.{{ .Service.ClientInterceptorsDeclaration.Name }} {{- end }} {{- end }} ) diff --git a/http/codegen/templates/cli_usage.go.tpl b/http/codegen/templates/cli_usage.go.tpl index 49b881eb34..edc67cd66b 100644 --- a/http/codegen/templates/cli_usage.go.tpl +++ b/http/codegen/templates/cli_usage.go.tpl @@ -1,8 +1,4 @@ -func {{ .VarPrefix }}UsageCommands() []string { - return cli.UsageCommands() -} - func {{ .VarPrefix }}UsageExamples() string { - return cli.UsageExamples() + return {{ .CLIPkg }}.{{ .Parser.UsageExamples.Name }}() } diff --git a/http/codegen/templates/client_body_init.go.tpl b/http/codegen/templates/client_body_init.go.tpl index 1d598660bf..42f68d0067 100644 --- a/http/codegen/templates/client_body_init.go.tpl +++ b/http/codegen/templates/client_body_init.go.tpl @@ -1,5 +1,5 @@ {{ comment .Description }} -func {{ .Name }}({{ range .ClientArgs }}{{ .VarName }} {{.TypeRef }}, {{ end }}) {{ .ReturnTypeRef }} { +func {{ .Declaration.Name }}({{ range .ClientArgs }}{{ .VarName }} {{.TypeRef }}, {{ end }}) {{ .ReturnTypeRef }} { {{ .ClientCode }} return body } diff --git a/http/codegen/templates/client_endpoint_init.go.tpl b/http/codegen/templates/client_endpoint_init.go.tpl index e9cf0c79bf..bdc1956bfe 100644 --- a/http/codegen/templates/client_endpoint_init.go.tpl +++ b/http/codegen/templates/client_endpoint_init.go.tpl @@ -1,22 +1,22 @@ {{- $retry := and .Method.Idempotent (eq .Method.StreamKind 1) (not .Method.SkipRequestBodyEncodeDecode) (not .MultipartRequestEncoder) (not (isWebSocketEndpoint .)) (not (isSSEEndpoint .)) }} {{ printf "%s returns an endpoint that makes HTTP requests to the %s service %s server." .EndpointInit .ServiceName .Method.Name | comment }} -func (c *{{ .ClientStruct }}) {{ .EndpointInit }}({{ if .MultipartRequestEncoder }}{{ .MultipartRequestEncoder.VarName }} {{ .MultipartRequestEncoder.FuncName }}{{ end }}) goa.Endpoint { +func (c *{{ .ClientStructDeclaration.Name }}) {{ .EndpointInit }}({{ if .MultipartRequestEncoder }}{{ .MultipartRequestEncoder.VarName }} {{ .MultipartRequestEncoder.FuncDeclaration.Name }}{{ end }}) goa.Endpoint { var ( - {{- if .RequestEncoder }} - encodeRequest = {{ .RequestEncoder }}({{ if .MultipartRequestEncoder }}{{ .MultipartRequestEncoder.InitName }}({{ .MultipartRequestEncoder.VarName }}){{ else }}c.encoder{{ end }}) + {{- if .RequestEncoderDeclaration }} + encodeRequest = {{ .RequestEncoderDeclaration.Name }}({{ if .MultipartRequestEncoder }}{{ .MultipartRequestEncoder.InitDeclaration.Name }}({{ .MultipartRequestEncoder.VarName }}){{ else }}c.encoder{{ end }}) {{- end }} - decodeResponse = {{ .ResponseDecoder }}(c.decoder, c.RestoreResponseBody) + decodeResponse = {{ .ResponseDecoderDeclaration.Name }}(c.decoder, c.RestoreResponseBody) ) {{- if $retry }} endpoint := func(ctx context.Context, v any) (any, error) { {{- else }} return func(ctx context.Context, v any) (any, error) { {{- end }} - req, err := c.{{ .RequestInit.Name }}(ctx, {{ range .RequestInit.ClientArgs }}{{ .Ref }}, {{ end }}) + req, err := c.{{ .RequestInit.Declaration.Name }}(ctx, {{ range .RequestInit.ClientArgs }}{{ .Ref }}, {{ end }}) if err != nil { return nil, err } - {{- if .RequestEncoder }} + {{- if .RequestEncoderDeclaration }} err = encodeRequest(req, v) if err != nil { return nil, err @@ -32,7 +32,7 @@ func (c *{{ .ClientStruct }}) {{ .EndpointInit }}({{ if .MultipartRequestEncoder return nil, goahttp.ErrRequestError("{{ .ServiceName }}", "{{ .Method.Name }}", err) } if c.configurer.{{ .Method.VarName }}Fn != nil { - {{- if eq .ClientWebSocket.SendName "" }} + {{- if isServerStreamKind .ClientWebSocket.Kind }} var cancel context.CancelFunc ctx, cancel = context.WithCancel(ctx) conn = c.configurer.{{ .Method.VarName }}Fn(conn, cancel) @@ -40,7 +40,7 @@ func (c *{{ .ClientStruct }}) {{ .EndpointInit }}({{ if .MultipartRequestEncoder conn = c.configurer.{{ .Method.VarName }}Fn(conn, nil) {{- end }} } - {{- if eq .ClientWebSocket.SendName "" }} + {{- if isServerStreamKind .ClientWebSocket.Kind }} go func() { <-ctx.Done() conn.WriteControl( @@ -51,7 +51,7 @@ func (c *{{ .ClientStruct }}) {{ .EndpointInit }}({{ if .MultipartRequestEncoder conn.Close() }() {{- end }} - stream := &{{ .ClientWebSocket.VarName }}{conn: conn} + stream := &{{ .ClientWebSocket.VarDeclaration.Name }}{conn: conn} {{- if .Method.ViewedResult }} {{- if not .Method.ViewedResult.ViewName }} view := resp.Header.Get("goa-view") @@ -76,12 +76,16 @@ func (c *{{ .ClientStruct }}) {{ .EndpointInit }}({{ if .MultipartRequestEncoder } contentType := resp.Header.Get("Content-Type") - if contentType != "" && !strings.HasPrefix(contentType, "text/event-stream") { - resp.Body.Close() - return nil, fmt.Errorf("unexpected content type: %s (expected text/event-stream)", contentType) + mediaType, _, mediaTypeErr := mime.ParseMediaType(contentType) + if mediaTypeErr != nil || mediaType != "text/event-stream" { + contentTypeErr := fmt.Errorf("unexpected content type: %s (expected text/event-stream)", contentType) + if err := resp.Body.Close(); err != nil { + return nil, errors.Join(contentTypeErr, goahttp.ErrDecodingError("{{ .ServiceName }}", "{{ .Method.Name }}", err)) + } + return nil, contentTypeErr } - return New{{ .Method.VarName }}Stream(resp, c.decoder), nil + return {{ .SSE.ClientInitDeclaration.Name }}(resp, c.decoder), nil {{- else }} resp, err := c.{{ .Method.VarName }}Doer.Do(req) if err != nil { @@ -90,10 +94,12 @@ func (c *{{ .ClientStruct }}) {{ .EndpointInit }}({{ if .MultipartRequestEncoder {{- if .Method.SkipResponseBodyEncodeDecode }} {{ if .Result.Ref }}res{{ else }}_{{ end }}, err {{ if .Result.Ref }}:{{ end }}= decodeResponse(resp) if err != nil { - resp.Body.Close() + if closeErr := resp.Body.Close(); closeErr != nil { + return nil, errors.Join(err, goahttp.ErrDecodingError("{{ .ServiceName }}", "{{ .Method.Name }}", closeErr)) + } return nil, err } - return &{{ responseStructPkg .Method .ServicePkgName }}.{{ .Method.ResponseStruct }}{ {{ if .Result.Ref }}Result: res.({{ .Result.Ref }}), {{ end }}Body: resp.Body}, nil + return &{{ .ServicePkgName }}.{{ .Method.ResponseStruct }}{ {{ if .Result.Ref }}Result: res.({{ .Result.Ref }}), {{ end }}Body: resp.Body}, nil {{- else }} return decodeResponse(resp) {{- end }} diff --git a/http/codegen/templates/client_init.go.tpl b/http/codegen/templates/client_init.go.tpl index 9d76771638..104d2877ea 100644 --- a/http/codegen/templates/client_init.go.tpl +++ b/http/codegen/templates/client_init.go.tpl @@ -1,5 +1,5 @@ -{{ printf "New%s instantiates HTTP clients for all the %s service servers." .ClientStruct .Service.Name | comment }} -func New{{ .ClientStruct }}( +{{ printf "%s instantiates HTTP clients for all the %s service servers." .ClientInitDeclaration.Name .Service.Name | comment }} +func {{ .ClientInitDeclaration.Name }}( scheme string, host string, doer goahttp.Doer, @@ -10,13 +10,13 @@ func New{{ .ClientStruct }}( dialer goahttp.Dialer, cfn *ConnConfigurer, {{- end }} -) *{{ .ClientStruct }} { +) *{{ .ClientStructDeclaration.Name }} { {{- if hasWebSocket . }} if cfn == nil { cfn = &ConnConfigurer{} } {{- end }} - return &{{ .ClientStruct }}{ + return &{{ .ClientStructDeclaration.Name }}{ {{- range .Endpoints }} {{ .Method.VarName }}Doer: doer, {{- end }} diff --git a/http/codegen/templates/client_sse.go.tpl b/http/codegen/templates/client_sse.go.tpl index ecba21651f..522743f9d1 100644 --- a/http/codegen/templates/client_sse.go.tpl +++ b/http/codegen/templates/client_sse.go.tpl @@ -1,5 +1,9 @@ -// {{ .Method.VarName }}ClientStream is the interface for reading Server-Sent Events. -type {{ .Method.VarName }}ClientStream interface { +{{/* +client_sse.go.tpl writes the HTTP client stream for one SSE endpoint. The plan +provides the exact data and retry types used to rebuild each service result. +*/ -}} +// {{ .SSE.ClientInterfaceDeclaration.Name }} is the interface for reading Server-Sent Events. +type {{ .SSE.ClientInterfaceDeclaration.Name }} interface { // {{ .Method.ClientStream.RecvName }} reads and returns the next event from the SSE stream. {{ .Method.ClientStream.RecvName }}() ({{ .SSE.EventTypeRef }}, error) // {{ .Method.ClientStream.RecvWithContextName }} reads and returns the next event from the SSE stream with context. @@ -9,59 +13,76 @@ type {{ .Method.VarName }}ClientStream interface { } type ( - // {{ .Method.VarName }}StreamImpl implements the {{ .Method.VarName }}ClientStream interface. - {{ .Method.VarName }}StreamImpl struct { + // {{ .SSE.ClientStructDeclaration.Name }} implements the {{ .SSE.ClientInterfaceDeclaration.Name }} interface. + {{ .SSE.ClientStructDeclaration.Name }} struct { resp *http.Response decoder func(*http.Response) goahttp.Decoder buffer []byte // Buffer for unprocessed data + // started records whether the first stream line was read. + started bool + {{- if .SSE.IDField }} + // lastEventID is the last valid id field read from the stream. + lastEventID string + // hasLastEventID distinguishes no id from an explicitly empty id. + hasLastEventID bool + {{- end }} lock sync.Mutex closed bool + {{- if .SSE.VariableView }} + view string + {{- end }} } ) -// {{ .Method.VarName }}StreamImpl implements the {{ .Method.VarName }}ClientStream interface. -var _ {{ .Method.VarName }}ClientStream = (*{{ .Method.VarName }}StreamImpl)(nil) +// {{ .SSE.ClientStructDeclaration.Name }} implements the {{ .SSE.ClientInterfaceDeclaration.Name }} interface. +var _ {{ .SSE.ClientInterfaceDeclaration.Name }} = (*{{ .SSE.ClientStructDeclaration.Name }})(nil) -// {{ .Method.VarName }}StreamImpl implements the service client stream +// {{ .SSE.ClientStructDeclaration.Name }} implements the service client stream // interface so the generated endpoint client can return it directly. -var _ {{ .ServicePkgName }}.{{ .Method.ClientStream.Interface }} = (*{{ .Method.VarName }}StreamImpl)(nil) +var _ {{ .ServicePkgName }}.{{ .Method.ClientStream.Interface }} = (*{{ .SSE.ClientStructDeclaration.Name }})(nil) -// New{{ .Method.VarName }}Stream creates a new {{ .Method.VarName }}ClientStream. -func New{{ .Method.VarName }}Stream(resp *http.Response, decoder func(*http.Response) goahttp.Decoder) {{ .Method.VarName }}ClientStream { - return &{{ .Method.VarName }}StreamImpl{ +// {{ .SSE.ClientInitDeclaration.Name }} creates a new {{ .SSE.ClientInterfaceDeclaration.Name }}. +func {{ .SSE.ClientInitDeclaration.Name }}(resp *http.Response, decoder func(*http.Response) goahttp.Decoder) {{ .SSE.ClientInterfaceDeclaration.Name }} { + return &{{ .SSE.ClientStructDeclaration.Name }}{ resp: resp, decoder: decoder, buffer: make([]byte, 0, 4096), // Pre-allocate buffer + {{- if .SSE.VariableView }} + view: resp.Header.Get("goa-view"), + {{- end }} } } // {{ .Method.ClientStream.RecvName }} reads and returns the next event from the SSE stream. -func (s *{{ .Method.VarName }}StreamImpl) {{ .Method.ClientStream.RecvName }}() ({{ .SSE.EventTypeRef }}, error) { +func (s *{{ .SSE.ClientStructDeclaration.Name }}) {{ .Method.ClientStream.RecvName }}() ({{ .SSE.EventTypeRef }}, error) { return s.{{ .Method.ClientStream.RecvWithContextName }}(context.Background()) } // {{ .Method.ClientStream.RecvWithContextName }} reads and returns the next event from the SSE stream, respecting context cancellation. -func (s *{{ .Method.VarName }}StreamImpl) {{ .Method.ClientStream.RecvWithContextName }}(ctx context.Context) (event {{ .SSE.EventTypeRef }}, err error) { - var byts []byte - byts, err = s.readEvent(ctx) - if err != nil { - if errors.Is(err, io.EOF) || errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) { - // Clean up on EOF or context cancellation. io.EOF - // propagates to the caller to signal end of stream. - s.Close() - } - return - } - return s.processEvent(byts) +func (s *{{ .SSE.ClientStructDeclaration.Name }}) {{ .Method.ClientStream.RecvWithContextName }}(ctx context.Context) (event {{ .SSE.EventTypeRef }}, err error) { + for { + var byts []byte + byts, err = s.readEvent(ctx) + if err != nil { + if errors.Is(err, io.EOF) || errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) { + // Clean up on EOF or context cancellation. io.EOF + // propagates to the caller to signal end of stream. + s.Close() + } + return + } + var hasData bool + event, hasData, err = s.processEvent(byts) + if err != nil || hasData { + return + } + } } // readEvent reads a single SSE event from the stream, respecting context -// cancellation. It first checks the internal buffer for a complete event -// (delimited by double newlines). If no complete event is found, it reads from -// the HTTP response body until it either finds an event boundary, reaches EOF, -// or encounters an error. Any data after the event boundary is saved in the -// buffer for the next call. -func (s *{{ .Method.VarName }}StreamImpl) readEvent(ctx context.Context) ([]byte, error) { +// cancellation. It accepts CR, LF, and CRLF line endings. A blank line ends an +// event; bytes after that line remain buffered for the next call. +func (s *{{ .SSE.ClientStructDeclaration.Name }}) readEvent(ctx context.Context) ([]byte, error) { const bufSize = 4096 // 4KB buffer size // Check for event in existing buffer @@ -70,9 +91,7 @@ func (s *{{ .Method.VarName }}StreamImpl) readEvent(ctx context.Context) ([]byte return event, nil } - // Initialize with any data from buffer eventData := event - wasNewline := len(eventData) > 0 && eventData[len(eventData)-1] == '\n' buf := make([]byte, bufSize) // Read data in chunks until we find an event or hit EOF. A stream that @@ -103,28 +122,15 @@ func (s *{{ .Method.VarName }}StreamImpl) readEvent(ctx context.Context) ([]byte return nil, err } - // Process data if we got any - if n > 0 { - // Look for event boundary in this chunk - for i := 0; i < n; i++ { - b := buf[i] - eventData = append(eventData, b) - - // Check for double newlines (event boundary) - if b == '\n' && wasNewline { - // Save any remaining data for next read - if i+1 < n { - s.lock.Lock() - s.buffer = append(s.buffer[:0], buf[i+1:n]...) - s.lock.Unlock() - } - return eventData, nil - } - - // Update newline tracking - wasNewline = (b == '\n') - } - } + if n > 0 { + eventData = append(eventData, buf[:n]...) + } + if end := s.eventEnd(eventData, errors.Is(err, io.EOF)); end >= 0 { + s.lock.Lock() + s.buffer = append(s.buffer[:0], eventData[end:]...) + s.lock.Unlock() + return eventData[:end], nil + } // Discard any partial frame at EOF: an event that ends before its // blank-line delimiter was truncated by the transport. @@ -134,12 +140,9 @@ func (s *{{ .Method.VarName }}StreamImpl) readEvent(ctx context.Context) ([]byte } } -// checkBuffer examines the internal buffer for a complete SSE event (delimited -// by double newlines). It returns two values: the event data (or all buffer -// contents if no complete event is found), and a boolean indicating whether a -// complete event was found. If a complete event is found, any remaining data -// after the event is kept in the buffer for the next call. -func (s *{{ .Method.VarName }}StreamImpl) checkBuffer() ([]byte, bool) { +// checkBuffer returns the first complete event already read from the response. +// When no blank line is present, it returns a copy that readEvent can extend. +func (s *{{ .SSE.ClientStructDeclaration.Name }}) checkBuffer() ([]byte, bool) { s.lock.Lock() defer s.lock.Unlock() @@ -148,25 +151,11 @@ func (s *{{ .Method.VarName }}StreamImpl) checkBuffer() ([]byte, bool) { return nil, false } - // Look for double newline in buffer - for i := 0; i < len(s.buffer)-1; i++ { - if s.buffer[i] == '\n' && s.buffer[i+1] == '\n' { - // Found complete event. Copy it out: compacting the buffer - // below would otherwise overwrite the returned bytes, since - // both slices share the same backing array. - eventEnd := i + 2 // Include both newlines - eventData := make([]byte, eventEnd) - copy(eventData, s.buffer[:eventEnd]) - - // Save remaining data for next time - if eventEnd < len(s.buffer) { - s.buffer = append(s.buffer[:0], s.buffer[eventEnd:]...) - } else { - s.buffer = s.buffer[:0] - } - - return eventData, true - } + if end := s.eventEnd(s.buffer, false); end >= 0 { + eventData := make([]byte, end) + copy(eventData, s.buffer[:end]) + s.buffer = append(s.buffer[:0], s.buffer[end:]...) + return eventData, true } // No complete event found, return a copy of the buffer contents: the @@ -178,8 +167,56 @@ func (s *{{ .Method.VarName }}StreamImpl) checkBuffer() ([]byte, bool) { return eventData, false } +// eventEnd returns the byte after the blank line that ends the first event. +// A CR at the end waits for the next byte unless the response has ended. +func (s *{{ .SSE.ClientStructDeclaration.Name }}) eventEnd(data []byte, atEOF bool) int { + lineStart := 0 + for i := 0; i < len(data); { + if data[i] != '\r' && data[i] != '\n' { + i++ + continue + } + lineEnd := i + 1 + if data[i] == '\r' { + if lineEnd == len(data) && !atEOF { + return -1 + } + if lineEnd < len(data) && data[lineEnd] == '\n' { + lineEnd++ + } + } + if i == lineStart { + return lineEnd + } + lineStart = lineEnd + i = lineEnd + } + return -1 +} + +// eventLines splits one complete event using every line ending allowed by SSE. +func (s *{{ .SSE.ClientStructDeclaration.Name }}) eventLines(data []byte) [][]byte { + var lines [][]byte + for len(data) > 0 { + lineEnd := 0 + for lineEnd < len(data) && data[lineEnd] != '\r' && data[lineEnd] != '\n' { + lineEnd++ + } + lines = append(lines, data[:lineEnd]) + if lineEnd == len(data) { + break + } + next := lineEnd + 1 + if data[lineEnd] == '\r' && next < len(data) && data[next] == '\n' { + next++ + } + data = data[next:] + } + return lines +} + // Close closes the SSE stream and releases any associated resources. -func (s *{{ .Method.VarName }}StreamImpl) Close() error { +func (s *{{ .SSE.ClientStructDeclaration.Name }}) Close() error { s.lock.Lock() defer s.lock.Unlock() if s.closed { @@ -190,71 +227,435 @@ func (s *{{ .Method.VarName }}StreamImpl) Close() error { } // processEvent processes a raw SSE event into the expected type -func (s *{{ .Method.VarName }}StreamImpl) processEvent(eventData []byte) (event {{ .SSE.EventTypeRef }}, err error) { - {{- if .SSE.EventIsStruct }} - event = new({{ deref .SSE.EventTypeRef }}) - {{- end }} +func (s *{{ .SSE.ClientStructDeclaration.Name }}) processEvent(eventData []byte) (event {{ .SSE.EventTypeRef }}, hasData bool, err error) { + {{- if and .SSE.EventIsStruct (not .HasMixedResults) (not .Method.ViewedResult) (not .SSE.Response.ClientBody) }} + event = new({{ .SSE.EventTypeName }}) + {{- end }} + {{- if and (not .Method.ViewedResult) .SSE.Response.ClientBody }} + {{- with .SSE.Response.ClientBody }} + var body {{ if .Declaration }}{{ .Declaration.Name }}{{ else }}{{ .VarName }}{{ end }} + {{- end }} + {{- end }} + {{- if .Method.ViewedResult }} + var ( + {{- if .SSE.IDField }} + idContent string + hasID bool + {{- end }} + {{- if .SSE.EventField }} + eventContent string + hasEvent bool + {{- end }} + {{- if .SSE.RetryField }} + retryContent string + hasRetry bool + {{- end }} + ) + {{- end }} var dataLines []string - for _, line := range bytes.Split(eventData, []byte("\n")) { + for _, line := range s.eventLines(eventData) { + if !s.started { + line = bytes.TrimPrefix(line, []byte{0xef, 0xbb, 0xbf}) + s.started = true + } if len(line) == 0 { continue } - if bytes.HasPrefix(line, []byte("data:")) { - dataLines = append(dataLines, s.trimHeader(len("data:"), line)) + field, value, _ := bytes.Cut(line, []byte(":")) + if len(value) > 0 && value[0] == ' ' { + value = value[1:] + } + if bytes.Equal(field, []byte("data")) { + dataLines = append(dataLines, string(value)) continue } {{- if .SSE.IDField }} - if bytes.HasPrefix(line, []byte("id:")) { - event.{{ .SSE.IDField }} = s.trimHeader(len("id:"), line) + if bytes.Equal(field, []byte("id")) { + if !bytes.ContainsRune(value, '\x00') { + s.lastEventID = string(value) + s.hasLastEventID = true + } continue } {{- end }} {{- if .SSE.EventField }} - if bytes.HasPrefix(line, []byte("event:")) { - event.{{ .SSE.EventField }} = s.trimHeader(len("event:"), line) + if bytes.Equal(field, []byte("event")) { + {{- if $.Method.ViewedResult }} + eventContent = string(value) + hasEvent = true + {{- else if and $.SSE.Response.ClientBody $.SSE.ClientEventPointer }} + eventContent := string(value) + body.{{ .SSE.EventField }} = &eventContent + {{- else }} + {{ if and (not $.Method.ViewedResult) $.SSE.Response.ClientBody }}body{{ else }}event{{ end }}.{{ .SSE.EventField }} = string(value) + {{- end }} continue } {{- end }} {{- if .SSE.RetryField }} - if bytes.HasPrefix(line, []byte("retry:")) { - // Note: retry value parsing depends on the field type; client currently expects integer-like types. - // We deliberately leave conversion to a future enhancement that includes the field type reference. - // For now this branch is kept for completeness; services using RetryField should be handled server-side. + if bytes.Equal(field, []byte("retry")) { + // An invalid retry field does not replace an earlier valid field. + validRetry := len(value) > 0 + for i := 0; validRetry && i < len(value); i++ { + validRetry = value[i] >= '0' && value[i] <= '9' + } + if !validRetry { + continue + } + {{- if $.Method.ViewedResult }} + retryContent = string(value) + hasRetry = true + {{- else }} + retryContent := string(value) + {{- end }} + {{- if $.Method.ViewedResult }} + {{- else if $.HasMixedResults }} + {{ template "partial_sse_parse" dict "Target" (printf "body.%s" .SSE.RetryField) "Source" "retryContent" "Encoding" .SSE.Retry "TypeRef" .SSE.Retry.ClientTypeRef "Named" false "Nullable" false "TargetPointer" .SSE.Retry.ClientPointer }} + {{- else if and (not $.Method.ViewedResult) $.SSE.Response.ClientBody }} + {{ template "partial_sse_parse" dict "Target" (printf "body.%s" .SSE.RetryField) "Source" "retryContent" "Encoding" .SSE.Retry "TypeRef" .SSE.Retry.ClientTypeRef "Named" false "Nullable" false "TargetPointer" .SSE.Retry.ClientPointer }} + {{- else }} + {{ template "partial_sse_parse" dict "Target" (printf "event.%s" .SSE.RetryField) "Source" "retryContent" "Encoding" .SSE.Retry "TypeRef" .SSE.Retry.TypeRef "Named" .SSE.Retry.Named "Nullable" false "TargetPointer" .SSE.Retry.Pointer }} + {{- end }} continue - } + } {{- end }} } - if len(dataLines) > 0 { - dataContent := strings.Join(dataLines, "\n") - {{- if .SSE.DataField }} - {{ template "partial_sse_parse" dict "Target" (printf "event.%s" .SSE.DataField) "TypeRef" .SSE.DataFieldTypeRef }} + if len(dataLines) == 0 { + return event, false, nil + } + {{- if .SSE.IDField }} + if s.hasLastEventID { + {{- if $.Method.ViewedResult }} + idContent = s.lastEventID + hasID = true + {{- else if and $.SSE.Response.ClientBody $.SSE.ClientIDPointer }} + idContent := s.lastEventID + body.{{ .SSE.IDField }} = &idContent + {{- else }} + {{ if and (not $.Method.ViewedResult) $.SSE.Response.ClientBody }}body{{ else }}event{{ end }}.{{ .SSE.IDField }} = s.lastEventID + {{- end }} + } + {{- end }} + {{- if .Method.ViewedResult }} + {{- if and .SSE.ID .SSE.ID.HasDefault }} + if !hasID { + idContent = {{ printf "%q" .SSE.ID.DefaultValue }} + hasID = true + } + {{- end }} + {{- if and .SSE.Event .SSE.Event.HasDefault }} + if !hasEvent { + eventContent = {{ printf "%q" .SSE.Event.DefaultValue }} + hasEvent = true + } + {{- end }} + {{- if and .SSE.Retry .SSE.Retry.HasDefault }} + if !hasRetry { + retryContent = {{ printf "%q" (printf "%v" .SSE.Retry.DefaultValue) }} + hasRetry = true + } + {{- end }} + {{- else if .SSE.Response.ClientBody }} + {{- if and .SSE.ID .SSE.ID.HasDefault }} + {{- if .SSE.ClientIDPointer }} + if body.{{ .SSE.IDField }} == nil { + value := {{ printf "%q" .SSE.ID.DefaultValue }} + body.{{ .SSE.IDField }} = &value + } + {{- else }} + body.{{ .SSE.IDField }} = {{ printf "%q" .SSE.ID.DefaultValue }} + {{- end }} + {{- end }} + {{- if and .SSE.Event .SSE.Event.HasDefault }} + {{- if .SSE.ClientEventPointer }} + if body.{{ .SSE.EventField }} == nil { + value := {{ printf "%q" .SSE.Event.DefaultValue }} + body.{{ .SSE.EventField }} = &value + } + {{- else }} + body.{{ .SSE.EventField }} = {{ printf "%q" .SSE.Event.DefaultValue }} + {{- end }} + {{- end }} + {{- if and .SSE.Retry .SSE.Retry.HasDefault }} + {{- if .SSE.Retry.ClientPointer }} + if body.{{ .SSE.RetryField }} == nil { + value := {{ printf "%#v" .SSE.Retry.DefaultValue }} + body.{{ .SSE.RetryField }} = &value + } + {{- else }} + body.{{ .SSE.RetryField }} = {{ printf "%#v" .SSE.Retry.DefaultValue }} + {{- end }} + {{- end }} + {{- end }} + {{- with .SSE.Response }} + {{- if or .Headers .Cookies }} + {{- template "sse_response_elements" $ }} + {{- end }} + {{- end }} + {{- if .Method.ViewedResult }} + {{- if .SSE.VariableView }} + view := s.view + switch view { + {{- range .SSE.Response.ViewedRepresentations }} + case {{ printf "%q" .View }}: + {{- template "viewed_sse_client_result" dict "Endpoint" $ "Representation" . }} + {{- end }} + default: + return event, false, goahttp.ErrValidationError("{{ .ServiceName }}", "{{ .Method.Name }}", goa.InvalidEnumValueError("view", view, []any{ {{ range .Method.ViewedResult.Views }}{{ printf "%q" .Name }}, {{ end }} })) + } + {{- else }} + view := {{ printf "%q" .Method.ViewedResult.ViewName }} + {{- range .SSE.Response.ViewedRepresentations }} + {{- template "viewed_sse_client_result" dict "Endpoint" $ "Representation" . }} + {{- end }} + {{- end }} + {{- else if .HasMixedResults }} + {{- with .SSE.Response.ClientBody }} + dataContent := strings.Join(dataLines, "\n") + {{- if $.SSE.DataField }} + {{ template "partial_sse_parse" dict "Target" (printf "body.%s" $.SSE.DataField) "Source" "dataContent" "Encoding" $.SSE.Data "TypeRef" $.SSE.Data.ClientTypeRef "Named" false "Nullable" $.SSE.Data.Pointer "TargetPointer" $.SSE.Data.ClientPointer }} + {{- else if ssePrimitive $.SSE.Data }} + {{ template "partial_sse_parse" dict "Target" "body" "Source" "dataContent" "Encoding" $.SSE.Data "TypeRef" $.SSE.Data.ClientTypeRef "Named" false "Nullable" $.SSE.Data.Pointer "TargetPointer" $.SSE.Data.ClientPointer }} {{- else }} - {{- if .SSE.EventIsStruct }} - // Decode JSON into the struct pointer directly respBody := &http.Response{ StatusCode: http.StatusOK, Body: io.NopCloser(bytes.NewReader([]byte(dataContent))), } - err = s.decoder(respBody).Decode(event) + if err = s.decoder(respBody).Decode(&body); err != nil { + return event, false, goahttp.ErrDecodingError("{{ $.ServiceName }}", "{{ $.Method.Name }}", err) + } + {{- end }} + {{- if and .ValidatorDeclaration .ValidationTarget }} + err = {{ .ValidatorDeclaration.Name }}({{ .ValidationTarget }}) + if err != nil { + return event, false, goahttp.ErrValidationError("{{ $.ServiceName }}", "{{ $.Method.Name }}", err) + } + {{- else if .ValidateRef }} + {{ .ValidateRef }} + if err != nil { + return event, false, goahttp.ErrValidationError("{{ $.ServiceName }}", "{{ $.Method.Name }}", err) + } + {{- end }} + {{ $.SSE.ClientEventCode }} + return result, true, nil + {{- else }} + return event, true, nil + {{- end }} + {{- else }} + dataContent := strings.Join(dataLines, "\n") + {{- if .SSE.DataField }} + {{- if .SSE.Response.ClientBody }} + {{ template "partial_sse_parse" dict "Target" (printf "body.%s" .SSE.DataField) "Source" "dataContent" "Encoding" .SSE.Data "TypeRef" .SSE.Data.ClientTypeRef "Named" false "Nullable" .SSE.Data.Pointer "TargetPointer" .SSE.Data.ClientPointer }} + {{- else }} + {{ template "partial_sse_parse" dict "Target" (printf "event.%s" .SSE.DataField) "Source" "dataContent" "Encoding" .SSE.Data "TypeRef" .SSE.Data.TypeRef "Named" .SSE.Data.Named "Nullable" .SSE.Data.Pointer "TargetPointer" .SSE.Data.Pointer }} + {{- end }} + {{- else if .SSE.EventIsStruct }} + // Decode the event data into the HTTP response type so required + // primitive fields retain their presence for validation. + respBody := &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(bytes.NewReader([]byte(dataContent))), + } + err = s.decoder(respBody).Decode({{ if .SSE.Response.ClientBody }}&body{{ else }}event{{ end }}) if err != nil { return } {{- else }} - {{ template "partial_sse_parse" dict "Target" "event" "TypeRef" .SSE.EventTypeRef }} - {{- end }} - {{- end }} - } - return + {{- if .SSE.Response.ClientBody }} + {{ template "partial_sse_parse" dict "Target" "body" "Source" "dataContent" "Encoding" .SSE.Data "TypeRef" .SSE.Data.ClientTypeRef "Named" false "Nullable" .SSE.Data.Pointer "TargetPointer" .SSE.Data.ClientPointer }} + {{- else }} + {{ template "partial_sse_parse" dict "Target" "event" "Source" "dataContent" "Encoding" .SSE.Data "TypeRef" .SSE.Data.TypeRef "Named" .SSE.Data.Named "Nullable" .SSE.Data.Pointer "TargetPointer" .SSE.Data.Pointer }} + {{- end }} + {{- end }} + {{- with .SSE.Response.ClientBody }} + {{- if and .ValidatorDeclaration .ValidationTarget }} + err = {{ .ValidatorDeclaration.Name }}({{ .ValidationTarget }}) + if err != nil { + return event, false, goahttp.ErrValidationError("{{ $.ServiceName }}", "{{ $.Method.Name }}", err) + } + {{- else if .ValidateRef }} + {{ .ValidateRef }} + if err != nil { + return event, false, goahttp.ErrValidationError("{{ $.ServiceName }}", "{{ $.Method.Name }}", err) + } + {{- end }} + {{- end }} + {{- with .SSE.Response.ResultInit }} + event = {{ .Declaration.Name }}({{ range .ClientArgs }}{{ .Ref }},{{ end }}) + {{- else }} + {{- if .SSE.Response.ClientBody }} + event = body + {{- end }} + {{- end }} + {{- end }} + {{- if not .HasMixedResults }} + hasData = true + return + {{- end }} } -// trimHeader removes the header prefix and optional leading space -func (s *{{ .Method.VarName }}StreamImpl) trimHeader(size int, data []byte) string { - if len(data) < size { - return string(data) - } - data = data[size:] - if len(data) > 0 && data[0] == ' ' { - data = data[1:] - } - return string(data) -} +{{- define "viewed_sse_client_result" }} + {{- $endpoint := .Endpoint }} + {{- with .Representation }} + {{- if .ClientBody }} + var body {{ if .ClientBody.Declaration }}{{ .ClientBody.Declaration.Name }}{{ else }}{{ .ClientBody.VarName }}{{ end }} + {{- if $endpoint.SSE.IDField }} + if hasID { + {{- if $endpoint.SSE.ClientIDPointer }} + body.{{ $endpoint.SSE.IDField }} = &idContent + {{- else }} + body.{{ $endpoint.SSE.IDField }} = idContent + {{- end }} + } + {{- end }} + {{- if $endpoint.SSE.EventField }} + if hasEvent { + {{- if $endpoint.SSE.ClientEventPointer }} + body.{{ $endpoint.SSE.EventField }} = &eventContent + {{- else }} + body.{{ $endpoint.SSE.EventField }} = eventContent + {{- end }} + } + {{- end }} + {{- if $endpoint.SSE.RetryField }} + if hasRetry { + {{ template "partial_sse_parse" dict "Target" (printf "body.%s" $endpoint.SSE.RetryField) "Source" "retryContent" "Encoding" $endpoint.SSE.Retry "TypeRef" $endpoint.SSE.Retry.ClientTypeRef "Named" false "Nullable" false "TargetPointer" $endpoint.SSE.Retry.ClientPointer }} + } + {{- end }} + dataContent := strings.Join(dataLines, "\n") + {{- if ssePrimitive $endpoint.SSE.Data }} + {{- if $endpoint.SSE.DataField }} + {{ template "partial_sse_parse" dict "Target" (printf "body.%s" $endpoint.SSE.DataField) "Source" "dataContent" "Encoding" $endpoint.SSE.Data "TypeRef" $endpoint.SSE.Data.ClientTypeRef "Named" false "Nullable" $endpoint.SSE.Data.Pointer "TargetPointer" .ClientDataPointer }} + {{- else }} + {{ template "partial_sse_parse" dict "Target" "body" "Source" "dataContent" "Encoding" $endpoint.SSE.Data "TypeRef" $endpoint.SSE.Data.ClientTypeRef "Named" false "Nullable" $endpoint.SSE.Data.Pointer "TargetPointer" .ClientDataPointer }} + {{- end }} + {{- else }} + respBody := &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(bytes.NewReader([]byte(dataContent))), + } + {{- if $endpoint.SSE.DataField }} + if err = s.decoder(respBody).Decode(&body.{{ $endpoint.SSE.DataField }}); err != nil { + {{- else }} + if err = s.decoder(respBody).Decode(&body); err != nil { + {{- end }} + return event, false, goahttp.ErrDecodingError("{{ $endpoint.ServiceName }}", "{{ $endpoint.Method.Name }}", err) + } + {{- end }} + {{- end }} + {{- if and .ClientBody.ValidatorDeclaration .ClientBody.ValidationTarget }} + err = {{ .ClientBody.ValidatorDeclaration.Name }}({{ .ClientBody.ValidationTarget }}) + if err != nil { + return event, false, goahttp.ErrValidationError("{{ $endpoint.ServiceName }}", "{{ $endpoint.Method.Name }}", err) + } + {{- else if .ClientBody.ValidateRef }} + {{ .ClientBody.ValidateRef }} + if err != nil { + return event, false, goahttp.ErrValidationError("{{ $endpoint.ServiceName }}", "{{ $endpoint.Method.Name }}", err) + } + {{- end }} + projected := {{ .ResultInit.Declaration.Name }}({{ range .ResultInit.ClientArgs }}{{ .Ref }}, {{ end }}) + viewed := {{ if not $endpoint.Method.ViewedResult.IsCollection }}&{{ end }}{{ $endpoint.Method.ViewedResult.ViewsPkg }}.{{ $endpoint.Method.ViewedResult.VarName }}{Projected: projected, View: view} + if err = {{ $endpoint.Method.ViewedResult.ViewsPkg }}.{{ $endpoint.Method.ViewedResult.Validate.Declaration.Name }}(viewed); err != nil { + return event, false, goahttp.ErrValidationError("{{ $endpoint.ServiceName }}", "{{ $endpoint.Method.Name }}", err) + } + result := {{ $endpoint.ServicePkgName }}.{{ $endpoint.Method.ViewedResult.ResultInit.Declaration.Name }}(viewed) + return result, true, nil + {{- end }} +{{- end }} + +{{- define "sse_response_elements" }} + {{- with .SSE.Response }} + {{- if .Headers }} + var ( + {{- range .Headers }} + {{ .VarName }} {{ .TypeRef }} + {{- end }} + ) + {{- range .Headers }} + {{- if (or (eq .Type.Name "string") (eq .Type.Name "any")) }} + {{ .VarName }}Raw := s.resp.Header.Get("{{ .CanonicalName }}") + {{- if .Required }} + if {{ .VarName }}Raw == "" { + return event, false, goahttp.ErrValidationError("{{ $.ServiceName }}", "{{ $.Method.Name }}", goa.MissingFieldError("{{ .Name }}", "header")) + } + {{ .VarName }} = {{ if and (eq .Type.Name "string") .Pointer }}&{{ end }}{{ .VarName }}Raw + {{- else }} + if {{ .VarName }}Raw != "" { + {{ .VarName }} = {{ if and (eq .Type.Name "string") .Pointer }}&{{ end }}{{ .VarName }}Raw + } + {{- end }} + {{- else if .StringSlice }} + {{ .VarName }} = s.resp.Header["{{ .CanonicalName }}"] + {{- if .Required }} + if {{ .VarName }} == nil { + return event, false, goahttp.ErrValidationError("{{ $.ServiceName }}", "{{ $.Method.Name }}", goa.MissingFieldError("{{ .Name }}", "header")) + } + {{- end }} + {{- else if .Slice }} + {{ .VarName }}Raw := s.resp.Header["{{ .CanonicalName }}"] + {{- if .Required }} + if {{ .VarName }}Raw == nil { + return event, false, goahttp.ErrValidationError("{{ $.ServiceName }}", "{{ $.Method.Name }}", goa.MissingFieldError("{{ .Name }}", "header")) + } + {{- end }} + if {{ .VarName }}Raw != nil { + {{- template "partial_element_slice_conversion" . }} + } + {{- else }} + {{ .VarName }}Raw := s.resp.Header.Get("{{ .CanonicalName }}") + {{- if .Required }} + if {{ .VarName }}Raw == "" { + return event, false, goahttp.ErrValidationError("{{ $.ServiceName }}", "{{ $.Method.Name }}", goa.MissingFieldError("{{ .Name }}", "header")) + } + {{- end }} + if {{ .VarName }}Raw != "" { + {{- template "partial_query_type_conversion" . }} + } + {{- end }} + {{- if .Validate }} + {{ .Validate }} + if err != nil { + return event, false, goahttp.ErrValidationError("{{ $.ServiceName }}", "{{ $.Method.Name }}", err) + } + {{- end }} + {{- end }} + {{- end }} + {{- if .Cookies }} + var ( + {{- range .Cookies }} + {{ .VarName }} {{ .TypeRef }} + {{ .VarName }}Raw string + {{- end }} + ) + for _, cookie := range s.resp.Cookies() { + switch cookie.Name { + {{- range .Cookies }} + case {{ printf "%q" .HTTPName }}: + {{ .VarName }}Raw = cookie.Value + {{- end }} + } + } + {{- range .Cookies }} + {{- if .Required }} + if {{ .VarName }}Raw == "" { + return event, false, goahttp.ErrValidationError("{{ $.ServiceName }}", "{{ $.Method.Name }}", goa.MissingFieldError("{{ .Name }}", "cookie")) + } + {{- end }} + if {{ .VarName }}Raw != "" { + {{- if (or (eq .Type.Name "string") (eq .Type.Name "any")) }} + {{ .VarName }} = {{ if and (eq .Type.Name "string") .Pointer }}&{{ end }}{{ .VarName }}Raw + {{- else }} + {{- template "partial_query_type_conversion" . }} + {{- end }} + } + {{- if .Validate }} + {{ .Validate }} + if err != nil { + return event, false, goahttp.ErrValidationError("{{ $.ServiceName }}", "{{ $.Method.Name }}", err) + } + {{- end }} + {{- end }} + {{- end }} + {{- end }} +{{- end }} diff --git a/http/codegen/templates/client_struct.go.tpl b/http/codegen/templates/client_struct.go.tpl index f7b286636e..01c41eab00 100644 --- a/http/codegen/templates/client_struct.go.tpl +++ b/http/codegen/templates/client_struct.go.tpl @@ -1,5 +1,5 @@ -{{ printf "%s lists the %s service endpoint HTTP clients." .ClientStruct .Service.Name | comment }} -type {{ .ClientStruct }} struct { +{{ printf "%s lists the %s service endpoint HTTP clients." .ClientStructDeclaration.Name .Service.Name | comment }} +type {{ .ClientStructDeclaration.Name }} struct { {{- range .Endpoints }} {{ printf "%s Doer is the HTTP client used to make requests to the %s endpoint." .Method.VarName .Method.Name | comment }} {{ .Method.VarName }}Doer goahttp.Doer diff --git a/http/codegen/templates/client_type_init.go.tpl b/http/codegen/templates/client_type_init.go.tpl index 7324604835..f90e77688b 100644 --- a/http/codegen/templates/client_type_init.go.tpl +++ b/http/codegen/templates/client_type_init.go.tpl @@ -1,5 +1,12 @@ {{ comment .Description }} -func {{ .Name }}({{- range .ClientArgs }}{{ .VarName }} {{ .TypeRef }}, {{ end }}) {{ .ReturnTypeRef }} { +func {{ .Declaration.Name }}({{- range .ClientArgs }}{{ .VarName }} {{ .TypeRef }}, {{ end }}) {{ .ReturnTypeRef }} { +{{- if .OptionalBody }} + res := &{{ .ReturnTypeName }}{} + if body != nil { + {{ .ClientCode }} + res.{{ .ReturnTypeAttribute }} = {{ if .ReturnIsPrimitivePointer }}&{{ else if .ReturnIsUnion }}*{{ end }}v + } +{{- else }} {{- if .ClientCode }} {{ .ClientCode }} {{- if .ReturnTypeAttribute }} @@ -13,6 +20,7 @@ func {{ .Name }}({{- range .ClientArgs }}{{ .VarName }} {{ .TypeRef }}, {{ end } {{ if .ReturnTypeAttribute }}res{{ else }}v{{ end }} := &{{ .ReturnTypeName }}{} {{- end }} {{- end }} + {{- end }} {{ fieldCode . "client" }} return {{ if .ReturnTypeAttribute }}res{{ else }}v{{ end }} } diff --git a/http/codegen/templates/dummy_multipart_request_decoder.go.tpl b/http/codegen/templates/dummy_multipart_request_decoder.go.tpl index 132d6ba52c..4ab092f1e7 100644 --- a/http/codegen/templates/dummy_multipart_request_decoder.go.tpl +++ b/http/codegen/templates/dummy_multipart_request_decoder.go.tpl @@ -1,5 +1,5 @@ -{{ printf "%s implements the multipart decoder for service %q endpoint %q. The decoder must populate the argument p after encoding." .FuncName .ServiceName .MethodName | comment }} -func {{ .FuncName }}(mr *multipart.Reader, p *{{ .Payload.Ref }}) error { +{{ printf "%s reads the multipart request body for service %q endpoint %q into body." .FuncDeclaration.Name .ServiceName .MethodName | comment }} +func {{ .FuncDeclaration.Name }}(mr *multipart.Reader, body *{{ .BodyType }}) error { // Add multipart request decoder logic here return nil } diff --git a/http/codegen/templates/dummy_multipart_request_encoder.go.tpl b/http/codegen/templates/dummy_multipart_request_encoder.go.tpl index ec588acd12..c27201f641 100644 --- a/http/codegen/templates/dummy_multipart_request_encoder.go.tpl +++ b/http/codegen/templates/dummy_multipart_request_encoder.go.tpl @@ -1,5 +1,5 @@ -{{ printf "%s implements the multipart encoder for service %q endpoint %q." .FuncName .ServiceName .MethodName | comment }} -func {{ .FuncName }}(mw *multipart.Writer, p {{ .Payload.Ref }}) error { +{{ printf "%s implements the multipart encoder for service %q endpoint %q." .FuncDeclaration.Name .ServiceName .MethodName | comment }} +func {{ .FuncDeclaration.Name }}(mw *multipart.Writer, p {{ .Payload.Ref }}) error { // Add multipart request encoder logic here return nil } diff --git a/http/codegen/templates/error_encoder.go.tpl b/http/codegen/templates/error_encoder.go.tpl index 31f48dbe6b..067e6fc0f6 100644 --- a/http/codegen/templates/error_encoder.go.tpl +++ b/http/codegen/templates/error_encoder.go.tpl @@ -1,5 +1,5 @@ -{{ printf "%s returns an encoder for errors returned by the %s %s endpoint." .ErrorEncoder .Method.Name .ServiceName | comment }} -func {{ .ErrorEncoder }}(encoder func(context.Context, http.ResponseWriter) goahttp.Encoder, formatter func(ctx context.Context, err error) goahttp.Statuser) func(context.Context, http.ResponseWriter, error) error { +{{ printf "%s returns an encoder for errors returned by the %s %s endpoint." .ErrorEncoderDeclaration.Name .Method.Name .ServiceName | comment }} +func {{ .ErrorEncoderDeclaration.Name }}(encoder func(context.Context, http.ResponseWriter) goahttp.Encoder, formatter func(ctx context.Context, err error) goahttp.Statuser) func(context.Context, http.ResponseWriter, error) error { encodeError := goahttp.ErrorEncoder(encoder, formatter) return func(ctx context.Context, w http.ResponseWriter, v error) error { var en goa.GoaErrorNamer diff --git a/http/codegen/templates/file_server.go.tpl b/http/codegen/templates/file_server.go.tpl index f3b2d1bf06..fde37a6570 100644 --- a/http/codegen/templates/file_server.go.tpl +++ b/http/codegen/templates/file_server.go.tpl @@ -1,5 +1,8 @@ -{{ printf "%s configures the mux to serve GET request made to %q." .MountHandler (join .RequestPaths ", ") | comment }} -func {{ .MountHandler }}(mux goahttp.Muxer, h http.Handler) { +{{ printf "%s configures the mux to serve GET request made to %q." .MountHandlerDeclaration.Name (join .RequestPaths ", ") | comment }} +func {{ .MountHandlerDeclaration.Name }}(mux goahttp.Muxer, h http.Handler) { + {{- if .ServerHandlerWrappers }} + h = {{ range .ServerHandlerWrappers }}{{ .Name }}({{ end }}h{{ range .ServerHandlerWrappers }}){{ end }} + {{- end }} {{- if .IsDir }} {{- range .RequestPaths }} mux.Handle("GET", "{{ . }}{{if ne . "/"}}/{{end}}", h.ServeHTTP) diff --git a/http/codegen/templates/mount_point_struct.go.tpl b/http/codegen/templates/mount_point_struct.go.tpl index b4739ba2a7..d928733168 100644 --- a/http/codegen/templates/mount_point_struct.go.tpl +++ b/http/codegen/templates/mount_point_struct.go.tpl @@ -1,5 +1,5 @@ -{{ printf "%s holds information about the mounted endpoints." .MountPointStruct | comment }} -type {{ .MountPointStruct }} struct { +{{ printf "%s holds information about the mounted endpoints." .MountPointStructDeclaration.Name | comment }} +type {{ .MountPointStructDeclaration.Name }} struct { {{ printf "Method is the name of the service method served by the mounted HTTP handler." | comment }} Method string {{ printf "Verb is the HTTP method used to match requests to the mounted handler." | comment }} diff --git a/http/codegen/templates/multipart_request_decoder.go.tpl b/http/codegen/templates/multipart_request_decoder.go.tpl index dab9d3ef8f..b7e3d47e5c 100644 --- a/http/codegen/templates/multipart_request_decoder.go.tpl +++ b/http/codegen/templates/multipart_request_decoder.go.tpl @@ -1,28 +1,15 @@ -{{ printf "%s returns a decoder to decode the multipart request for the %q service %q endpoint." .InitName .ServiceName .MethodName | comment }} -func {{ .InitName }}(mux goahttp.Muxer, {{ .VarName }} {{ .FuncName }}) func(r *http.Request) goahttp.Decoder { +{{ printf "%s returns a decoder to decode the multipart request for the %q service %q endpoint." .InitDeclaration.Name .ServiceName .MethodName | comment }} +func {{ .InitDeclaration.Name }}(_ goahttp.Muxer, {{ .VarName }} {{ .FuncDeclaration.Name }}) func(r *http.Request) goahttp.Decoder { return func(r *http.Request) goahttp.Decoder { return goahttp.EncodingFunc(func(v any) error { mr, merr := r.MultipartReader() if merr != nil { return merr } - p := v.(*{{ .Payload.Ref }}) - if err := {{ .VarName }}(mr, p); err != nil { + body := v.(*{{ if .Payload.Request.ServerBody.Declaration }}{{ .Payload.Request.ServerBody.Declaration.Name }}{{ else }}{{ .Payload.Request.ServerBody.VarName }}{{ end }}) + if err := {{ .VarName }}(mr, body); err != nil { return err } - {{- template "partial_request_elements" .Payload.Request }} - {{- if .Payload.Request.MustValidate }} - if err != nil { - return err - } - {{- end }} - {{- if .Payload.Request.PayloadInit }} - {{- range .Payload.Request.PayloadInit.ServerArgs }} - {{- if .FieldName }} - (*p).{{ .FieldName }} = {{ if and (not .Pointer) .FieldPointer }}&{{ end }}{{ .VarName }} - {{- end }} - {{- end }} - {{- end }} return nil }) } diff --git a/http/codegen/templates/multipart_request_decoder_type.go.tpl b/http/codegen/templates/multipart_request_decoder_type.go.tpl index 7dda99adff..db7211a4f6 100644 --- a/http/codegen/templates/multipart_request_decoder_type.go.tpl +++ b/http/codegen/templates/multipart_request_decoder_type.go.tpl @@ -1,2 +1,2 @@ -{{ printf "%s is the type to decode multipart request for the %q service %q endpoint." .FuncName .ServiceName .MethodName | comment }} -type {{ .FuncName }} func(*multipart.Reader, *{{ .Payload.Ref }}) error +{{ printf "%s is the type to decode multipart request for the %q service %q endpoint." .FuncDeclaration.Name .ServiceName .MethodName | comment }} +type {{ .FuncDeclaration.Name }} func(*multipart.Reader, *{{ if .Payload.Request.ServerBody.Declaration }}{{ .Payload.Request.ServerBody.Declaration.Name }}{{ else }}{{ .Payload.Request.ServerBody.VarName }}{{ end }}) error diff --git a/http/codegen/templates/multipart_request_encoder.go.tpl b/http/codegen/templates/multipart_request_encoder.go.tpl index 9fd9625b98..d75fea859a 100644 --- a/http/codegen/templates/multipart_request_encoder.go.tpl +++ b/http/codegen/templates/multipart_request_encoder.go.tpl @@ -1,5 +1,5 @@ -{{ printf "%s returns an encoder to encode the multipart request for the %q service %q endpoint." .InitName .ServiceName .MethodName | comment }} -func {{ .InitName }}(encoderFn {{ .FuncName }}) func(r *http.Request) goahttp.Encoder { +{{ printf "%s returns an encoder to encode the multipart request for the %q service %q endpoint." .InitDeclaration.Name .ServiceName .MethodName | comment }} +func {{ .InitDeclaration.Name }}(encoderFn {{ .FuncDeclaration.Name }}) func(r *http.Request) goahttp.Encoder { return func(r *http.Request) goahttp.Encoder { body := &bytes.Buffer{} mw := multipart.NewWriter(body) diff --git a/http/codegen/templates/multipart_request_encoder_type.go.tpl b/http/codegen/templates/multipart_request_encoder_type.go.tpl index c6633349fa..1e0c11ec34 100644 --- a/http/codegen/templates/multipart_request_encoder_type.go.tpl +++ b/http/codegen/templates/multipart_request_encoder_type.go.tpl @@ -1,2 +1,2 @@ -{{ printf "%s is the type to encode multipart request for the %q service %q endpoint." .FuncName .ServiceName .MethodName | comment }} -type {{ .FuncName }} func(*multipart.Writer, {{ .Payload.Ref }}) error +{{ printf "%s is the type to encode multipart request for the %q service %q endpoint." .FuncDeclaration.Name .ServiceName .MethodName | comment }} +type {{ .FuncDeclaration.Name }} func(*multipart.Writer, {{ .Payload.Ref }}) error diff --git a/http/codegen/templates/parse_endpoint.go.tpl b/http/codegen/templates/parse_endpoint.go.tpl index 64f098eead..2eee58ffd9 100644 --- a/http/codegen/templates/parse_endpoint.go.tpl +++ b/http/codegen/templates/parse_endpoint.go.tpl @@ -1,71 +1,75 @@ // ParseEndpoint returns the endpoint and payload as specified on the command // line. -func ParseEndpoint( - scheme, host string, - doer goahttp.Doer, - enc func(*http.Request) goahttp.Encoder, - dec func(*http.Response) goahttp.Decoder, - restore bool, +func {{ .Declaration.Name }}( + {{ .Variables.Scheme }}, {{ .Variables.Host }} string, + {{ .Variables.Doer }} goahttp.Doer, + {{ .Variables.Encoder }} func(*http.Request) goahttp.Encoder, + {{ .Variables.Decoder }} func(*http.Response) goahttp.Decoder, + {{ .Variables.Restore }} bool, {{- if streamingCmdExists .Commands }} - dialer goahttp.Dialer, + {{ .Variables.Dialer }} goahttp.Dialer, {{- range .Commands }} {{- if .NeedDialer }} - {{ if .JSONRPC }}{{ .VarName }}ConfigFn goahttp.ConnConfigureFunc,{{ else }}{{ .VarName }}Configurer *{{ .PkgName }}.ConnConfigurer,{{ end }} + {{ if .JSONRPC }}{{ .ConfigurerLocal.VarName }} goahttp.ConnConfigureFunc,{{ else }}{{ .ConfigurerLocal.VarName }} *{{ .PkgName }}.{{ .Configurer.Name }},{{ end }} {{- end }} {{- end }} {{- end }} {{- range $i, $c := .Commands }} {{- range .Subcommands }} {{- if .MultipartVarName }} - {{ .MultipartVarName }} {{ $c.PkgName }}.{{ .MultipartFuncName }}, + {{ .MultipartLocal.VarName }} {{ $c.PkgName }}.{{ .MultipartFuncDeclaration.Name }}, {{- end }} {{- end }} {{- if .Interceptors }} - {{ .Interceptors.VarName }} {{ .Interceptors.PkgName }}.ClientInterceptors, + {{ .Interceptors.ParserVar }} {{ .Interceptors.PkgName }}.{{ .Interceptors.ClientInterceptorsDeclaration.Name }}, {{- end }} {{- end }} ) (goa.Endpoint, any, error) { {{ .FlagsCode }} var ( - data any - endpoint goa.Endpoint - err error + {{ .Variables.Data }} any + {{ .Variables.Endpoint }} goa.Endpoint + {{ .Variables.Error }} error ) { - switch svcn { + switch {{ .Variables.ServiceName }} { {{- range .Commands }} case "{{ .Name }}": - c := {{ .PkgName }}.NewClient(scheme, host, doer, enc, dec, restore{{ if .NeedDialer }}, dialer, {{ if .JSONRPC }}{{ .VarName }}ConfigFn{{ else }}{{ .VarName }}Configurer{{ end }}{{ end }}) - switch epn { + {{ $.Variables.Client }} := {{ .PkgName }}.{{ .ClientInit.Name }}({{ $.Variables.Scheme }}, {{ $.Variables.Host }}, {{ $.Variables.Doer }}, {{ $.Variables.Encoder }}, {{ $.Variables.Decoder }}, {{ $.Variables.Restore }}{{ if .NeedDialer }}, {{ $.Variables.Dialer }}, {{ .ConfigurerLocal.VarName }}{{ end }}) + switch {{ $.Variables.MethodName }} { {{- $pkgName := .PkgName }} {{- range .Subcommands }} case "{{ .Name }}": - endpoint = c.{{ .MethodVarName }}({{ if .MultipartVarName }}{{ .MultipartVarName }}{{ end }}) + {{ $.Variables.Endpoint }} = {{ $.Variables.Client }}.{{ .MethodVarName }}({{ if .MultipartLocal }}{{ .MultipartLocal.VarName }}{{ end }}) {{- if .Interceptors }} - endpoint = {{ .Interceptors.PkgName }}.Wrap{{ .MethodVarName }}ClientEndpoint(endpoint, {{ .Interceptors.VarName }}) + {{ $.Variables.Endpoint }} = {{ .Interceptors.PkgName }}.{{ .Interceptors.ClientEndpointWrapperDeclaration.Name }}({{ $.Variables.Endpoint }}, {{ .Interceptors.ParserVar }}) {{- end }} {{- if .BuildFunction }} - data, err = {{ $pkgName }}.{{ .BuildFunction.Name }}({{ range .BuildFunction.ActualParams }}*{{ . }}Flag, {{ end }}) + {{ $.Variables.Data }}, {{ $.Variables.Error }} = {{ $pkgName }}.{{ .BuildFunction.Name }}({{- if .ActualArgs }}{{ range $index, $argument := .ActualArgs }}{{ if $index }}, {{ end }}{{ $argument }}{{ end }}{{ else }}{{ range $index, $variable := .ActualPointerVars }}{{ if $index }}, {{ end }}*{{ $variable }}{{ end }}{{ end }}) {{- else if .Conversion }} {{ .Conversion }} {{- end }} {{- if .StreamFlag }} - {{- if .BuildFunction }} - if err == nil { - {{- end }} - data, err = {{ $pkgName }}.{{ .BuildStreamPayload }}({{ if or .BuildFunction .Conversion }}data, {{ end }}*{{ .StreamFlag.FullName }}Flag) - {{- if .BuildFunction }} + if {{ $.Variables.Error }} == nil { + {{- if .StreamFlag.TracksPresence }} + if {{ .StreamPointerVar }}.value == nil { + {{ $.Variables.Error }} = fmt.Errorf("missing required flag --{{ .StreamFlag.Name }}") + } else { + {{ $.Variables.Data }}, {{ $.Variables.Error }} = {{ $pkgName }}.{{ .BuildStreamPayloadDeclaration.Name }}({{ if or .BuildFunction .Conversion }}{{ $.Variables.Data }}, {{ end }}*{{ .StreamPointerVar }}.value) + } + {{- else }} + {{ $.Variables.Data }}, {{ $.Variables.Error }} = {{ $pkgName }}.{{ .BuildStreamPayloadDeclaration.Name }}({{ if or .BuildFunction .Conversion }}{{ $.Variables.Data }}, {{ end }}*{{ .StreamPointerVar }}) + {{- end }} } - {{- end }} {{- end }} {{- end }} } {{- end }} } } - if err != nil { - return nil, nil, err + if {{ .Variables.Error }} != nil { + return nil, nil, {{ .Variables.Error }} } - return endpoint, data, nil + return {{ .Variables.Endpoint }}, {{ .Variables.Data }}, nil } diff --git a/http/codegen/templates/partial/client_type_conversion.go.tpl b/http/codegen/templates/partial/client_type_conversion.go.tpl index d4382c027f..7e1fc34b5c 100644 --- a/http/codegen/templates/partial/client_type_conversion.go.tpl +++ b/http/codegen/templates/partial/client_type_conversion.go.tpl @@ -1,27 +1 @@ - {{- if eq .Type.Name "boolean" -}} - {{ .VarName }} := strconv.FormatBool({{ if .IsAliased }}bool({{ end }}{{ .Target }}{{ if .IsAliased }}){{ end }}) - {{- else if eq .Type.Name "int" -}} - {{ .VarName }} := strconv.Itoa({{ if .IsAliased }}int({{ end }}{{ .Target }}{{ if .IsAliased }}){{ end }}) - {{- else if eq .Type.Name "int32" -}} - {{ .VarName }} := strconv.FormatInt(int64({{ .Target }}), 10) - {{- else if eq .Type.Name "int64" -}} - {{ .VarName }} := strconv.FormatInt({{ if .IsAliased }}int64({{ end }}{{ .Target }}{{ if .IsAliased }}){{ end }}, 10) - {{- else if eq .Type.Name "uint" -}} - {{ .VarName }} := strconv.FormatUint(uint64({{ .Target }}), 10) - {{- else if eq .Type.Name "uint32" -}} - {{ .VarName }} := strconv.FormatUint(uint64({{ .Target }}), 10) - {{- else if eq .Type.Name "uint64" -}} - {{ .VarName }} := strconv.FormatUint({{ if .IsAliased }}uint64({{ end }}{{ .Target }}{{ if .IsAliased }}){{ end }}, 10) - {{- else if eq .Type.Name "float32" -}} - {{ .VarName }} := strconv.FormatFloat(float64({{ .Target }}), 'f', -1, 32) - {{- else if eq .Type.Name "float64" -}} - {{ .VarName }} := strconv.FormatFloat({{ if .IsAliased }}float64({{ end }}{{ .Target }}{{ if .IsAliased }}){{ end }}, 'f', -1, 64) - {{- else if eq .Type.Name "string" -}} - {{ .VarName }} := {{ if .IsAliased }}string({{ end }}{{ .Target }}{{ if .IsAliased }}){{ end }} - {{- else if eq .Type.Name "bytes" -}} - {{ .VarName }} := string({{ .Target }}) - {{- else if eq .Type.Name "any" -}} - {{ .VarName }} := fmt.Sprintf("%v", {{ .Target }}) - {{- else }} - // unsupported type {{ .Type.Name }} for field {{ .FieldName }} - {{- end }} +{{- .VarName }} := {{ template "partial_client_type_expression" . }} diff --git a/http/codegen/templates/partial/client_type_expression.go.tpl b/http/codegen/templates/partial/client_type_expression.go.tpl new file mode 100644 index 0000000000..be77be13f0 --- /dev/null +++ b/http/codegen/templates/partial/client_type_expression.go.tpl @@ -0,0 +1,23 @@ +{{- if eq .Type.Name "boolean" -}} +strconv.FormatBool({{ if .IsAliased }}bool({{ end }}{{ .Target }}{{ if .IsAliased }}){{ end }}) +{{- else if eq .Type.Name "int" -}} +strconv.Itoa({{ if .IsAliased }}int({{ end }}{{ .Target }}{{ if .IsAliased }}){{ end }}) +{{- else if eq .Type.Name "int32" -}} +strconv.FormatInt(int64({{ .Target }}), 10) +{{- else if eq .Type.Name "int64" -}} +strconv.FormatInt({{ if .IsAliased }}int64({{ end }}{{ .Target }}{{ if .IsAliased }}){{ end }}, 10) +{{- else if eq .Type.Name "uint" "uint32" -}} +strconv.FormatUint(uint64({{ .Target }}), 10) +{{- else if eq .Type.Name "uint64" -}} +strconv.FormatUint({{ if .IsAliased }}uint64({{ end }}{{ .Target }}{{ if .IsAliased }}){{ end }}, 10) +{{- else if eq .Type.Name "float32" -}} +strconv.FormatFloat(float64({{ .Target }}), 'g', -1, 32) +{{- else if eq .Type.Name "float64" -}} +strconv.FormatFloat({{ if .IsAliased }}float64({{ end }}{{ .Target }}{{ if .IsAliased }}){{ end }}, 'g', -1, 64) +{{- else if eq .Type.Name "string" -}} +{{ if .IsAliased }}string({{ end }}{{ .Target }}{{ if .IsAliased }}){{ end }} +{{- else if eq .Type.Name "bytes" -}} +string({{ .Target }}) +{{- else if eq .Type.Name "any" -}} +fmt.Sprintf("%v", {{ .Target }}) +{{- end }} diff --git a/http/codegen/templates/partial/jsonrpc_request_envelope.go.tpl b/http/codegen/templates/partial/jsonrpc_request_envelope.go.tpl index 0d5ffa20af..3e1bf4bff9 100644 --- a/http/codegen/templates/partial/jsonrpc_request_envelope.go.tpl +++ b/http/codegen/templates/partial/jsonrpc_request_envelope.go.tpl @@ -2,32 +2,42 @@ body := &jsonrpc.Request{ JSONRPC: "2.0", Method: "{{ .Method.Name }}", - Params: b, + {{- if .Payload.Request.ClientBody }} + {{- if not .Payload.Request.Params.OmitAbsent }} + Params: {{ if and .Payload.Request.Params .Payload.Request.Params.Positional }}[]{{ .Payload.Request.Params.TypeRef }}{b}{{ else }}b{{ end }}, + {{- end }} + {{- end }} } - {{- if .Payload.IDAttribute }} - {{- if .Payload.IDAttributeRequired }} - if p.{{ .Payload.IDAttribute }} != "" { - body.ID = p.{{ .Payload.IDAttribute }} + {{- if .IsJSONRPCNotification }} + // This method is a notification, so the envelope has no ID. + {{- else if .JSONRPCRequestID.Attribute }} + {{- if .JSONRPCRequestID.Pointer }} + var requestID string + if p.{{ .JSONRPCRequestID.Attribute }} == nil { + requestID = uuid.New().String() + } else { + requestID = {{ if .JSONRPCRequestID.Aliased }}string({{ end }}*p.{{ .JSONRPCRequestID.Attribute }}{{ if .JSONRPCRequestID.Aliased }}){{ end }} } - // If ID is empty, this is a notification - no ID field {{- else }} - if p.{{ .Payload.IDAttribute }} != nil && *p.{{ .Payload.IDAttribute }} != "" { - body.ID = p.{{ .Payload.IDAttribute }} - } - // If ID is nil or empty, this is a notification - no ID field + requestID := {{ if .JSONRPCRequestID.Aliased }}string({{ end }}p.{{ .JSONRPCRequestID.Attribute }}{{ if .JSONRPCRequestID.Aliased }}){{ end }} {{- end }} + body.ID = requestID {{- else }} - // No ID field in payload - always send as a request with generated ID - id := uuid.New().String() - body.ID = id + requestID := uuid.New().String() + body.ID = requestID {{- end }} {{- else }} - // For JSON-RPC methods without payloads, we still need to send the method envelope - // Generate a unique ID for the request - id := uuid.New().String() + {{- if .IsJSONRPCNotification }} + body := &jsonrpc.Request{ + JSONRPC: "2.0", + Method: "{{ .Method.Name }}", + } + {{- else }} + requestID := uuid.New().String() body := &jsonrpc.Request{ JSONRPC: "2.0", Method: "{{ .Method.Name }}", - ID: id, + ID: requestID, } + {{- end }} {{- end }} diff --git a/http/codegen/templates/partial/request_elements.go.tpl b/http/codegen/templates/partial/request_elements.go.tpl index 6849830237..037ac2205f 100644 --- a/http/codegen/templates/partial/request_elements.go.tpl +++ b/http/codegen/templates/partial/request_elements.go.tpl @@ -14,7 +14,7 @@ {{- range .Cookies }} {{ .VarName }} {{ .TypeRef }} {{- end }} - {{- if and .MustValidate (or (not .ServerBody) .Multipart) }} + {{- if and .MustValidate (not .ServerBody) }} err error {{- end }} {{- if .Cookies }} @@ -225,7 +225,38 @@ qp := r.URL.Query() {{- end }} {{- range .Headers }} - {{- if and (or (eq .Type.Name "string") (eq .Type.Name "any")) .Required }} + {{- if .PreserveEmpty }} + {{ .VarName }}Values, {{ .VarName }}Present := r.Header["{{ .CanonicalName }}"] + {{- if .Required }} + if !{{ .VarName }}Present || len({{ .VarName }}Values) == 0 { + err = goa.MergeErrors(err, goa.MissingFieldError("{{ .Name }}", "header")) + } else { + for _, character := range {{ .VarName }}Values[0] { + if character == 0 || character == '\r' || character == '\n' { + err = goa.MergeErrors(err, goa.InvalidPatternError("{{ .Name }}", {{ .VarName }}Values[0], "[^\\x00\\r\\n]*")) + break + } + } + value := {{ if .IsAliased }}{{ .ValueTypeRef }}({{ end }}{{ .VarName }}Values[0]{{ if .IsAliased }}){{ end }} + {{ .VarName }} = {{ if .Pointer }}&{{ end }}value + } + {{- else }} + if {{ .VarName }}Present && len({{ .VarName }}Values) > 0 { + for _, character := range {{ .VarName }}Values[0] { + if character == 0 || character == '\r' || character == '\n' { + err = goa.MergeErrors(err, goa.InvalidPatternError("{{ .Name }}", {{ .VarName }}Values[0], "[^\\x00\\r\\n]*")) + break + } + } + value := {{ if .IsAliased }}{{ .ValueTypeRef }}({{ end }}{{ .VarName }}Values[0]{{ if .IsAliased }}){{ end }} + {{ .VarName }} = {{ if .Pointer }}&{{ end }}value + }{{ if .HasDefault }} else { + {{ .VarName }} = {{ if .IsAliased }}{{ .ValueTypeRef }}({{ end }}{{ printf "%q" .DefaultValue }}{{ if .IsAliased }}){{ end }} + } + {{- end }} + {{- end }} + + {{- else if and (or (eq .Type.Name "string") (eq .Type.Name "any")) .Required }} {{ .VarName }} = r.Header.Get("{{ .HTTPName }}") if {{ .VarName }} == "" { err = goa.MergeErrors(err, goa.MissingFieldError("{{ .Name }}", "header")) diff --git a/http/codegen/templates/partial/response.go.tpl b/http/codegen/templates/partial/response.go.tpl index 895c35517c..68e589ce1c 100644 --- a/http/codegen/templates/partial/response.go.tpl +++ b/http/codegen/templates/partial/response.go.tpl @@ -7,7 +7,7 @@ {{- range $.ViewedResult.Views }} case {{ printf "%q" .Name }}{{ if eq .Name "default" }}, ""{{ end }}: {{- $vsb := (viewedServerBody $.ServerBody .Name) }} - body = {{ $vsb.Init.Name }}({{ range $vsb.Init.ServerArgs }}{{ .Ref }}, {{ end }}) + body = {{ $vsb.Init.Declaration.Name }}({{ range $vsb.Init.ServerArgs }}{{ .Ref }}, {{ end }}) {{- end }} } {{- else if (index .ServerBody 0).Init }} @@ -17,7 +17,7 @@ body = formatter(ctx, {{ (index (index .ServerBody 0).Init.ServerArgs 0).Ref }}) } else { {{- end }} - body {{ if not .ErrorHeader}}:{{ end }}= {{ (index .ServerBody 0).Init.Name }}({{ range (index .ServerBody 0).Init.ServerArgs }}{{ .Ref }}, {{ end }}) + body {{ if not .ErrorHeader}}:{{ end }}= {{ (index .ServerBody 0).Init.Declaration.Name }}({{ range (index .ServerBody 0).Init.ServerArgs }}{{ .Ref }}, {{ end }}) {{- if .ErrorHeader }} } {{- end }} diff --git a/http/codegen/templates/partial/single_response.go.tpl b/http/codegen/templates/partial/single_response.go.tpl index 764446207d..85eafbcfe7 100644 --- a/http/codegen/templates/partial/single_response.go.tpl +++ b/http/codegen/templates/partial/single_response.go.tpl @@ -1,14 +1,19 @@ {{- with .Data }} {{- if .ClientBody }} var ( - body {{ .ClientBody.VarName }} + body {{ if .ClientBody.Declaration }}{{ .ClientBody.Declaration.Name }}{{ else }}{{ .ClientBody.VarName }}{{ end }} err error ) err = decoder(resp).Decode(&body) if err != nil { return nil, goahttp.ErrDecodingError("{{ $.ServiceName }}", "{{ $.Method.Name }}", err) } - {{- if .ClientBody.ValidateRef }} + {{- if and .ClientBody.ValidatorDeclaration .ClientBody.ValidationTarget }} + err = {{ .ClientBody.ValidatorDeclaration.Name }}({{ .ClientBody.ValidationTarget }}) + if err != nil { + return nil, goahttp.ErrValidationError("{{ $.ServiceName }}", "{{ $.Method.Name }}", err) + } + {{- else if .ClientBody.ValidateRef }} {{ .ClientBody.ValidateRef }} if err != nil { return nil, goahttp.ErrValidationError("{{ $.ServiceName }}", "{{ $.Method.Name }}", err) diff --git a/http/codegen/templates/partial/sse_format.go.tpl b/http/codegen/templates/partial/sse_format.go.tpl index 1745c21900..f46be3b6f4 100644 --- a/http/codegen/templates/partial/sse_format.go.tpl +++ b/http/codegen/templates/partial/sse_format.go.tpl @@ -1,21 +1,35 @@ -{{- if eq .TypeRef "string" }} - data = {{ .VarName }} -{{- else if eq .TypeRef "boolean" }} - if {{ .VarName }} { +{{/* +sse_format.go.tpl converts one planned service value to SSE event text. Every +type choice is resolved before this template writes the send method. +*/ -}} +{{- $value := .Value }} +{{- if .Encoding.Pointer }} + if {{ .Value }} != nil { + {{- $value = printf "*%s" .Value }} +{{- end }} +{{- if sseString .Encoding }} + data = string({{ $value }}) +{{- else if sseBoolean .Encoding }} + if {{ $value }} { data = "true" } else { data = "false" } -{{- else if eq .TypeRef "bytes" }} - data = string({{ .VarName }}) -{{- else if or (eq .TypeRef "int") (eq .TypeRef "int32") (eq .TypeRef "int64") (eq .TypeRef "uint") (eq .TypeRef "uint32") (eq .TypeRef "uint64") }} - data = fmt.Sprintf("%d", {{ .VarName }}) -{{- else if or (eq .TypeRef "float32") (eq .TypeRef "float64") }} - data = fmt.Sprintf("%g", {{ .VarName }}) +{{- else if sseBytes .Encoding }} + data = string({{ $value }}) +{{- else if or (sseSignedInteger .Encoding) (sseUnsignedInteger .Encoding) }} + data = fmt.Sprintf("%d", {{ $value }}) +{{- else if sseFloat .Encoding }} + data = fmt.Sprintf("%g", {{ $value }}) {{- else }} - byts, err := json.Marshal({{ .VarName }}) + byts, err := json.Marshal({{ $value }}) if err != nil { return err } data = string(byts) -{{- end }} \ No newline at end of file +{{- end }} +{{- if .Encoding.Pointer }} + } else { + hasData = false + } +{{- end }} diff --git a/http/codegen/templates/partial/sse_parse.go.tpl b/http/codegen/templates/partial/sse_parse.go.tpl index abc3995d0c..cbf0679e46 100644 --- a/http/codegen/templates/partial/sse_parse.go.tpl +++ b/http/codegen/templates/partial/sse_parse.go.tpl @@ -1,58 +1,87 @@ -{{- if eq .TypeRef "string" }} - {{ .Target }} = dataContent -{{- else if eq .TypeRef "boolean" }} +{{/* +sse_parse.go.tpl rebuilds one planned Go value from SSE event text. The +generated client contains only the conversion required by that value. +*/ -}} +{{- if sseString .Encoding }} + {{- if .TargetPointer }} + {{- if .Named }} + value := {{ .TypeRef }}({{ .Source }}) + {{- else }} + value := {{ .Source }} + {{- end }} + {{ .Target }} = &value + {{- else if .Named }} + {{ .Target }} = {{ .TypeRef }}({{ .Source }}) + {{- else }} + {{ .Target }} = {{ .Source }} + {{- end }} +{{- else if sseBoolean .Encoding }} var val bool - val, err = strconv.ParseBool(dataContent) + val, err = strconv.ParseBool({{ .Source }}) if err != nil { return } + {{- if .TargetPointer }} + value := {{ .TypeRef }}(val) + {{ .Target }} = &value + {{- else if .Named }} + {{ .Target }} = {{ .TypeRef }}(val) + {{- else }} {{ .Target }} = val -{{- else if eq .TypeRef "bytes" }} - {{ .Target }} = []byte(dataContent) -{{- else if or (eq .TypeRef "int") (eq .TypeRef "int32") }} + {{- end }} +{{- else if sseBytes .Encoding }} + {{- if .TargetPointer }} + value := {{ .TypeRef }}([]byte({{ .Source }})) + {{ .Target }} = &value + {{- else if .Named }} + {{ .Target }} = {{ .TypeRef }}([]byte({{ .Source }})) + {{- else }} + {{ .Target }} = []byte({{ .Source }}) + {{- end }} +{{- else if sseSignedInteger .Encoding }} var val int64 - val, err = strconv.ParseInt(dataContent, 10, 0) + val, err = strconv.ParseInt({{ .Source }}, 10, {{ sseBitSize .Encoding }}) if err != nil { return } + {{- if .TargetPointer }} + value := {{ .TypeRef }}(val) + {{ .Target }} = &value + {{- else }} {{ .Target }} = {{ .TypeRef }}(val) -{{- else if eq .TypeRef "int64" }} - {{ .Target }}, err = strconv.ParseInt(dataContent, 10, 64) - if err != nil { - return - } -{{- else if or (eq .TypeRef "uint") (eq .TypeRef "uint32") }} + {{- end }} +{{- else if sseUnsignedInteger .Encoding }} var val uint64 - val, err = strconv.ParseUint(dataContent, 10, 0) + val, err = strconv.ParseUint({{ .Source }}, 10, {{ sseBitSize .Encoding }}) if err != nil { return } + {{- if .TargetPointer }} + value := {{ .TypeRef }}(val) + {{ .Target }} = &value + {{- else }} {{ .Target }} = {{ .TypeRef }}(val) -{{- else if eq .TypeRef "uint64" }} - {{ .Target }}, err = strconv.ParseUint(dataContent, 10, 64) - if err != nil { - return - } -{{- else if eq .TypeRef "float32" }} + {{- end }} +{{- else if sseFloat .Encoding }} var val float64 - val, err = strconv.ParseFloat(dataContent, 32) - if err != nil { - return - } - {{ .Target }} = float32(val) -{{- else if eq .TypeRef "float64" }} - {{ .Target }}, err = strconv.ParseFloat(dataContent, 64) + val, err = strconv.ParseFloat({{ .Source }}, {{ sseBitSize .Encoding }}) if err != nil { return } + {{- if .TargetPointer }} + value := {{ .TypeRef }}(val) + {{ .Target }} = &value + {{- else }} + {{ .Target }} = {{ .TypeRef }}(val) + {{- end }} {{- else }} - // Use user-provided decoder for complex types + // The configured decoder handles structured event data. respBody := &http.Response{ StatusCode: http.StatusOK, - Body: io.NopCloser(bytes.NewReader([]byte(dataContent))), + Body: io.NopCloser(bytes.NewReader([]byte({{ .Source }}))), } err = s.decoder(respBody).Decode(&{{ .Target }}) if err != nil { return } -{{- end }} \ No newline at end of file +{{- end }} diff --git a/http/codegen/templates/partial/websocket_upgrade.go.tpl b/http/codegen/templates/partial/websocket_upgrade.go.tpl index 86cbb06e87..18944f7f0c 100644 --- a/http/codegen/templates/partial/websocket_upgrade.go.tpl +++ b/http/codegen/templates/partial/websocket_upgrade.go.tpl @@ -1,13 +1,13 @@ {{ printf "Upgrade the HTTP connection to a websocket connection only once. Connection upgrade is done here so that authorization logic in the endpoint is executed before calling the actual service method which may call %s()." .Function | comment }} s.once.Do(func() { - {{- if and .ViewedResult (eq .Function "Send") }} + {{- if and .ViewedResult (or (eq .Function "Send") (eq .Function "Close")) }} {{- if not .ViewedResult.ViewName }} respHdr := make(http.Header) - respHdr.Add("goa-view", s.view) + respHdr.Add("goa-view", view) {{- end }} {{- end }} var conn *websocket.Conn - {{- if eq .Function "Send" }} + {{- if or (eq .Function "Send") (eq .Function "Close") }} {{- if .ViewedResult }} {{- if not .ViewedResult.ViewName }} conn, err = s.upgrader.Upgrade(s.w, s.r, respHdr) diff --git a/http/codegen/templates/path.go.tpl b/http/codegen/templates/path.go.tpl index 30d0663e67..b555c2a341 100644 --- a/http/codegen/templates/path.go.tpl +++ b/http/codegen/templates/path.go.tpl @@ -1,5 +1,5 @@ {{ range .Routes }}// {{ .PathInit.Description }} -func {{ .PathInit.Name }}({{ range .PathInit.ServerArgs }}{{ .VarName }} {{ .TypeRef }}, {{ end }}) {{ .PathInit.ReturnTypeRef }} { +func {{ if $.Client }}{{ .PathInit.ClientDeclaration.Name }}{{ else }}{{ .PathInit.Declaration.Name }}{{ end }}({{ range .PathInit.ServerArgs }}{{ .VarName }} {{ .TypeRef }}, {{ end }}) {{ .PathInit.ReturnTypeRef }} { {{- .PathInit.ServerCode }} } {{ end }} diff --git a/http/codegen/templates/request_builder.go.tpl b/http/codegen/templates/request_builder.go.tpl index 5fb72f304a..1eb1935bd1 100644 --- a/http/codegen/templates/request_builder.go.tpl +++ b/http/codegen/templates/request_builder.go.tpl @@ -1,4 +1,4 @@ {{ comment .RequestInit.Description }} -func (c *{{ .ClientStruct }}) {{ .RequestInit.Name }}(ctx context.Context, {{ range .RequestInit.ClientArgs }}{{ .VarName }} {{ .TypeRef }},{{ end }}) (*http.Request, error) { +func (c *{{ .ClientStructDeclaration.Name }}) {{ .RequestInit.Declaration.Name }}(ctx context.Context, {{ range .RequestInit.ClientArgs }}{{ .VarName }} {{ .TypeRef }},{{ end }}) (*http.Request, error) { {{- .RequestInit.ClientCode }} } diff --git a/http/codegen/templates/request_decoder.go.tpl b/http/codegen/templates/request_decoder.go.tpl index 0b4570c7f1..0ae7836999 100644 --- a/http/codegen/templates/request_decoder.go.tpl +++ b/http/codegen/templates/request_decoder.go.tpl @@ -1,21 +1,84 @@ -{{ printf "%s returns a decoder for requests sent to the %s %s endpoint." .RequestDecoder .ServiceName .Method.Name | comment }} -func {{ .RequestDecoder }}(mux goahttp.Muxer, decoder func(*http.Request) goahttp.Decoder) func(*http.Request{{ if .IsJSONRPC }}, *jsonrpc.RawRequest{{ end }}) ({{ .Payload.Ref }}, error) { +{{ printf "%s returns a decoder for requests sent to the %s %s endpoint." .RequestDecoderDeclaration.Name .ServiceName .Method.Name | comment }} +func {{ .RequestDecoderDeclaration.Name }}(mux goahttp.Muxer, decoder func(*http.Request) goahttp.Decoder) func(*http.Request{{ if .IsJSONRPC }}, *jsonrpc.RawRequest{{ end }}) ({{ .Payload.Ref }}, error) { return func(r *http.Request{{ if .IsJSONRPC }}, req *jsonrpc.RawRequest{{ end }}) ({{ .Payload.Ref }}, error) { + {{- if and .IsJSONRPC .Payload.Request.Params .Payload.Request.Params.Positional }} + var payload {{ .Payload.Ref }} + {{- end }} {{- if .IsJSONRPC }} + {{- if and .Payload.Request.Params .Payload.Request.Params.Positional }} + {{- if .Payload.Request.Params.AllowAbsent }} + if len(req.Params) > 0 { + {{- end }} + param, paramErr := jsonrpc.SinglePositionalParam(req.Params) + if paramErr != nil { + return payload, goa.DecodePayloadError(paramErr.Error()) + } + {{- if .Payload.Request.Params.RejectNull }} + if bytes.Equal(bytes.TrimSpace(param), []byte("null")) { + return payload, goa.MissingPayloadError() + } + {{- end }} + r.Body = io.NopCloser(bytes.NewReader(param)) + {{- if .Payload.Request.Params.AllowAbsent }} + } else { + r.Body = http.NoBody + } + {{- end }} + {{- else }} r.Body = io.NopCloser(bytes.NewReader(req.Params)) + {{- end }} {{- end }} + {{- if not (and .IsJSONRPC .Payload.Request.Params .Payload.Request.Params.Positional) }} var payload {{ .Payload.Ref }} -{{- if .MultipartRequestDecoder }} - if err := decoder(r).Decode(&payload); err != nil { - var gerr *goa.ServiceError - if errors.As(err, &gerr) { - return payload, gerr + {{- end }} +{{- with .JSONRPCRequestID }} +{{- if .Attribute }} +{{- if .MustHave }} + if !req.HasID || req.ID == nil { + return payload, goa.MissingFieldError("id", "JSON-RPC request") + } +{{- end }} +{{- if .Pointer }} + var {{ .Variable }} *{{ .ValueTypeRef }} + if req.ID != nil { + decodedID, decodeErr := jsonrpc.IDToString(req.ID) + if decodeErr != nil { + return payload, goa.DecodePayloadError(decodeErr.Error()) } - return payload, goa.DecodePayloadError(err.Error()) + value := {{ if .Aliased }}{{ .ValueTypeRef }}({{ end }}decodedID{{ if .Aliased }}){{ end }} + {{ .Variable }} = &value + } +{{- else }} + var {{ .Variable }} {{ .ValueTypeRef }} + {{- if .HasDefault }} + {{ .Variable }} = {{ if .Aliased }}{{ .ValueTypeRef }}({{ end }}{{ printf "%q" .DefaultValue }}{{ if .Aliased }}){{ end }} + {{- end }} + {{- if not .MustHave }} + if req.ID != nil { + {{- end }} + decodedID, decodeErr := jsonrpc.IDToString(req.ID) + if decodeErr != nil { + return payload, goa.DecodePayloadError(decodeErr.Error()) + } + {{ .Variable }} = {{ if .Aliased }}{{ .ValueTypeRef }}({{ end }}decodedID{{ if .Aliased }}){{ end }} + {{- if not .MustHave }} } -{{- else if .Payload.Request.ServerBody }} + {{- end }} +{{- end }} +{{- if .Validate }} + { + var err error + {{ .Validate }} + if err != nil { + return payload, err + } + } +{{- end }} +{{- end }} +{{- end }} +{{- if .Payload.Request.ServerBody }} var ( - body {{ .Payload.Request.ServerBody.VarName }} + body {{ if .Payload.Request.OptionalBody }}{{ (index .Payload.Request.PayloadInit.ServerArgs 0).TypeRef }}{{ else if .Payload.Request.ServerBody.Declaration }}{{ .Payload.Request.ServerBody.Declaration.Name }}{{ else }}{{ .Payload.Request.ServerBody.VarName }}{{ end }} err error ) err = decoder(r).Decode(&body) @@ -38,48 +101,73 @@ func {{ .RequestDecoder }}(mux goahttp.Muxer, decoder func(*http.Request) goahtt } {{- end }} } - {{- if .Payload.Request.ServerBody.ValidateRef }} + {{- if and .Payload.Request.OptionalBody (or .Payload.Request.ServerBody.ValidatorDeclaration .Payload.Request.ServerBody.ValidateRef) }} + if body != nil { + {{- end }} + {{- if and .Payload.Request.ServerBody.ValidatorDeclaration .Payload.Request.ServerBody.ValidationTarget }} + err = {{ .Payload.Request.ServerBody.ValidatorDeclaration.Name }}({{ if .Payload.Request.OptionalBody }}body{{ else }}{{ .Payload.Request.ServerBody.ValidationTarget }}{{ end }}) + if err != nil { + return payload, err + } + {{- else if .Payload.Request.ServerBody.ValidateRef }} {{ .Payload.Request.ServerBody.ValidateRef }} if err != nil { return payload, err } {{- end }} + {{- if and .Payload.Request.OptionalBody (or .Payload.Request.ServerBody.ValidatorDeclaration .Payload.Request.ServerBody.ValidateRef) }} + } + {{- end }} {{- end }} -{{- if not .MultipartRequestDecoder }} {{- template "partial_request_elements" .Payload.Request }} {{- if .Payload.Request.MustValidate }} if err != nil { return payload, err } {{- end }} - {{- if .Payload.Request.PayloadInit }} - payload = {{ .Payload.Request.PayloadInit.Name }}({{ range .Payload.Request.PayloadInit.ServerArgs }}{{ .Ref }}, {{ end }}) - {{- else if .Payload.DecoderReturnValue }} - payload = {{ .Payload.DecoderReturnValue }} - {{- else }} - payload = body - {{- end }} -{{- end }} {{- if .BasicScheme }}{{ with .BasicScheme }} - user, pass, {{ if or .UsernameRequired .PasswordRequired }}ok{{ else }}_{{ end }} := r.BasicAuth() + user, pass, ok := r.BasicAuth() {{- if or .UsernameRequired .PasswordRequired}} if !ok { return payload, goa.MissingFieldError("Authorization", "header") } {{- end }} - payload.{{ .UsernameField }} = {{ if .UsernamePointer }}&{{ end }}user - payload.{{ .PasswordField }} = {{ if .PasswordPointer }}&{{ end }}pass + {{- if and .UsernamePointer .PasswordPointer }} + var userPtr, passPtr *string + if ok { + userPtr = &user + passPtr = &pass + } + {{- else }} + {{- if .UsernamePointer }} + userPtr := &user + {{- end }} + {{- if .PasswordPointer }} + passPtr := &pass + {{- end }} + {{- end }} {{- end }}{{ end }} + {{- if .Payload.Request.PayloadInit }} + payload = {{ .Payload.Request.PayloadInit.Declaration.Name }}({{ range .Payload.Request.PayloadInit.ServerArgs }}{{ .Ref }}, {{ end }}) + {{- else if .Payload.DecoderReturnValue }} + payload = {{ .Payload.DecoderReturnValue }} + {{- else }} + payload = body + {{- end }} {{- range .HeaderSchemes }} {{- if not .CredRequired }} if payload.{{ .CredField }} != nil { - {{- end }} - if strings.Contains({{ if .CredPointer }}*{{ end }}payload.{{ .CredField }}, " ") { - // Remove authorization scheme prefix (e.g. "Bearer") - cred := strings.SplitN({{ if .CredPointer }}*{{ end }}payload.{{ .CredField }}, " ", 2)[1] - payload.{{ .CredField }} = {{ if .CredPointer }}&{{ end }}cred + cred := *payload.{{ .CredField }} + if index := strings.IndexByte(string(cred), ' '); index >= 0 { + // Remove authorization scheme prefix (e.g. "Bearer") + cred = cred[index+1:] + payload.{{ .CredField }} = &cred + } } - {{- if not .CredRequired }} + {{- else }} + if index := strings.IndexByte(string(payload.{{ .CredField }}), ' '); index >= 0 { + // Remove authorization scheme prefix (e.g. "Bearer") + payload.{{ .CredField }} = payload.{{ .CredField }}[index+1:] } {{- end }} {{- end }} diff --git a/http/codegen/templates/request_encoder.go.tpl b/http/codegen/templates/request_encoder.go.tpl index da463f0b68..3e0efc7f99 100644 --- a/http/codegen/templates/request_encoder.go.tpl +++ b/http/codegen/templates/request_encoder.go.tpl @@ -1,23 +1,28 @@ -{{ if and .IsJSONRPC (not .Payload.Ref) }}{{ printf "%s returns an encoder for requests sent to the %s service %s JSON-RPC method." .RequestEncoder .ServiceName .Method.Name | comment }}{{ else }}{{ printf "%s returns an encoder for requests sent to the %s %s server." .RequestEncoder .ServiceName .Method.Name | comment }}{{ end }} -func {{ .RequestEncoder }}(encoder func(*http.Request) goahttp.Encoder) func(*http.Request, any) error { - return func(req *http.Request, v any) error { +{{- $returnsID := and .IsJSONRPC (not .IsJSONRPCNotification) }} +{{- $omitSelectedBody := .Payload.Request.BodyFieldCanBeAbsent }} +{{- if .IsJSONRPC }} + {{- $omitSelectedBody = and $omitSelectedBody .Payload.Request.Params.OmitAbsent }} +{{- end }} +{{ if $returnsID }}{{ printf "%s returns an encoder for requests sent to the %s service %s JSON-RPC method. The encoder returns the request ID written into the JSON-RPC message." .RequestEncoderDeclaration.Name .ServiceName .Method.Name | comment }}{{ else if and .IsJSONRPC (not .Payload.Ref) }}{{ printf "%s returns an encoder for requests sent to the %s service %s JSON-RPC method." .RequestEncoderDeclaration.Name .ServiceName .Method.Name | comment }}{{ else }}{{ printf "%s returns an encoder for requests sent to the %s %s server." .RequestEncoderDeclaration.Name .ServiceName .Method.Name | comment }}{{ end }} +func {{ .RequestEncoderDeclaration.Name }}(encoder func(*http.Request) goahttp.Encoder) func(*http.Request, any) {{ if $returnsID }}(string, error){{ else }}error{{ end }} { + return func(req *http.Request, v any) {{ if $returnsID }}(string, error){{ else }}error{{ end }} { {{- if and .IsJSONRPC (not .Payload.Ref) }} {{- template "partial_jsonrpc_request_envelope" . }} if err := encoder(req).Encode(body); err != nil { - return goahttp.ErrEncodingError("{{ .ServiceName }}", "{{ .Method.Name }}", err) + return {{ if $returnsID }}"", {{ end }}goahttp.ErrEncodingError("{{ .ServiceName }}", "{{ .Method.Name }}", err) } - return nil + return {{ if $returnsID }}requestID, {{ end }}nil {{- else }} {{- if .Method.SkipRequestBodyEncodeDecode }} - data, ok := v.(*{{ requestStructPkg .Method .ServicePkgName }}.{{ .Method.RequestStruct }}) + data, ok := v.(*{{ .ServicePkgName }}.{{ .Method.RequestStruct }}) if !ok { - return goahttp.ErrInvalidType("{{ .ServiceName }}", "{{ .Method.Name }}", "*{{ requestStructPkg .Method .ServicePkgName }}.{{ .Method.RequestStruct }}", v) + return {{ if $returnsID }}"", {{ end }}goahttp.ErrInvalidType("{{ .ServiceName }}", "{{ .Method.Name }}", "*{{ .ServicePkgName }}.{{ .Method.RequestStruct }}", v) } p := data.Payload {{- else }} p, ok := v.({{ .Payload.Ref }}) if !ok { - return goahttp.ErrInvalidType("{{ .ServiceName }}", "{{ .Method.Name }}", "{{ .Payload.Ref }}", v) + return {{ if $returnsID }}"", {{ end }}goahttp.ErrInvalidType("{{ .ServiceName }}", "{{ .Method.Name }}", "{{ .Payload.Ref }}", v) } {{- end }} {{- range .Payload.Request.Headers }} @@ -28,6 +33,13 @@ func {{ .RequestEncoder }}(encoder func(*http.Request) goahttp.Encoder) func(*ht { {{- end }} head := {{ if .FieldPointer }}*{{ end }}p.{{ .FieldName }} + {{- if .PreserveEmpty }} + for _, character := range head { + if character == 0 || character == '\r' || character == '\n' { + return {{ if $returnsID }}"", {{ end }}goahttp.ErrValidationError("{{ $.ServiceName }}", "{{ $.Method.Name }}", goa.InvalidPatternError("{{ .Name }}", string(head), "[^\\x00\\r\\n]*")) + } + } + {{- end }} {{- if (and (eq .HTTPName "Authorization") (isBearer $.HeaderSchemes)) }} if !strings.Contains(head, " ") { req.Header.Set({{ printf "%q" .HTTPName }}, "Bearer "+head) @@ -126,13 +138,11 @@ func {{ .RequestEncoder }}(encoder func(*http.Request) goahttp.Encoder) func(*ht {{- if .FieldPointer }} if p.{{ .FieldName }} != nil { {{- end }} - values.Add("{{ .HTTPName }}", - {{- if or (eq .Type.Name "bytes") (and (isAlias .FieldType) (eq (underlyingType .FieldType).Name "string")) }} string( - {{- else if not (eq .Type.Name "string") }} fmt.Sprintf("%v", + {{- $target := printf "p.%s" .FieldName }} + {{- if .FieldPointer }} + {{- $target = printf "*p.%s" .FieldName }} {{- end }} - {{- if .FieldPointer }}*{{ end }}p.{{ .FieldName }} - {{- if or (eq .Type.Name "bytes") (not (eq .Type.Name "string")) (and (isAlias .FieldType) (eq (underlyingType .FieldType).Name "string")) }}) - {{- end }}) + values.Add("{{ .HTTPName }}", {{ template "partial_client_type_expression" (typeConversionData .Type .FieldType "" $target) }}) {{- if .FieldPointer }} } {{- end }} @@ -152,11 +162,36 @@ func {{ .RequestEncoder }}(encoder func(*http.Request) goahttp.Encoder) func(*ht {{- end }} {{- if .MultipartRequestEncoder }} if err := encoder(req).Encode(p); err != nil { - return goahttp.ErrEncodingError("{{ .ServiceName }}", "{{ .Method.Name }}", err) + return {{ if $returnsID }}"", {{ end }}goahttp.ErrEncodingError("{{ .ServiceName }}", "{{ .Method.Name }}", err) } {{- else if .Payload.Request.ClientBody }} + {{- if and .IsJSONRPC $omitSelectedBody }} + {{- template "partial_jsonrpc_request_envelope" . }} + if {{ if .Payload.Request.BodyIsUnion }}p.{{ .Payload.Request.PayloadAttr }}.Kind() != ""{{ else }}p.{{ .Payload.Request.PayloadAttr }} != nil{{ end }} { + {{- if .Payload.Request.ClientBody.Init }} + b := {{ .Payload.Request.ClientBody.Init.Declaration.Name }}({{ range .Payload.Request.ClientBody.Init.ClientArgs }}{{ if .FieldPointer }}&{{ end }}{{ .VarName }}, {{ end }}) + {{- else }} + b := {{ if .Payload.Request.BodyFieldPointer }}*{{ end }}p.{{ .Payload.Request.PayloadAttr }} + {{- end }} + body.Params = b + } + if err := encoder(req).Encode(&body); err != nil { + return {{ if $returnsID }}"", {{ end }}goahttp.ErrEncodingError("{{ .ServiceName }}", "{{ .Method.Name }}", err) + } + {{- else if $omitSelectedBody }} + if {{ if .Payload.Request.BodyIsUnion }}p.{{ .Payload.Request.PayloadAttr }}.Kind() != ""{{ else }}p.{{ .Payload.Request.PayloadAttr }} != nil{{ end }} { + {{- if .Payload.Request.ClientBody.Init }} + body := {{ .Payload.Request.ClientBody.Init.Declaration.Name }}({{ range .Payload.Request.ClientBody.Init.ClientArgs }}{{ if .FieldPointer }}&{{ end }}{{ .VarName }}, {{ end }}) + {{- else }} + body := {{ if .Payload.Request.BodyFieldPointer }}*{{ end }}p.{{ .Payload.Request.PayloadAttr }} + {{- end }} + if err := encoder(req).Encode(&body); err != nil { + return {{ if $returnsID }}"", {{ end }}goahttp.ErrEncodingError("{{ .ServiceName }}", "{{ .Method.Name }}", err) + } + } + {{- else }} {{- if .Payload.Request.ClientBody.Init }} - {{ if .IsJSONRPC }}b{{ else }}body{{ end }} := {{ .Payload.Request.ClientBody.Init.Name }}({{ range .Payload.Request.ClientBody.Init.ClientArgs }}{{ if .FieldPointer }}&{{ end }}{{ .VarName }}, {{ end }}) + {{ if .IsJSONRPC }}b{{ else }}body{{ end }} := {{ .Payload.Request.ClientBody.Init.Declaration.Name }}({{ range .Payload.Request.ClientBody.Init.ClientArgs }}{{ if .FieldPointer }}&{{ end }}{{ .VarName }}, {{ end }}) {{- else }} {{ if .IsJSONRPC }}b{{ else }}body{{ end }} := p{{ if .Payload.Request.PayloadAttr }}.{{ .Payload.Request.PayloadAttr }}{{ end }} {{- end }} @@ -164,25 +199,37 @@ func {{ .RequestEncoder }}(encoder func(*http.Request) goahttp.Encoder) func(*ht {{- template "partial_jsonrpc_request_envelope" . }} {{- end }} if err := encoder(req).Encode(&body); err != nil { - return goahttp.ErrEncodingError("{{ .ServiceName }}", "{{ .Method.Name }}", err) + return {{ if $returnsID }}"", {{ end }}goahttp.ErrEncodingError("{{ .ServiceName }}", "{{ .Method.Name }}", err) + } + {{- end }} + {{- else if .IsJSONRPC }} + {{- template "partial_jsonrpc_request_envelope" . }} + if err := encoder(req).Encode(&body); err != nil { + return {{ if $returnsID }}"", {{ end }}goahttp.ErrEncodingError("{{ .ServiceName }}", "{{ .Method.Name }}", err) } {{- end }} {{- if .BasicScheme }}{{ with .BasicScheme }} - {{- if not .UsernameRequired }} + {{- if .UsernamePointer }} + var user string if p.{{ .UsernameField }} != nil { + user = string(*p.{{ .UsernameField }}) + } {{- end }} - {{- if not .PasswordRequired }} + {{- if .PasswordPointer }} + var pass string if p.{{ .PasswordField }} != nil { - {{- end }} - req.SetBasicAuth({{ if .UsernamePointer }}*{{ end }}p.{{ .UsernameField }}, {{ if .PasswordPointer }}*{{ end }}p.{{ .PasswordField }}) - {{- if not .UsernameRequired }} + pass = string(*p.{{ .PasswordField }}) } {{- end }} - {{- if not .PasswordRequired }} + {{- if and .UsernamePointer .PasswordPointer }} + if p.{{ .UsernameField }} != nil || p.{{ .PasswordField }} != nil { + {{- end }} + req.SetBasicAuth({{ if .UsernamePointer }}user{{ else }}string(p.{{ .UsernameField }}){{ end }}, {{ if .PasswordPointer }}pass{{ else }}string(p.{{ .PasswordField }}){{ end }}) + {{- if and .UsernamePointer .PasswordPointer }} } {{- end }} {{- end }}{{ end }} - return nil + return {{ if $returnsID }}requestID, {{ end }}nil {{- end }} } } diff --git a/http/codegen/templates/request_init.go.tpl b/http/codegen/templates/request_init.go.tpl index a3031e072f..0d4821c930 100644 --- a/http/codegen/templates/request_init.go.tpl +++ b/http/codegen/templates/request_init.go.tpl @@ -28,7 +28,7 @@ if p{{ if $.HasFields }}.{{ .FieldName }}{{ end }} != nil { {{- end }} {{- if .IsAliased }} - {{ .VarName }} = {{ .ServiceTypeRef }}({{ if .Pointer }}*{{ end }}p{{ if $.HasFields }}.{{ .FieldName }}{{ end }}) + {{ .VarName }} = {{ .TypeRef }}({{ if .Pointer }}*{{ end }}p{{ if $.HasFields }}.{{ .FieldName }}{{ end }}) {{- else }} {{ .VarName }} = {{ if .Pointer }}*{{ end }}p{{ if $.HasFields }}.{{ .FieldName }}{{ end }} {{- end }} @@ -53,7 +53,7 @@ scheme = "wss" } {{- end }} - u := &url.URL{Scheme: {{ if .IsWebSocket }}scheme{{ else }}c.scheme{{ end }}, Host: c.host, Path: {{ .PathInit.Name }}({{ range .Args }}{{ .Ref }}, {{ end }})} + u := &url.URL{Scheme: {{ if .IsWebSocket }}scheme{{ else }}c.scheme{{ end }}, Host: c.host, Path: {{ .PathInit.ClientDeclaration.Name }}({{ range .Args }}{{ .Ref }}, {{ end }})} req, err := http.NewRequest("{{ .Verb }}", u.String(), {{ if .RequestStruct }}body{{ else }}nil{{ end }}) if err != nil { return nil, goahttp.ErrInvalidURL("{{ .ServiceName }}", "{{ .EndpointName }}", u.String(), err) diff --git a/http/codegen/templates/response_decoder.go.tpl b/http/codegen/templates/response_decoder.go.tpl index 17ea5eeae8..2973d1246d 100644 --- a/http/codegen/templates/response_decoder.go.tpl +++ b/http/codegen/templates/response_decoder.go.tpl @@ -1,6 +1,6 @@ -{{ printf "%s returns a decoder for responses returned by the %s %s endpoint. restoreBody controls whether the response body should be restored after having been read." .ResponseDecoder .ServiceName .Method.Name | comment }} +{{ printf "%s returns a decoder for responses returned by the %s %s endpoint. restoreBody controls whether the response body should be restored after having been read." .ResponseDecoderDeclaration.Name .ServiceName .Method.Name | comment }} {{- if .Errors }} -{{ printf "%s may return the following errors:" .ResponseDecoder | comment }} +{{ printf "%s may return the following errors:" .ResponseDecoderDeclaration.Name | comment }} {{- range $gerr := .Errors }} {{- range $errors := .Errors }} // - {{ printf "%q" .Name }} (type {{ .Ref }}): {{ .Response.StatusCode }}{{ if .Response.Description }}, {{ .Response.Description }}{{ end }} @@ -8,29 +8,63 @@ {{- end }} // - error: internal error {{- end }} -func {{ .ResponseDecoder }}(decoder func(*http.Response) goahttp.Decoder, restoreBody bool) func(*http.Response) (any, error) { - return func(resp *http.Response) (any, error) { +func {{ .ResponseDecoderDeclaration.Name }}(decoder func(*http.Response) goahttp.Decoder, restoreBody bool) func(*http.Response) (any, error) { + return func(resp *http.Response) {{ if .Method.SkipResponseBodyEncodeDecode }}(any, error){{ else }}(result any, decodeErr error){{ end }} { + {{- if not .Method.SkipResponseBodyEncodeDecode }} + responseBody := resp.Body if restoreBody { - b, err := io.ReadAll(resp.Body) - if err != nil { - return nil, err + b, readErr := io.ReadAll(responseBody) + closeErr := responseBody.Close() + if err := errors.Join(readErr, closeErr); err != nil { + return nil, goahttp.ErrDecodingError("{{ .ServiceName }}", "{{ .Method.Name }}", err) } resp.Body = io.NopCloser(bytes.NewBuffer(b)) defer func() { resp.Body = io.NopCloser(bytes.NewBuffer(b)) }() - } - {{- if not .Method.SkipResponseBodyEncodeDecode }} else { - defer resp.Body.Close() + } else { + defer func() { + if err := responseBody.Close(); err != nil { + decodeErr = errors.Join(decodeErr, goahttp.ErrDecodingError("{{ .ServiceName }}", "{{ .Method.Name }}", err)) + } + }() } {{- end }} switch resp.StatusCode { {{- range .Result.Responses }} case {{ .StatusCode }}: + {{- if .SelectClientBodyByView }} + view := resp.Header.Get("goa-view") + if view == "" { + view = "default" + } + switch view { + {{- $response := . }} + {{- range .ViewedRepresentations }} + case {{ printf "%q" .View }}: + {{- template "partial_single_response" (buildViewedResponseData $response . $.ServiceName $.Method) }} + p := {{ .ResultInit.Declaration.Name }}({{ range .ResultInit.ClientArgs }}{{ .Ref }},{{ end }}) + {{- if $response.TagName }} + tmp := {{ printf "%q" $response.TagValue }} + p.{{ $response.TagName }} = &tmp + {{- end }} + vres := {{ if not $.Method.ViewedResult.IsCollection }}&{{ end }}{{ $.Method.ViewedResult.ViewsPkg}}.{{ $.Method.ViewedResult.VarName }}{Projected: p, View: view} + {{- if .ClientBody }} + if err = {{ $.Method.ViewedResult.ViewsPkg}}.{{ $.Method.ViewedResult.Validate.Declaration.Name }}(vres); err != nil { + return nil, goahttp.ErrValidationError("{{ $.ServiceName }}", "{{ $.Method.Name }}", err) + } + {{- end }} + res := {{ $.ServicePkgName }}.{{ $.Method.ViewedResult.ResultInit.Declaration.Name }}(vres) + return res, nil + {{- end }} + default: + return nil, goahttp.ErrValidationError("{{ $.ServiceName }}", "{{ $.Method.Name }}", goa.InvalidEnumValueError("view", view, []any{ {{ range $.Method.ViewedResult.Views }}{{ printf "%q" .Name }}, {{ end }} })) + } + {{- else }} {{- template "partial_single_response" (buildResponseData . $.ServiceName $.Method) }} {{- if .ResultInit }} {{- if .ViewedResult }} - p := {{ .ResultInit.Name }}({{ range .ResultInit.ClientArgs }}{{ .Ref }},{{ end }}) + p := {{ .ResultInit.Declaration.Name }}({{ range .ResultInit.ClientArgs }}{{ .Ref }},{{ end }}) {{- if .TagName }} tmp := {{ printf "%q" .TagValue }} p.{{ .TagName }} = &tmp @@ -39,16 +73,19 @@ func {{ .ResponseDecoder }}(decoder func(*http.Response) goahttp.Decoder, restor view := {{ printf "%q" $.Method.ViewedResult.ViewName }} {{- else }} view := resp.Header.Get("goa-view") + if view == "" { + view = "default" + } {{- end }} vres := {{ if not $.Method.ViewedResult.IsCollection }}&{{ end }}{{ $.Method.ViewedResult.ViewsPkg}}.{{ $.Method.ViewedResult.VarName }}{Projected: p, View: view} {{- if .ClientBody }} - if err = {{ $.Method.ViewedResult.ViewsPkg}}.Validate{{ $.Method.Result }}(vres); err != nil { + if err = {{ $.Method.ViewedResult.ViewsPkg}}.{{ $.Method.ViewedResult.Validate.Declaration.Name }}(vres); err != nil { return nil, goahttp.ErrValidationError("{{ $.ServiceName }}", "{{ $.Method.Name }}", err) } {{- end }} - res := {{ $.ServicePkgName }}.{{ $.Method.ViewedResult.ResultInit.Name }}(vres) + res := {{ $.ServicePkgName }}.{{ $.Method.ViewedResult.ResultInit.Declaration.Name }}(vres) {{- else }} - res := {{ .ResultInit.Name }}({{ range .ResultInit.ClientArgs }}{{ .Ref }},{{ end }}) + res := {{ .ResultInit.Declaration.Name }}({{ range .ResultInit.ClientArgs }}{{ .Ref }},{{ end }}) {{- end }} {{- if and .TagName (not .ViewedResult) }} {{- if .TagPointer }} @@ -65,8 +102,9 @@ func {{ .ResponseDecoder }}(decoder func(*http.Response) goahttp.Decoder, restor return {{ (index .Headers 0).VarName }}, nil {{- else if .Cookies }} return {{ (index .Cookies 0).VarName }}, nil - {{- else }} - return nil, nil + {{- else }} + return nil, nil + {{- end }} {{- end }} {{- end }} {{- range .Errors }} @@ -79,7 +117,7 @@ func {{ .ResponseDecoder }}(decoder func(*http.Response) goahttp.Decoder, restor {{- with .Response }} {{- template "partial_single_response" (buildResponseData . $.ServiceName $.Method) }} {{- if .ResultInit }} - return nil, {{ .ResultInit.Name }}({{ range .ResultInit.ClientArgs }}{{ .Ref }},{{ end }}) + return nil, {{ .ResultInit.Declaration.Name }}({{ range .ResultInit.ClientArgs }}{{ .Ref }},{{ end }}) {{- else if .ClientBody }} return nil, body {{- else }} @@ -88,14 +126,17 @@ func {{ .ResponseDecoder }}(decoder func(*http.Response) goahttp.Decoder, restor {{- end }} {{- end }} default: - body, _ := io.ReadAll(resp.Body) + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, goahttp.ErrDecodingError("{{ $.ServiceName }}", "{{ $.Method.Name }}", err) + } return nil, goahttp.ErrInvalidResponse({{ printf "%q" $.ServiceName }}, {{ printf "%q" $.Method.Name }}, resp.StatusCode, string(body)) } {{- else }} {{- with (index .Errors 0).Response }} {{- template "partial_single_response" (buildResponseData . $.ServiceName $.Method) }} {{- if .ResultInit }} - return nil, {{ .ResultInit.Name }}({{ range .ResultInit.ClientArgs }}{{ .Ref }},{{ end }}) + return nil, {{ .ResultInit.Declaration.Name }}({{ range .ResultInit.ClientArgs }}{{ .Ref }},{{ end }}) {{- else if .ClientBody }} return nil, body {{- else }} @@ -105,7 +146,10 @@ func {{ .ResponseDecoder }}(decoder func(*http.Response) goahttp.Decoder, restor {{- end }} {{- end }} default: - body, _ := io.ReadAll(resp.Body) + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, goahttp.ErrDecodingError("{{ .ServiceName }}", "{{ .Method.Name }}", err) + } return nil, goahttp.ErrInvalidResponse({{ printf "%q" .ServiceName }}, {{ printf "%q" .Method.Name }}, resp.StatusCode, string(body)) } } diff --git a/http/codegen/templates/response_encoder.go.tpl b/http/codegen/templates/response_encoder.go.tpl index 5b5ab0b5a9..3399652511 100644 --- a/http/codegen/templates/response_encoder.go.tpl +++ b/http/codegen/templates/response_encoder.go.tpl @@ -1,5 +1,5 @@ -{{ printf "%s returns an encoder for responses returned by the %s %s endpoint." .ResponseEncoder .ServiceName .Method.Name | comment }} -func {{ .ResponseEncoder }}(encoder func(context.Context, http.ResponseWriter) goahttp.Encoder) func(context.Context, http.ResponseWriter, any) error { +{{ printf "%s returns an encoder for responses returned by the %s %s endpoint." .ResponseEncoderDeclaration.Name .ServiceName .Method.Name | comment }} +func {{ .ResponseEncoderDeclaration.Name }}(encoder func(context.Context, http.ResponseWriter) goahttp.Encoder) func(context.Context, http.ResponseWriter, any) error { return func(ctx context.Context, w http.ResponseWriter, v any) error { {{- if .Result.MustInit }} {{- if .Method.ViewedResult }} diff --git a/http/codegen/templates/server_body_init.go.tpl b/http/codegen/templates/server_body_init.go.tpl index ee77042eb9..11c44ccb2d 100644 --- a/http/codegen/templates/server_body_init.go.tpl +++ b/http/codegen/templates/server_body_init.go.tpl @@ -1,5 +1,5 @@ {{ comment .Description }} -func {{ .Name }}({{ range .ServerArgs }}{{ .VarName }} {{.TypeRef }}, {{ end }}) {{ .ReturnTypeRef }} { +func {{ .Declaration.Name }}({{ range .ServerArgs }}{{ .VarName }} {{.TypeRef }}, {{ end }}) {{ .ReturnTypeRef }} { {{ .ServerCode }} return body } diff --git a/http/codegen/templates/server_configure.go.tpl b/http/codegen/templates/server_configure.go.tpl index a65e268891..69c71dc3e9 100644 --- a/http/codegen/templates/server_configure.go.tpl +++ b/http/codegen/templates/server_configure.go.tpl @@ -5,36 +5,35 @@ // responses. var ( {{- range .Services }} - {{ .Service.VarName }}Server *{{.Service.PkgName}}svr.Server + {{ .Service.VarName }}Server *{{ .ServerPkgName }}.{{ .ServerStructDeclaration.Name }} {{- end }} {{- range .JSONRPCServices }} - {{ .Service.VarName }}JSONRPCServer *{{ .Service.PkgName }}jssvr.Server + {{ .Service.VarName }}JSONRPCServer *{{ .ServerPkgName }}.{{ .ServerStructDeclaration.Name }} {{- end }} ) { eh := errorHandler(ctx) - {{- if or (needDialer .Services) (needDialer .JSONRPCServices) }} + {{- if needDialer .Services }} upgrader := &websocket.Upgrader{} {{- end }} {{- range $svc := .Services }} {{- if .Endpoints }} - {{ .Service.VarName }}Server = {{ .Service.PkgName }}svr.New({{ .Service.VarName }}Endpoints, mux, dec, enc, eh, nil{{ if hasWebSocket $svc }}, upgrader, nil{{ end }}{{ range .Endpoints }}{{ if .MultipartRequestDecoder }}, {{ $.APIPkg }}.{{ .MultipartRequestDecoder.FuncName }}{{ end }}{{ end }}{{ range .FileServers }}, nil{{ end }}) + {{ .Service.VarName }}Server = {{ .ServerPkgName }}.{{ .ServerInitDeclaration.Name }}({{ .Service.VarName }}Endpoints, mux, dec, enc, eh, nil{{ if hasWebSocket $svc }}, upgrader, nil{{ end }}{{ range .Endpoints }}{{ if .MultipartRequestDecoder }}, {{ $.APIPkg }}.{{ .MultipartRequestDecoder.FuncDeclaration.Name }}{{ end }}{{ end }}{{ range .FileServers }}, nil{{ end }}) {{- else }} - {{ .Service.VarName }}Server = {{ .Service.PkgName }}svr.New(nil, mux, dec, enc, eh, nil{{ range .FileServers }}, nil{{ end }}) - {{- end }} - {{- end }} - {{- range $svcData := .JSONRPCServices }} - {{- if .Endpoints }} - {{- $svc := . }} - {{ .Service.VarName }}JSONRPCServer = {{ .Service.PkgName }}jssvr.New({{ if hasWebSocket $svc }}{{ .Service.VarName }}Svc.HandleStream, {{ end }}{{ .Service.VarName }}Endpoints, mux, dec, enc, eh{{ if hasWebSocket $svc }}, upgrader, nil{{ end }}) + {{ .Service.VarName }}Server = {{ .ServerPkgName }}.{{ .ServerInitDeclaration.Name }}(nil, mux, dec, enc, eh, nil{{ range .FileServers }}, nil{{ end }}) {{- end }} {{- end }} + {{- range .JSONRPCServices }} + {{- if .Endpoints }} + {{ .Service.VarName }}JSONRPCServer = {{ .ServerPkgName }}.{{ .ServerInitDeclaration.Name }}({{ .Service.VarName }}Endpoints, mux, dec, enc, eh) + {{- end }} + {{- end }} } // Configure the mux. {{- range .Services }} - {{ .Service.PkgName }}svr.Mount(mux, {{ .Service.VarName }}Server) + {{ .ServerPkgName }}.{{ .MountServerDeclaration.Name }}(mux, {{ .Service.VarName }}Server) {{- end }} {{- range .JSONRPCServices }} - {{ .Service.PkgName }}jssvr.Mount(mux, {{ .Service.VarName }}JSONRPCServer) + {{ .ServerPkgName }}.{{ .MountServerDeclaration.Name }}(mux, {{ .Service.VarName }}JSONRPCServer) {{- end }} diff --git a/http/codegen/templates/server_handler.go.tpl b/http/codegen/templates/server_handler.go.tpl index 6428a945c9..9091126752 100644 --- a/http/codegen/templates/server_handler.go.tpl +++ b/http/codegen/templates/server_handler.go.tpl @@ -1,5 +1,8 @@ -{{ printf "%s configures the mux to serve the %q service %q endpoint." .MountHandler .ServiceName .Method.Name | comment }} -func {{ .MountHandler }}(mux goahttp.Muxer, h http.Handler) { +{{ printf "%s configures the mux to serve the %q service %q endpoint." .MountHandlerDeclaration.Name .ServiceName .Method.Name | comment }} +func {{ .MountHandlerDeclaration.Name }}(mux goahttp.Muxer, h http.Handler) { + {{- if .ServerHandlerWrappers }} + h = {{ range .ServerHandlerWrappers }}{{ .Name }}({{ end }}h{{ range .ServerHandlerWrappers }}){{ end }} + {{- end }} f, ok := h.(http.HandlerFunc) if !ok { f = func(w http.ResponseWriter, r *http.Request) { diff --git a/http/codegen/templates/server_handler_init.go.tpl b/http/codegen/templates/server_handler_init.go.tpl index 08ff40ffe0..66cf43a0e1 100644 --- a/http/codegen/templates/server_handler_init.go.tpl +++ b/http/codegen/templates/server_handler_init.go.tpl @@ -15,13 +15,13 @@ func {{ .HandlerInit }}( var ( {{- end }} {{- if mustDecodeRequest . }} - decodeRequest = {{ .RequestDecoder }}(mux, decoder) + decodeRequest = {{ .RequestDecoderDeclaration.Name }}(mux, decoder) {{- end }} {{- if not (or .Redirect (isWebSocketEndpoint .) (and (isSSEEndpoint .) (not .HasMixedResults))) }} - encodeResponse = {{ .ResponseEncoder }}(encoder) + encodeResponse = {{ .ResponseEncoderDeclaration.Name }}(encoder) {{- end }} {{- if (or (mustDecodeRequest .) (not .Redirect) .Method.SkipResponseBodyEncodeDecode) }} - encodeError = {{ if .Errors }}{{ .ErrorEncoder }}{{ else }}goahttp.ErrorEncoder{{ end }}(encoder, formatter) + encodeError = {{ if .Errors }}{{ .ErrorEncoderDeclaration.Name }}{{ else }}goahttp.ErrorEncoder{{ end }}(encoder, formatter) {{- end }} {{- if (or (mustDecodeRequest .) (not (or .Redirect (isWebSocketEndpoint .) (and (isSSEEndpoint .) (not .HasMixedResults)))) (not .Redirect) .Method.SkipResponseBodyEncodeDecode) }} ) @@ -63,7 +63,7 @@ func {{ .HandlerInit }}( } {{- end }} v := &{{ .ServicePkgName }}.{{ .Method.ServerStream.EndpointStruct }}{ - Stream: &{{ .SSE.StructName }}{ + Stream: &{{ .SSE.StructDeclaration.Name }}{ w: w, r: r, }, @@ -72,7 +72,17 @@ func {{ .HandlerInit }}( {{- end }} } _, err = endpoint(ctx, v) + stream := v.Stream.(*{{ .SSE.StructDeclaration.Name }}) + if err == nil { + err = stream.finish() + } if err != nil { + if stream.attempted { + if errhandler != nil { + errhandler(ctx, w, err) + } + return + } if err := encodeError(ctx, w, err); err != nil && errhandler != nil { errhandler(ctx, w, err) } @@ -98,7 +108,7 @@ func {{ .HandlerInit }}( // In the standard (non-SSE) mode, Stream discards events and the service // must return the synchronous result. v := &{{ .ServicePkgName }}.{{ .Method.ServerStream.EndpointStruct }}{ - Stream: &discard{{ .Method.VarName }}ServerStream{}, + Stream: &{{ .DiscardStreamDeclaration.Name }}{}, {{- if .Payload.Ref }} Payload: payload, {{- end }} @@ -176,7 +186,7 @@ func {{ .HandlerInit }}( var cancel context.CancelFunc ctx, cancel = context.WithCancel(ctx) v := &{{ .ServicePkgName }}.{{ .Method.ServerStream.EndpointStruct }}{ - Stream: &{{ .ServerWebSocket.VarName }}{ + Stream: &{{ .ServerWebSocket.VarDeclaration.Name }}{ upgrader: upgrader, configurer: configurer, cancel: cancel, @@ -205,7 +215,7 @@ func {{ .HandlerInit }}( } {{- end }} v := &{{ .ServicePkgName }}.{{ .Method.ServerStream.EndpointStruct }}{ - Stream: &{{ .SSE.StructName }}{ + Stream: &{{ .SSE.StructDeclaration.Name }}{ w: w, r: r, }, @@ -214,6 +224,10 @@ func {{ .HandlerInit }}( {{- end }} } _, err = endpoint(ctx, v) + if err == nil { + stream := v.Stream.(*{{ .SSE.StructDeclaration.Name }}) + err = stream.finish() + } {{- else if .Method.SkipRequestBodyEncodeDecode }} data := &{{ .ServicePkgName }}.{{ .Method.RequestStruct }}{ {{ if .Payload.Ref }}Payload: payload, {{ end }}Body: r.Body } res, err := endpoint(ctx, data) @@ -225,11 +239,11 @@ func {{ .HandlerInit }}( {{- if not .Redirect }} if err != nil { {{- if isWebSocketEndpoint . }} - var stream *{{ .ServerWebSocket.VarName }} + var stream *{{ .ServerWebSocket.VarDeclaration.Name }} if wrapper, ok := v.Stream.(interface{ Unwrap() any }); ok { - stream = wrapper.Unwrap().(*{{ .ServerWebSocket.VarName }}) + stream = wrapper.Unwrap().(*{{ .ServerWebSocket.VarDeclaration.Name }}) } else { - stream = v.Stream.(*{{ .ServerWebSocket.VarName }}) + stream = v.Stream.(*{{ .ServerWebSocket.VarDeclaration.Name }}) } if stream != nil && stream.conn != nil { // Response writer has been hijacked, do not encode the error @@ -239,6 +253,15 @@ func {{ .HandlerInit }}( return } {{- end }} + {{- if isSSEEndpoint . }} + stream := v.Stream.(*{{ .SSE.StructDeclaration.Name }}) + if stream.attempted { + if errhandler != nil { + errhandler(ctx, w, err) + } + return + } + {{- end }} if err := encodeError(ctx, w, err); err != nil && errhandler != nil { errhandler(ctx, w, err) } @@ -301,24 +324,24 @@ func {{ .HandlerInit }}( {{- if .HasMixedResults }} -// discard{{ .Method.VarName }}ServerStream implements the {{ .SSE.Interface }} +// {{ .DiscardStreamDeclaration.Name }} implements the {{ .SSE.Interface }} // interface and drops all events. It is used for mixed results endpoints in -// unary (non-SSE) mode so service implementations can use the stream parameter -// without nil checks. -type discard{{ .Method.VarName }}ServerStream struct{} +// regular HTTP requests so service implementations can use the stream +// parameter without nil checks. +type {{ .DiscardStreamDeclaration.Name }} struct{} // {{ .SSE.SendName }} discards the event. -func (s *discard{{ .Method.VarName }}ServerStream) {{ .SSE.SendName }}(v {{ .SSE.EventTypeRef }}) error { +func (s *{{ .DiscardStreamDeclaration.Name }}) {{ .SSE.SendName }}(v {{ .SSE.EventTypeRef }}) error { return nil } // {{ .SSE.SendWithContextName }} discards the event. -func (s *discard{{ .Method.VarName }}ServerStream) {{ .SSE.SendWithContextName }}(ctx context.Context, v {{ .SSE.EventTypeRef }}) error { +func (s *{{ .DiscardStreamDeclaration.Name }}) {{ .SSE.SendWithContextName }}(ctx context.Context, v {{ .SSE.EventTypeRef }}) error { return nil } // Close is a no-op. -func (s *discard{{ .Method.VarName }}ServerStream) Close() error { +func (s *{{ .DiscardStreamDeclaration.Name }}) Close() error { return nil } {{- end }} diff --git a/http/codegen/templates/server_init.go.tpl b/http/codegen/templates/server_init.go.tpl index 0562b3bc5e..894a3b903a 100644 --- a/http/codegen/templates/server_init.go.tpl +++ b/http/codegen/templates/server_init.go.tpl @@ -1,6 +1,6 @@ -{{ printf "%s instantiates HTTP handlers for all the %s service endpoints using the provided encoder and decoder. The handlers are mounted on the given mux using the HTTP verb and path defined in the design. errhandler is called whenever a response fails to be encoded. formatter is used to format errors returned by the service methods prior to encoding. Both errhandler and formatter are optional and can be nil." .ServerInit .Service.Name | comment }} -func {{ .ServerInit }}( - e *{{ .Service.PkgName }}.Endpoints, +{{ printf "%s instantiates HTTP handlers for all the %s service endpoints using the provided encoder and decoder. The handlers are mounted on the given mux using the HTTP verb and path defined in the design. errhandler is called whenever a response fails to be encoded. formatter is used to format errors returned by the service methods prior to encoding. Both errhandler and formatter are optional and can be nil." .ServerInitDeclaration.Name .Service.Name | comment }} +func {{ .ServerInitDeclaration.Name }}( + e *{{ .Service.PkgName }}.{{ .Service.EndpointsDeclaration.Name }}, mux goahttp.Muxer, decoder func(*http.Request) goahttp.Decoder, encoder func(context.Context, http.ResponseWriter) goahttp.Encoder, @@ -8,20 +8,20 @@ func {{ .ServerInit }}( formatter func(ctx context.Context, err error) goahttp.Statuser, {{- if hasWebSocket . }} upgrader goahttp.Upgrader, - configurer *ConnConfigurer, + configurer *{{ .ServerConnConfigurerDeclaration.Name }}, {{- end }} {{- range .Endpoints }} {{- if .MultipartRequestDecoder }} - {{ .MultipartRequestDecoder.VarName }} {{ .MultipartRequestDecoder.FuncName }}, + {{ .MultipartRequestDecoder.VarName }} {{ .MultipartRequestDecoder.FuncDeclaration.Name }}, {{- end }} {{- end }} {{- range .FileServers }} {{ .ArgName }} http.FileSystem, {{- end }} -) *{{ .ServerStruct }} { +) *{{ .ServerStructDeclaration.Name }} { {{- if hasWebSocket . }} if configurer == nil { - configurer = &ConnConfigurer{} + configurer = &{{ .ServerConnConfigurerDeclaration.Name }}{} } {{- end }} {{- range .FileServers }} @@ -32,10 +32,10 @@ func {{ .ServerInit }}( {{- if not .IsDir }} {{- $prefix = dir $prefix }} {{- end }} - {{ .ArgName }} = appendPrefix({{ .ArgName }}, "{{ $prefix }}") + {{ .ArgName }} = {{ $.AppendPrefixDeclaration.Name }}({{ .ArgName }}, "{{ $prefix }}") {{- end }} - return &{{ .ServerStruct }}{ - Mounts: []*{{ .MountPointStruct }}{ + return &{{ .ServerStructDeclaration.Name }}{ + Mounts: []*{{ .MountPointStructDeclaration.Name }}{ {{- range $e := .Endpoints }} {{- range $e.Routes }} {"{{ $e.Method.VarName }}", "{{ .Verb }}", "{{ .Path }}"}, @@ -47,6 +47,11 @@ func {{ .ServerInit }}( {"Serve {{ $filepath }}", "GET", "{{ . }}"}, {{- end }} {{- end }} + {{- range .ServerMounts }} + {{- range .MountPoints }} + { {{ printf "%q" .Method }}, {{ printf "%q" .Verb }}, {{ printf "%q" .Pattern }} }, + {{- end }} + {{- end }} }, {{- range .Endpoints }} {{ .Method.VarName }}: {{ .HandlerInit }}(e.{{ .Method.VarName }}, mux, {{ if .MultipartRequestDecoder }}{{ .MultipartRequestDecoder.InitName }}(mux, {{ .MultipartRequestDecoder.VarName }}){{ else }}decoder{{ end }}, encoder, errhandler, formatter{{ if isWebSocketEndpoint . }}, upgrader, configurer.{{ .Method.VarName }}Fn{{ end }}), diff --git a/http/codegen/templates/server_method_names.go.tpl b/http/codegen/templates/server_method_names.go.tpl index aec727ee7d..d6a7ddc2aa 100644 --- a/http/codegen/templates/server_method_names.go.tpl +++ b/http/codegen/templates/server_method_names.go.tpl @@ -1,2 +1,2 @@ {{ printf "MethodNames returns the methods served." | comment }} -func (s *{{ .ServerStruct }}) MethodNames() []string { return {{ .Service.PkgName }}.MethodNames[:] } +func (s *{{ .ServerStructDeclaration.Name }}) MethodNames() []string { return {{ .Service.PkgName }}.{{ .Service.MethodNamesDeclaration.Name }}[:] } diff --git a/http/codegen/templates/server_mount.go.tpl b/http/codegen/templates/server_mount.go.tpl index 01b4fc294a..fb7ab6edd5 100644 --- a/http/codegen/templates/server_mount.go.tpl +++ b/http/codegen/templates/server_mount.go.tpl @@ -1,15 +1,15 @@ -{{ printf "%s configures the mux to serve the %s endpoints." .MountServer .Service.Name | comment }} -func {{ .MountServer }}(mux goahttp.Muxer, h *{{ .ServerStruct }}) { +{{ printf "%s configures the mux to serve the %s endpoints." .MountServerDeclaration.Name .Service.Name | comment }} +func {{ .MountServerDeclaration.Name }}(mux goahttp.Muxer, h *{{ .ServerStructDeclaration.Name }}) { {{- range .Endpoints }} - {{ .MountHandler }}(mux, h.{{ .Method.VarName }}) + {{ if .MountHandlerDeclaration }}{{ .MountHandlerDeclaration.Name }}{{ else }}{{ .MountHandler }}{{ end }}(mux, h.{{ .Method.VarName }}) {{- end }} {{- range .FileServers }} {{- if .Redirect }} - {{ .MountHandler }}(mux, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + {{ .MountHandlerDeclaration.Name }}(mux, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, "{{ .Redirect.URL }}", {{ .Redirect.StatusCode }}) })) {{- else }} - {{- $mountHandler := .MountHandler }} + {{- $mountHandler := .MountHandlerDeclaration.Name }} {{- $varName := .VarName }} {{- $isDir := .IsDir }} {{- range .RequestPaths }} @@ -18,16 +18,19 @@ func {{ .MountServer }}(mux goahttp.Muxer, h *{{ .ServerStruct }}) { {{- $stripped = (dir $stripped) }} {{- end }} {{- if eq $stripped "/" }} - {{ $mountHandler }}(mux, h.{{ $varName }}) + {{ $mountHandler }}(mux, h.{{ $varName }}) {{- else }} {{ $mountHandler }}(mux, http.StripPrefix("{{ $stripped }}", h.{{ $varName }})) {{- end }} {{- end }} {{- end }} {{- end }} + {{- range .ServerMounts }} + {{ .Declaration.Name }}(mux) + {{- end }} } -{{ printf "%s configures the mux to serve the %s endpoints." .MountServer .Service.Name | comment }} -func (s *{{ .ServerStruct }}) {{ .MountServer }}(mux goahttp.Muxer) { - {{ .MountServer }}(mux, s) +{{ printf "%s configures the mux to serve the %s endpoints." .MountServerDeclaration.Name .Service.Name | comment }} +func (s *{{ .ServerStructDeclaration.Name }}) {{ .MountServerDeclaration.Name }}(mux goahttp.Muxer) { + {{ .MountServerDeclaration.Name }}(mux, s) } diff --git a/http/codegen/templates/server_service.go.tpl b/http/codegen/templates/server_service.go.tpl index 744fae2dea..c8337b8caa 100644 --- a/http/codegen/templates/server_service.go.tpl +++ b/http/codegen/templates/server_service.go.tpl @@ -1,2 +1,2 @@ {{ printf "%s returns the name of the service served." .ServerService | comment }} -func (s *{{ .ServerStruct }}) {{ .ServerService }}() string { return "{{ .Service.Name }}" } +func (s *{{ .ServerStructDeclaration.Name }}) {{ .ServerService }}() string { return "{{ .Service.Name }}" } diff --git a/http/codegen/templates/server_sse.go.tpl b/http/codegen/templates/server_sse.go.tpl index 571f98f62a..db6d2531f2 100644 --- a/http/codegen/templates/server_sse.go.tpl +++ b/http/codegen/templates/server_sse.go.tpl @@ -1,20 +1,50 @@ -{{ printf "%s implements the %s interface using Server-Sent Events." .SSE.StructName .SSE.Interface | comment }} -type {{ .SSE.StructName }} struct { +{{/* +server_sse.go.tpl writes the HTTP server stream for one SSE endpoint. The plan +provides the exact response value and selected view used for each event. +*/ -}} +{{ printf "%s implements the %s interface using Server-Sent Events." .SSE.StructDeclaration.Name .SSE.Interface | comment }} +type {{ .SSE.StructDeclaration.Name }} struct { {{ comment "once ensures the headers are written once." }} once sync.Once {{ comment "w is the HTTP response writer used to send the SSE events." }} w http.ResponseWriter {{ comment "r is the HTTP request." }} r *http.Request + {{ comment "attempted is true after this stream writes the HTTP success status." }} + attempted bool + {{- if .SSE.VariableView }} + {{ comment "view is the result view selected for events in this HTTP response." }} + view string + {{ comment "sentView is the result view used by the first event. Later sends must use the same view." }} + sentView string + {{- end }} } -{{ printf "%s %s" .SSE.SendName .SSE.SendDesc | comment }} -func (s *{{ .SSE.StructName }}) {{ .SSE.SendName }}(v {{ .SSE.EventTypeRef }}) error { - return s.{{ .SSE.SendWithContextName }}(context.Background(), v) +{{- if .SSE.VariableView }} +{{ comment "SetView selects the result view used by subsequent sends on this stream." }} +func (s *{{ .SSE.StructDeclaration.Name }}) SetView(view string) { + s.view = view } -{{ printf "%s %s" .SSE.SendWithContextName .SSE.SendWithContextDesc | comment }} -func (s *{{ .SSE.StructName }}) {{ .SSE.SendWithContextName }}(ctx context.Context, v {{ .SSE.EventTypeRef }}) error { +{{ comment "selectedView returns the valid result view for this HTTP response." }} +func (s *{{ .SSE.StructDeclaration.Name }}) selectedView() (string, error) { + view := s.view + if view == "" { + view = {{ printf "%q" .SSE.DefaultView }} + } + switch view { + {{- range .Method.ViewedResult.Views }} + case {{ printf "%q" .Name }}: + {{- end }} + default: + return "", goa.InvalidEnumValueError("view", view, []any{ {{ range .Method.ViewedResult.Views }}{{ printf "%q" .Name }}, {{ end }} }) + } + return view, nil +} +{{- end }} + +{{ comment "start writes the headers that identify a successful SSE response." }} +func (s *{{ .SSE.StructDeclaration.Name }}) start({{ if .SSE.VariableView }}view string{{ end }}) { s.once.Do(func() { header := s.w.Header() if header.Get("Content-Type") == "" { @@ -26,104 +56,252 @@ func (s *{{ .SSE.StructName }}) {{ .SSE.SendWithContextName }}(ctx context.Conte if header.Get("Connection") == "" { header.Set("Connection", "keep-alive") } + {{- if .SSE.VariableView }} + header.Set("goa-view", view) + {{- end }} s.w.WriteHeader(http.StatusOK) + s.attempted = true }) +} + +{{ comment "finish writes an empty successful SSE response when the service sent no events." }} +func (s *{{ .SSE.StructDeclaration.Name }}) finish() error { + if s.attempted { + return nil + } + {{- if .SSE.VariableView }} + view, err := s.selectedView() + if err != nil { + return err + } + s.start(view) + {{- else }} + s.start() + {{- end }} + return nil +} + +{{ printf "%s %s" .SSE.SendName .SSE.SendDesc | comment }} +func (s *{{ .SSE.StructDeclaration.Name }}) {{ .SSE.SendName }}(v {{ .SSE.EventTypeRef }}) error { + return s.{{ .SSE.SendWithContextName }}(context.Background(), v) +} + +{{ printf "%s %s" .SSE.SendWithContextName .SSE.SendWithContextDesc | comment }} +func (s *{{ .SSE.StructDeclaration.Name }}) {{ .SSE.SendWithContextName }}(ctx context.Context, v {{ .SSE.EventTypeRef }}) error { + {{- if .SSE.VariableView }} + view, err := s.selectedView() + if err != nil { + return err + } + if s.sentView != "" && view != s.sentView { + return goa.InvalidEnumValueError("view", view, []any{s.sentView}) + } + {{- else if .Method.ViewedResult }} + view := {{ printf "%q" .Method.ViewedResult.ViewName }} + {{- end }} {{- if .Method.ViewedResult }} - {{- if .Method.ViewedResult.ViewName }} - res := {{ .Service.PkgName }}.{{ .Method.ViewedResult.Init.Name }}(v, {{ printf "%q" .Method.ViewedResult.ViewName }}) - {{- else }} - res := {{ .Service.PkgName }}.{{ .Method.ViewedResult.Init.Name }}(v, "default") + res := {{ .ServicePkgName }}.{{ .Method.ViewedResult.Init.Declaration.Name }}(v, view) + {{- if or .SSE.IDField .SSE.EventField .SSE.RetryField (not .SSE.HasResponseBody) }} + projected := res.Projected {{- end }} {{- else }} res := v {{- end }} - {{ if .SSE.IDField }} - if id := res.{{ .SSE.IDField }}; id != "" { - fmt.Fprintf(s.w, "id: %s\n", id) + var data string + {{- if .SSE.Data.Pointer }} + hasData := true + {{- end }} + {{- if .SSE.HasResponseBody }} + {{- if .Method.ViewedResult }} + {{- if .SSE.VariableView }} + switch view { + {{- range .SSE.Response.ViewedRepresentations }} + case {{ printf "%q" .View }}: + {{- template "viewed_sse_server_body" dict "Endpoint" $ "Representation" . }} + {{- end }} + } + {{- else }} + {{- range .SSE.Response.ViewedRepresentations }} + {{- template "viewed_sse_server_body" dict "Endpoint" $ "Representation" . }} + {{- end }} + {{- end }} + {{- else }} + {{- if (index .SSE.Response.ServerBody 0).Init }} + body := {{ (index .SSE.Response.ServerBody 0).Init.Declaration.Name }}({{ range (index .SSE.Response.ServerBody 0).Init.ServerArgs }}{{ .Ref }}, {{ end }}) + {{- else }} + body := res + {{- end }} + {{- if .SSE.DataField }} + {{ template "partial_sse_format" dict "Value" (printf "body.%s" .SSE.DataField) "Encoding" .SSE.Data }} + {{- else }} + {{ template "partial_sse_format" dict "Value" "body" "Encoding" .SSE.Data }} + {{- end }} + {{- end }} + {{- else }} + {{- if .SSE.DataField }} + {{- if .Method.ViewedResult }} + {{ template "partial_sse_format" dict "Value" (printf "projected.%s" .SSE.DataField) "Encoding" .SSE.Data }} + {{- else }} + {{ template "partial_sse_format" dict "Value" (printf "res.%s" .SSE.DataField) "Encoding" .SSE.Data }} + {{- end }} + {{- else }} + {{- if .Method.ViewedResult }} + {{ template "partial_sse_format" dict "Value" "projected" "Encoding" .SSE.Data }} + {{- else }} + {{ template "partial_sse_format" dict "Value" "res" "Encoding" .SSE.Data }} + {{- end }} + {{- end }} + {{- end }} + {{- if .SSE.VariableView }} + s.sentView = view + {{- end }} + {{- if .SSE.IDField }} + id := {{ if .Method.ViewedResult }}projected{{ else }}res{{ end }}.{{ .SSE.IDField }} + {{- if .SSE.ID.Pointer }} + if id != nil { + for i := 0; i < len(*id); i++ { + if (*id)[i] == '\r' || (*id)[i] == '\n' || (*id)[i] == 0 { + return fmt.Errorf("SSE event ID contains a character that cannot appear on an id line") + } + } } + {{- else }} + for i := 0; i < len(id); i++ { + if id[i] == '\r' || id[i] == '\n' || id[i] == 0 { + return fmt.Errorf("SSE event ID contains a character that cannot appear on an id line") + } + } + {{- end }} {{- end }} - {{- if .SSE.EventField }} - if event := res.{{ .SSE.EventField }}; event != "" { - fmt.Fprintf(s.w, "event: %s\n", event) + event := {{ if .Method.ViewedResult }}projected{{ else }}res{{ end }}.{{ .SSE.EventField }} + {{- if .SSE.Event.Pointer }} + if event != nil { + for i := 0; i < len(*event); i++ { + if (*event)[i] == '\r' || (*event)[i] == '\n' { + return fmt.Errorf("SSE event type contains a line break") + } + } } + {{- else }} + for i := 0; i < len(event); i++ { + if event[i] == '\r' || event[i] == '\n' { + return fmt.Errorf("SSE event type contains a line break") + } + } + {{- end }} {{- end }} - {{- if .SSE.RetryField }} - if retry := res.{{ .SSE.RetryField }}; retry > 0 { - fmt.Fprintf(s.w, "retry: %d\n", retry) + retry := {{ if .Method.ViewedResult }}projected{{ else }}res{{ end }}.{{ .SSE.RetryField }} + {{- if and (sseSignedInteger .SSE.Retry) .SSE.Retry.Pointer }} + if retry != nil && *retry < 0 { + return fmt.Errorf("SSE retry value cannot be negative") + } + {{- else if sseSignedInteger .SSE.Retry }} + if retry < 0 { + return fmt.Errorf("SSE retry value cannot be negative") } + {{- end }} {{- end }} + s.start({{ if .SSE.VariableView }}view{{ end }}) - var data string - var payload any - {{- if .SSE.HasResponseBody }} - body := New{{ goify .Method.Name true }}ResponseBody(res) - {{- if .SSE.DataField }} - payload = body.{{ .SSE.DataField }} + {{ if .SSE.IDField }} + {{- if .SSE.ID.Pointer }} + if id != nil { + if _, err := fmt.Fprintf(s.w, "id: %s\n", *id); err != nil { + return err + } + } {{- else }} - payload = body + if _, err := fmt.Fprintf(s.w, "id: %s\n", id); err != nil { + return err + } {{- end }} - {{- else }} - {{- if .SSE.DataField }} - payload = res.{{ .SSE.DataField }} + {{- end }} + + {{- if .SSE.EventField }} + {{- if .SSE.Event.Pointer }} + if event != nil { + if _, err := fmt.Fprintf(s.w, "event: %s\n", *event); err != nil { + return err + } + } {{- else }} - payload = res + if _, err := fmt.Fprintf(s.w, "event: %s\n", event); err != nil { + return err + } {{- end }} {{- end }} - switch v := payload.(type) { - case nil: - data = "null" - case string: - data = v - case []byte: - data = string(v) - case bool: - if v { - data = "true" - } else { - data = "false" - } - case int: - data = fmt.Sprintf("%d", v) - case int8: - data = fmt.Sprintf("%d", v) - case int16: - data = fmt.Sprintf("%d", v) - case int32: - data = fmt.Sprintf("%d", v) - case int64: - data = fmt.Sprintf("%d", v) - case uint: - data = fmt.Sprintf("%d", v) - case uint8: - data = fmt.Sprintf("%d", v) - case uint16: - data = fmt.Sprintf("%d", v) - case uint32: - data = fmt.Sprintf("%d", v) - case uint64: - data = fmt.Sprintf("%d", v) - case float32: - data = fmt.Sprintf("%g", v) - case float64: - data = fmt.Sprintf("%g", v) - default: - byts, err := json.Marshal(payload) - if err != nil { + + {{- if .SSE.RetryField }} + {{- if .SSE.Retry.Pointer }} + if retry != nil { + if _, err := fmt.Fprintf(s.w, "retry: %d\n", {{ if .SSE.Retry.Pointer }}*{{ end }}retry); err != nil { return err } - data = string(byts) } - fmt.Fprintf(s.w, "data: %s\n\n", data) + {{- else }} + if _, err := fmt.Fprintf(s.w, "retry: %d\n", retry); err != nil { + return err + } + {{- end }} + {{- end }} + {{- if .SSE.Data.Pointer }} + if hasData { + {{- template "write_sse_data" . }} + } + if _, err := fmt.Fprintln(s.w); err != nil { + return err + } + {{- else }} + {{- template "write_sse_data" . }} + if _, err := fmt.Fprintln(s.w); err != nil { + return err + } + {{- end }} - http.NewResponseController(s.w).Flush() + if err := http.NewResponseController(s.w).Flush(); err != nil { + return err + } return nil } -{{ comment "Close is a no-op for SSE. We keep the method for compatibility with other stream types." }} -func (s *{{ .SSE.StructName }}) Close() error { +{{- define "write_sse_data" }} + remaining := data + for { + lineEnd := 0 + for lineEnd < len(remaining) && remaining[lineEnd] != '\r' && remaining[lineEnd] != '\n' { + lineEnd++ + } + if _, err := fmt.Fprintf(s.w, "data: %s\n", remaining[:lineEnd]); err != nil { + return err + } + if lineEnd == len(remaining) { + break + } + next := lineEnd + 1 + if remaining[lineEnd] == '\r' && next < len(remaining) && remaining[next] == '\n' { + next++ + } + remaining = remaining[next:] + } +{{- end }} + +{{- define "viewed_sse_server_body" }} + {{- $endpoint := .Endpoint }} + {{- with .Representation }} + body := {{ .ServerBody.Init.Declaration.Name }}({{ range .ServerBody.Init.ServerArgs }}{{ .Ref }}, {{ end }}) + {{- if $endpoint.SSE.DataField }} + {{ template "partial_sse_format" dict "Value" (printf "body.%s" $endpoint.SSE.DataField) "Encoding" $endpoint.SSE.Data }} + {{- else }} + {{ template "partial_sse_format" dict "Value" "body" "Encoding" $endpoint.SSE.Data }} + {{- end }} + {{- end }} +{{- end }} + +{{ comment "Close does nothing because an SSE stream closes with its HTTP response. The common stream interface still requires this method." }} +func (s *{{ .SSE.StructDeclaration.Name }}) Close() error { return nil } diff --git a/http/codegen/templates/server_start.go.tpl b/http/codegen/templates/server_start.go.tpl index f84053eab5..ef1a5e965f 100644 --- a/http/codegen/templates/server_start.go.tpl +++ b/http/codegen/templates/server_start.go.tpl @@ -1,3 +1,3 @@ {{ comment "handleHTTPServer starts configures and starts a HTTP server on the given URL. It shuts down the server if any error is received in the error channel." }} -func handleHTTPServer(ctx context.Context, u *url.URL{{ range $.Services }}{{ if .Service.Methods }}, {{ .Service.VarName }}Endpoints *{{ .Service.PkgName }}.Endpoints{{ end }}{{ end }}{{ range $.JSONRPCServices }}, {{ .Service.VarName }}Svc {{ .Service.PkgName }}.Service{{- $serviceName := .Service.Name }}{{- $found := false }}{{- range $.Services }}{{- if eq .Service.Name $serviceName }}{{- $found = true }}{{- break }}{{- end }}{{- end }}{{ if not $found }}, {{ .Service.VarName }}Endpoints *{{ .Service.PkgName }}.Endpoints{{ end }}{{ end }}, wg *sync.WaitGroup, errc chan error, dbg bool) {{ printf "{" }}{{ if not .JSONRPCServices }} +func handleHTTPServer(ctx context.Context, u *url.URL{{ range .HandlerArgs }}, {{ .Name }} {{ if .Pointer }}*{{ end }}{{ .PkgName }}.{{ .TypeName }}{{ end }}, wg *sync.WaitGroup, errc chan error, dbg bool) {{ printf "{" }}{{ if not .JSONRPCServices }} {{ end -}} diff --git a/http/codegen/templates/server_struct.go.tpl b/http/codegen/templates/server_struct.go.tpl index 3e56fa99d9..fd33726250 100644 --- a/http/codegen/templates/server_struct.go.tpl +++ b/http/codegen/templates/server_struct.go.tpl @@ -1,6 +1,6 @@ -{{ printf "%s lists the %s service endpoint HTTP handlers." .ServerStruct .Service.Name | comment }} -type {{ .ServerStruct }} struct { - Mounts []*{{ .MountPointStruct }} +{{ printf "%s lists the %s service endpoint HTTP handlers." .ServerStructDeclaration.Name .Service.Name | comment }} +type {{ .ServerStructDeclaration.Name }} struct { + Mounts []*{{ .MountPointStructDeclaration.Name }} {{- range .Endpoints }} {{ .Method.VarName }} http.Handler {{- end }} diff --git a/http/codegen/templates/server_type_init.go.tpl b/http/codegen/templates/server_type_init.go.tpl index 5c138ffa2a..0f5f2179bc 100644 --- a/http/codegen/templates/server_type_init.go.tpl +++ b/http/codegen/templates/server_type_init.go.tpl @@ -1,5 +1,19 @@ {{ comment .Description }} -func {{ .Name }}({{- range .ServerArgs }}{{ .VarName }} {{ .TypeRef }}, {{ end }}) {{ .ReturnTypeRef }} { +func {{ .Declaration.Name }}({{- range .ServerArgs }}{{ .VarName }} {{ .TypeRef }}, {{ end }}) {{ .ReturnTypeRef }} { +{{- if .OptionalBody }} + res := &{{ .ReturnTypeName }}{} + if body != nil { + {{ .ServerCode }} + res.{{ .ReturnTypeAttribute }} = {{ if .ReturnIsPrimitivePointer }}&{{ else if .ReturnIsUnion }}*{{ end }}v + } + {{- if .BodyDefault }} else { + {{- range .BodyDefault.Declarations }} + {{ . }} + {{- end }} + res.{{ .ReturnTypeAttribute }} = {{ .BodyDefault.Expression }} + } + {{- end }} +{{- else }} {{- if .ServerCode }} {{ .ServerCode }} {{- if .ReturnTypeAttribute }} @@ -14,5 +28,9 @@ func {{ .Name }}({{- range .ServerArgs }}{{ .VarName }} {{ .TypeRef }}, {{ end } {{- end }} {{ fieldCode . "server" }} {{- end }} + {{- end }} + {{- if and .OptionalBody .ReturnIsStruct }} + {{ fieldCode . "server" }} + {{- end }} return {{ if .ReturnTypeAttribute }}res{{ else }}v{{ end }} } diff --git a/http/codegen/templates/server_use.go.tpl b/http/codegen/templates/server_use.go.tpl index 1f2d4df41b..a1146574c5 100644 --- a/http/codegen/templates/server_use.go.tpl +++ b/http/codegen/templates/server_use.go.tpl @@ -1,5 +1,5 @@ {{ printf "Use wraps the server handlers with the given middleware." | comment }} -func (s *{{ .ServerStruct }}) Use(m func(http.Handler) http.Handler) { +func (s *{{ .ServerStructDeclaration.Name }}) Use(m func(http.Handler) http.Handler) { {{- range .Endpoints }} s.{{ .Method.VarName }} = m(s.{{ .Method.VarName }}) {{- end }} diff --git a/http/codegen/templates/transform_helper.go.tpl b/http/codegen/templates/transform_helper.go.tpl index f240c92412..24aa5e8a64 100644 --- a/http/codegen/templates/transform_helper.go.tpl +++ b/http/codegen/templates/transform_helper.go.tpl @@ -1,5 +1,9 @@ -{{ printf "%s builds a value of type %s from a value of type %s." .Name .ResultTypeRef .ParamTypeRef | comment }} -func {{ .Name }}(v {{ .ParamTypeRef }}) {{ .ResultTypeRef }} { +{{- $name := .Name -}} +{{- if .Declaration -}} +{{- $name = .Declaration.Name -}} +{{- end }} +{{ printf "%s builds a value of type %s from a value of type %s." $name .ResultTypeRef .ParamTypeRef | comment }} +func {{ $name }}(v {{ .ParamTypeRef }}) {{ .ResultTypeRef }} { {{ .Code }} return res } diff --git a/http/codegen/templates/type_decl.go.tpl b/http/codegen/templates/type_decl.go.tpl index d51ad9e777..9ab7fceb83 100644 --- a/http/codegen/templates/type_decl.go.tpl +++ b/http/codegen/templates/type_decl.go.tpl @@ -1,2 +1,2 @@ {{ comment .Description }} -type {{ .VarName }} {{ .Def }} +type {{ .Declaration.Name }} {{ .Def }} diff --git a/http/codegen/templates/union_type.go.tpl b/http/codegen/templates/union_type.go.tpl index ed49a4d5d0..d1849540ec 100644 --- a/http/codegen/templates/union_type.go.tpl +++ b/http/codegen/templates/union_type.go.tpl @@ -1,65 +1,67 @@ -{{- /* Union sum-type definition and helpers. */ -}} -// {{ .Name }} is a sum-type union. -type {{ .Name }} struct { - kind {{ .KindName }} +{{- /* Definition and helpers for a value that holds exactly one branch. */ -}} +// {{ .TypeDeclaration.Name }} holds exactly one of its branch values. +type {{ .TypeDeclaration.Name }} struct { + kind {{ .KindDeclaration.Name }} {{- range .Fields }} - {{ .FieldName }} {{ .FieldType }} + {{ .StorageName }} {{ .FieldType }} {{- end }} } -// {{ .KindName }} enumerates the union variants for {{ .Name }}. -type {{ .KindName }} string +// {{ .KindDeclaration.Name }} records which {{ .TypeDeclaration.Name }} branch is selected. +type {{ .KindDeclaration.Name }} string const ( {{- range .Fields }} - // {{ .KindConst }} identifies the {{ .Name }} branch of the union. - {{ .KindConst }} {{ $.KindName }} = "{{ .TypeTag }}" + // {{ .KindDeclaration.Name }} identifies the {{ .Name }} branch. + {{ .KindDeclaration.Name }} {{ $.KindDeclaration.Name }} = "{{ .TypeTag }}" {{- end }} ) -// Kind returns the discriminator value of the union. -func (u {{ .Name }}) Kind() {{ .KindName }} { +// Kind returns the selected branch. +func (u {{ .TypeDeclaration.Name }}) Kind() {{ .KindDeclaration.Name }} { return u.kind } {{- range .Fields }} -// New{{ $.Name }}{{ .FieldName }} constructs {{ $.Name }} with the {{ .Name }} branch set. -func New{{ $.Name }}{{ .FieldName }}(v {{ .FieldType }}) {{ $.Name }} { - return {{ $.Name }}{ - kind: {{ .KindConst }}, - {{ .FieldName }}: v, +// {{ .ConstructorDeclaration.Name }} constructs {{ $.TypeDeclaration.Name }} with the {{ .Name }} branch set. +func {{ .ConstructorDeclaration.Name }}(v {{ .FieldType }}) {{ $.TypeDeclaration.Name }} { + return {{ $.TypeDeclaration.Name }}{ + kind: {{ .KindDeclaration.Name }}, + {{ .StorageName }}: v, } } -// As{{ .FieldName }} returns the value of the {{ .Name }} branch if set. -func (u {{ $.Name }}) As{{ .FieldName }}() (_ {{ .FieldType }}, ok bool) { - if u.kind != {{ .KindConst }} { +// As{{ .FieldName }} returns the value when the {{ .Name }} branch is selected. +func (u {{ $.TypeDeclaration.Name }}) As{{ .FieldName }}() (_ {{ .FieldType }}, ok bool) { + if u.kind != {{ .KindDeclaration.Name }} { return } - return u.{{ .FieldName }}, true + return u.{{ .StorageName }}, true } -// Set{{ .FieldName }} sets the {{ .Name }} branch of the union. -func (u *{{ $.Name }}) Set{{ .FieldName }}(v {{ .FieldType }}) { - u.kind = {{ .KindConst }} - u.{{ .FieldName }} = v +// Set{{ .FieldName }} selects the {{ .Name }} branch and stores v. +func (u *{{ $.TypeDeclaration.Name }}) Set{{ .FieldName }}(v {{ .FieldType }}) { + *u = {{ $.TypeDeclaration.Name }}{ + kind: {{ .KindDeclaration.Name }}, + {{ .StorageName }}: v, + } } {{- end }} -// Validate ensures the union discriminant is valid. -func (u {{ .Name }}) Validate() error { +// Validate ensures exactly one valid branch is selected. +func (u {{ .TypeDeclaration.Name }}) Validate() error { switch u.kind { case "": return goa.InvalidEnumValueError({{ printf "%q" .TypeKey }}, "", []any{ {{- range .Fields }} - string({{ .KindConst }}), + string({{ .KindDeclaration.Name }}), {{- end }} }) {{- range .Fields }} - case {{ .KindConst }}: + case {{ .KindDeclaration.Name }}: {{- if .Nilable }} - if u.{{ .FieldName }} == nil { - return goa.MissingFieldError({{ printf "%q" $.ValueKey }}, "{{ $.Name }}") + if u.{{ .StorageName }} == nil { + return goa.MissingFieldError({{ printf "%q" $.ValueKey }}, "{{ $.TypeDeclaration.Name }}") } {{- end }} return nil @@ -67,14 +69,14 @@ func (u {{ .Name }}) Validate() error { default: return goa.InvalidEnumValueError({{ printf "%q" $.TypeKey }}, u.kind, []any{ {{- range .Fields }} - string({{ .KindConst }}), + string({{ .KindDeclaration.Name }}), {{- end }} }) } } // MarshalJSON marshals the union into the canonical {type,value} JSON shape. -func (u {{ .Name }}) MarshalJSON() ([]byte, error) { +func (u {{ .TypeDeclaration.Name }}) MarshalJSON() ([]byte, error) { if err := u.Validate(); err != nil { return nil, err } @@ -83,11 +85,11 @@ func (u {{ .Name }}) MarshalJSON() ([]byte, error) { ) switch u.kind { {{- range .Fields }} - case {{ .KindConst }}: - value = u.{{ .FieldName }} + case {{ .KindDeclaration.Name }}: + value = u.{{ .StorageName }} {{- end }} default: - return nil, fmt.Errorf("unexpected {{ .Name }} discriminant %q", u.kind) + return nil, fmt.Errorf("unexpected {{ .TypeDeclaration.Name }} kind %q", u.kind) } return json.Marshal(struct { Type string {{ printf "`json:\"%s\"`" .TypeKey }} @@ -99,7 +101,7 @@ func (u {{ .Name }}) MarshalJSON() ([]byte, error) { } // UnmarshalJSON unmarshals the union from the canonical {type,value} JSON shape. -func (u *{{ .Name }}) UnmarshalJSON(data []byte) error { +func (u *{{ .TypeDeclaration.Name }}) UnmarshalJSON(data []byte) error { var raw struct { Type string {{ printf "`json:\"%s\"`" .TypeKey }} Value json.RawMessage {{ printf "`json:\"%s\"`" .ValueKey }} @@ -108,28 +110,27 @@ func (u *{{ .Name }}) UnmarshalJSON(data []byte) error { return err } if len(raw.Value) == 0 { - return goa.MissingFieldError({{ printf "%q" .ValueKey }}, "{{ .Name }}") + return goa.MissingFieldError({{ printf "%q" .ValueKey }}, "{{ .TypeDeclaration.Name }}") } if bytes.Equal(bytes.TrimSpace(raw.Value), []byte("null")) { return goa.InvalidFieldTypeError({{ printf "%q" .ValueKey }}, nil, "non-null JSON value") } switch raw.Type { {{- range .Fields }} - case string({{ .KindConst }}): + case string({{ .KindDeclaration.Name }}): var v {{ .FieldType }} if err := json.Unmarshal(raw.Value, &v); err != nil { return err } - u.kind = {{ .KindConst }} - u.{{ .FieldName }} = v + u.Set{{ .FieldName }}(v) {{- end }} default: if raw.Type == "" { - return goa.MissingFieldError({{ printf "%q" .TypeKey }}, "{{ .Name }}") + return goa.MissingFieldError({{ printf "%q" .TypeKey }}, "{{ .TypeDeclaration.Name }}") } return goa.InvalidEnumValueError({{ printf "%q" .TypeKey }}, raw.Type, []any{ {{- range .Fields }} - string({{ .KindConst }}), + string({{ .KindDeclaration.Name }}), {{- end }} }) } diff --git a/http/codegen/templates/validate.go.tpl b/http/codegen/templates/validate.go.tpl index 115410fef4..70bf1959f9 100644 --- a/http/codegen/templates/validate.go.tpl +++ b/http/codegen/templates/validate.go.tpl @@ -1,5 +1,13 @@ -{{ printf "Validate%s runs the validations defined on %s" .VarName .Name | comment }} -func Validate{{ .VarName }}(body {{ .Ref }}) (err error) { +{{ printf "%s runs the validations defined on %s" .ValidatorDeclaration.Name .Name | comment }} +func {{ .ValidatorDeclaration.Name }}(body {{ .Ref }}) (err error) { {{ .ValidateDef }} - return + return } + +{{- if .NestedValidatorDeclaration }} +{{ printf "%s checks %s and reports errors using the path supplied by its caller" .NestedValidatorDeclaration.Name .Name | comment }} +func {{ .NestedValidatorDeclaration.Name }}(body {{ .Ref }}, path string) (err error) { + {{ .NestedValidateDef }} + return +} +{{- end }} diff --git a/http/codegen/templates/websocket_close.go.tpl b/http/codegen/templates/websocket_close.go.tpl index c7e832ac80..fa8c1d4f8b 100644 --- a/http/codegen/templates/websocket_close.go.tpl +++ b/http/codegen/templates/websocket_close.go.tpl @@ -1,10 +1,29 @@ {{ printf "Close closes the %q endpoint websocket connection." .Endpoint.Method.Name | comment }} -func (s *{{ .VarName }}) Close() error { - var err error +func (s *{{ .VarDeclaration.Name }}) Close() error { {{- if eq .Type "server" }} - if s.conn == nil { - return nil + s.closeOnce.Do(func() { + s.closeErr = s.close() + }) + return s.closeErr +} + +{{ comment "close opens the websocket connection when needed, sends its normal close message, and closes it." }} +func (s *{{ .VarDeclaration.Name }}) close() error { + var err error + {{- if and .Endpoint.Method.ViewedResult (not .Endpoint.Method.ViewedResult.ViewName) }} + view := s.view + if view == "" { + view = "default" + } + switch view { + {{- range .Endpoint.Method.ViewedResult.Views }} + case {{ printf "%q" .Name }}: + {{- end }} + default: + return goa.InvalidEnumValueError("view", view, []any{ {{ range .Endpoint.Method.ViewedResult.Views }}{{ printf "%q" .Name }}, {{ end }} }) } + {{- end }} + {{- template "partial_websocket_upgrade" (upgradeParams .Endpoint "Close") }} if err = s.conn.WriteControl( websocket.CloseMessage, websocket.FormatCloseMessage(websocket.CloseNormalClosure, "server closing connection"), @@ -12,11 +31,13 @@ func (s *{{ .VarName }}) Close() error { ); err != nil { return err } + return s.conn.Close() {{- else }} {{/* client side code */}} + var err error {{ comment "Send a nil payload to the server implying client closing connection." }} if err = s.conn.WriteJSON(nil); err != nil { return err } -{{- end }} return s.conn.Close() +{{- end }} } diff --git a/http/codegen/templates/websocket_conn_configurer_struct.go.tpl b/http/codegen/templates/websocket_conn_configurer_struct.go.tpl index 2a4947699d..588001b35f 100644 --- a/http/codegen/templates/websocket_conn_configurer_struct.go.tpl +++ b/http/codegen/templates/websocket_conn_configurer_struct.go.tpl @@ -1,5 +1,5 @@ -{{ printf "ConnConfigurer holds the websocket connection configurer functions for the streaming endpoints in %q service." .Service.Name | comment }} -type ConnConfigurer struct { +{{ printf "%s holds the websocket connection configurer functions for the streaming endpoints in %q service." .Declaration.Name .Service.Name | comment }} +type {{ .Declaration.Name }} struct { {{- range .Endpoints }} {{- if isWebSocketEndpoint . }} {{ .Method.VarName }}Fn goahttp.ConnConfigureFunc diff --git a/http/codegen/templates/websocket_conn_configurer_struct_init.go.tpl b/http/codegen/templates/websocket_conn_configurer_struct_init.go.tpl index 9a7eb29382..69207e866f 100644 --- a/http/codegen/templates/websocket_conn_configurer_struct_init.go.tpl +++ b/http/codegen/templates/websocket_conn_configurer_struct_init.go.tpl @@ -1,6 +1,6 @@ -{{ printf "NewConnConfigurer initializes the websocket connection configurer function with fn for all the streaming endpoints in %q service." .Service.Name | comment }} -func NewConnConfigurer(fn goahttp.ConnConfigureFunc) *ConnConfigurer { - return &ConnConfigurer{ +{{ printf "%s initializes the websocket connection configurer function with fn for all the streaming endpoints in %q service." .InitDeclaration.Name .Service.Name | comment }} +func {{ .InitDeclaration.Name }}(fn goahttp.ConnConfigureFunc) *{{ .Declaration.Name }} { + return &{{ .Declaration.Name }}{ {{- range .Endpoints }} {{- if isWebSocketEndpoint . }} {{ .Method.VarName}}Fn: fn, diff --git a/http/codegen/templates/websocket_recv.go.tpl b/http/codegen/templates/websocket_recv.go.tpl index b5056a38e3..54b8662264 100644 --- a/http/codegen/templates/websocket_recv.go.tpl +++ b/http/codegen/templates/websocket_recv.go.tpl @@ -1,15 +1,18 @@ {{ comment .RecvDesc }} -func (s *{{ .VarName }}) {{ .RecvName }}() ({{ .RecvTypeRef }}, error) { +func (s *{{ .VarDeclaration.Name }}) {{ .RecvName }}() ({{ .RecvTypeRef }}, error) { var ( rv {{ .RecvTypeRef }} {{- if eq .Type "server" }} {{- if .RecvTypeIsPointer }} - body {{ .Payload.VarName }} + body {{ if .Payload.Declaration }}{{ .Payload.Declaration.Name }}{{ else }}{{ .Payload.VarName }}{{ end }} {{- else }} - msg *{{ .Payload.VarName }} + msg *{{ if .Payload.Declaration }}{{ .Payload.Declaration.Name }}{{ else }}{{ .Payload.VarName }}{{ end }} {{- end }} {{- else }} - body {{ .Response.ClientBody.VarName }} + {{- if .SelectClientBodyByView }} + {{- else }} + body {{ if .Response.ClientBody.Declaration }}{{ .Response.ClientBody.Declaration.Name }}{{ else }}{{ .Response.ClientBody.VarName }}{{ end }} + {{- end }} {{- end }} err error ) @@ -29,28 +32,80 @@ func (s *{{ .VarName }}) {{ .RecvName }}() ({{ .RecvTypeRef }}, error) { {{- end }} return rv, io.EOF } - {{- if .Payload.ValidateRef }} + {{- if or (and .Payload.ValidatorDeclaration .Payload.ValidationTarget) .Payload.ValidateRef }} {{- if not .RecvTypeIsPointer }} body := *msg {{- end }} - {{ .Payload.ValidateRef }} + {{- if and .Payload.ValidatorDeclaration .Payload.ValidationTarget }} + err = {{ .Payload.ValidatorDeclaration.Name }}({{ .Payload.ValidationTarget }}) + {{- else }} + {{ .Payload.ValidateRef }} + {{- end }} if err != nil { return rv, err } {{- end }} {{- if .Payload.Init }} - return {{ .Payload.Init.Name }}({{ if .RecvTypeIsPointer }}body{{ else }}msg{{ end }}), nil + return {{ .Payload.Init.Declaration.Name }}({{ if .RecvTypeIsPointer }}body{{ else }}msg{{ end }}), nil {{- else }} return {{ if .RecvTypeIsPointer }}body{{ else }}*msg{{ end }}, nil {{- end }} {{- else }} {{/* client side code */}} - {{- if eq .RecvName "CloseAndRecv" }} + {{- if isClientStreamKind .Kind }} defer s.conn.Close() {{ comment "Send a nil payload to the server implying end of message" }} if err = s.conn.WriteJSON(nil); err != nil { return rv, err } {{- end }} + {{- if .SelectClientBodyByView }} + view := s.view + if view == "" { + view = "default" + } + switch view { + {{- range .Response.ViewedRepresentations }} + case {{ printf "%q" .View }}: + var body {{ if .ClientBody.Declaration }}{{ .ClientBody.Declaration.Name }}{{ else }}{{ .ClientBody.VarName }}{{ end }} + err = s.conn.ReadJSON(&body) + if websocket.IsCloseError(err, websocket.CloseNormalClosure) { + {{- if not $.MustClose }} + s.conn.Close() + {{- end }} + return rv, io.EOF + } + if err != nil { + return rv, err + } + {{- if and .ClientBody.ValidatorDeclaration .ClientBody.ValidationTarget }} + err = {{ .ClientBody.ValidatorDeclaration.Name }}({{ .ClientBody.ValidationTarget }}) + {{- else if .ClientBody.ValidateRef }} + {{ .ClientBody.ValidateRef }} + {{- end }} + {{- if or (and .ClientBody.ValidatorDeclaration .ClientBody.ValidationTarget) .ClientBody.ValidateRef }} + if err != nil { + return rv, err + } + {{- end }} + res := {{ .ResultInit.Declaration.Name }}({{ range .ResultInit.ClientArgs }}{{ .Ref }},{{ end }}) + {{- with $.Endpoint.Method.ViewedResult }} + vres := {{ if not .IsCollection }}&{{ end }}{{ .ViewsPkg }}.{{ .VarName }}{Projected: res, View: view} + if err := {{ .ViewsPkg }}.{{ .Validate.Declaration.Name }}(vres); err != nil { + return rv, goahttp.ErrValidationError("{{ $.Endpoint.ServiceName }}", "{{ $.Endpoint.Method.Name }}", err) + } + return {{ $.PkgName }}.{{ .ResultInit.Declaration.Name }}(vres), nil + {{- end }} + {{- end }} + default: + return rv, goahttp.ErrValidationError("{{ .Endpoint.ServiceName }}", "{{ .Endpoint.Method.Name }}", goa.InvalidEnumValueError("view", view, []any{ {{ range .Endpoint.Method.ViewedResult.Views }}{{ printf "%q" .Name }}, {{ end }} })) + } + {{- else }} + {{- if and .Endpoint.Method.ViewedResult (not .Endpoint.Method.ViewedResult.ViewName) }} + view := s.view + if view == "" { + view = "default" + } + {{- end }} err = s.conn.ReadJSON(&body) if websocket.IsCloseError(err, websocket.CloseNormalClosure) { {{- if not .MustClose }} @@ -61,30 +116,35 @@ func (s *{{ .VarName }}) {{ .RecvName }}() ({{ .RecvTypeRef }}, error) { if err != nil { return rv, err } - {{- if and .Response.ClientBody.ValidateRef (not .Endpoint.Method.ViewedResult) }} + {{- if or (and .Response.ClientBody.ValidatorDeclaration .Response.ClientBody.ValidationTarget) .Response.ClientBody.ValidateRef }} + {{- if and .Response.ClientBody.ValidatorDeclaration .Response.ClientBody.ValidationTarget }} + err = {{ .Response.ClientBody.ValidatorDeclaration.Name }}({{ .Response.ClientBody.ValidationTarget }}) + {{- else }} {{ .Response.ClientBody.ValidateRef }} + {{- end }} if err != nil { return rv, err } {{- end }} {{- if .Response.ResultInit }} - res := {{ .Response.ResultInit.Name }}({{ range .Response.ResultInit.ClientArgs }}{{ .Ref }},{{ end }}) + res := {{ .Response.ResultInit.Declaration.Name }}({{ range .Response.ResultInit.ClientArgs }}{{ .Ref }},{{ end }}) {{- if .Endpoint.Method.ViewedResult }}{{ with .Endpoint.Method.ViewedResult }} - vres := {{ if not .IsCollection }}&{{ end }}{{ .ViewsPkg }}.{{ .VarName }}{Projected: res, View: {{ if .ViewName }}{{ printf "%q" .ViewName }}{{ else }}s.view{{ end }} } - if err := {{ .ViewsPkg }}.Validate{{ $.Endpoint.Method.Result }}(vres); err != nil { + vres := {{ if not .IsCollection }}&{{ end }}{{ .ViewsPkg }}.{{ .VarName }}{Projected: res, View: {{ if .ViewName }}{{ printf "%q" .ViewName }}{{ else }}view{{ end }} } + if err := {{ .ViewsPkg }}.{{ .Validate.Declaration.Name }}(vres); err != nil { return rv, goahttp.ErrValidationError("{{ $.Endpoint.ServiceName }}", "{{ $.Endpoint.Method.Name }}", err) } - return {{ $.PkgName }}.{{ .ResultInit.Name }}(vres){{ end }}, nil + return {{ $.PkgName }}.{{ .ResultInit.Declaration.Name }}(vres){{ end }}, nil {{- else }} return res, nil {{- end }} {{- else }} return body, nil {{- end }} + {{- end }} {{- end }} } {{ comment .RecvWithContextDesc }} -func (s *{{ .VarName }}) {{ .RecvWithContextName }}(ctx context.Context) ({{ .RecvTypeRef }}, error) { +func (s *{{ .VarDeclaration.Name }}) {{ .RecvWithContextName }}(ctx context.Context) ({{ .RecvTypeRef }}, error) { return s.{{ .RecvName }}() } diff --git a/http/codegen/templates/websocket_send.go.tpl b/http/codegen/templates/websocket_send.go.tpl index 11f2f02b22..5cbf7ce92d 100644 --- a/http/codegen/templates/websocket_send.go.tpl +++ b/http/codegen/templates/websocket_send.go.tpl @@ -1,17 +1,40 @@ +{{/* +websocket_send.go.tpl writes one service result to a WebSocket. A method with +several views keeps only the caller's view choice in generated code. +*/ -}} {{ comment .SendDesc }} -func (s *{{ .VarName }}) {{ .SendName }}(v {{ .SendTypeRef }}) error { +func (s *{{ .VarDeclaration.Name }}) {{ .SendName }}(v {{ .SendTypeRef }}) error { {{- if eq .Type "server" }} - {{- if eq .SendName "Send" }} + {{- if and .Endpoint.Method.ViewedResult (not .Endpoint.Method.ViewedResult.ViewName) }} + view := s.view + if view == "" { + view = "default" + } + if s.sentView != "" && view != s.sentView { + return goa.InvalidEnumValueError("view", view, []any{s.sentView}) + } + switch view { + {{- range .Endpoint.Method.ViewedResult.Views }} + case {{ printf "%q" .Name }}: + {{- end }} + default: + return goa.InvalidEnumValueError("view", view, []any{ {{ range .Endpoint.Method.ViewedResult.Views }}{{ printf "%q" .Name }}, {{ end }} }) + } + {{- end }} + {{- if not (isClientStreamKind .Kind) }} var err error {{- template "partial_websocket_upgrade" (upgradeParams .Endpoint .SendName) }} - {{- else }} {{/* SendAndClose */}} + {{- if and .Endpoint.Method.ViewedResult (not .Endpoint.Method.ViewedResult.ViewName) }} + if s.sentView == "" { + s.sentView = view + } + {{- end }} + {{- else }} defer s.conn.Close() {{- end }} {{- if .Endpoint.Method.ViewedResult }} {{- if .Endpoint.Method.ViewedResult.ViewName }} - res := {{ .PkgName }}.{{ .Endpoint.Method.ViewedResult.Init.Name }}(v, {{ printf "%q" .Endpoint.Method.ViewedResult.ViewName }}) - {{- else }} - res := {{ .PkgName }}.{{ .Endpoint.Method.ViewedResult.Init.Name }}(v, s.view) + res := {{ .PkgName }}.{{ .Endpoint.Method.ViewedResult.Init.Declaration.Name }}(v, {{ printf "%q" .Endpoint.Method.ViewedResult.ViewName }}) {{- end }} {{- else }} res := v @@ -22,21 +45,25 @@ func (s *{{ .VarName }}) {{ .SendName }}(v {{ .SendTypeRef }}) error { {{- if .Endpoint.Method.ViewedResult }} {{- if .Endpoint.Method.ViewedResult.ViewName }} {{- $vsb := (viewedServerBody $.Response.ServerBody .Endpoint.Method.ViewedResult.ViewName) }} - body := {{ $vsb.Init.Name }}({{ range $vsb.Init.ServerArgs }}{{ .Ref }}, {{ end }}) + body := {{ $vsb.Init.Declaration.Name }}({{ range $vsb.Init.ServerArgs }}{{ .Ref }}, {{ end }}) {{- else }} - var body any - switch s.view { + switch view { {{- range .Endpoint.Method.ViewedResult.Views }} case {{ printf "%q" .Name }}{{ if eq .Name "default" }}, ""{{ end }}: + res := {{ $.PkgName }}.{{ $.Endpoint.Method.ViewedResult.Init.Declaration.Name }}(v, {{ printf "%q" .Name }}) {{- $vsb := (viewedServerBody $.Response.ServerBody .Name) }} - body = {{ $vsb.Init.Name }}({{ range $vsb.Init.ServerArgs }}{{ .Ref }}, {{ end }}) + return s.conn.WriteJSON({{ $vsb.Init.Declaration.Name }}({{ range $vsb.Init.ServerArgs }}{{ .Ref }}, {{ end }})) {{- end }} + default: + return goa.InvalidEnumValueError("view", view, []any{ {{ range .Endpoint.Method.ViewedResult.Views }}{{ printf "%q" .Name }}, {{ end }} }) } {{- end }} {{- else }} - body := {{ (index .Response.ServerBody 0).Init.Name }}({{ range (index .Response.ServerBody 0).Init.ServerArgs }}{{ .Ref }}, {{ end }}) + body := {{ (index .Response.ServerBody 0).Init.Declaration.Name }}({{ range (index .Response.ServerBody 0).Init.ServerArgs }}{{ .Ref }}, {{ end }}) {{- end }} + {{- if or (not .Endpoint.Method.ViewedResult) .Endpoint.Method.ViewedResult.ViewName }} return s.conn.WriteJSON(body) + {{- end }} {{- else }} return s.conn.WriteJSON(res) {{- end }} @@ -45,7 +72,7 @@ func (s *{{ .VarName }}) {{ .SendName }}(v {{ .SendTypeRef }}) error { {{- end }} {{- else }} {{- if .Payload.Init }} - body := {{ .Payload.Init.Name }}(v) + body := {{ .Payload.Init.Declaration.Name }}(v) return s.conn.WriteJSON(body) {{- else }} return s.conn.WriteJSON(v) @@ -54,6 +81,6 @@ func (s *{{ .VarName }}) {{ .SendName }}(v {{ .SendTypeRef }}) error { } {{ comment .SendWithContextDesc }} -func (s *{{ .VarName }}) {{ .SendWithContextName }}(ctx context.Context, v {{ .SendTypeRef }}) error { +func (s *{{ .VarDeclaration.Name }}) {{ .SendWithContextName }}(ctx context.Context, v {{ .SendTypeRef }}) error { return s.{{ .SendName }}(v) } diff --git a/http/codegen/templates/websocket_set_view.go.tpl b/http/codegen/templates/websocket_set_view.go.tpl index b8e44d61ff..08fe850b1a 100644 --- a/http/codegen/templates/websocket_set_view.go.tpl +++ b/http/codegen/templates/websocket_set_view.go.tpl @@ -1,4 +1,8 @@ +{{- if eq .Type "server" }} {{ printf "SetView sets the view to render the %s type before sending to the %q endpoint websocket connection." .SendTypeName .Endpoint.Method.Name | comment }} -func (s *{{ .VarName }}) SetView(view string) { +{{- else }} +{{ printf "SetView sets the result view used to decode values received from the %q endpoint websocket connection." .Endpoint.Method.Name | comment }} +{{- end }} +func (s *{{ .VarDeclaration.Name }}) SetView(view string) { s.view = view } diff --git a/http/codegen/templates/websocket_struct_type.go.tpl b/http/codegen/templates/websocket_struct_type.go.tpl index a3ed5d915d..d7e2e95a21 100644 --- a/http/codegen/templates/websocket_struct_type.go.tpl +++ b/http/codegen/templates/websocket_struct_type.go.tpl @@ -1,9 +1,15 @@ -{{ printf "%s implements the %s interface." .VarName .Interface | comment }} -type {{ .VarName }} struct { +{{ printf "%s implements the %s interface." .VarDeclaration.Name .Interface | comment }} +type {{ .VarDeclaration.Name }} struct { {{- if eq .Type "server" }} once sync.Once {{ comment "upgradeErr is the error returned by the websocket upgrade attempt." }} upgradeErr error + {{- if .MustClose }} + {{ comment "closeOnce makes repeated Close calls return the first close result without writing again." }} + closeOnce sync.Once + {{ comment "closeErr is the result of the first Close call." }} + closeErr error + {{- end }} {{ comment "upgrader is the websocket connection upgrader." }} upgrader goahttp.Upgrader {{ comment "configurer is the websocket connection configurer." }} @@ -19,8 +25,16 @@ type {{ .VarName }} struct { conn *websocket.Conn {{- if .Endpoint.Method.ViewedResult }} {{- if not .Endpoint.Method.ViewedResult.ViewName }} + {{- if eq .Type "server" }} {{ printf "view is the view to render %s result type before sending to the websocket connection." .SendTypeName | comment }} + {{- else }} + {{ printf "view is the result view used to decode values received from the websocket connection." | comment }} + {{- end }} view string + {{- if eq .Type "server" }} + {{ comment "sentView is the result view named during the WebSocket upgrade. Later sends must use the same view." }} + sentView string + {{- end }} {{- end }} {{- end }} } diff --git a/http/codegen/testdata/error_response_dsls.go b/http/codegen/testdata/error_response_dsls.go index b43c63add4..b7ffd59f2a 100644 --- a/http/codegen/testdata/error_response_dsls.go +++ b/http/codegen/testdata/error_response_dsls.go @@ -1,3 +1,5 @@ +// This file defines HTTP error response designs used by transport codegen +// tests, including reusable API mappings and service-level error contracts. package testdata import ( @@ -148,7 +150,7 @@ var APINoBodyErrorResponseDSL = func() { }) }) Service("ServiceNoBodyErrorResponse", func() { - Error("bad_request") + Error("bad_request", StringError) Method("MethodServiceErrorResponse", func() { HTTP(func() { GET("/one/two") @@ -171,7 +173,7 @@ var APINoBodyErrorResponseWithContentTypeDSL = func() { }) }) Service("ServiceNoBodyErrorResponse", func() { - Error("bad_request") + Error("bad_request", StringError) Method("MethodServiceErrorResponse", func() { HTTP(func() { GET("/one/two") @@ -232,6 +234,19 @@ var ErrorExamplesDSL = func() { var _ = Service("Errors", func() { Method("Error", func() { Error("not_found") // default example + Error("retry", func() { + Temporary() + }) + Error("deadline", func() { + Timeout() + }) + Error("retry_deadline", func() { + Temporary() + Timeout() + }) + Error("internal", func() { + Fault() + }) Error("bad_request", func() { Example("BadRequest example", func() { Value(Val{ @@ -248,6 +263,10 @@ var ErrorExamplesDSL = func() { HTTP(func() { GET("/") Response("not_found", StatusNotFound) + Response("retry", StatusTooManyRequests) + Response("deadline", StatusGatewayTimeout) + Response("retry_deadline", StatusServiceUnavailable) + Response("internal", StatusInternalServerError) Response("bad_request", StatusBadRequest) Response("custom", StatusConflict) }) diff --git a/http/codegen/testdata/golden/client-mixed-results.golden b/http/codegen/testdata/golden/client-mixed-results.golden new file mode 100644 index 0000000000..6276078067 --- /dev/null +++ b/http/codegen/testdata/golden/client-mixed-results.golden @@ -0,0 +1,36 @@ +func doHTTP(ctx context.Context, scheme, host string, timeout int, debug bool, stdout io.Writer) error { + var ( + doer goahttp.Doer + ) + { + doer = &http.Client{Timeout: time.Duration(timeout) * time.Second} + if debug { + doer = goahttp.NewDebugDoer(doer) + } + } + + endpoint, payload, err := cli.ParseEndpoint( + scheme, + host, + doer, + goahttp.RequestEncoder, + goahttp.ResponseDecoder, + debug, + ) + if err != nil { + return fmt.Errorf("parse endpoint: %w", err) + } + + switch flag.Arg(0) { + case "mixed-results-service": + switch flag.Arg(1) { + case "create": + return writeEndpointResult(ctx, stdout, endpoint, payload) + } + } + panic("parsed HTTP command has no generated result writer") +} + +func httpUsageExamples() string { + return cli.UsageExamples() +} diff --git a/http/codegen/testdata/golden/client-no-server.golden b/http/codegen/testdata/golden/client-no-server.golden index 91381eb4ed..05bac95ee1 100644 --- a/http/codegen/testdata/golden/client-no-server.golden +++ b/http/codegen/testdata/golden/client-no-server.golden @@ -1,4 +1,4 @@ -func doHTTP(scheme, host string, timeout int, debug bool) (goa.Endpoint, any, error) { +func doHTTP(ctx context.Context, scheme, host string, timeout int, debug bool, stdout io.Writer) error { var ( doer goahttp.Doer ) @@ -18,13 +18,17 @@ func doHTTP(scheme, host string, timeout int, debug bool) (goa.Endpoint, any, er debug, ) if err != nil { - return nil, nil, fmt.Errorf("parse endpoint: %w", err) + return fmt.Errorf("parse endpoint: %w", err) } - return endpoint, payload, nil -} -func httpUsageCommands() []string { - return cli.UsageCommands() + switch flag.Arg(0) { + case "service": + switch flag.Arg(1) { + case "method": + return writeEndpointResult(ctx, stdout, endpoint, payload) + } + } + panic("parsed HTTP command has no generated result writer") } func httpUsageExamples() string { diff --git a/http/codegen/testdata/golden/client-server-hosting-multiple-services.golden b/http/codegen/testdata/golden/client-server-hosting-multiple-services.golden index 91381eb4ed..a820b824e2 100644 --- a/http/codegen/testdata/golden/client-server-hosting-multiple-services.golden +++ b/http/codegen/testdata/golden/client-server-hosting-multiple-services.golden @@ -1,4 +1,4 @@ -func doHTTP(scheme, host string, timeout int, debug bool) (goa.Endpoint, any, error) { +func doHTTP(ctx context.Context, scheme, host string, timeout int, debug bool, stdout io.Writer) error { var ( doer goahttp.Doer ) @@ -18,13 +18,22 @@ func doHTTP(scheme, host string, timeout int, debug bool) (goa.Endpoint, any, er debug, ) if err != nil { - return nil, nil, fmt.Errorf("parse endpoint: %w", err) + return fmt.Errorf("parse endpoint: %w", err) } - return endpoint, payload, nil -} -func httpUsageCommands() []string { - return cli.UsageCommands() + switch flag.Arg(0) { + case "service": + switch flag.Arg(1) { + case "method": + return writeEndpointResult(ctx, stdout, endpoint, payload) + } + case "another-service": + switch flag.Arg(1) { + case "method": + return writeEndpointResult(ctx, stdout, endpoint, payload) + } + } + panic("parsed HTTP command has no generated result writer") } func httpUsageExamples() string { diff --git a/http/codegen/testdata/golden/client-server-hosting-service-subset.golden b/http/codegen/testdata/golden/client-server-hosting-service-subset.golden index 91381eb4ed..05bac95ee1 100644 --- a/http/codegen/testdata/golden/client-server-hosting-service-subset.golden +++ b/http/codegen/testdata/golden/client-server-hosting-service-subset.golden @@ -1,4 +1,4 @@ -func doHTTP(scheme, host string, timeout int, debug bool) (goa.Endpoint, any, error) { +func doHTTP(ctx context.Context, scheme, host string, timeout int, debug bool, stdout io.Writer) error { var ( doer goahttp.Doer ) @@ -18,13 +18,17 @@ func doHTTP(scheme, host string, timeout int, debug bool) (goa.Endpoint, any, er debug, ) if err != nil { - return nil, nil, fmt.Errorf("parse endpoint: %w", err) + return fmt.Errorf("parse endpoint: %w", err) } - return endpoint, payload, nil -} -func httpUsageCommands() []string { - return cli.UsageCommands() + switch flag.Arg(0) { + case "service": + switch flag.Arg(1) { + case "method": + return writeEndpointResult(ctx, stdout, endpoint, payload) + } + } + panic("parsed HTTP command has no generated result writer") } func httpUsageExamples() string { diff --git a/http/codegen/testdata/golden/client-streaming-input-only.golden b/http/codegen/testdata/golden/client-streaming-input-only.golden new file mode 100644 index 0000000000..2e37ed9912 --- /dev/null +++ b/http/codegen/testdata/golden/client-streaming-input-only.golden @@ -0,0 +1,45 @@ +func doHTTP(ctx context.Context, scheme, host string, timeout int, debug bool, stdout io.Writer) error { + var ( + doer goahttp.Doer + ) + { + doer = &http.Client{Timeout: time.Duration(timeout) * time.Second} + if debug { + doer = goahttp.NewDebugDoer(doer) + } + } + + var ( + dialer *websocket.Dialer + ) + { + dialer = websocket.DefaultDialer + } + + switch flag.Arg(0) { + case "streaming-payload-service": + switch flag.Arg(1) { + case "streaming-payload-method": + return errors.New("example client does not support streamed input for service \"StreamingPayloadService\" method \"StreamingPayloadMethod\"") + } + } + _, _, err := cli.ParseEndpoint( + scheme, + host, + doer, + goahttp.RequestEncoder, + goahttp.ResponseDecoder, + debug, + dialer, + nil, + ) + if err != nil { + return fmt.Errorf("parse endpoint: %w", err) + } + + panic("parsed HTTP command has no generated result writer") +} + +func httpUsageExamples() string { + return cli.UsageExamples() +} diff --git a/http/codegen/testdata/golden/client-streaming-multiple-services.golden b/http/codegen/testdata/golden/client-streaming-multiple-services.golden index 3d8ce10318..ba65d0dc66 100644 --- a/http/codegen/testdata/golden/client-streaming-multiple-services.golden +++ b/http/codegen/testdata/golden/client-streaming-multiple-services.golden @@ -1,4 +1,4 @@ -func doHTTP(scheme, host string, timeout int, debug bool) (goa.Endpoint, any, error) { +func doHTTP(ctx context.Context, scheme, host string, timeout int, debug bool, stdout io.Writer) error { var ( doer goahttp.Doer ) @@ -16,6 +16,13 @@ func doHTTP(scheme, host string, timeout int, debug bool) (goa.Endpoint, any, er dialer = websocket.DefaultDialer } + switch flag.Arg(0) { + case "streaming-service-b": + switch flag.Arg(1) { + case "method": + return errors.New("example client does not support streamed input for service \"StreamingServiceB\" method \"Method\"") + } + } endpoint, payload, err := cli.ParseEndpoint( scheme, host, @@ -28,13 +35,22 @@ func doHTTP(scheme, host string, timeout int, debug bool) (goa.Endpoint, any, er nil, ) if err != nil { - return nil, nil, fmt.Errorf("parse endpoint: %w", err) + return fmt.Errorf("parse endpoint: %w", err) } - return endpoint, payload, nil -} -func httpUsageCommands() []string { - return cli.UsageCommands() + switch flag.Arg(0) { + case "streaming-service-a": + switch flag.Arg(1) { + case "method": + data, err := endpoint(ctx, payload) + if err != nil { + return err + } + stream := data.(streamingservicea.MethodClientStream) + return writeStreamResults(ctx, stdout, stream.RecvWithContext) + } + } + panic("parsed HTTP command has no generated result writer") } func httpUsageExamples() string { diff --git a/http/codegen/testdata/golden/client-streaming.golden b/http/codegen/testdata/golden/client-streaming.golden index fbd2b88761..995491ae9c 100644 --- a/http/codegen/testdata/golden/client-streaming.golden +++ b/http/codegen/testdata/golden/client-streaming.golden @@ -1,4 +1,4 @@ -func doHTTP(scheme, host string, timeout int, debug bool) (goa.Endpoint, any, error) { +func doHTTP(ctx context.Context, scheme, host string, timeout int, debug bool, stdout io.Writer) error { var ( doer goahttp.Doer ) @@ -27,13 +27,22 @@ func doHTTP(scheme, host string, timeout int, debug bool) (goa.Endpoint, any, er nil, ) if err != nil { - return nil, nil, fmt.Errorf("parse endpoint: %w", err) + return fmt.Errorf("parse endpoint: %w", err) } - return endpoint, payload, nil -} -func httpUsageCommands() []string { - return cli.UsageCommands() + switch flag.Arg(0) { + case "streaming-result-service": + switch flag.Arg(1) { + case "streaming-result-method": + data, err := endpoint(ctx, payload) + if err != nil { + return err + } + stream := data.(streamingresultservice.StreamingResultMethodClientStream) + return writeStreamResults(ctx, stdout, stream.RecvWithContext) + } + } + panic("parsed HTTP command has no generated result writer") } func httpUsageExamples() string { diff --git a/http/codegen/testdata/golden/client_body_type_init_result-explicit-body-primitive-default-view.go.golden b/http/codegen/testdata/golden/client_body_type_init_result-explicit-body-primitive-default-view.go.golden new file mode 100644 index 0000000000..5a5f1b8fa7 --- /dev/null +++ b/http/codegen/testdata/golden/client_body_type_init_result-explicit-body-primitive-default-view.go.golden @@ -0,0 +1,13 @@ +// NewMethodExplicitBodyPrimitiveResultDefaultViewResulttypedefaultviewOK +// builds a "ServiceExplicitBodyPrimitiveResultDefaultView" service +// "MethodExplicitBodyPrimitiveResultDefaultView" endpoint result from a HTTP +// "OK" response. +func NewMethodExplicitBodyPrimitiveResultDefaultViewResulttypedefaultviewOK(body string, b *string) *serviceexplicitbodyprimitiveresultdefaultviewviews.ResulttypedefaultviewView { + v := body + res := &serviceexplicitbodyprimitiveresultdefaultviewviews.ResulttypedefaultviewView{ + A: &v, + } + res.B = b + + return res +} diff --git a/http/codegen/testdata/golden/client_build_request_path-int-alias.go.golden b/http/codegen/testdata/golden/client_build_request_path-int-alias.go.golden new file mode 100644 index 0000000000..063b38ee57 --- /dev/null +++ b/http/codegen/testdata/golden/client_build_request_path-int-alias.go.golden @@ -0,0 +1,34 @@ +// BuildMethodARequest instantiates a HTTP request object with method and path +// set to call the "ServicePathIntAlias" service "MethodA" endpoint +func (c *Client) BuildMethodARequest(ctx context.Context, v any) (*http.Request, error) { + var ( + int_ int + int32_ int32 + int64_ int64 + ) + { + p, ok := v.(*servicepathintalias.MethodAPayload) + if !ok { + return nil, goahttp.ErrInvalidType("ServicePathIntAlias", "MethodA", "*servicepathintalias.MethodAPayload", v) + } + if p.Int != nil { + int_ = int(*p.Int) + } + if p.Int32 != nil { + int32_ = int32(*p.Int32) + } + if p.Int64 != nil { + int64_ = int64(*p.Int64) + } + } + u := &url.URL{Scheme: c.scheme, Host: c.host, Path: MethodAServicePathIntAliasPath(int_, int32_, int64_)} + req, err := http.NewRequest("POST", u.String(), nil) + if err != nil { + return nil, goahttp.ErrInvalidURL("ServicePathIntAlias", "MethodA", u.String(), err) + } + if ctx != nil { + req = req.WithContext(ctx) + } + + return req, nil +} diff --git a/http/codegen/testdata/golden/client_build_request_path-primitive-string.go.golden b/http/codegen/testdata/golden/client_build_request_path-primitive-string.go.golden new file mode 100644 index 0000000000..877d376da0 --- /dev/null +++ b/http/codegen/testdata/golden/client_build_request_path-primitive-string.go.golden @@ -0,0 +1,26 @@ +// BuildMethodPathPrimitiveStringFormatIPValidateRequest instantiates a HTTP +// request object with method and path set to call the +// "ServicePathPrimitiveStringFormatIPValidate" service +// "MethodPathPrimitiveStringFormatIPValidate" endpoint +func (c *Client) BuildMethodPathPrimitiveStringFormatIPValidateRequest(ctx context.Context, v any) (*http.Request, error) { + var ( + ip string + ) + { + p, ok := v.(string) + if !ok { + return nil, goahttp.ErrInvalidType("ServicePathPrimitiveStringFormatIPValidate", "MethodPathPrimitiveStringFormatIPValidate", "string", v) + } + ip = p + } + u := &url.URL{Scheme: c.scheme, Host: c.host, Path: MethodPathPrimitiveStringFormatIPValidateServicePathPrimitiveStringFormatIPValidatePath(ip)} + req, err := http.NewRequest("GET", u.String(), nil) + if err != nil { + return nil, goahttp.ErrInvalidURL("ServicePathPrimitiveStringFormatIPValidate", "MethodPathPrimitiveStringFormatIPValidate", u.String(), err) + } + if ctx != nil { + req = req.WithContext(ctx) + } + + return req, nil +} diff --git a/http/codegen/testdata/golden/client_cli_body-custom-name.go.golden b/http/codegen/testdata/golden/client_cli_body-custom-name.go.golden index a520c7dfae..d29d6a82f3 100644 --- a/http/codegen/testdata/golden/client_cli_body-custom-name.go.golden +++ b/http/codegen/testdata/golden/client_cli_body-custom-name.go.golden @@ -1,12 +1,15 @@ // BuildMethodBodyCustomNamePayload builds the payload for the // ServiceBodyCustomName MethodBodyCustomName endpoint from CLI flags. -func BuildMethodBodyCustomNamePayload(serviceBodyCustomNameMethodBodyCustomNameBody string) (*servicebodycustomname.MethodBodyCustomNamePayload, error) { +func BuildMethodBodyCustomNamePayload(serviceBodyCustomNameMethodBodyCustomNameBody *string) (*servicebodycustomname.MethodBodyCustomNamePayload, error) { var err error var body MethodBodyCustomNameRequestBody { - err = json.Unmarshal([]byte(serviceBodyCustomNameMethodBodyCustomNameBody), &body) + if serviceBodyCustomNameMethodBodyCustomNameBody == nil { + return nil, fmt.Errorf("missing required flag --body") + } + err = json.Unmarshal([]byte(*serviceBodyCustomNameMethodBodyCustomNameBody), &body) if err != nil { - return nil, fmt.Errorf("invalid JSON for body, \nerror: %s, \nexample of valid JSON:\n%s", err, "'{\n \"b\": \"Itaque ab itaque.\"\n }'") + return nil, fmt.Errorf("invalid JSON for body, \nerror: %s, \nexample of valid JSON:\n%s", err, "'{\n \"b\": \"Et consequatur molestiae.\"\n }'") } } v := &servicebodycustomname.MethodBodyCustomNamePayload{ diff --git a/http/codegen/testdata/golden/client_cli_body-nested-user-build.go.golden b/http/codegen/testdata/golden/client_cli_body-nested-user-build.go.golden new file mode 100644 index 0000000000..4dd703400c --- /dev/null +++ b/http/codegen/testdata/golden/client_cli_body-nested-user-build.go.golden @@ -0,0 +1,24 @@ +// BuildMethodBodyUserPayload builds the payload for the ServiceBodyUser +// MethodBodyUser endpoint from CLI flags. +func BuildMethodBodyUserPayload(serviceBodyUserMethodBodyUserBody *string) (*servicebodyuser.PayloadType, error) { + var err error + var body *MethodBodyUserRequestBody + { + if serviceBodyUserMethodBodyUserBody != nil { + err = json.Unmarshal([]byte(*serviceBodyUserMethodBodyUserBody), &body) + if err != nil { + return nil, fmt.Errorf("invalid JSON for body, \nerror: %s, \nexample of valid JSON:\n%s", err, "'{\n \"a\": \"Qui aut voluptatibus voluptatem sed consectetur quis.\",\n \"b\": \"Porro eligendi quo molestiae.\"\n }'") + } + } + } + res := &servicebodyuser.PayloadType{} + if body != nil { + v := &servicebodyuser.NestedType{ + A: body.A, + B: body.B, + } + res.Data = v + } + + return res, nil +} diff --git a/http/codegen/testdata/golden/client_cli_body-query-path-object-build.go.golden b/http/codegen/testdata/golden/client_cli_body-query-path-object-build.go.golden index 0e95662003..23252b5a09 100644 --- a/http/codegen/testdata/golden/client_cli_body-query-path-object-build.go.golden +++ b/http/codegen/testdata/golden/client_cli_body-query-path-object-build.go.golden @@ -1,22 +1,28 @@ // BuildMethodBodyQueryPathObjectPayload builds the payload for the // ServiceBodyQueryPathObject MethodBodyQueryPathObject endpoint from CLI flags. -func BuildMethodBodyQueryPathObjectPayload(serviceBodyQueryPathObjectMethodBodyQueryPathObjectBody string, serviceBodyQueryPathObjectMethodBodyQueryPathObjectC2 string, serviceBodyQueryPathObjectMethodBodyQueryPathObjectB string) (*servicebodyquerypathobject.MethodBodyQueryPathObjectPayload, error) { +func BuildMethodBodyQueryPathObjectPayload(serviceBodyQueryPathObjectMethodBodyQueryPathObjectBody *string, serviceBodyQueryPathObjectMethodBodyQueryPathObjectC2 *string, serviceBodyQueryPathObjectMethodBodyQueryPathObjectB *string) (*servicebodyquerypathobject.MethodBodyQueryPathObjectPayload, error) { var err error var body MethodBodyQueryPathObjectRequestBody { - err = json.Unmarshal([]byte(serviceBodyQueryPathObjectMethodBodyQueryPathObjectBody), &body) + if serviceBodyQueryPathObjectMethodBodyQueryPathObjectBody == nil { + return nil, fmt.Errorf("missing required flag --body") + } + err = json.Unmarshal([]byte(*serviceBodyQueryPathObjectMethodBodyQueryPathObjectBody), &body) if err != nil { - return nil, fmt.Errorf("invalid JSON for body, \nerror: %s, \nexample of valid JSON:\n%s", err, "'{\n \"a\": \"Hic at eveniet porro sit nisi.\"\n }'") + return nil, fmt.Errorf("invalid JSON for body, \nerror: %s, \nexample of valid JSON:\n%s", err, "'{\n \"a\": \"Dolor rerum.\"\n }'") } } var c2 string { - c2 = serviceBodyQueryPathObjectMethodBodyQueryPathObjectC2 + if serviceBodyQueryPathObjectMethodBodyQueryPathObjectC2 == nil { + return nil, fmt.Errorf("missing required flag --c2") + } + c2 = *serviceBodyQueryPathObjectMethodBodyQueryPathObjectC2 } var b *string { - if serviceBodyQueryPathObjectMethodBodyQueryPathObjectB != "" { - b = &serviceBodyQueryPathObjectMethodBodyQueryPathObjectB + if serviceBodyQueryPathObjectMethodBodyQueryPathObjectB != nil { + b = serviceBodyQueryPathObjectMethodBodyQueryPathObjectB } } v := &servicebodyquerypathobject.MethodBodyQueryPathObjectPayload{ diff --git a/http/codegen/testdata/golden/client_cli_bool-build.go.golden b/http/codegen/testdata/golden/client_cli_bool-build.go.golden index d2f7ae1b21..893e7d0341 100644 --- a/http/codegen/testdata/golden/client_cli_bool-build.go.golden +++ b/http/codegen/testdata/golden/client_cli_bool-build.go.golden @@ -1,12 +1,12 @@ // BuildMethodQueryBoolPayload builds the payload for the ServiceQueryBool // MethodQueryBool endpoint from CLI flags. -func BuildMethodQueryBoolPayload(serviceQueryBoolMethodQueryBoolQ string) (*servicequerybool.MethodQueryBoolPayload, error) { +func BuildMethodQueryBoolPayload(serviceQueryBoolMethodQueryBoolQ *string) (*servicequerybool.MethodQueryBoolPayload, error) { var err error var q *bool { - if serviceQueryBoolMethodQueryBoolQ != "" { + if serviceQueryBoolMethodQueryBoolQ != nil { var val bool - val, err = strconv.ParseBool(serviceQueryBoolMethodQueryBoolQ) + val, err = strconv.ParseBool(*serviceQueryBoolMethodQueryBoolQ) q = &val if err != nil { return nil, fmt.Errorf("invalid value for q, must be BOOL") diff --git a/http/codegen/testdata/golden/client_cli_cookie-custom-name.go.golden b/http/codegen/testdata/golden/client_cli_cookie-custom-name.go.golden index b3186f6849..a305870223 100644 --- a/http/codegen/testdata/golden/client_cli_cookie-custom-name.go.golden +++ b/http/codegen/testdata/golden/client_cli_cookie-custom-name.go.golden @@ -1,10 +1,10 @@ // BuildMethodCookieCustomNamePayload builds the payload for the // ServiceCookieCustomName MethodCookieCustomName endpoint from CLI flags. -func BuildMethodCookieCustomNamePayload(serviceCookieCustomNameMethodCookieCustomNameC2 string) (*servicecookiecustomname.MethodCookieCustomNamePayload, error) { +func BuildMethodCookieCustomNamePayload(serviceCookieCustomNameMethodCookieCustomNameC2 *string) (*servicecookiecustomname.MethodCookieCustomNamePayload, error) { var c2 *string { - if serviceCookieCustomNameMethodCookieCustomNameC2 != "" { - c2 = &serviceCookieCustomNameMethodCookieCustomNameC2 + if serviceCookieCustomNameMethodCookieCustomNameC2 != nil { + c2 = serviceCookieCustomNameMethodCookieCustomNameC2 } } v := &servicecookiecustomname.MethodCookieCustomNamePayload{} diff --git a/http/codegen/testdata/golden/client_cli_empty-body-build.go.golden b/http/codegen/testdata/golden/client_cli_empty-body-build.go.golden index 68335349e1..aaff3e640a 100644 --- a/http/codegen/testdata/golden/client_cli_empty-body-build.go.golden +++ b/http/codegen/testdata/golden/client_cli_empty-body-build.go.golden @@ -1,14 +1,14 @@ // BuildMethodBodyPrimitiveArrayUserPayload builds the payload for the // ServiceBodyPrimitiveArrayUser MethodBodyPrimitiveArrayUser endpoint from CLI // flags. -func BuildMethodBodyPrimitiveArrayUserPayload(serviceBodyPrimitiveArrayUserMethodBodyPrimitiveArrayUserA string) (*servicebodyprimitivearrayuser.PayloadType, error) { +func BuildMethodBodyPrimitiveArrayUserPayload(serviceBodyPrimitiveArrayUserMethodBodyPrimitiveArrayUserA *string) (*servicebodyprimitivearrayuser.PayloadType, error) { var err error var a []string { - if serviceBodyPrimitiveArrayUserMethodBodyPrimitiveArrayUserA != "" { - err = json.Unmarshal([]byte(serviceBodyPrimitiveArrayUserMethodBodyPrimitiveArrayUserA), &a) + if serviceBodyPrimitiveArrayUserMethodBodyPrimitiveArrayUserA != nil { + err = json.Unmarshal([]byte(*serviceBodyPrimitiveArrayUserMethodBodyPrimitiveArrayUserA), &a) if err != nil { - return nil, fmt.Errorf("invalid JSON for a, \nerror: %s, \nexample of valid JSON:\n%s", err, "'[\n \"Ducimus hic sint temporibus velit blanditiis in.\",\n \"Sint qui sit quaerat quas illo.\"\n ]'") + return nil, fmt.Errorf("invalid JSON for a, \nerror: %s, \nexample of valid JSON:\n%s", err, "'[\n \"Enim ut qui quaerat assumenda voluptatum.\",\n \"Ut iste molestiae.\"\n ]'") } } } diff --git a/http/codegen/testdata/golden/client_cli_flag-presence-build.go.golden b/http/codegen/testdata/golden/client_cli_flag-presence-build.go.golden new file mode 100644 index 0000000000..7f3a507710 --- /dev/null +++ b/http/codegen/testdata/golden/client_cli_flag-presence-build.go.golden @@ -0,0 +1,46 @@ +// BuildCheckPayload builds the payload for the FlagPresence check endpoint +// from CLI flags. +func BuildCheckPayload(flagPresenceCheckRequired *string, flagPresenceCheckOptional *string, flagPresenceCheckEmpty string, flagPresenceCheckDisabled string, flagPresenceCheckZero string) (*flagpresence.CheckPayload, error) { + var err error + var required string + { + if flagPresenceCheckRequired == nil { + return nil, fmt.Errorf("missing required flag --required") + } + required = *flagPresenceCheckRequired + } + var optional *string + { + if flagPresenceCheckOptional != nil { + optional = flagPresenceCheckOptional + } + } + var empty string + { + empty = flagPresenceCheckEmpty + } + var disabled bool + { + disabled, err = strconv.ParseBool(flagPresenceCheckDisabled) + if err != nil { + return nil, fmt.Errorf("invalid value for disabled, must be BOOL") + } + } + var zero int + { + var v int64 + v, err = strconv.ParseInt(flagPresenceCheckZero, 10, strconv.IntSize) + zero = int(v) + if err != nil { + return nil, fmt.Errorf("invalid value for zero, must be INT") + } + } + v := &flagpresence.CheckPayload{} + v.Required = required + v.Optional = optional + v.Empty = empty + v.Disabled = disabled + v.Zero = zero + + return v, nil +} diff --git a/http/codegen/testdata/golden/client_cli_flag-presence-parse.go.golden b/http/codegen/testdata/golden/client_cli_flag-presence-parse.go.golden new file mode 100644 index 0000000000..cfd2c8004a --- /dev/null +++ b/http/codegen/testdata/golden/client_cli_flag-presence-parse.go.golden @@ -0,0 +1,118 @@ +// cliStringFlag keeps an omitted command-line flag distinct from an explicitly empty flag. +type cliStringFlag struct { + value *string +} + +// String returns the flag text shown by the standard flag package. +func (f *cliStringFlag) String() string { + if f.value == nil { + return "" + } + return *f.value +} + +// Set records that the user supplied the flag, even when value is empty. +func (f *cliStringFlag) Set(value string) error { + f.value = &value + return nil +} + +// ParseEndpoint returns the endpoint and payload as specified on the command +// line. +func ParseEndpoint( + scheme, host string, + doer goahttp.Doer, + enc func(*http.Request) goahttp.Encoder, + dec func(*http.Response) goahttp.Decoder, + restore bool, +) (goa.Endpoint, any, error) { + var ( + flagPresenceFlags = flag.NewFlagSet("flag-presence", flag.ContinueOnError) + + flagPresenceCheckFlags = flag.NewFlagSet("check", flag.ExitOnError) + flagPresenceCheckRequiredFlag = new(cliStringFlag) + flagPresenceCheckOptionalFlag = new(cliStringFlag) + flagPresenceCheckEmptyFlag = flagPresenceCheckFlags.String("empty", "", "") + flagPresenceCheckDisabledFlag = flagPresenceCheckFlags.String("disabled", "false", "") + flagPresenceCheckZeroFlag = flagPresenceCheckFlags.String("zero", "0", "") + ) + flagPresenceCheckFlags.Var(flagPresenceCheckRequiredFlag, "required", "") + flagPresenceCheckFlags.Var(flagPresenceCheckOptionalFlag, "optional", "") + + flagPresenceFlags.Usage = flagPresenceUsage + flagPresenceCheckFlags.Usage = flagPresenceCheckUsage + + if err := flag.CommandLine.Parse(os.Args[1:]); err != nil { + return nil, nil, err + } + + if flag.NArg() < 2 { // two non flag args are required: SERVICE and ENDPOINT (aka COMMAND) + return nil, nil, fmt.Errorf("not enough arguments") + } + + var ( + svcn string + svcf *flag.FlagSet + ) + { + svcn = flag.Arg(0) + switch svcn { + case "flag-presence": + svcf = flagPresenceFlags + default: + return nil, nil, fmt.Errorf("unknown service %q", svcn) + } + } + if err := svcf.Parse(flag.Args()[1:]); err != nil { + return nil, nil, err + } + + var ( + epn string + epf *flag.FlagSet + ) + { + epn = svcf.Arg(0) + switch svcn { + case "flag-presence": + switch epn { + case "check": + epf = flagPresenceCheckFlags + + } + + } + } + if epf == nil { + return nil, nil, fmt.Errorf("unknown %q endpoint %q", svcn, epn) + } + + // Parse endpoint flags if any + if svcf.NArg() > 1 { + if err := epf.Parse(svcf.Args()[1:]); err != nil { + return nil, nil, err + } + } + + var ( + data any + endpoint goa.Endpoint + err error + ) + { + switch svcn { + case "flag-presence": + c := flagpresencec.NewClient(scheme, host, doer, enc, dec, restore) + switch epn { + case "check": + endpoint = c.Check() + data, err = flagpresencec.BuildCheckPayload(flagPresenceCheckRequiredFlag.value, flagPresenceCheckOptionalFlag.value, *flagPresenceCheckEmptyFlag, *flagPresenceCheckDisabledFlag, *flagPresenceCheckZeroFlag) + } + } + } + if err != nil { + return nil, nil, err + } + + return endpoint, data, nil +} diff --git a/http/codegen/testdata/golden/client_cli_header-custom-name.go.golden b/http/codegen/testdata/golden/client_cli_header-custom-name.go.golden index 0e7e480a98..9b69c15a55 100644 --- a/http/codegen/testdata/golden/client_cli_header-custom-name.go.golden +++ b/http/codegen/testdata/golden/client_cli_header-custom-name.go.golden @@ -1,10 +1,10 @@ // BuildMethodHeaderCustomNamePayload builds the payload for the // ServiceHeaderCustomName MethodHeaderCustomName endpoint from CLI flags. -func BuildMethodHeaderCustomNamePayload(serviceHeaderCustomNameMethodHeaderCustomNameH string) (*serviceheadercustomname.MethodHeaderCustomNamePayload, error) { +func BuildMethodHeaderCustomNamePayload(serviceHeaderCustomNameMethodHeaderCustomNameH *string) (*serviceheadercustomname.MethodHeaderCustomNamePayload, error) { var h *string { - if serviceHeaderCustomNameMethodHeaderCustomNameH != "" { - h = &serviceHeaderCustomNameMethodHeaderCustomNameH + if serviceHeaderCustomNameMethodHeaderCustomNameH != nil { + h = serviceHeaderCustomNameMethodHeaderCustomNameH } } v := &serviceheadercustomname.MethodHeaderCustomNamePayload{} diff --git a/http/codegen/testdata/golden/client_cli_map-query-object.go.golden b/http/codegen/testdata/golden/client_cli_map-query-object.go.golden index ff5cf7ad44..95bd5dc0d7 100644 --- a/http/codegen/testdata/golden/client_cli_map-query-object.go.golden +++ b/http/codegen/testdata/golden/client_cli_map-query-object.go.golden @@ -1,10 +1,13 @@ // BuildMethodMapQueryObjectPayload builds the payload for the // ServiceMapQueryObject MethodMapQueryObject endpoint from CLI flags. -func BuildMethodMapQueryObjectPayload(serviceMapQueryObjectMethodMapQueryObjectBody string, serviceMapQueryObjectMethodMapQueryObjectA string, serviceMapQueryObjectMethodMapQueryObjectC string) (*servicemapqueryobject.PayloadType, error) { +func BuildMethodMapQueryObjectPayload(serviceMapQueryObjectMethodMapQueryObjectBody *string, serviceMapQueryObjectMethodMapQueryObjectA *string, serviceMapQueryObjectMethodMapQueryObjectC *string) (*servicemapqueryobject.PayloadType, error) { var err error var body MethodMapQueryObjectRequestBody { - err = json.Unmarshal([]byte(serviceMapQueryObjectMethodMapQueryObjectBody), &body) + if serviceMapQueryObjectMethodMapQueryObjectBody == nil { + return nil, fmt.Errorf("missing required flag --body") + } + err = json.Unmarshal([]byte(*serviceMapQueryObjectMethodMapQueryObjectBody), &body) if err != nil { return nil, fmt.Errorf("invalid JSON for body, \nerror: %s, \nexample of valid JSON:\n%s", err, "'{\n \"b\": \"patternb\"\n }'") } @@ -17,7 +20,10 @@ func BuildMethodMapQueryObjectPayload(serviceMapQueryObjectMethodMapQueryObjectB } var a string { - a = serviceMapQueryObjectMethodMapQueryObjectA + if serviceMapQueryObjectMethodMapQueryObjectA == nil { + return nil, fmt.Errorf("missing required flag --a") + } + a = *serviceMapQueryObjectMethodMapQueryObjectA err = goa.MergeErrors(err, goa.ValidatePattern("a", a, "patterna")) if err != nil { return nil, err @@ -25,9 +31,12 @@ func BuildMethodMapQueryObjectPayload(serviceMapQueryObjectMethodMapQueryObjectB } var c map[int][]string { - err = json.Unmarshal([]byte(serviceMapQueryObjectMethodMapQueryObjectC), &c) + if serviceMapQueryObjectMethodMapQueryObjectC == nil { + return nil, fmt.Errorf("missing required flag --c") + } + err = json.Unmarshal([]byte(*serviceMapQueryObjectMethodMapQueryObjectC), &c) if err != nil { - return nil, fmt.Errorf("invalid JSON for c, \nerror: %s, \nexample of valid JSON:\n%s", err, "'{\n \"5691875720969669573\": [\n \"Rerum quia ea.\",\n \"Qui iure hic quisquam nulla error.\",\n \"Nihil necessitatibus expedita architecto atque.\",\n \"Nulla nisi.\"\n ],\n \"7072545540989245598\": [\n \"Adipisci veritatis sunt impedit et soluta fugiat.\",\n \"Ut commodi cum exercitationem voluptas autem voluptates.\",\n \"Debitis voluptatem.\"\n ],\n \"986504572350809452\": [\n \"Accusantium corrupti sed enim optio consequatur aut.\",\n \"Est molestiae qui.\"\n ]\n }'") + return nil, fmt.Errorf("invalid JSON for c, \nerror: %s, \nexample of valid JSON:\n%s", err, "'{\n \"1025921908706477149\": [\n \"Nostrum et.\",\n \"Reiciendis rerum aliquam vitae esse.\",\n \"Omnis alias cumque nulla rerum.\"\n ]\n }'") } } v := &servicemapqueryobject.PayloadType{ diff --git a/http/codegen/testdata/golden/client_cli_map-query.go.golden b/http/codegen/testdata/golden/client_cli_map-query.go.golden index f9367d6765..9895282544 100644 --- a/http/codegen/testdata/golden/client_cli_map-query.go.golden +++ b/http/codegen/testdata/golden/client_cli_map-query.go.golden @@ -1,3 +1,22 @@ +// cliStringFlag keeps an omitted command-line flag distinct from an explicitly empty flag. +type cliStringFlag struct { + value *string +} + +// String returns the flag text shown by the standard flag package. +func (f *cliStringFlag) String() string { + if f.value == nil { + return "" + } + return *f.value +} + +// Set records that the user supplied the flag, even when value is empty. +func (f *cliStringFlag) Set(value string) error { + f.value = &value + return nil +} + // ParseEndpoint returns the endpoint and payload as specified on the command // line. func ParseEndpoint( @@ -11,8 +30,10 @@ func ParseEndpoint( serviceMapQueryPrimitiveArrayFlags = flag.NewFlagSet("service-map-query-primitive-array", flag.ContinueOnError) serviceMapQueryPrimitiveArrayMapQueryPrimitiveArrayFlags = flag.NewFlagSet("map-query-primitive-array", flag.ExitOnError) - serviceMapQueryPrimitiveArrayMapQueryPrimitiveArrayPFlag = serviceMapQueryPrimitiveArrayMapQueryPrimitiveArrayFlags.String("p", "REQUIRED", "map[string][]uint is the payload type of the ServiceMapQueryPrimitiveArray service MapQueryPrimitiveArray method.") + serviceMapQueryPrimitiveArrayMapQueryPrimitiveArrayPFlag = new(cliStringFlag) ) + serviceMapQueryPrimitiveArrayMapQueryPrimitiveArrayFlags.Var(serviceMapQueryPrimitiveArrayMapQueryPrimitiveArrayPFlag, "p", "map[string][]uint is the payload type of the ServiceMapQueryPrimitiveArray service MapQueryPrimitiveArray method.") + serviceMapQueryPrimitiveArrayFlags.Usage = serviceMapQueryPrimitiveArrayUsage serviceMapQueryPrimitiveArrayMapQueryPrimitiveArrayFlags.Usage = serviceMapQueryPrimitiveArrayMapQueryPrimitiveArrayUsage @@ -81,11 +102,14 @@ func ParseEndpoint( case "map-query-primitive-array": endpoint = c.MapQueryPrimitiveArray() var err error + if serviceMapQueryPrimitiveArrayMapQueryPrimitiveArrayPFlag.value == nil { + return nil, nil, fmt.Errorf("missing required flag --p") + } var val map[string][]uint - err = json.Unmarshal([]byte(*serviceMapQueryPrimitiveArrayMapQueryPrimitiveArrayPFlag), &val) + err = json.Unmarshal([]byte(*serviceMapQueryPrimitiveArrayMapQueryPrimitiveArrayPFlag.value), &val) data = val if err != nil { - return nil, nil, fmt.Errorf("invalid JSON for serviceMapQueryPrimitiveArrayMapQueryPrimitiveArrayPFlag, \nerror: %s, \nexample of valid JSON:\n%s", err, "'{\n \"Quidem accusamus.\": [\n 3906106080549376696,\n 18039872150139340048\n ],\n \"Repellat occaecati officiis doloremque.\": [\n 8210943955859777434,\n 7701234893751856770,\n 12687845642945213165,\n 5080739739723289788\n ],\n \"Voluptas officiis in eum nostrum voluptatem.\": [\n 7799018251876253497,\n 23602025540978920\n ]\n }'") + return nil, nil, fmt.Errorf("invalid JSON for serviceMapQueryPrimitiveArrayMapQueryPrimitiveArrayPFlag, \nerror: %s, \nexample of valid JSON:\n%s", err, "'{\n \"Deserunt itaque pariatur ipsam consequuntur nostrum.\": [\n 11990920063555956079,\n 17089161724625472560\n ],\n \"Libero dolore est accusantium explicabo nostrum rerum.\": [\n 2438159965416078706,\n 977176377124085955,\n 16802288397808742928\n ],\n \"Occaecati nulla iusto.\": [\n 11103002557489322756,\n 17383528869145716448,\n 5505226717552383690,\n 3219217238434884659\n ]\n }'") } } } diff --git a/http/codegen/testdata/golden/client_cli_multi-build.go.golden b/http/codegen/testdata/golden/client_cli_multi-build.go.golden index 9675487ba0..db9b907a2f 100644 --- a/http/codegen/testdata/golden/client_cli_multi-build.go.golden +++ b/http/codegen/testdata/golden/client_cli_multi-build.go.golden @@ -1,25 +1,28 @@ // BuildMethodMultiPayloadPayload builds the payload for the ServiceMulti // MethodMultiPayload endpoint from CLI flags. -func BuildMethodMultiPayloadPayload(serviceMultiMethodMultiPayloadBody string, serviceMultiMethodMultiPayloadB string, serviceMultiMethodMultiPayloadA string) (*servicemulti.MethodMultiPayloadPayload, error) { +func BuildMethodMultiPayloadPayload(serviceMultiMethodMultiPayloadBody *string, serviceMultiMethodMultiPayloadB *string, serviceMultiMethodMultiPayloadA *string) (*servicemulti.MethodMultiPayloadPayload, error) { var err error var body MethodMultiPayloadRequestBody { - err = json.Unmarshal([]byte(serviceMultiMethodMultiPayloadBody), &body) + if serviceMultiMethodMultiPayloadBody == nil { + return nil, fmt.Errorf("missing required flag --body") + } + err = json.Unmarshal([]byte(*serviceMultiMethodMultiPayloadBody), &body) if err != nil { - return nil, fmt.Errorf("invalid JSON for body, \nerror: %s, \nexample of valid JSON:\n%s", err, "'{\n \"c\": {\n \"att\": true,\n \"att10\": \"Aut et.\",\n \"att11\": \"SGljIHZpdGFlIHZlbGl0IG1hZ25hbSBhdC4=\",\n \"att12\": \"Voluptatibus harum autem totam quaerat quis ut.\",\n \"att13\": [\n \"Sunt inventore est voluptatum ipsam omnis.\",\n \"Quidem quo a non sequi quo.\",\n \"Voluptate id corrupti.\",\n \"Sit cum quia magnam est nihil illo.\"\n ],\n \"att14\": {\n \"Fuga consequatur magnam vel sint doloribus.\": \"Corporis deserunt.\",\n \"Omnis eveniet.\": \"Nobis quia incidunt nemo illum.\"\n },\n \"att15\": {\n \"inline\": \"Suscipit voluptate magnam facilis.\"\n },\n \"att2\": 1586082787030249061,\n \"att3\": 1689516036,\n \"att4\": 2650159337126663108,\n \"att5\": 15366113216962746133,\n \"att6\": 2615133795,\n \"att7\": 13370278622483873840,\n \"att8\": 0.7927184,\n \"att9\": 0.12075756206016808\n }\n }'") + return nil, fmt.Errorf("invalid JSON for body, \nerror: %s, \nexample of valid JSON:\n%s", err, "'{\n \"c\": {\n \"att\": false,\n \"att10\": \"Fugiat deserunt qui unde odit blanditiis aut.\",\n \"att11\": \"SXBzYSBtaW51cyBpdXJlIHZlcml0YXRpcyByZXJ1bS4=\",\n \"att12\": \"Illum nulla alias.\",\n \"att13\": [\n \"Quod quos architecto.\",\n \"Deleniti numquam.\",\n \"Similique voluptatibus non quaerat eum nobis.\"\n ],\n \"att14\": {\n \"Libero laboriosam.\": \"Et nesciunt corrupti.\"\n },\n \"att15\": {\n \"inline\": \"Labore hic unde.\"\n },\n \"att2\": 7364427417869607579,\n \"att3\": 1230775405,\n \"att4\": 7548751036052687007,\n \"att5\": 13421568780903955996,\n \"att6\": 955870894,\n \"att7\": 5943663396465067570,\n \"att8\": 0.45085564,\n \"att9\": 0.9928171957403447\n }\n }'") } } var b *string { - if serviceMultiMethodMultiPayloadB != "" { - b = &serviceMultiMethodMultiPayloadB + if serviceMultiMethodMultiPayloadB != nil { + b = serviceMultiMethodMultiPayloadB } } var a *bool { - if serviceMultiMethodMultiPayloadA != "" { + if serviceMultiMethodMultiPayloadA != nil { var val bool - val, err = strconv.ParseBool(serviceMultiMethodMultiPayloadA) + val, err = strconv.ParseBool(*serviceMultiMethodMultiPayloadA) a = &val if err != nil { return nil, fmt.Errorf("invalid value for a, must be BOOL") diff --git a/http/codegen/testdata/golden/client_cli_multi-parse.go.golden b/http/codegen/testdata/golden/client_cli_multi-parse.go.golden index 5d9771d329..e19fb25ccb 100644 --- a/http/codegen/testdata/golden/client_cli_multi-parse.go.golden +++ b/http/codegen/testdata/golden/client_cli_multi-parse.go.golden @@ -1,3 +1,22 @@ +// cliStringFlag keeps an omitted command-line flag distinct from an explicitly empty flag. +type cliStringFlag struct { + value *string +} + +// String returns the flag text shown by the standard flag package. +func (f *cliStringFlag) String() string { + if f.value == nil { + return "" + } + return *f.value +} + +// Set records that the user supplied the flag, even when value is empty. +func (f *cliStringFlag) Set(value string) error { + f.value = &value + return nil +} + // ParseEndpoint returns the endpoint and payload as specified on the command // line. func ParseEndpoint( @@ -13,10 +32,14 @@ func ParseEndpoint( serviceMultiMethodMultiNoPayloadFlags = flag.NewFlagSet("method-multi-no-payload", flag.ExitOnError) serviceMultiMethodMultiPayloadFlags = flag.NewFlagSet("method-multi-payload", flag.ExitOnError) - serviceMultiMethodMultiPayloadBodyFlag = serviceMultiMethodMultiPayloadFlags.String("body", "REQUIRED", "") - serviceMultiMethodMultiPayloadBFlag = serviceMultiMethodMultiPayloadFlags.String("b", "", "") - serviceMultiMethodMultiPayloadAFlag = serviceMultiMethodMultiPayloadFlags.String("a", "", "") + serviceMultiMethodMultiPayloadBodyFlag = new(cliStringFlag) + serviceMultiMethodMultiPayloadBFlag = new(cliStringFlag) + serviceMultiMethodMultiPayloadAFlag = new(cliStringFlag) ) + serviceMultiMethodMultiPayloadFlags.Var(serviceMultiMethodMultiPayloadBodyFlag, "body", "") + serviceMultiMethodMultiPayloadFlags.Var(serviceMultiMethodMultiPayloadBFlag, "b", "") + serviceMultiMethodMultiPayloadFlags.Var(serviceMultiMethodMultiPayloadAFlag, "a", "") + serviceMultiFlags.Usage = serviceMultiUsage serviceMultiMethodMultiNoPayloadFlags.Usage = serviceMultiMethodMultiNoPayloadUsage serviceMultiMethodMultiPayloadFlags.Usage = serviceMultiMethodMultiPayloadUsage @@ -90,7 +113,7 @@ func ParseEndpoint( endpoint = c.MethodMultiNoPayload() case "method-multi-payload": endpoint = c.MethodMultiPayload() - data, err = servicemultic.BuildMethodMultiPayloadPayload(*serviceMultiMethodMultiPayloadBodyFlag, *serviceMultiMethodMultiPayloadBFlag, *serviceMultiMethodMultiPayloadAFlag) + data, err = servicemultic.BuildMethodMultiPayloadPayload(serviceMultiMethodMultiPayloadBodyFlag.value, serviceMultiMethodMultiPayloadBFlag.value, serviceMultiMethodMultiPayloadAFlag.value) } } } diff --git a/http/codegen/testdata/golden/client_cli_multi-required-payload.go.golden b/http/codegen/testdata/golden/client_cli_multi-required-payload.go.golden index 36793eec83..5cc369aaf1 100644 --- a/http/codegen/testdata/golden/client_cli_multi-required-payload.go.golden +++ b/http/codegen/testdata/golden/client_cli_multi-required-payload.go.golden @@ -1,3 +1,22 @@ +// cliStringFlag keeps an omitted command-line flag distinct from an explicitly empty flag. +type cliStringFlag struct { + value *string +} + +// String returns the flag text shown by the standard flag package. +func (f *cliStringFlag) String() string { + if f.value == nil { + return "" + } + return *f.value +} + +// Set records that the user supplied the flag, even when value is empty. +func (f *cliStringFlag) Set(value string) error { + f.value = &value + return nil +} + // ParseEndpoint returns the endpoint and payload as specified on the command // line. func ParseEndpoint( @@ -11,15 +30,18 @@ func ParseEndpoint( serviceMultiRequired1Flags = flag.NewFlagSet("service-multi-required1", flag.ContinueOnError) serviceMultiRequired1MethodMultiRequiredPayloadFlags = flag.NewFlagSet("method-multi-required-payload", flag.ExitOnError) - serviceMultiRequired1MethodMultiRequiredPayloadBodyFlag = serviceMultiRequired1MethodMultiRequiredPayloadFlags.String("body", "REQUIRED", "") + serviceMultiRequired1MethodMultiRequiredPayloadBodyFlag = new(cliStringFlag) serviceMultiRequired2Flags = flag.NewFlagSet("service-multi-required2", flag.ContinueOnError) serviceMultiRequired2MethodMultiRequiredNoPayloadFlags = flag.NewFlagSet("method-multi-required-no-payload", flag.ExitOnError) serviceMultiRequired2MethodMultiRequiredPayloadFlags = flag.NewFlagSet("method-multi-required-payload", flag.ExitOnError) - serviceMultiRequired2MethodMultiRequiredPayloadAFlag = serviceMultiRequired2MethodMultiRequiredPayloadFlags.String("a", "REQUIRED", "") + serviceMultiRequired2MethodMultiRequiredPayloadAFlag = new(cliStringFlag) ) + serviceMultiRequired1MethodMultiRequiredPayloadFlags.Var(serviceMultiRequired1MethodMultiRequiredPayloadBodyFlag, "body", "") + serviceMultiRequired2MethodMultiRequiredPayloadFlags.Var(serviceMultiRequired2MethodMultiRequiredPayloadAFlag, "a", "") + serviceMultiRequired1Flags.Usage = serviceMultiRequired1Usage serviceMultiRequired1MethodMultiRequiredPayloadFlags.Usage = serviceMultiRequired1MethodMultiRequiredPayloadUsage @@ -103,7 +125,7 @@ func ParseEndpoint( switch epn { case "method-multi-required-payload": endpoint = c.MethodMultiRequiredPayload() - data, err = servicemultirequired1c.BuildMethodMultiRequiredPayloadPayload(*serviceMultiRequired1MethodMultiRequiredPayloadBodyFlag) + data, err = servicemultirequired1c.BuildMethodMultiRequiredPayloadPayload(serviceMultiRequired1MethodMultiRequiredPayloadBodyFlag.value) } case "service-multi-required2": c := servicemultirequired2c.NewClient(scheme, host, doer, enc, dec, restore) @@ -112,7 +134,7 @@ func ParseEndpoint( endpoint = c.MethodMultiRequiredNoPayload() case "method-multi-required-payload": endpoint = c.MethodMultiRequiredPayload() - data, err = servicemultirequired2c.BuildMethodMultiRequiredPayloadPayload(*serviceMultiRequired2MethodMultiRequiredPayloadAFlag) + data, err = servicemultirequired2c.BuildMethodMultiRequiredPayloadPayload(serviceMultiRequired2MethodMultiRequiredPayloadAFlag.value) } } } diff --git a/http/codegen/testdata/golden/client_cli_param-validation-build.go.golden b/http/codegen/testdata/golden/client_cli_param-validation-build.go.golden index bcb2801b73..2702792ced 100644 --- a/http/codegen/testdata/golden/client_cli_param-validation-build.go.golden +++ b/http/codegen/testdata/golden/client_cli_param-validation-build.go.golden @@ -1,19 +1,19 @@ // BuildMethodParamValidatePayload builds the payload for the // ServiceParamValidate MethodParamValidate endpoint from CLI flags. -func BuildMethodParamValidatePayload(serviceParamValidateMethodParamValidateA string) (*serviceparamvalidate.MethodParamValidatePayload, error) { +func BuildMethodParamValidatePayload(serviceParamValidateMethodParamValidateA *string) (*serviceparamvalidate.MethodParamValidatePayload, error) { var err error var a *int { - if serviceParamValidateMethodParamValidateA != "" { + if serviceParamValidateMethodParamValidateA != nil { var v int64 - v, err = strconv.ParseInt(serviceParamValidateMethodParamValidateA, 10, strconv.IntSize) + v, err = strconv.ParseInt(*serviceParamValidateMethodParamValidateA, 10, strconv.IntSize) val := int(v) a = &val if err != nil { return nil, fmt.Errorf("invalid value for a, must be INT") } - if *a < 1 { - err = goa.MergeErrors(err, goa.InvalidRangeError("a", *a, 1, true)) + if val < 1 { + err = goa.MergeErrors(err, goa.InvalidRangeError("a", val, 1, true)) } if err != nil { return nil, err diff --git a/http/codegen/testdata/golden/client_cli_path-custom-name.go.golden b/http/codegen/testdata/golden/client_cli_path-custom-name.go.golden index 537aaea204..cb90bde3f7 100644 --- a/http/codegen/testdata/golden/client_cli_path-custom-name.go.golden +++ b/http/codegen/testdata/golden/client_cli_path-custom-name.go.golden @@ -1,9 +1,12 @@ // BuildMethodPathCustomNamePayload builds the payload for the // ServicePathCustomName MethodPathCustomName endpoint from CLI flags. -func BuildMethodPathCustomNamePayload(servicePathCustomNameMethodPathCustomNameP string) (*servicepathcustomname.MethodPathCustomNamePayload, error) { +func BuildMethodPathCustomNamePayload(servicePathCustomNameMethodPathCustomNameP *string) (*servicepathcustomname.MethodPathCustomNamePayload, error) { var p string { - p = servicePathCustomNameMethodPathCustomNameP + if servicePathCustomNameMethodPathCustomNameP == nil { + return nil, fmt.Errorf("missing required flag --p") + } + p = *servicePathCustomNameMethodPathCustomNameP } v := &servicepathcustomname.MethodPathCustomNamePayload{} v.Path = p diff --git a/http/codegen/testdata/golden/client_cli_path-string-default-build.go.golden b/http/codegen/testdata/golden/client_cli_path-string-default-build.go.golden new file mode 100644 index 0000000000..d706c5f67a --- /dev/null +++ b/http/codegen/testdata/golden/client_cli_path-string-default-build.go.golden @@ -0,0 +1,15 @@ +// BuildMethodPathStringDefaultPayload builds the payload for the +// ServicePathStringDefault MethodPathStringDefault endpoint from CLI flags. +func BuildMethodPathStringDefaultPayload(servicePathStringDefaultMethodPathStringDefaultP *string) (*servicepathstringdefault.MethodPathStringDefaultPayload, error) { + var p string + { + if servicePathStringDefaultMethodPathStringDefaultP == nil { + return nil, fmt.Errorf("missing required flag --p") + } + p = *servicePathStringDefaultMethodPathStringDefaultP + } + v := &servicepathstringdefault.MethodPathStringDefaultPayload{} + v.P = p + + return v, nil +} diff --git a/http/codegen/testdata/golden/client_cli_payload-array-primitive-type.go.golden b/http/codegen/testdata/golden/client_cli_payload-array-primitive-type.go.golden index 78b13409ba..17a1782fc4 100644 --- a/http/codegen/testdata/golden/client_cli_payload-array-primitive-type.go.golden +++ b/http/codegen/testdata/golden/client_cli_payload-array-primitive-type.go.golden @@ -1,3 +1,22 @@ +// cliStringFlag keeps an omitted command-line flag distinct from an explicitly empty flag. +type cliStringFlag struct { + value *string +} + +// String returns the flag text shown by the standard flag package. +func (f *cliStringFlag) String() string { + if f.value == nil { + return "" + } + return *f.value +} + +// Set records that the user supplied the flag, even when value is empty. +func (f *cliStringFlag) Set(value string) error { + f.value = &value + return nil +} + // ParseEndpoint returns the endpoint and payload as specified on the command // line. func ParseEndpoint( @@ -11,8 +30,10 @@ func ParseEndpoint( serviceBodyPrimitiveArrayStringValidateFlags = flag.NewFlagSet("service-body-primitive-array-string-validate", flag.ContinueOnError) serviceBodyPrimitiveArrayStringValidateMethodBodyPrimitiveArrayStringValidateFlags = flag.NewFlagSet("method-body-primitive-array-string-validate", flag.ExitOnError) - serviceBodyPrimitiveArrayStringValidateMethodBodyPrimitiveArrayStringValidatePFlag = serviceBodyPrimitiveArrayStringValidateMethodBodyPrimitiveArrayStringValidateFlags.String("p", "REQUIRED", "[]string is the payload type of the ServiceBodyPrimitiveArrayStringValidate service MethodBodyPrimitiveArrayStringValidate method.") + serviceBodyPrimitiveArrayStringValidateMethodBodyPrimitiveArrayStringValidatePFlag = new(cliStringFlag) ) + serviceBodyPrimitiveArrayStringValidateMethodBodyPrimitiveArrayStringValidateFlags.Var(serviceBodyPrimitiveArrayStringValidateMethodBodyPrimitiveArrayStringValidatePFlag, "p", "[]string is the payload type of the ServiceBodyPrimitiveArrayStringValidate service MethodBodyPrimitiveArrayStringValidate method.") + serviceBodyPrimitiveArrayStringValidateFlags.Usage = serviceBodyPrimitiveArrayStringValidateUsage serviceBodyPrimitiveArrayStringValidateMethodBodyPrimitiveArrayStringValidateFlags.Usage = serviceBodyPrimitiveArrayStringValidateMethodBodyPrimitiveArrayStringValidateUsage @@ -81,11 +102,14 @@ func ParseEndpoint( case "method-body-primitive-array-string-validate": endpoint = c.MethodBodyPrimitiveArrayStringValidate() var err error + if serviceBodyPrimitiveArrayStringValidateMethodBodyPrimitiveArrayStringValidatePFlag.value == nil { + return nil, nil, fmt.Errorf("missing required flag --p") + } var val []string - err = json.Unmarshal([]byte(*serviceBodyPrimitiveArrayStringValidateMethodBodyPrimitiveArrayStringValidatePFlag), &val) + err = json.Unmarshal([]byte(*serviceBodyPrimitiveArrayStringValidateMethodBodyPrimitiveArrayStringValidatePFlag.value), &val) data = val if err != nil { - return nil, nil, fmt.Errorf("invalid JSON for serviceBodyPrimitiveArrayStringValidateMethodBodyPrimitiveArrayStringValidatePFlag, \nerror: %s, \nexample of valid JSON:\n%s", err, "'[\n \"val\",\n \"val\",\n \"val\"\n ]'") + return nil, nil, fmt.Errorf("invalid JSON for serviceBodyPrimitiveArrayStringValidateMethodBodyPrimitiveArrayStringValidatePFlag, \nerror: %s, \nexample of valid JSON:\n%s", err, "'[\n \"val\"\n ]'") } } } diff --git a/http/codegen/testdata/golden/client_cli_payload-array-user-type.go.golden b/http/codegen/testdata/golden/client_cli_payload-array-user-type.go.golden index 461f40f436..7a2f833878 100644 --- a/http/codegen/testdata/golden/client_cli_payload-array-user-type.go.golden +++ b/http/codegen/testdata/golden/client_cli_payload-array-user-type.go.golden @@ -1,12 +1,15 @@ // BuildMethodBodyInlineArrayUserPayload builds the payload for the // ServiceBodyInlineArrayUser MethodBodyInlineArrayUser endpoint from CLI flags. -func BuildMethodBodyInlineArrayUserPayload(serviceBodyInlineArrayUserMethodBodyInlineArrayUserBody string) ([]*servicebodyinlinearrayuser.ElemType, error) { +func BuildMethodBodyInlineArrayUserPayload(serviceBodyInlineArrayUserMethodBodyInlineArrayUserBody *string) ([]*servicebodyinlinearrayuser.ElemType, error) { var err error var body []*ElemTypeRequestBody { - err = json.Unmarshal([]byte(serviceBodyInlineArrayUserMethodBodyInlineArrayUserBody), &body) + if serviceBodyInlineArrayUserMethodBodyInlineArrayUserBody == nil { + return nil, fmt.Errorf("missing required flag --body") + } + err = json.Unmarshal([]byte(*serviceBodyInlineArrayUserMethodBodyInlineArrayUserBody), &body) if err != nil { - return nil, fmt.Errorf("invalid JSON for body, \nerror: %s, \nexample of valid JSON:\n%s", err, "'[\n {\n \"a\": \"patterna\",\n \"b\": \"patternb\"\n },\n {\n \"a\": \"patterna\",\n \"b\": \"patternb\"\n }\n ]'") + return nil, fmt.Errorf("invalid JSON for body, \nerror: %s, \nexample of valid JSON:\n%s", err, "'[\n {\n \"a\": \"patterna\",\n \"b\": \"patternb\"\n },\n {\n \"a\": \"patterna\",\n \"b\": \"patternb\"\n },\n {\n \"a\": \"patterna\",\n \"b\": \"patternb\"\n }\n ]'") } } v := make([]*servicebodyinlinearrayuser.ElemType, len(body)) diff --git a/http/codegen/testdata/golden/client_cli_payload-map-user-type.go.golden b/http/codegen/testdata/golden/client_cli_payload-map-user-type.go.golden index 4d25a84bfd..26d74e6f66 100644 --- a/http/codegen/testdata/golden/client_cli_payload-map-user-type.go.golden +++ b/http/codegen/testdata/golden/client_cli_payload-map-user-type.go.golden @@ -1,17 +1,20 @@ // BuildMethodBodyInlineMapUserPayload builds the payload for the // ServiceBodyInlineMapUser MethodBodyInlineMapUser endpoint from CLI flags. -func BuildMethodBodyInlineMapUserPayload(serviceBodyInlineMapUserMethodBodyInlineMapUserBody string) (map[*servicebodyinlinemapuser.KeyType]*servicebodyinlinemapuser.ElemType, error) { +func BuildMethodBodyInlineMapUserPayload(serviceBodyInlineMapUserMethodBodyInlineMapUserBody *string) (map[*servicebodyinlinemapuser.KeyType]*servicebodyinlinemapuser.ElemType, error) { var err error var body map[*KeyTypeRequestBody]*ElemTypeRequestBody { - err = json.Unmarshal([]byte(serviceBodyInlineMapUserMethodBodyInlineMapUserBody), &body) + if serviceBodyInlineMapUserMethodBodyInlineMapUserBody == nil { + return nil, fmt.Errorf("missing required flag --body") + } + err = json.Unmarshal([]byte(*serviceBodyInlineMapUserMethodBodyInlineMapUserBody), &body) if err != nil { return nil, fmt.Errorf("invalid JSON for body, \nerror: %s, \nexample of valid JSON:\n%s", err, "null") } } v := make(map[*servicebodyinlinemapuser.KeyType]*servicebodyinlinemapuser.ElemType, len(body)) for key, val := range body { - tk := marshalKeyTypeRequestBodyToServicebodyinlinemapuserKeyType(val) + tk := marshalKeyTypeRequestBodyToServicebodyinlinemapuserKeyType(key) if val == nil { v[tk] = nil continue diff --git a/http/codegen/testdata/golden/client_cli_payload-object-default-type.go.golden b/http/codegen/testdata/golden/client_cli_payload-object-default-type.go.golden index 4ce075ee76..be9c1a06f5 100644 --- a/http/codegen/testdata/golden/client_cli_payload-object-default-type.go.golden +++ b/http/codegen/testdata/golden/client_cli_payload-object-default-type.go.golden @@ -1,14 +1,17 @@ // BuildMethodBodyInlineObjectPayload builds the payload for the // ServiceBodyInlineObject MethodBodyInlineObject endpoint from CLI flags. -func BuildMethodBodyInlineObjectPayload(serviceBodyInlineObjectMethodBodyInlineObjectBody string) (*servicebodyinlineobject.MethodBodyInlineObjectPayload, error) { +func BuildMethodBodyInlineObjectPayload(serviceBodyInlineObjectMethodBodyInlineObjectBody *string) (*servicebodyinlineobject.MethodBodyInlineObjectPayload, error) { var err error var body struct { A string `form:"a" json:"a" xml:"a"` } { - err = json.Unmarshal([]byte(serviceBodyInlineObjectMethodBodyInlineObjectBody), &body) + if serviceBodyInlineObjectMethodBodyInlineObjectBody == nil { + return nil, fmt.Errorf("missing required flag --body") + } + err = json.Unmarshal([]byte(*serviceBodyInlineObjectMethodBodyInlineObjectBody), &body) if err != nil { - return nil, fmt.Errorf("invalid JSON for body, \nerror: %s, \nexample of valid JSON:\n%s", err, "'{\n \"a\": \"Magnam id itaque quo.\"\n }'") + return nil, fmt.Errorf("invalid JSON for body, \nerror: %s, \nexample of valid JSON:\n%s", err, "'{\n \"a\": \"Fugit atque.\"\n }'") } } v := &servicebodyinlineobject.MethodBodyInlineObjectPayload{ diff --git a/http/codegen/testdata/golden/client_cli_payload-object-type.go.golden b/http/codegen/testdata/golden/client_cli_payload-object-type.go.golden index a0564a8397..6ccfde800b 100644 --- a/http/codegen/testdata/golden/client_cli_payload-object-type.go.golden +++ b/http/codegen/testdata/golden/client_cli_payload-object-type.go.golden @@ -1,14 +1,17 @@ // BuildMethodBodyInlineObjectPayload builds the payload for the // ServiceBodyInlineObject MethodBodyInlineObject endpoint from CLI flags. -func BuildMethodBodyInlineObjectPayload(serviceBodyInlineObjectMethodBodyInlineObjectBody string) (*servicebodyinlineobject.MethodBodyInlineObjectPayload, error) { +func BuildMethodBodyInlineObjectPayload(serviceBodyInlineObjectMethodBodyInlineObjectBody *string) (*servicebodyinlineobject.MethodBodyInlineObjectPayload, error) { var err error var body struct { A *string `form:"a" json:"a" xml:"a"` } { - err = json.Unmarshal([]byte(serviceBodyInlineObjectMethodBodyInlineObjectBody), &body) + if serviceBodyInlineObjectMethodBodyInlineObjectBody == nil { + return nil, fmt.Errorf("missing required flag --body") + } + err = json.Unmarshal([]byte(*serviceBodyInlineObjectMethodBodyInlineObjectBody), &body) if err != nil { - return nil, fmt.Errorf("invalid JSON for body, \nerror: %s, \nexample of valid JSON:\n%s", err, "'{\n \"a\": \"Magnam id itaque quo.\"\n }'") + return nil, fmt.Errorf("invalid JSON for body, \nerror: %s, \nexample of valid JSON:\n%s", err, "'{\n \"a\": \"Fugit atque.\"\n }'") } } v := &servicebodyinlineobject.MethodBodyInlineObjectPayload{ diff --git a/http/codegen/testdata/golden/client_cli_payload-primitive-type.go.golden b/http/codegen/testdata/golden/client_cli_payload-primitive-type.go.golden index f82ea4f457..a008918961 100644 --- a/http/codegen/testdata/golden/client_cli_payload-primitive-type.go.golden +++ b/http/codegen/testdata/golden/client_cli_payload-primitive-type.go.golden @@ -1,3 +1,22 @@ +// cliStringFlag keeps an omitted command-line flag distinct from an explicitly empty flag. +type cliStringFlag struct { + value *string +} + +// String returns the flag text shown by the standard flag package. +func (f *cliStringFlag) String() string { + if f.value == nil { + return "" + } + return *f.value +} + +// Set records that the user supplied the flag, even when value is empty. +func (f *cliStringFlag) Set(value string) error { + f.value = &value + return nil +} + // ParseEndpoint returns the endpoint and payload as specified on the command // line. func ParseEndpoint( @@ -11,8 +30,10 @@ func ParseEndpoint( serviceBodyPrimitiveBoolValidateFlags = flag.NewFlagSet("service-body-primitive-bool-validate", flag.ContinueOnError) serviceBodyPrimitiveBoolValidateMethodBodyPrimitiveBoolValidateFlags = flag.NewFlagSet("method-body-primitive-bool-validate", flag.ExitOnError) - serviceBodyPrimitiveBoolValidateMethodBodyPrimitiveBoolValidatePFlag = serviceBodyPrimitiveBoolValidateMethodBodyPrimitiveBoolValidateFlags.String("p", "REQUIRED", "bool is the payload type of the ServiceBodyPrimitiveBoolValidate service MethodBodyPrimitiveBoolValidate method.") + serviceBodyPrimitiveBoolValidateMethodBodyPrimitiveBoolValidatePFlag = new(cliStringFlag) ) + serviceBodyPrimitiveBoolValidateMethodBodyPrimitiveBoolValidateFlags.Var(serviceBodyPrimitiveBoolValidateMethodBodyPrimitiveBoolValidatePFlag, "p", "bool is the payload type of the ServiceBodyPrimitiveBoolValidate service MethodBodyPrimitiveBoolValidate method.") + serviceBodyPrimitiveBoolValidateFlags.Usage = serviceBodyPrimitiveBoolValidateUsage serviceBodyPrimitiveBoolValidateMethodBodyPrimitiveBoolValidateFlags.Usage = serviceBodyPrimitiveBoolValidateMethodBodyPrimitiveBoolValidateUsage @@ -81,7 +102,10 @@ func ParseEndpoint( case "method-body-primitive-bool-validate": endpoint = c.MethodBodyPrimitiveBoolValidate() var err error - data, err = strconv.ParseBool(*serviceBodyPrimitiveBoolValidateMethodBodyPrimitiveBoolValidatePFlag) + if serviceBodyPrimitiveBoolValidateMethodBodyPrimitiveBoolValidatePFlag.value == nil { + return nil, nil, fmt.Errorf("missing required flag --p") + } + data, err = strconv.ParseBool(*serviceBodyPrimitiveBoolValidateMethodBodyPrimitiveBoolValidatePFlag.value) if err != nil { return nil, nil, fmt.Errorf("invalid value for serviceBodyPrimitiveBoolValidateMethodBodyPrimitiveBoolValidatePFlag, must be BOOL") } diff --git a/http/codegen/testdata/golden/client_cli_query-custom-name.go.golden b/http/codegen/testdata/golden/client_cli_query-custom-name.go.golden index bcbb21b1fe..d049ee56e8 100644 --- a/http/codegen/testdata/golden/client_cli_query-custom-name.go.golden +++ b/http/codegen/testdata/golden/client_cli_query-custom-name.go.golden @@ -1,10 +1,10 @@ // BuildMethodQueryCustomNamePayload builds the payload for the // ServiceQueryCustomName MethodQueryCustomName endpoint from CLI flags. -func BuildMethodQueryCustomNamePayload(serviceQueryCustomNameMethodQueryCustomNameQ string) (*servicequerycustomname.MethodQueryCustomNamePayload, error) { +func BuildMethodQueryCustomNamePayload(serviceQueryCustomNameMethodQueryCustomNameQ *string) (*servicequerycustomname.MethodQueryCustomNamePayload, error) { var q *string { - if serviceQueryCustomNameMethodQueryCustomNameQ != "" { - q = &serviceQueryCustomNameMethodQueryCustomNameQ + if serviceQueryCustomNameMethodQueryCustomNameQ != nil { + q = serviceQueryCustomNameMethodQueryCustomNameQ } } v := &servicequerycustomname.MethodQueryCustomNamePayload{} diff --git a/http/codegen/testdata/golden/client_cli_simple-build.go.golden b/http/codegen/testdata/golden/client_cli_simple-build.go.golden index 4b8d815c4b..e291b4edca 100644 --- a/http/codegen/testdata/golden/client_cli_simple-build.go.golden +++ b/http/codegen/testdata/golden/client_cli_simple-build.go.golden @@ -1,12 +1,15 @@ // BuildMethodMultiSimplePayloadPayload builds the payload for the // ServiceMultiSimple1 MethodMultiSimplePayload endpoint from CLI flags. -func BuildMethodMultiSimplePayloadPayload(serviceMultiSimple1MethodMultiSimplePayloadBody string) (*servicemultisimple1.MethodMultiSimplePayloadPayload, error) { +func BuildMethodMultiSimplePayloadPayload(serviceMultiSimple1MethodMultiSimplePayloadBody *string) (*servicemultisimple1.MethodMultiSimplePayloadPayload, error) { var err error var body MethodMultiSimplePayloadRequestBody { - err = json.Unmarshal([]byte(serviceMultiSimple1MethodMultiSimplePayloadBody), &body) + if serviceMultiSimple1MethodMultiSimplePayloadBody == nil { + return nil, fmt.Errorf("missing required flag --body") + } + err = json.Unmarshal([]byte(*serviceMultiSimple1MethodMultiSimplePayloadBody), &body) if err != nil { - return nil, fmt.Errorf("invalid JSON for body, \nerror: %s, \nexample of valid JSON:\n%s", err, "'{\n \"a\": false\n }'") + return nil, fmt.Errorf("invalid JSON for body, \nerror: %s, \nexample of valid JSON:\n%s", err, "'{\n \"a\": true\n }'") } } v := &servicemultisimple1.MethodMultiSimplePayloadPayload{ diff --git a/http/codegen/testdata/golden/client_cli_simple-parse.go.golden b/http/codegen/testdata/golden/client_cli_simple-parse.go.golden index 874674d5f9..56a5799231 100644 --- a/http/codegen/testdata/golden/client_cli_simple-parse.go.golden +++ b/http/codegen/testdata/golden/client_cli_simple-parse.go.golden @@ -1,3 +1,22 @@ +// cliStringFlag keeps an omitted command-line flag distinct from an explicitly empty flag. +type cliStringFlag struct { + value *string +} + +// String returns the flag text shown by the standard flag package. +func (f *cliStringFlag) String() string { + if f.value == nil { + return "" + } + return *f.value +} + +// Set records that the user supplied the flag, even when value is empty. +func (f *cliStringFlag) Set(value string) error { + f.value = &value + return nil +} + // ParseEndpoint returns the endpoint and payload as specified on the command // line. func ParseEndpoint( @@ -13,15 +32,18 @@ func ParseEndpoint( serviceMultiSimple1MethodMultiSimpleNoPayloadFlags = flag.NewFlagSet("method-multi-simple-no-payload", flag.ExitOnError) serviceMultiSimple1MethodMultiSimplePayloadFlags = flag.NewFlagSet("method-multi-simple-payload", flag.ExitOnError) - serviceMultiSimple1MethodMultiSimplePayloadBodyFlag = serviceMultiSimple1MethodMultiSimplePayloadFlags.String("body", "REQUIRED", "") + serviceMultiSimple1MethodMultiSimplePayloadBodyFlag = new(cliStringFlag) serviceMultiSimple2Flags = flag.NewFlagSet("service-multi-simple2", flag.ContinueOnError) serviceMultiSimple2MethodMultiSimpleNoPayloadFlags = flag.NewFlagSet("method-multi-simple-no-payload", flag.ExitOnError) serviceMultiSimple2MethodMultiSimplePayloadFlags = flag.NewFlagSet("method-multi-simple-payload", flag.ExitOnError) - serviceMultiSimple2MethodMultiSimplePayloadBodyFlag = serviceMultiSimple2MethodMultiSimplePayloadFlags.String("body", "REQUIRED", "") + serviceMultiSimple2MethodMultiSimplePayloadBodyFlag = new(cliStringFlag) ) + serviceMultiSimple1MethodMultiSimplePayloadFlags.Var(serviceMultiSimple1MethodMultiSimplePayloadBodyFlag, "body", "") + serviceMultiSimple2MethodMultiSimplePayloadFlags.Var(serviceMultiSimple2MethodMultiSimplePayloadBodyFlag, "body", "") + serviceMultiSimple1Flags.Usage = serviceMultiSimple1Usage serviceMultiSimple1MethodMultiSimpleNoPayloadFlags.Usage = serviceMultiSimple1MethodMultiSimpleNoPayloadUsage serviceMultiSimple1MethodMultiSimplePayloadFlags.Usage = serviceMultiSimple1MethodMultiSimplePayloadUsage @@ -111,7 +133,7 @@ func ParseEndpoint( endpoint = c.MethodMultiSimpleNoPayload() case "method-multi-simple-payload": endpoint = c.MethodMultiSimplePayload() - data, err = servicemultisimple1c.BuildMethodMultiSimplePayloadPayload(*serviceMultiSimple1MethodMultiSimplePayloadBodyFlag) + data, err = servicemultisimple1c.BuildMethodMultiSimplePayloadPayload(serviceMultiSimple1MethodMultiSimplePayloadBodyFlag.value) } case "service-multi-simple2": c := servicemultisimple2c.NewClient(scheme, host, doer, enc, dec, restore) @@ -120,7 +142,7 @@ func ParseEndpoint( endpoint = c.MethodMultiSimpleNoPayload() case "method-multi-simple-payload": endpoint = c.MethodMultiSimplePayload() - data, err = servicemultisimple2c.BuildMethodMultiSimplePayloadPayload(*serviceMultiSimple2MethodMultiSimplePayloadBodyFlag) + data, err = servicemultisimple2c.BuildMethodMultiSimplePayloadPayload(serviceMultiSimple2MethodMultiSimplePayloadBodyFlag.value) } } } diff --git a/http/codegen/testdata/golden/client_cli_skip-request-body-encode-decode.go.golden b/http/codegen/testdata/golden/client_cli_skip-request-body-encode-decode.go.golden index bf714846c7..73b96917e7 100644 --- a/http/codegen/testdata/golden/client_cli_skip-request-body-encode-decode.go.golden +++ b/http/codegen/testdata/golden/client_cli_skip-request-body-encode-decode.go.golden @@ -1,3 +1,22 @@ +// cliStringFlag keeps an omitted command-line flag distinct from an explicitly empty flag. +type cliStringFlag struct { + value *string +} + +// String returns the flag text shown by the standard flag package. +func (f *cliStringFlag) String() string { + if f.value == nil { + return "" + } + return *f.value +} + +// Set records that the user supplied the flag, even when value is empty. +func (f *cliStringFlag) Set(value string) error { + f.value = &value + return nil +} + // ParseEndpoint returns the endpoint and payload as specified on the command // line. func ParseEndpoint( @@ -11,8 +30,10 @@ func ParseEndpoint( skipRequestBodyEncodeDecodeFlags = flag.NewFlagSet("skip-request-body-encode-decode", flag.ContinueOnError) skipRequestBodyEncodeDecodeSkipRequestBodyEncodeDecodeMethodFlags = flag.NewFlagSet("skip-request-body-encode-decode-method", flag.ExitOnError) - skipRequestBodyEncodeDecodeSkipRequestBodyEncodeDecodeMethodStreamFlag = skipRequestBodyEncodeDecodeSkipRequestBodyEncodeDecodeMethodFlags.String("stream", "REQUIRED", "path to file containing the streamed request body") + skipRequestBodyEncodeDecodeSkipRequestBodyEncodeDecodeMethodStreamFlag = new(cliStringFlag) ) + skipRequestBodyEncodeDecodeSkipRequestBodyEncodeDecodeMethodFlags.Var(skipRequestBodyEncodeDecodeSkipRequestBodyEncodeDecodeMethodStreamFlag, "stream", "path to file containing the streamed request body") + skipRequestBodyEncodeDecodeFlags.Usage = skipRequestBodyEncodeDecodeUsage skipRequestBodyEncodeDecodeSkipRequestBodyEncodeDecodeMethodFlags.Usage = skipRequestBodyEncodeDecodeSkipRequestBodyEncodeDecodeMethodUsage @@ -80,7 +101,13 @@ func ParseEndpoint( switch epn { case "skip-request-body-encode-decode-method": endpoint = c.SkipRequestBodyEncodeDecodeMethod() - data, err = skiprequestbodyencodedecodec.BuildSkipRequestBodyEncodeDecodeMethodStreamPayload(*skipRequestBodyEncodeDecodeSkipRequestBodyEncodeDecodeMethodStreamFlag) + if err == nil { + if skipRequestBodyEncodeDecodeSkipRequestBodyEncodeDecodeMethodStreamFlag.value == nil { + err = fmt.Errorf("missing required flag --stream") + } else { + data, err = skiprequestbodyencodedecodec.BuildSkipRequestBodyEncodeDecodeMethodStreamPayload(*skipRequestBodyEncodeDecodeSkipRequestBodyEncodeDecodeMethodStreamFlag.value) + } + } } } } diff --git a/http/codegen/testdata/golden/client_cli_string-build.go.golden b/http/codegen/testdata/golden/client_cli_string-build.go.golden index b5da013fdd..d232c84655 100644 --- a/http/codegen/testdata/golden/client_cli_string-build.go.golden +++ b/http/codegen/testdata/golden/client_cli_string-build.go.golden @@ -1,10 +1,10 @@ // BuildMethodQueryStringPayload builds the payload for the ServiceQueryString // MethodQueryString endpoint from CLI flags. -func BuildMethodQueryStringPayload(serviceQueryStringMethodQueryStringQ string) (*servicequerystring.MethodQueryStringPayload, error) { +func BuildMethodQueryStringPayload(serviceQueryStringMethodQueryStringQ *string) (*servicequerystring.MethodQueryStringPayload, error) { var q *string { - if serviceQueryStringMethodQueryStringQ != "" { - q = &serviceQueryStringMethodQueryStringQ + if serviceQueryStringMethodQueryStringQ != nil { + q = serviceQueryStringMethodQueryStringQ } } v := &servicequerystring.MethodQueryStringPayload{} diff --git a/http/codegen/testdata/golden/client_cli_string-default-build.go.golden b/http/codegen/testdata/golden/client_cli_string-default-build.go.golden index a90931ddfc..735f5ad576 100644 --- a/http/codegen/testdata/golden/client_cli_string-default-build.go.golden +++ b/http/codegen/testdata/golden/client_cli_string-default-build.go.golden @@ -3,9 +3,7 @@ func BuildMethodQueryStringDefaultPayload(serviceQueryStringDefaultMethodQueryStringDefaultQ string) (*servicequerystringdefault.MethodQueryStringDefaultPayload, error) { var q string { - if serviceQueryStringDefaultMethodQueryStringDefaultQ != "" { - q = serviceQueryStringDefaultMethodQueryStringDefaultQ - } + q = serviceQueryStringDefaultMethodQueryStringDefaultQ } v := &servicequerystringdefault.MethodQueryStringDefaultPayload{} v.Q = q diff --git a/http/codegen/testdata/golden/client_cli_string-required-build.go.golden b/http/codegen/testdata/golden/client_cli_string-required-build.go.golden index ea903306b0..0d91a838f1 100644 --- a/http/codegen/testdata/golden/client_cli_string-required-build.go.golden +++ b/http/codegen/testdata/golden/client_cli_string-required-build.go.golden @@ -1,10 +1,13 @@ // BuildMethodQueryStringValidatePayload builds the payload for the // ServiceQueryStringValidate MethodQueryStringValidate endpoint from CLI flags. -func BuildMethodQueryStringValidatePayload(serviceQueryStringValidateMethodQueryStringValidateQ string) (*servicequerystringvalidate.MethodQueryStringValidatePayload, error) { +func BuildMethodQueryStringValidatePayload(serviceQueryStringValidateMethodQueryStringValidateQ *string) (*servicequerystringvalidate.MethodQueryStringValidatePayload, error) { var err error var q string { - q = serviceQueryStringValidateMethodQueryStringValidateQ + if serviceQueryStringValidateMethodQueryStringValidateQ == nil { + return nil, fmt.Errorf("missing required flag --q") + } + q = *serviceQueryStringValidateMethodQueryStringValidateQ if !(q == "val") { err = goa.MergeErrors(err, goa.InvalidEnumValueError("q", q, []any{"val"})) } diff --git a/http/codegen/testdata/golden/client_cli_uint32-build.go.golden b/http/codegen/testdata/golden/client_cli_uint32-build.go.golden index 2177e35027..d6da456ad3 100644 --- a/http/codegen/testdata/golden/client_cli_uint32-build.go.golden +++ b/http/codegen/testdata/golden/client_cli_uint32-build.go.golden @@ -1,12 +1,12 @@ // BuildMethodQueryUInt32Payload builds the payload for the ServiceQueryUInt32 // MethodQueryUInt32 endpoint from CLI flags. -func BuildMethodQueryUInt32Payload(serviceQueryUInt32MethodQueryUInt32Q string) (*servicequeryuint32.MethodQueryUInt32Payload, error) { +func BuildMethodQueryUInt32Payload(serviceQueryUInt32MethodQueryUInt32Q *string) (*servicequeryuint32.MethodQueryUInt32Payload, error) { var err error var q *uint32 { - if serviceQueryUInt32MethodQueryUInt32Q != "" { + if serviceQueryUInt32MethodQueryUInt32Q != nil { var v uint64 - v, err = strconv.ParseUint(serviceQueryUInt32MethodQueryUInt32Q, 10, 32) + v, err = strconv.ParseUint(*serviceQueryUInt32MethodQueryUInt32Q, 10, 32) val := uint32(v) q = &val if err != nil { diff --git a/http/codegen/testdata/golden/client_cli_uint64-build.go.golden b/http/codegen/testdata/golden/client_cli_uint64-build.go.golden index 3803676c3f..d1924de48b 100644 --- a/http/codegen/testdata/golden/client_cli_uint64-build.go.golden +++ b/http/codegen/testdata/golden/client_cli_uint64-build.go.golden @@ -1,12 +1,12 @@ // BuildMethodQueryUIntPayload builds the payload for the ServiceQueryUInt // MethodQueryUInt endpoint from CLI flags. -func BuildMethodQueryUIntPayload(serviceQueryUIntMethodQueryUIntQ string) (*servicequeryuint.MethodQueryUIntPayload, error) { +func BuildMethodQueryUIntPayload(serviceQueryUIntMethodQueryUIntQ *string) (*servicequeryuint.MethodQueryUIntPayload, error) { var err error var q *uint { - if serviceQueryUIntMethodQueryUIntQ != "" { + if serviceQueryUIntMethodQueryUIntQ != nil { var v uint64 - v, err = strconv.ParseUint(serviceQueryUIntMethodQueryUIntQ, 10, strconv.IntSize) + v, err = strconv.ParseUint(*serviceQueryUIntMethodQueryUIntQ, 10, strconv.IntSize) val := uint(v) q = &val if err != nil { diff --git a/http/codegen/testdata/golden/client_cli_with-params-and-headers-dsl.go.golden b/http/codegen/testdata/golden/client_cli_with-params-and-headers-dsl.go.golden index 6aad04fffb..cf8d4e11d1 100644 --- a/http/codegen/testdata/golden/client_cli_with-params-and-headers-dsl.go.golden +++ b/http/codegen/testdata/golden/client_cli_with-params-and-headers-dsl.go.golden @@ -1,18 +1,24 @@ // BuildMethodAPayload builds the payload for the // ServiceWithParamsAndHeadersBlock MethodA endpoint from CLI flags. -func BuildMethodAPayload(serviceWithParamsAndHeadersBlockMethodABody string, serviceWithParamsAndHeadersBlockMethodAPath string, serviceWithParamsAndHeadersBlockMethodAOptional string, serviceWithParamsAndHeadersBlockMethodAOptionalButRequiredParam string, serviceWithParamsAndHeadersBlockMethodARequired string, serviceWithParamsAndHeadersBlockMethodAOptionalButRequiredHeader string) (*servicewithparamsandheadersblock.MethodAPayload, error) { +func BuildMethodAPayload(serviceWithParamsAndHeadersBlockMethodABody *string, serviceWithParamsAndHeadersBlockMethodAPath *string, serviceWithParamsAndHeadersBlockMethodAOptional *string, serviceWithParamsAndHeadersBlockMethodAOptionalButRequiredParam *string, serviceWithParamsAndHeadersBlockMethodARequired *string, serviceWithParamsAndHeadersBlockMethodAOptionalButRequiredHeader *string) (*servicewithparamsandheadersblock.MethodAPayload, error) { var err error var body MethodARequestBody { - err = json.Unmarshal([]byte(serviceWithParamsAndHeadersBlockMethodABody), &body) + if serviceWithParamsAndHeadersBlockMethodABody == nil { + return nil, fmt.Errorf("missing required flag --body") + } + err = json.Unmarshal([]byte(*serviceWithParamsAndHeadersBlockMethodABody), &body) if err != nil { - return nil, fmt.Errorf("invalid JSON for body, \nerror: %s, \nexample of valid JSON:\n%s", err, "'{\n \"body\": \"Molestias quia.\"\n }'") + return nil, fmt.Errorf("invalid JSON for body, \nerror: %s, \nexample of valid JSON:\n%s", err, "'{\n \"body\": \"Dolores eos voluptas.\"\n }'") } } var path uint { + if serviceWithParamsAndHeadersBlockMethodAPath == nil { + return nil, fmt.Errorf("missing required flag --path") + } var v uint64 - v, err = strconv.ParseUint(serviceWithParamsAndHeadersBlockMethodAPath, 10, strconv.IntSize) + v, err = strconv.ParseUint(*serviceWithParamsAndHeadersBlockMethodAPath, 10, strconv.IntSize) path = uint(v) if err != nil { return nil, fmt.Errorf("invalid value for path, must be UINT") @@ -20,9 +26,9 @@ func BuildMethodAPayload(serviceWithParamsAndHeadersBlockMethodABody string, ser } var optional *int { - if serviceWithParamsAndHeadersBlockMethodAOptional != "" { + if serviceWithParamsAndHeadersBlockMethodAOptional != nil { var v int64 - v, err = strconv.ParseInt(serviceWithParamsAndHeadersBlockMethodAOptional, 10, strconv.IntSize) + v, err = strconv.ParseInt(*serviceWithParamsAndHeadersBlockMethodAOptional, 10, strconv.IntSize) val := int(v) optional = &val if err != nil { @@ -32,8 +38,11 @@ func BuildMethodAPayload(serviceWithParamsAndHeadersBlockMethodABody string, ser } var optionalButRequiredParam float32 { + if serviceWithParamsAndHeadersBlockMethodAOptionalButRequiredParam == nil { + return nil, fmt.Errorf("missing required flag --optional-but-required-param") + } var v float64 - v, err = strconv.ParseFloat(serviceWithParamsAndHeadersBlockMethodAOptionalButRequiredParam, 32) + v, err = strconv.ParseFloat(*serviceWithParamsAndHeadersBlockMethodAOptionalButRequiredParam, 32) optionalButRequiredParam = float32(v) if err != nil { return nil, fmt.Errorf("invalid value for optionalButRequiredParam, must be FLOAT32") @@ -41,12 +50,18 @@ func BuildMethodAPayload(serviceWithParamsAndHeadersBlockMethodABody string, ser } var required string { - required = serviceWithParamsAndHeadersBlockMethodARequired + if serviceWithParamsAndHeadersBlockMethodARequired == nil { + return nil, fmt.Errorf("missing required flag --required") + } + required = *serviceWithParamsAndHeadersBlockMethodARequired } var optionalButRequiredHeader float32 { + if serviceWithParamsAndHeadersBlockMethodAOptionalButRequiredHeader == nil { + return nil, fmt.Errorf("missing required flag --optional-but-required-header") + } var v float64 - v, err = strconv.ParseFloat(serviceWithParamsAndHeadersBlockMethodAOptionalButRequiredHeader, 32) + v, err = strconv.ParseFloat(*serviceWithParamsAndHeadersBlockMethodAOptionalButRequiredHeader, 32) optionalButRequiredHeader = float32(v) if err != nil { return nil, fmt.Errorf("invalid value for optionalButRequiredHeader, must be FLOAT32") diff --git a/http/codegen/testdata/golden/client_decode_body-result-multiple-views.go.golden b/http/codegen/testdata/golden/client_decode_body-result-multiple-views.go.golden index 3dbbbb5f65..4ea6bd7730 100644 --- a/http/codegen/testdata/golden/client_decode_body-result-multiple-views.go.golden +++ b/http/codegen/testdata/golden/client_decode_body-result-multiple-views.go.golden @@ -3,46 +3,86 @@ // restoreBody controls whether the response body should be restored after // having been read. func DecodeMethodBodyMultipleViewResponse(decoder func(*http.Response) goahttp.Decoder, restoreBody bool) func(*http.Response) (any, error) { - return func(resp *http.Response) (any, error) { + return func(resp *http.Response) (result any, decodeErr error) { + responseBody := resp.Body if restoreBody { - b, err := io.ReadAll(resp.Body) - if err != nil { - return nil, err + b, readErr := io.ReadAll(responseBody) + closeErr := responseBody.Close() + if err := errors.Join(readErr, closeErr); err != nil { + return nil, goahttp.ErrDecodingError("ServiceBodyMultipleView", "MethodBodyMultipleView", err) } resp.Body = io.NopCloser(bytes.NewBuffer(b)) defer func() { resp.Body = io.NopCloser(bytes.NewBuffer(b)) }() } else { - defer resp.Body.Close() + defer func() { + if err := responseBody.Close(); err != nil { + decodeErr = errors.Join(decodeErr, goahttp.ErrDecodingError("ServiceBodyMultipleView", "MethodBodyMultipleView", err)) + } + }() } switch resp.StatusCode { case http.StatusOK: - var ( - body MethodBodyMultipleViewResponseBody - err error - ) - err = decoder(resp).Decode(&body) - if err != nil { - return nil, goahttp.ErrDecodingError("ServiceBodyMultipleView", "MethodBodyMultipleView", err) - } - var ( - c *string - ) - cRaw := resp.Header.Get("Location") - if cRaw != "" { - c = &cRaw - } - p := NewMethodBodyMultipleViewResulttypemultipleviewsOK(&body, c) view := resp.Header.Get("goa-view") - vres := &servicebodymultipleviewviews.Resulttypemultipleviews{Projected: p, View: view} - if err = servicebodymultipleviewviews.ValidateResulttypemultipleviews(vres); err != nil { - return nil, goahttp.ErrValidationError("ServiceBodyMultipleView", "MethodBodyMultipleView", err) + if view == "" { + view = "default" + } + switch view { + case "default": + var ( + body MethodBodyMultipleViewResponseBody + err error + ) + err = decoder(resp).Decode(&body) + if err != nil { + return nil, goahttp.ErrDecodingError("ServiceBodyMultipleView", "MethodBodyMultipleView", err) + } + var ( + c *string + ) + cRaw := resp.Header.Get("Location") + if cRaw != "" { + c = &cRaw + } + p := NewMethodBodyMultipleViewResultDefaultOK(&body, c) + vres := &servicebodymultipleviewviews.Resulttypemultipleviews{Projected: p, View: view} + if err = servicebodymultipleviewviews.ValidateResulttypemultipleviews(vres); err != nil { + return nil, goahttp.ErrValidationError("ServiceBodyMultipleView", "MethodBodyMultipleView", err) + } + res := servicebodymultipleview.NewResulttypemultipleviews(vres) + return res, nil + case "tiny": + var ( + body MethodBodyMultipleViewResponseBodyTiny + err error + ) + err = decoder(resp).Decode(&body) + if err != nil { + return nil, goahttp.ErrDecodingError("ServiceBodyMultipleView", "MethodBodyMultipleView", err) + } + var ( + c *string + ) + cRaw := resp.Header.Get("Location") + if cRaw != "" { + c = &cRaw + } + p := NewMethodBodyMultipleViewResultTinyOK(&body, c) + vres := &servicebodymultipleviewviews.Resulttypemultipleviews{Projected: p, View: view} + if err = servicebodymultipleviewviews.ValidateResulttypemultipleviews(vres); err != nil { + return nil, goahttp.ErrValidationError("ServiceBodyMultipleView", "MethodBodyMultipleView", err) + } + res := servicebodymultipleview.NewResulttypemultipleviews(vres) + return res, nil + default: + return nil, goahttp.ErrValidationError("ServiceBodyMultipleView", "MethodBodyMultipleView", goa.InvalidEnumValueError("view", view, []any{"default", "tiny"})) } - res := servicebodymultipleview.NewResulttypemultipleviews(vres) - return res, nil default: - body, _ := io.ReadAll(resp.Body) + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, goahttp.ErrDecodingError("ServiceBodyMultipleView", "MethodBodyMultipleView", err) + } return nil, goahttp.ErrInvalidResponse("ServiceBodyMultipleView", "MethodBodyMultipleView", resp.StatusCode, string(body)) } } diff --git a/http/codegen/testdata/golden/client_decode_empty-body-result-multiple-views.go.golden b/http/codegen/testdata/golden/client_decode_empty-body-result-multiple-views.go.golden index 4d1836051f..3ac9a74d84 100644 --- a/http/codegen/testdata/golden/client_decode_empty-body-result-multiple-views.go.golden +++ b/http/codegen/testdata/golden/client_decode_empty-body-result-multiple-views.go.golden @@ -3,35 +3,64 @@ // MethodEmptyBodyResultMultipleView endpoint. restoreBody controls whether the // response body should be restored after having been read. func DecodeMethodEmptyBodyResultMultipleViewResponse(decoder func(*http.Response) goahttp.Decoder, restoreBody bool) func(*http.Response) (any, error) { - return func(resp *http.Response) (any, error) { + return func(resp *http.Response) (result any, decodeErr error) { + responseBody := resp.Body if restoreBody { - b, err := io.ReadAll(resp.Body) - if err != nil { - return nil, err + b, readErr := io.ReadAll(responseBody) + closeErr := responseBody.Close() + if err := errors.Join(readErr, closeErr); err != nil { + return nil, goahttp.ErrDecodingError("ServiceEmptyBodyResultMultipleView", "MethodEmptyBodyResultMultipleView", err) } resp.Body = io.NopCloser(bytes.NewBuffer(b)) defer func() { resp.Body = io.NopCloser(bytes.NewBuffer(b)) }() } else { - defer resp.Body.Close() + defer func() { + if err := responseBody.Close(); err != nil { + decodeErr = errors.Join(decodeErr, goahttp.ErrDecodingError("ServiceEmptyBodyResultMultipleView", "MethodEmptyBodyResultMultipleView", err)) + } + }() } switch resp.StatusCode { case http.StatusOK: - var ( - c *string - ) - cRaw := resp.Header.Get("Location") - if cRaw != "" { - c = &cRaw - } - p := NewMethodEmptyBodyResultMultipleViewResulttypemultipleviewsOK(c) view := resp.Header.Get("goa-view") - vres := &serviceemptybodyresultmultipleviewviews.Resulttypemultipleviews{Projected: p, View: view} - res := serviceemptybodyresultmultipleview.NewResulttypemultipleviews(vres) - return res, nil + if view == "" { + view = "default" + } + switch view { + case "default": + var ( + c *string + ) + cRaw := resp.Header.Get("Location") + if cRaw != "" { + c = &cRaw + } + p := NewMethodEmptyBodyResultMultipleViewResultDefaultOK(c) + vres := &serviceemptybodyresultmultipleviewviews.Resulttypemultipleviews{Projected: p, View: view} + res := serviceemptybodyresultmultipleview.NewResulttypemultipleviews(vres) + return res, nil + case "tiny": + var ( + c *string + ) + cRaw := resp.Header.Get("Location") + if cRaw != "" { + c = &cRaw + } + p := NewMethodEmptyBodyResultMultipleViewResultTinyOK(c) + vres := &serviceemptybodyresultmultipleviewviews.Resulttypemultipleviews{Projected: p, View: view} + res := serviceemptybodyresultmultipleview.NewResulttypemultipleviews(vres) + return res, nil + default: + return nil, goahttp.ErrValidationError("ServiceEmptyBodyResultMultipleView", "MethodEmptyBodyResultMultipleView", goa.InvalidEnumValueError("view", view, []any{"default", "tiny"})) + } default: - body, _ := io.ReadAll(resp.Body) + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, goahttp.ErrDecodingError("ServiceEmptyBodyResultMultipleView", "MethodEmptyBodyResultMultipleView", err) + } return nil, goahttp.ErrInvalidResponse("ServiceEmptyBodyResultMultipleView", "MethodEmptyBodyResultMultipleView", resp.StatusCode, string(body)) } } diff --git a/http/codegen/testdata/golden/client_decode_empty-body.go.golden b/http/codegen/testdata/golden/client_decode_empty-body.go.golden index 76693be53d..de696c4d18 100644 --- a/http/codegen/testdata/golden/client_decode_empty-body.go.golden +++ b/http/codegen/testdata/golden/client_decode_empty-body.go.golden @@ -3,25 +3,34 @@ // endpoint. restoreBody controls whether the response body should be restored // after having been read. func DecodeMethodEmptyServerResponseResponse(decoder func(*http.Response) goahttp.Decoder, restoreBody bool) func(*http.Response) (any, error) { - return func(resp *http.Response) (any, error) { + return func(resp *http.Response) (result any, decodeErr error) { + responseBody := resp.Body if restoreBody { - b, err := io.ReadAll(resp.Body) - if err != nil { - return nil, err + b, readErr := io.ReadAll(responseBody) + closeErr := responseBody.Close() + if err := errors.Join(readErr, closeErr); err != nil { + return nil, goahttp.ErrDecodingError("ServiceEmptyServerResponse", "MethodEmptyServerResponse", err) } resp.Body = io.NopCloser(bytes.NewBuffer(b)) defer func() { resp.Body = io.NopCloser(bytes.NewBuffer(b)) }() } else { - defer resp.Body.Close() + defer func() { + if err := responseBody.Close(); err != nil { + decodeErr = errors.Join(decodeErr, goahttp.ErrDecodingError("ServiceEmptyServerResponse", "MethodEmptyServerResponse", err)) + } + }() } switch resp.StatusCode { case http.StatusOK: res := NewMethodEmptyServerResponseResultOK() return res, nil default: - body, _ := io.ReadAll(resp.Body) + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, goahttp.ErrDecodingError("ServiceEmptyServerResponse", "MethodEmptyServerResponse", err) + } return nil, goahttp.ErrInvalidResponse("ServiceEmptyServerResponse", "MethodEmptyServerResponse", resp.StatusCode, string(body)) } } diff --git a/http/codegen/testdata/golden/client_decode_empty-error-response-body.go.golden b/http/codegen/testdata/golden/client_decode_empty-error-response-body.go.golden index e81fe10765..45abf451f5 100644 --- a/http/codegen/testdata/golden/client_decode_empty-error-response-body.go.golden +++ b/http/codegen/testdata/golden/client_decode_empty-error-response-body.go.golden @@ -7,18 +7,24 @@ // - "not_found" (type serviceemptyerrorresponsebody.NotFound): http.StatusNotFound // - error: internal error func DecodeMethodEmptyErrorResponseBodyResponse(decoder func(*http.Response) goahttp.Decoder, restoreBody bool) func(*http.Response) (any, error) { - return func(resp *http.Response) (any, error) { + return func(resp *http.Response) (result any, decodeErr error) { + responseBody := resp.Body if restoreBody { - b, err := io.ReadAll(resp.Body) - if err != nil { - return nil, err + b, readErr := io.ReadAll(responseBody) + closeErr := responseBody.Close() + if err := errors.Join(readErr, closeErr); err != nil { + return nil, goahttp.ErrDecodingError("ServiceEmptyErrorResponseBody", "MethodEmptyErrorResponseBody", err) } resp.Body = io.NopCloser(bytes.NewBuffer(b)) defer func() { resp.Body = io.NopCloser(bytes.NewBuffer(b)) }() } else { - defer resp.Body.Close() + defer func() { + if err := responseBody.Close(); err != nil { + decodeErr = errors.Join(decodeErr, goahttp.ErrDecodingError("ServiceEmptyErrorResponseBody", "MethodEmptyErrorResponseBody", err)) + } + }() } switch resp.StatusCode { case http.StatusOK: @@ -100,7 +106,10 @@ func DecodeMethodEmptyErrorResponseBodyResponse(decoder func(*http.Response) goa } return nil, NewMethodEmptyErrorResponseBodyNotFound(inHeader) default: - body, _ := io.ReadAll(resp.Body) + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, goahttp.ErrDecodingError("ServiceEmptyErrorResponseBody", "MethodEmptyErrorResponseBody", err) + } return nil, goahttp.ErrInvalidResponse("ServiceEmptyErrorResponseBody", "MethodEmptyErrorResponseBody", resp.StatusCode, string(body)) } } diff --git a/http/codegen/testdata/golden/client_decode_empty-server-response-with-tags.go.golden b/http/codegen/testdata/golden/client_decode_empty-server-response-with-tags.go.golden index 486e080587..529e9df15a 100644 --- a/http/codegen/testdata/golden/client_decode_empty-server-response-with-tags.go.golden +++ b/http/codegen/testdata/golden/client_decode_empty-server-response-with-tags.go.golden @@ -3,18 +3,24 @@ // MethodEmptyServerResponseWithTags endpoint. restoreBody controls whether the // response body should be restored after having been read. func DecodeMethodEmptyServerResponseWithTagsResponse(decoder func(*http.Response) goahttp.Decoder, restoreBody bool) func(*http.Response) (any, error) { - return func(resp *http.Response) (any, error) { + return func(resp *http.Response) (result any, decodeErr error) { + responseBody := resp.Body if restoreBody { - b, err := io.ReadAll(resp.Body) - if err != nil { - return nil, err + b, readErr := io.ReadAll(responseBody) + closeErr := responseBody.Close() + if err := errors.Join(readErr, closeErr); err != nil { + return nil, goahttp.ErrDecodingError("ServiceEmptyServerResponseWithTags", "MethodEmptyServerResponseWithTags", err) } resp.Body = io.NopCloser(bytes.NewBuffer(b)) defer func() { resp.Body = io.NopCloser(bytes.NewBuffer(b)) }() } else { - defer resp.Body.Close() + defer func() { + if err := responseBody.Close(); err != nil { + decodeErr = errors.Join(decodeErr, goahttp.ErrDecodingError("ServiceEmptyServerResponseWithTags", "MethodEmptyServerResponseWithTags", err)) + } + }() } switch resp.StatusCode { case http.StatusNotModified: @@ -25,7 +31,10 @@ func DecodeMethodEmptyServerResponseWithTagsResponse(decoder func(*http.Response res := NewMethodEmptyServerResponseWithTagsResultNoContent() return res, nil default: - body, _ := io.ReadAll(resp.Body) + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, goahttp.ErrDecodingError("ServiceEmptyServerResponseWithTags", "MethodEmptyServerResponseWithTags", err) + } return nil, goahttp.ErrInvalidResponse("ServiceEmptyServerResponseWithTags", "MethodEmptyServerResponseWithTags", resp.StatusCode, string(body)) } } diff --git a/http/codegen/testdata/golden/client_decode_explicit-body-primitive-result.go.golden b/http/codegen/testdata/golden/client_decode_explicit-body-primitive-result.go.golden index 45a3efe2ab..700967648c 100644 --- a/http/codegen/testdata/golden/client_decode_explicit-body-primitive-result.go.golden +++ b/http/codegen/testdata/golden/client_decode_explicit-body-primitive-result.go.golden @@ -4,18 +4,24 @@ // MethodExplicitBodyPrimitiveResultMultipleView endpoint. restoreBody controls // whether the response body should be restored after having been read. func DecodeMethodExplicitBodyPrimitiveResultMultipleViewResponse(decoder func(*http.Response) goahttp.Decoder, restoreBody bool) func(*http.Response) (any, error) { - return func(resp *http.Response) (any, error) { + return func(resp *http.Response) (result any, decodeErr error) { + responseBody := resp.Body if restoreBody { - b, err := io.ReadAll(resp.Body) - if err != nil { - return nil, err + b, readErr := io.ReadAll(responseBody) + closeErr := responseBody.Close() + if err := errors.Join(readErr, closeErr); err != nil { + return nil, goahttp.ErrDecodingError("ServiceExplicitBodyPrimitiveResultMultipleView", "MethodExplicitBodyPrimitiveResultMultipleView", err) } resp.Body = io.NopCloser(bytes.NewBuffer(b)) defer func() { resp.Body = io.NopCloser(bytes.NewBuffer(b)) }() } else { - defer resp.Body.Close() + defer func() { + if err := responseBody.Close(); err != nil { + decodeErr = errors.Join(decodeErr, goahttp.ErrDecodingError("ServiceExplicitBodyPrimitiveResultMultipleView", "MethodExplicitBodyPrimitiveResultMultipleView", err)) + } + }() } switch resp.StatusCode { case http.StatusOK: @@ -42,6 +48,9 @@ func DecodeMethodExplicitBodyPrimitiveResultMultipleViewResponse(decoder func(*h } p := NewMethodExplicitBodyPrimitiveResultMultipleViewResulttypemultipleviewsOK(body, c) view := resp.Header.Get("goa-view") + if view == "" { + view = "default" + } vres := &serviceexplicitbodyprimitiveresultmultipleviewviews.Resulttypemultipleviews{Projected: p, View: view} if err = serviceexplicitbodyprimitiveresultmultipleviewviews.ValidateResulttypemultipleviews(vres); err != nil { return nil, goahttp.ErrValidationError("ServiceExplicitBodyPrimitiveResultMultipleView", "MethodExplicitBodyPrimitiveResultMultipleView", err) @@ -49,7 +58,10 @@ func DecodeMethodExplicitBodyPrimitiveResultMultipleViewResponse(decoder func(*h res := serviceexplicitbodyprimitiveresultmultipleview.NewResulttypemultipleviews(vres) return res, nil default: - body, _ := io.ReadAll(resp.Body) + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, goahttp.ErrDecodingError("ServiceExplicitBodyPrimitiveResultMultipleView", "MethodExplicitBodyPrimitiveResultMultipleView", err) + } return nil, goahttp.ErrInvalidResponse("ServiceExplicitBodyPrimitiveResultMultipleView", "MethodExplicitBodyPrimitiveResultMultipleView", resp.StatusCode, string(body)) } } diff --git a/http/codegen/testdata/golden/client_decode_explicit-body-result-collection.go.golden b/http/codegen/testdata/golden/client_decode_explicit-body-result-collection.go.golden index 794f6fa6ba..3b33387e82 100644 --- a/http/codegen/testdata/golden/client_decode_explicit-body-result-collection.go.golden +++ b/http/codegen/testdata/golden/client_decode_explicit-body-result-collection.go.golden @@ -3,37 +3,46 @@ // MethodExplicitBodyResultCollection endpoint. restoreBody controls whether // the response body should be restored after having been read. func DecodeMethodExplicitBodyResultCollectionResponse(decoder func(*http.Response) goahttp.Decoder, restoreBody bool) func(*http.Response) (any, error) { - return func(resp *http.Response) (any, error) { + return func(resp *http.Response) (result any, decodeErr error) { + responseBody := resp.Body if restoreBody { - b, err := io.ReadAll(resp.Body) - if err != nil { - return nil, err + b, readErr := io.ReadAll(responseBody) + closeErr := responseBody.Close() + if err := errors.Join(readErr, closeErr); err != nil { + return nil, goahttp.ErrDecodingError("ServiceExplicitBodyResultCollection", "MethodExplicitBodyResultCollection", err) } resp.Body = io.NopCloser(bytes.NewBuffer(b)) defer func() { resp.Body = io.NopCloser(bytes.NewBuffer(b)) }() } else { - defer resp.Body.Close() + defer func() { + if err := responseBody.Close(); err != nil { + decodeErr = errors.Join(decodeErr, goahttp.ErrDecodingError("ServiceExplicitBodyResultCollection", "MethodExplicitBodyResultCollection", err)) + } + }() } switch resp.StatusCode { case http.StatusOK: var ( - body ResulttypeCollection + body ResulttypeResponseCollection err error ) err = decoder(resp).Decode(&body) if err != nil { return nil, goahttp.ErrDecodingError("ServiceExplicitBodyResultCollection", "MethodExplicitBodyResultCollection", err) } - err = ValidateResulttypeCollection(body) + err = ValidateResulttypeResponseCollection(body) if err != nil { return nil, goahttp.ErrValidationError("ServiceExplicitBodyResultCollection", "MethodExplicitBodyResultCollection", err) } res := NewMethodExplicitBodyResultCollectionResultOK(body) return res, nil default: - body, _ := io.ReadAll(resp.Body) + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, goahttp.ErrDecodingError("ServiceExplicitBodyResultCollection", "MethodExplicitBodyResultCollection", err) + } return nil, goahttp.ErrInvalidResponse("ServiceExplicitBodyResultCollection", "MethodExplicitBodyResultCollection", resp.StatusCode, string(body)) } } diff --git a/http/codegen/testdata/golden/client_decode_explicit-body-result-multiple-views.go.golden b/http/codegen/testdata/golden/client_decode_explicit-body-result-multiple-views.go.golden index 9b7d4c88ec..9059e8333e 100644 --- a/http/codegen/testdata/golden/client_decode_explicit-body-result-multiple-views.go.golden +++ b/http/codegen/testdata/golden/client_decode_explicit-body-result-multiple-views.go.golden @@ -3,18 +3,24 @@ // MethodExplicitBodyUserResultMultipleView endpoint. restoreBody controls // whether the response body should be restored after having been read. func DecodeMethodExplicitBodyUserResultMultipleViewResponse(decoder func(*http.Response) goahttp.Decoder, restoreBody bool) func(*http.Response) (any, error) { - return func(resp *http.Response) (any, error) { + return func(resp *http.Response) (result any, decodeErr error) { + responseBody := resp.Body if restoreBody { - b, err := io.ReadAll(resp.Body) - if err != nil { - return nil, err + b, readErr := io.ReadAll(responseBody) + closeErr := responseBody.Close() + if err := errors.Join(readErr, closeErr); err != nil { + return nil, goahttp.ErrDecodingError("ServiceExplicitBodyUserResultMultipleView", "MethodExplicitBodyUserResultMultipleView", err) } resp.Body = io.NopCloser(bytes.NewBuffer(b)) defer func() { resp.Body = io.NopCloser(bytes.NewBuffer(b)) }() } else { - defer resp.Body.Close() + defer func() { + if err := responseBody.Close(); err != nil { + decodeErr = errors.Join(decodeErr, goahttp.ErrDecodingError("ServiceExplicitBodyUserResultMultipleView", "MethodExplicitBodyUserResultMultipleView", err)) + } + }() } switch resp.StatusCode { case http.StatusOK: @@ -35,6 +41,9 @@ func DecodeMethodExplicitBodyUserResultMultipleViewResponse(decoder func(*http.R } p := NewMethodExplicitBodyUserResultMultipleViewResulttypemultipleviewsOK(&body, c) view := resp.Header.Get("goa-view") + if view == "" { + view = "default" + } vres := &serviceexplicitbodyuserresultmultipleviewviews.Resulttypemultipleviews{Projected: p, View: view} if err = serviceexplicitbodyuserresultmultipleviewviews.ValidateResulttypemultipleviews(vres); err != nil { return nil, goahttp.ErrValidationError("ServiceExplicitBodyUserResultMultipleView", "MethodExplicitBodyUserResultMultipleView", err) @@ -42,7 +51,10 @@ func DecodeMethodExplicitBodyUserResultMultipleViewResponse(decoder func(*http.R res := serviceexplicitbodyuserresultmultipleview.NewResulttypemultipleviews(vres) return res, nil default: - body, _ := io.ReadAll(resp.Body) + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, goahttp.ErrDecodingError("ServiceExplicitBodyUserResultMultipleView", "MethodExplicitBodyUserResultMultipleView", err) + } return nil, goahttp.ErrInvalidResponse("ServiceExplicitBodyUserResultMultipleView", "MethodExplicitBodyUserResultMultipleView", resp.StatusCode, string(body)) } } diff --git a/http/codegen/testdata/golden/client_decode_header-array-validate.go.golden b/http/codegen/testdata/golden/client_decode_header-array-validate.go.golden index be64f05f20..4b9d930c32 100644 --- a/http/codegen/testdata/golden/client_decode_header-array-validate.go.golden +++ b/http/codegen/testdata/golden/client_decode_header-array-validate.go.golden @@ -2,18 +2,24 @@ // ServiceHeaderArrayValidateResponse MethodA endpoint. restoreBody controls // whether the response body should be restored after having been read. func DecodeMethodAResponse(decoder func(*http.Response) goahttp.Decoder, restoreBody bool) func(*http.Response) (any, error) { - return func(resp *http.Response) (any, error) { + return func(resp *http.Response) (result any, decodeErr error) { + responseBody := resp.Body if restoreBody { - b, err := io.ReadAll(resp.Body) - if err != nil { - return nil, err + b, readErr := io.ReadAll(responseBody) + closeErr := responseBody.Close() + if err := errors.Join(readErr, closeErr); err != nil { + return nil, goahttp.ErrDecodingError("ServiceHeaderArrayValidateResponse", "MethodA", err) } resp.Body = io.NopCloser(bytes.NewBuffer(b)) defer func() { resp.Body = io.NopCloser(bytes.NewBuffer(b)) }() } else { - defer resp.Body.Close() + defer func() { + if err := responseBody.Close(); err != nil { + decodeErr = errors.Join(decodeErr, goahttp.ErrDecodingError("ServiceHeaderArrayValidateResponse", "MethodA", err)) + } + }() } switch resp.StatusCode { case http.StatusOK: @@ -46,7 +52,10 @@ func DecodeMethodAResponse(decoder func(*http.Response) goahttp.Decoder, restore res := NewMethodAResultOK(array) return res, nil default: - body, _ := io.ReadAll(resp.Body) + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, goahttp.ErrDecodingError("ServiceHeaderArrayValidateResponse", "MethodA", err) + } return nil, goahttp.ErrInvalidResponse("ServiceHeaderArrayValidateResponse", "MethodA", resp.StatusCode, string(body)) } } diff --git a/http/codegen/testdata/golden/client_decode_header-array.go.golden b/http/codegen/testdata/golden/client_decode_header-array.go.golden index b10174b8a2..d6c05ba6b8 100644 --- a/http/codegen/testdata/golden/client_decode_header-array.go.golden +++ b/http/codegen/testdata/golden/client_decode_header-array.go.golden @@ -2,18 +2,24 @@ // ServiceHeaderArrayResponse MethodA endpoint. restoreBody controls whether // the response body should be restored after having been read. func DecodeMethodAResponse(decoder func(*http.Response) goahttp.Decoder, restoreBody bool) func(*http.Response) (any, error) { - return func(resp *http.Response) (any, error) { + return func(resp *http.Response) (result any, decodeErr error) { + responseBody := resp.Body if restoreBody { - b, err := io.ReadAll(resp.Body) - if err != nil { - return nil, err + b, readErr := io.ReadAll(responseBody) + closeErr := responseBody.Close() + if err := errors.Join(readErr, closeErr); err != nil { + return nil, goahttp.ErrDecodingError("ServiceHeaderArrayResponse", "MethodA", err) } resp.Body = io.NopCloser(bytes.NewBuffer(b)) defer func() { resp.Body = io.NopCloser(bytes.NewBuffer(b)) }() } else { - defer resp.Body.Close() + defer func() { + if err := responseBody.Close(); err != nil { + decodeErr = errors.Join(decodeErr, goahttp.ErrDecodingError("ServiceHeaderArrayResponse", "MethodA", err)) + } + }() } switch resp.StatusCode { case http.StatusOK: @@ -41,7 +47,10 @@ func DecodeMethodAResponse(decoder func(*http.Response) goahttp.Decoder, restore res := NewMethodAResultOK(array) return res, nil default: - body, _ := io.ReadAll(resp.Body) + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, goahttp.ErrDecodingError("ServiceHeaderArrayResponse", "MethodA", err) + } return nil, goahttp.ErrInvalidResponse("ServiceHeaderArrayResponse", "MethodA", resp.StatusCode, string(body)) } } diff --git a/http/codegen/testdata/golden/client_decode_header-string-array-validate.go.golden b/http/codegen/testdata/golden/client_decode_header-string-array-validate.go.golden index a6ff6af677..918677d89e 100644 --- a/http/codegen/testdata/golden/client_decode_header-string-array-validate.go.golden +++ b/http/codegen/testdata/golden/client_decode_header-string-array-validate.go.golden @@ -2,18 +2,24 @@ // ServiceHeaderStringArrayValidateResponse MethodA endpoint. restoreBody // controls whether the response body should be restored after having been read. func DecodeMethodAResponse(decoder func(*http.Response) goahttp.Decoder, restoreBody bool) func(*http.Response) (any, error) { - return func(resp *http.Response) (any, error) { + return func(resp *http.Response) (result any, decodeErr error) { + responseBody := resp.Body if restoreBody { - b, err := io.ReadAll(resp.Body) - if err != nil { - return nil, err + b, readErr := io.ReadAll(responseBody) + closeErr := responseBody.Close() + if err := errors.Join(readErr, closeErr); err != nil { + return nil, goahttp.ErrDecodingError("ServiceHeaderStringArrayValidateResponse", "MethodA", err) } resp.Body = io.NopCloser(bytes.NewBuffer(b)) defer func() { resp.Body = io.NopCloser(bytes.NewBuffer(b)) }() } else { - defer resp.Body.Close() + defer func() { + if err := responseBody.Close(); err != nil { + decodeErr = errors.Join(decodeErr, goahttp.ErrDecodingError("ServiceHeaderStringArrayValidateResponse", "MethodA", err)) + } + }() } switch resp.StatusCode { case http.StatusOK: @@ -32,7 +38,10 @@ func DecodeMethodAResponse(decoder func(*http.Response) goahttp.Decoder, restore res := NewMethodAResultOK(array) return res, nil default: - body, _ := io.ReadAll(resp.Body) + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, goahttp.ErrDecodingError("ServiceHeaderStringArrayValidateResponse", "MethodA", err) + } return nil, goahttp.ErrInvalidResponse("ServiceHeaderStringArrayValidateResponse", "MethodA", resp.StatusCode, string(body)) } } diff --git a/http/codegen/testdata/golden/client_decode_header-string-array.go.golden b/http/codegen/testdata/golden/client_decode_header-string-array.go.golden index 94a679e52c..5cd520ddd8 100644 --- a/http/codegen/testdata/golden/client_decode_header-string-array.go.golden +++ b/http/codegen/testdata/golden/client_decode_header-string-array.go.golden @@ -2,18 +2,24 @@ // ServiceHeaderStringArrayResponse MethodA endpoint. restoreBody controls // whether the response body should be restored after having been read. func DecodeMethodAResponse(decoder func(*http.Response) goahttp.Decoder, restoreBody bool) func(*http.Response) (any, error) { - return func(resp *http.Response) (any, error) { + return func(resp *http.Response) (result any, decodeErr error) { + responseBody := resp.Body if restoreBody { - b, err := io.ReadAll(resp.Body) - if err != nil { - return nil, err + b, readErr := io.ReadAll(responseBody) + closeErr := responseBody.Close() + if err := errors.Join(readErr, closeErr); err != nil { + return nil, goahttp.ErrDecodingError("ServiceHeaderStringArrayResponse", "MethodA", err) } resp.Body = io.NopCloser(bytes.NewBuffer(b)) defer func() { resp.Body = io.NopCloser(bytes.NewBuffer(b)) }() } else { - defer resp.Body.Close() + defer func() { + if err := responseBody.Close(); err != nil { + decodeErr = errors.Join(decodeErr, goahttp.ErrDecodingError("ServiceHeaderStringArrayResponse", "MethodA", err)) + } + }() } switch resp.StatusCode { case http.StatusOK: @@ -25,7 +31,10 @@ func DecodeMethodAResponse(decoder func(*http.Response) goahttp.Decoder, restore res := NewMethodAResultOK(array) return res, nil default: - body, _ := io.ReadAll(resp.Body) + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, goahttp.ErrDecodingError("ServiceHeaderStringArrayResponse", "MethodA", err) + } return nil, goahttp.ErrInvalidResponse("ServiceHeaderStringArrayResponse", "MethodA", resp.StatusCode, string(body)) } } diff --git a/http/codegen/testdata/golden/client_decode_header-string-implicit.go.golden b/http/codegen/testdata/golden/client_decode_header-string-implicit.go.golden index 2d2edbac47..505ac4c77a 100644 --- a/http/codegen/testdata/golden/client_decode_header-string-implicit.go.golden +++ b/http/codegen/testdata/golden/client_decode_header-string-implicit.go.golden @@ -3,18 +3,24 @@ // endpoint. restoreBody controls whether the response body should be restored // after having been read. func DecodeMethodHeaderStringImplicitResponse(decoder func(*http.Response) goahttp.Decoder, restoreBody bool) func(*http.Response) (any, error) { - return func(resp *http.Response) (any, error) { + return func(resp *http.Response) (result any, decodeErr error) { + responseBody := resp.Body if restoreBody { - b, err := io.ReadAll(resp.Body) - if err != nil { - return nil, err + b, readErr := io.ReadAll(responseBody) + closeErr := responseBody.Close() + if err := errors.Join(readErr, closeErr); err != nil { + return nil, goahttp.ErrDecodingError("ServiceHeaderStringImplicit", "MethodHeaderStringImplicit", err) } resp.Body = io.NopCloser(bytes.NewBuffer(b)) defer func() { resp.Body = io.NopCloser(bytes.NewBuffer(b)) }() } else { - defer resp.Body.Close() + defer func() { + if err := responseBody.Close(); err != nil { + decodeErr = errors.Join(decodeErr, goahttp.ErrDecodingError("ServiceHeaderStringImplicit", "MethodHeaderStringImplicit", err)) + } + }() } switch resp.StatusCode { case http.StatusOK: @@ -32,7 +38,10 @@ func DecodeMethodHeaderStringImplicitResponse(decoder func(*http.Response) goaht } return h, nil default: - body, _ := io.ReadAll(resp.Body) + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, goahttp.ErrDecodingError("ServiceHeaderStringImplicit", "MethodHeaderStringImplicit", err) + } return nil, goahttp.ErrInvalidResponse("ServiceHeaderStringImplicit", "MethodHeaderStringImplicit", resp.StatusCode, string(body)) } } diff --git a/http/codegen/testdata/golden/client_decode_required-primitive-arrays.go.golden b/http/codegen/testdata/golden/client_decode_required-primitive-arrays.go.golden new file mode 100644 index 0000000000..b341d3d2ef --- /dev/null +++ b/http/codegen/testdata/golden/client_decode_required-primitive-arrays.go.golden @@ -0,0 +1,48 @@ +// DecodeStoreResponse returns a decoder for responses returned by the +// RequiredArrays Store endpoint. restoreBody controls whether the response +// body should be restored after having been read. +func DecodeStoreResponse(decoder func(*http.Response) goahttp.Decoder, restoreBody bool) func(*http.Response) (any, error) { + return func(resp *http.Response) (result any, decodeErr error) { + responseBody := resp.Body + if restoreBody { + b, readErr := io.ReadAll(responseBody) + closeErr := responseBody.Close() + if err := errors.Join(readErr, closeErr); err != nil { + return nil, goahttp.ErrDecodingError("RequiredArrays", "Store", err) + } + resp.Body = io.NopCloser(bytes.NewBuffer(b)) + defer func() { + resp.Body = io.NopCloser(bytes.NewBuffer(b)) + }() + } else { + defer func() { + if err := responseBody.Close(); err != nil { + decodeErr = errors.Join(decodeErr, goahttp.ErrDecodingError("RequiredArrays", "Store", err)) + } + }() + } + switch resp.StatusCode { + case http.StatusOK: + var ( + body StoreResponseBody + err error + ) + err = decoder(resp).Decode(&body) + if err != nil { + return nil, goahttp.ErrDecodingError("RequiredArrays", "Store", err) + } + err = ValidateStoreResponseBody(&body) + if err != nil { + return nil, goahttp.ErrValidationError("RequiredArrays", "Store", err) + } + res := NewStoreResultOK(&body) + return res, nil + default: + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, goahttp.ErrDecodingError("RequiredArrays", "Store", err) + } + return nil, goahttp.ErrInvalidResponse("RequiredArrays", "Store", resp.StatusCode, string(body)) + } + } +} diff --git a/http/codegen/testdata/golden/client_decode_skip-response-body-encode-decode.go.golden b/http/codegen/testdata/golden/client_decode_skip-response-body-encode-decode.go.golden new file mode 100644 index 0000000000..4fc444bb66 --- /dev/null +++ b/http/codegen/testdata/golden/client_decode_skip-response-body-encode-decode.go.golden @@ -0,0 +1,36 @@ +// DecodeMethodSkipResponseBodyEncodeDecodeResponse returns a decoder for +// responses returned by the ServiceSkipResponseBodyEncodeDecode +// MethodSkipResponseBodyEncodeDecode endpoint. restoreBody controls whether +// the response body should be restored after having been read. +// DecodeMethodSkipResponseBodyEncodeDecodeResponse may return the following +// errors: +// - "internal_error" (type *goa.ServiceError): http.StatusInternalServerError +// - error: internal error +func DecodeMethodSkipResponseBodyEncodeDecodeResponse(decoder func(*http.Response) goahttp.Decoder, restoreBody bool) func(*http.Response) (any, error) { + return func(resp *http.Response) (any, error) { + switch resp.StatusCode { + case http.StatusOK: + return nil, nil + case http.StatusInternalServerError: + var ( + body MethodSkipResponseBodyEncodeDecodeInternalErrorResponseBody + err error + ) + err = decoder(resp).Decode(&body) + if err != nil { + return nil, goahttp.ErrDecodingError("ServiceSkipResponseBodyEncodeDecode", "MethodSkipResponseBodyEncodeDecode", err) + } + err = ValidateMethodSkipResponseBodyEncodeDecodeInternalErrorResponseBody(&body) + if err != nil { + return nil, goahttp.ErrValidationError("ServiceSkipResponseBodyEncodeDecode", "MethodSkipResponseBodyEncodeDecode", err) + } + return nil, NewMethodSkipResponseBodyEncodeDecodeInternalError(&body) + default: + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, goahttp.ErrDecodingError("ServiceSkipResponseBodyEncodeDecode", "MethodSkipResponseBodyEncodeDecode", err) + } + return nil, goahttp.ErrInvalidResponse("ServiceSkipResponseBodyEncodeDecode", "MethodSkipResponseBodyEncodeDecode", resp.StatusCode, string(body)) + } + } +} diff --git a/http/codegen/testdata/golden/client_decode_status-tag-required.go.golden b/http/codegen/testdata/golden/client_decode_status-tag-required.go.golden new file mode 100644 index 0000000000..aa58e75f52 --- /dev/null +++ b/http/codegen/testdata/golden/client_decode_status-tag-required.go.golden @@ -0,0 +1,78 @@ +// DecodeMethodStatusTagRequiredResponse returns a decoder for responses +// returned by the ServiceStatusTagRequired MethodStatusTagRequired endpoint. +// restoreBody controls whether the response body should be restored after +// having been read. +func DecodeMethodStatusTagRequiredResponse(decoder func(*http.Response) goahttp.Decoder, restoreBody bool) func(*http.Response) (any, error) { + return func(resp *http.Response) (result any, decodeErr error) { + responseBody := resp.Body + if restoreBody { + b, readErr := io.ReadAll(responseBody) + closeErr := responseBody.Close() + if err := errors.Join(readErr, closeErr); err != nil { + return nil, goahttp.ErrDecodingError("ServiceStatusTagRequired", "MethodStatusTagRequired", err) + } + resp.Body = io.NopCloser(bytes.NewBuffer(b)) + defer func() { + resp.Body = io.NopCloser(bytes.NewBuffer(b)) + }() + } else { + defer func() { + if err := responseBody.Close(); err != nil { + decodeErr = errors.Join(decodeErr, goahttp.ErrDecodingError("ServiceStatusTagRequired", "MethodStatusTagRequired", err)) + } + }() + } + switch resp.StatusCode { + case http.StatusCreated: + var ( + body MethodStatusTagRequiredCreatedResponseBody + err error + ) + err = decoder(resp).Decode(&body) + if err != nil { + return nil, goahttp.ErrDecodingError("ServiceStatusTagRequired", "MethodStatusTagRequired", err) + } + err = ValidateMethodStatusTagRequiredCreatedResponseBody(&body) + if err != nil { + return nil, goahttp.ErrValidationError("ServiceStatusTagRequired", "MethodStatusTagRequired", err) + } + p := NewMethodStatusTagRequiredStatusTagResultCreated(&body) + tmp := "created" + p.Outcome = &tmp + view := "default" + vres := &servicestatustagrequiredviews.StatusTagResult{Projected: p, View: view} + if err = servicestatustagrequiredviews.ValidateStatusTagResult(vres); err != nil { + return nil, goahttp.ErrValidationError("ServiceStatusTagRequired", "MethodStatusTagRequired", err) + } + res := servicestatustagrequired.NewStatusTagResult(vres) + return res, nil + case http.StatusOK: + var ( + body MethodStatusTagRequiredOKResponseBody + err error + ) + err = decoder(resp).Decode(&body) + if err != nil { + return nil, goahttp.ErrDecodingError("ServiceStatusTagRequired", "MethodStatusTagRequired", err) + } + err = ValidateMethodStatusTagRequiredOKResponseBody(&body) + if err != nil { + return nil, goahttp.ErrValidationError("ServiceStatusTagRequired", "MethodStatusTagRequired", err) + } + p := NewMethodStatusTagRequiredStatusTagResultOK(&body) + view := "default" + vres := &servicestatustagrequiredviews.StatusTagResult{Projected: p, View: view} + if err = servicestatustagrequiredviews.ValidateStatusTagResult(vres); err != nil { + return nil, goahttp.ErrValidationError("ServiceStatusTagRequired", "MethodStatusTagRequired", err) + } + res := servicestatustagrequired.NewStatusTagResult(vres) + return res, nil + default: + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, goahttp.ErrDecodingError("ServiceStatusTagRequired", "MethodStatusTagRequired", err) + } + return nil, goahttp.ErrInvalidResponse("ServiceStatusTagRequired", "MethodStatusTagRequired", resp.StatusCode, string(body)) + } + } +} diff --git a/http/codegen/testdata/golden/client_decode_tag-result-multiple-views.go.golden b/http/codegen/testdata/golden/client_decode_tag-result-multiple-views.go.golden index 4420a82de0..9476cc7ed6 100644 --- a/http/codegen/testdata/golden/client_decode_tag-result-multiple-views.go.golden +++ b/http/codegen/testdata/golden/client_decode_tag-result-multiple-views.go.golden @@ -3,65 +3,131 @@ // restoreBody controls whether the response body should be restored after // having been read. func DecodeMethodTagMultipleViewsResponse(decoder func(*http.Response) goahttp.Decoder, restoreBody bool) func(*http.Response) (any, error) { - return func(resp *http.Response) (any, error) { + return func(resp *http.Response) (result any, decodeErr error) { + responseBody := resp.Body if restoreBody { - b, err := io.ReadAll(resp.Body) - if err != nil { - return nil, err + b, readErr := io.ReadAll(responseBody) + closeErr := responseBody.Close() + if err := errors.Join(readErr, closeErr); err != nil { + return nil, goahttp.ErrDecodingError("ServiceTagMultipleViews", "MethodTagMultipleViews", err) } resp.Body = io.NopCloser(bytes.NewBuffer(b)) defer func() { resp.Body = io.NopCloser(bytes.NewBuffer(b)) }() } else { - defer resp.Body.Close() + defer func() { + if err := responseBody.Close(); err != nil { + decodeErr = errors.Join(decodeErr, goahttp.ErrDecodingError("ServiceTagMultipleViews", "MethodTagMultipleViews", err)) + } + }() } switch resp.StatusCode { case http.StatusAccepted: - var ( - body MethodTagMultipleViewsAcceptedResponseBody - err error - ) - err = decoder(resp).Decode(&body) - if err != nil { - return nil, goahttp.ErrDecodingError("ServiceTagMultipleViews", "MethodTagMultipleViews", err) + view := resp.Header.Get("goa-view") + if view == "" { + view = "default" } - var ( - c *string - ) - cRaw := resp.Header.Get("C") - if cRaw != "" { - c = &cRaw + switch view { + case "default": + var ( + body MethodTagMultipleViewsAcceptedResponseBody + err error + ) + err = decoder(resp).Decode(&body) + if err != nil { + return nil, goahttp.ErrDecodingError("ServiceTagMultipleViews", "MethodTagMultipleViews", err) + } + var ( + c *string + ) + cRaw := resp.Header.Get("C") + if cRaw != "" { + c = &cRaw + } + p := NewMethodTagMultipleViewsResultDefaultAccepted(&body, c) + tmp := "value" + p.B = &tmp + vres := &servicetagmultipleviewsviews.Resulttypemultipleviews{Projected: p, View: view} + if err = servicetagmultipleviewsviews.ValidateResulttypemultipleviews(vres); err != nil { + return nil, goahttp.ErrValidationError("ServiceTagMultipleViews", "MethodTagMultipleViews", err) + } + res := servicetagmultipleviews.NewResulttypemultipleviews(vres) + return res, nil + case "tiny": + var ( + body MethodTagMultipleViewsAcceptedResponseBodyTiny + err error + ) + err = decoder(resp).Decode(&body) + if err != nil { + return nil, goahttp.ErrDecodingError("ServiceTagMultipleViews", "MethodTagMultipleViews", err) + } + var ( + c *string + ) + cRaw := resp.Header.Get("C") + if cRaw != "" { + c = &cRaw + } + p := NewMethodTagMultipleViewsResultTinyAccepted(&body, c) + tmp := "value" + p.B = &tmp + vres := &servicetagmultipleviewsviews.Resulttypemultipleviews{Projected: p, View: view} + if err = servicetagmultipleviewsviews.ValidateResulttypemultipleviews(vres); err != nil { + return nil, goahttp.ErrValidationError("ServiceTagMultipleViews", "MethodTagMultipleViews", err) + } + res := servicetagmultipleviews.NewResulttypemultipleviews(vres) + return res, nil + default: + return nil, goahttp.ErrValidationError("ServiceTagMultipleViews", "MethodTagMultipleViews", goa.InvalidEnumValueError("view", view, []any{"default", "tiny"})) } - p := NewMethodTagMultipleViewsResulttypemultipleviewsAccepted(&body, c) - tmp := "value" - p.B = &tmp + case http.StatusOK: view := resp.Header.Get("goa-view") - vres := &servicetagmultipleviewsviews.Resulttypemultipleviews{Projected: p, View: view} - if err = servicetagmultipleviewsviews.ValidateResulttypemultipleviews(vres); err != nil { - return nil, goahttp.ErrValidationError("ServiceTagMultipleViews", "MethodTagMultipleViews", err) + if view == "" { + view = "default" } - res := servicetagmultipleviews.NewResulttypemultipleviews(vres) - return res, nil - case http.StatusOK: - var ( - body MethodTagMultipleViewsOKResponseBody - err error - ) - err = decoder(resp).Decode(&body) + switch view { + case "default": + var ( + body MethodTagMultipleViewsOKResponseBody + err error + ) + err = decoder(resp).Decode(&body) + if err != nil { + return nil, goahttp.ErrDecodingError("ServiceTagMultipleViews", "MethodTagMultipleViews", err) + } + p := NewMethodTagMultipleViewsResultDefaultOK(&body) + vres := &servicetagmultipleviewsviews.Resulttypemultipleviews{Projected: p, View: view} + if err = servicetagmultipleviewsviews.ValidateResulttypemultipleviews(vres); err != nil { + return nil, goahttp.ErrValidationError("ServiceTagMultipleViews", "MethodTagMultipleViews", err) + } + res := servicetagmultipleviews.NewResulttypemultipleviews(vres) + return res, nil + case "tiny": + var ( + body MethodTagMultipleViewsOKResponseBodyTiny + err error + ) + err = decoder(resp).Decode(&body) + if err != nil { + return nil, goahttp.ErrDecodingError("ServiceTagMultipleViews", "MethodTagMultipleViews", err) + } + p := NewMethodTagMultipleViewsResultTinyOK(&body) + vres := &servicetagmultipleviewsviews.Resulttypemultipleviews{Projected: p, View: view} + if err = servicetagmultipleviewsviews.ValidateResulttypemultipleviews(vres); err != nil { + return nil, goahttp.ErrValidationError("ServiceTagMultipleViews", "MethodTagMultipleViews", err) + } + res := servicetagmultipleviews.NewResulttypemultipleviews(vres) + return res, nil + default: + return nil, goahttp.ErrValidationError("ServiceTagMultipleViews", "MethodTagMultipleViews", goa.InvalidEnumValueError("view", view, []any{"default", "tiny"})) + } + default: + body, err := io.ReadAll(resp.Body) if err != nil { return nil, goahttp.ErrDecodingError("ServiceTagMultipleViews", "MethodTagMultipleViews", err) } - p := NewMethodTagMultipleViewsResulttypemultipleviewsOK(&body) - view := resp.Header.Get("goa-view") - vres := &servicetagmultipleviewsviews.Resulttypemultipleviews{Projected: p, View: view} - if err = servicetagmultipleviewsviews.ValidateResulttypemultipleviews(vres); err != nil { - return nil, goahttp.ErrValidationError("ServiceTagMultipleViews", "MethodTagMultipleViews", err) - } - res := servicetagmultipleviews.NewResulttypemultipleviews(vres) - return res, nil - default: - body, _ := io.ReadAll(resp.Body) return nil, goahttp.ErrInvalidResponse("ServiceTagMultipleViews", "MethodTagMultipleViews", resp.StatusCode, string(body)) } } diff --git a/http/codegen/testdata/golden/client_decode_validate-error-response-type.go.golden b/http/codegen/testdata/golden/client_decode_validate-error-response-type.go.golden index 7042e4a30b..af4ac706d8 100644 --- a/http/codegen/testdata/golden/client_decode_validate-error-response-type.go.golden +++ b/http/codegen/testdata/golden/client_decode_validate-error-response-type.go.golden @@ -5,18 +5,24 @@ // - "some_error" (type *validateerrorresponsetype.AError): http.StatusBadRequest // - error: internal error func DecodeMethodAResponse(decoder func(*http.Response) goahttp.Decoder, restoreBody bool) func(*http.Response) (any, error) { - return func(resp *http.Response) (any, error) { + return func(resp *http.Response) (result any, decodeErr error) { + responseBody := resp.Body if restoreBody { - b, err := io.ReadAll(resp.Body) - if err != nil { - return nil, err + b, readErr := io.ReadAll(responseBody) + closeErr := responseBody.Close() + if err := errors.Join(readErr, closeErr); err != nil { + return nil, goahttp.ErrDecodingError("ValidateErrorResponseType", "MethodA", err) } resp.Body = io.NopCloser(bytes.NewBuffer(b)) defer func() { resp.Body = io.NopCloser(bytes.NewBuffer(b)) }() } else { - defer resp.Body.Close() + defer func() { + if err := responseBody.Close(); err != nil { + decodeErr = errors.Join(decodeErr, goahttp.ErrDecodingError("ValidateErrorResponseType", "MethodA", err)) + } + }() } switch resp.StatusCode { case http.StatusOK: @@ -38,7 +44,7 @@ func DecodeMethodAResponse(decoder func(*http.Response) goahttp.Decoder, restore if err != nil { return nil, goahttp.ErrValidationError("ValidateErrorResponseType", "MethodA", err) } - p := NewMethodAAResultOK(required) + p := NewMethodAResultOK(required) view := "default" vres := &validateerrorresponsetypeviews.AResult{Projected: p, View: view} res := validateerrorresponsetype.NewAResult(vres) @@ -75,7 +81,10 @@ func DecodeMethodAResponse(decoder func(*http.Response) goahttp.Decoder, restore } return nil, NewMethodASomeError(error_, numOccur) default: - body, _ := io.ReadAll(resp.Body) + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, goahttp.ErrDecodingError("ValidateErrorResponseType", "MethodA", err) + } return nil, goahttp.ErrInvalidResponse("ValidateErrorResponseType", "MethodA", resp.StatusCode, string(body)) } } diff --git a/http/codegen/testdata/golden/client_decode_with-headers-dsl-viewed-result.go.golden b/http/codegen/testdata/golden/client_decode_with-headers-dsl-viewed-result.go.golden index 6e55a27963..d370acc70e 100644 --- a/http/codegen/testdata/golden/client_decode_with-headers-dsl-viewed-result.go.golden +++ b/http/codegen/testdata/golden/client_decode_with-headers-dsl-viewed-result.go.golden @@ -2,70 +2,134 @@ // ServiceWithHeadersBlockViewedResult MethodA endpoint. restoreBody controls // whether the response body should be restored after having been read. func DecodeMethodAResponse(decoder func(*http.Response) goahttp.Decoder, restoreBody bool) func(*http.Response) (any, error) { - return func(resp *http.Response) (any, error) { + return func(resp *http.Response) (result any, decodeErr error) { + responseBody := resp.Body if restoreBody { - b, err := io.ReadAll(resp.Body) - if err != nil { - return nil, err + b, readErr := io.ReadAll(responseBody) + closeErr := responseBody.Close() + if err := errors.Join(readErr, closeErr); err != nil { + return nil, goahttp.ErrDecodingError("ServiceWithHeadersBlockViewedResult", "MethodA", err) } resp.Body = io.NopCloser(bytes.NewBuffer(b)) defer func() { resp.Body = io.NopCloser(bytes.NewBuffer(b)) }() } else { - defer resp.Body.Close() + defer func() { + if err := responseBody.Close(); err != nil { + decodeErr = errors.Join(decodeErr, goahttp.ErrDecodingError("ServiceWithHeadersBlockViewedResult", "MethodA", err)) + } + }() } switch resp.StatusCode { case http.StatusOK: - var ( - required int - optional *float32 - optionalButRequired uint - err error - ) - { - requiredRaw := resp.Header.Get("X-Request-Id") - if requiredRaw == "" { - return nil, goahttp.ErrValidationError("ServiceWithHeadersBlockViewedResult", "MethodA", goa.MissingFieldError("required", "header")) + view := resp.Header.Get("goa-view") + if view == "" { + view = "default" + } + switch view { + case "tiny": + var ( + required int + optional *float32 + optionalButRequired uint + err error + ) + { + requiredRaw := resp.Header.Get("X-Request-Id") + if requiredRaw == "" { + return nil, goahttp.ErrValidationError("ServiceWithHeadersBlockViewedResult", "MethodA", goa.MissingFieldError("required", "header")) + } + v, err2 := strconv.ParseInt(requiredRaw, 10, strconv.IntSize) + if err2 != nil { + err = goa.MergeErrors(err, goa.InvalidFieldTypeError("required", requiredRaw, "integer")) + } + required = int(v) } - v, err2 := strconv.ParseInt(requiredRaw, 10, strconv.IntSize) - if err2 != nil { - err = goa.MergeErrors(err, goa.InvalidFieldTypeError("required", requiredRaw, "integer")) + { + optionalRaw := resp.Header.Get("Authorization") + if optionalRaw != "" { + v, err2 := strconv.ParseFloat(optionalRaw, 32) + if err2 != nil { + err = goa.MergeErrors(err, goa.InvalidFieldTypeError("optional", optionalRaw, "float")) + } + pv := float32(v) + optional = &pv + } } - required = int(v) - } - { - optionalRaw := resp.Header.Get("Authorization") - if optionalRaw != "" { - v, err2 := strconv.ParseFloat(optionalRaw, 32) + { + optionalButRequiredRaw := resp.Header.Get("Location") + if optionalButRequiredRaw == "" { + return nil, goahttp.ErrValidationError("ServiceWithHeadersBlockViewedResult", "MethodA", goa.MissingFieldError("optional_but_required", "header")) + } + v, err2 := strconv.ParseUint(optionalButRequiredRaw, 10, strconv.IntSize) if err2 != nil { - err = goa.MergeErrors(err, goa.InvalidFieldTypeError("optional", optionalRaw, "float")) + err = goa.MergeErrors(err, goa.InvalidFieldTypeError("optional_but_required", optionalButRequiredRaw, "unsigned integer")) } - pv := float32(v) - optional = &pv + optionalButRequired = uint(v) } - } - { - optionalButRequiredRaw := resp.Header.Get("Location") - if optionalButRequiredRaw == "" { - return nil, goahttp.ErrValidationError("ServiceWithHeadersBlockViewedResult", "MethodA", goa.MissingFieldError("optional_but_required", "header")) + if err != nil { + return nil, goahttp.ErrValidationError("ServiceWithHeadersBlockViewedResult", "MethodA", err) } - v, err2 := strconv.ParseUint(optionalButRequiredRaw, 10, strconv.IntSize) - if err2 != nil { - err = goa.MergeErrors(err, goa.InvalidFieldTypeError("optional_but_required", optionalButRequiredRaw, "unsigned integer")) + p := NewMethodAResultTinyOK(required, optional, optionalButRequired) + vres := &servicewithheadersblockviewedresultviews.AResult{Projected: p, View: view} + res := servicewithheadersblockviewedresult.NewAResult(vres) + return res, nil + case "default": + var ( + required int + optional *float32 + optionalButRequired uint + err error + ) + { + requiredRaw := resp.Header.Get("X-Request-Id") + if requiredRaw == "" { + return nil, goahttp.ErrValidationError("ServiceWithHeadersBlockViewedResult", "MethodA", goa.MissingFieldError("required", "header")) + } + v, err2 := strconv.ParseInt(requiredRaw, 10, strconv.IntSize) + if err2 != nil { + err = goa.MergeErrors(err, goa.InvalidFieldTypeError("required", requiredRaw, "integer")) + } + required = int(v) } - optionalButRequired = uint(v) + { + optionalRaw := resp.Header.Get("Authorization") + if optionalRaw != "" { + v, err2 := strconv.ParseFloat(optionalRaw, 32) + if err2 != nil { + err = goa.MergeErrors(err, goa.InvalidFieldTypeError("optional", optionalRaw, "float")) + } + pv := float32(v) + optional = &pv + } + } + { + optionalButRequiredRaw := resp.Header.Get("Location") + if optionalButRequiredRaw == "" { + return nil, goahttp.ErrValidationError("ServiceWithHeadersBlockViewedResult", "MethodA", goa.MissingFieldError("optional_but_required", "header")) + } + v, err2 := strconv.ParseUint(optionalButRequiredRaw, 10, strconv.IntSize) + if err2 != nil { + err = goa.MergeErrors(err, goa.InvalidFieldTypeError("optional_but_required", optionalButRequiredRaw, "unsigned integer")) + } + optionalButRequired = uint(v) + } + if err != nil { + return nil, goahttp.ErrValidationError("ServiceWithHeadersBlockViewedResult", "MethodA", err) + } + p := NewMethodAResultDefaultOK(required, optional, optionalButRequired) + vres := &servicewithheadersblockviewedresultviews.AResult{Projected: p, View: view} + res := servicewithheadersblockviewedresult.NewAResult(vres) + return res, nil + default: + return nil, goahttp.ErrValidationError("ServiceWithHeadersBlockViewedResult", "MethodA", goa.InvalidEnumValueError("view", view, []any{"tiny", "default"})) } + default: + body, err := io.ReadAll(resp.Body) if err != nil { - return nil, goahttp.ErrValidationError("ServiceWithHeadersBlockViewedResult", "MethodA", err) + return nil, goahttp.ErrDecodingError("ServiceWithHeadersBlockViewedResult", "MethodA", err) } - p := NewMethodAAResultOK(required, optional, optionalButRequired) - view := resp.Header.Get("goa-view") - vres := &servicewithheadersblockviewedresultviews.AResult{Projected: p, View: view} - res := servicewithheadersblockviewedresult.NewAResult(vres) - return res, nil - default: - body, _ := io.ReadAll(resp.Body) return nil, goahttp.ErrInvalidResponse("ServiceWithHeadersBlockViewedResult", "MethodA", resp.StatusCode, string(body)) } } diff --git a/http/codegen/testdata/golden/client_decode_with-headers-dsl.go.golden b/http/codegen/testdata/golden/client_decode_with-headers-dsl.go.golden index c80495a8d8..5fbe40cfd4 100644 --- a/http/codegen/testdata/golden/client_decode_with-headers-dsl.go.golden +++ b/http/codegen/testdata/golden/client_decode_with-headers-dsl.go.golden @@ -2,18 +2,24 @@ // ServiceWithHeadersBlock MethodA endpoint. restoreBody controls whether the // response body should be restored after having been read. func DecodeMethodAResponse(decoder func(*http.Response) goahttp.Decoder, restoreBody bool) func(*http.Response) (any, error) { - return func(resp *http.Response) (any, error) { + return func(resp *http.Response) (result any, decodeErr error) { + responseBody := resp.Body if restoreBody { - b, err := io.ReadAll(resp.Body) - if err != nil { - return nil, err + b, readErr := io.ReadAll(responseBody) + closeErr := responseBody.Close() + if err := errors.Join(readErr, closeErr); err != nil { + return nil, goahttp.ErrDecodingError("ServiceWithHeadersBlock", "MethodA", err) } resp.Body = io.NopCloser(bytes.NewBuffer(b)) defer func() { resp.Body = io.NopCloser(bytes.NewBuffer(b)) }() } else { - defer resp.Body.Close() + defer func() { + if err := responseBody.Close(); err != nil { + decodeErr = errors.Join(decodeErr, goahttp.ErrDecodingError("ServiceWithHeadersBlock", "MethodA", err)) + } + }() } switch resp.StatusCode { case http.StatusOK: @@ -62,7 +68,10 @@ func DecodeMethodAResponse(decoder func(*http.Response) goahttp.Decoder, restore res := NewMethodAResultOK(required, optional, optionalButRequired) return res, nil default: - body, _ := io.ReadAll(resp.Body) + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, goahttp.ErrDecodingError("ServiceWithHeadersBlock", "MethodA", err) + } return nil, goahttp.ErrInvalidResponse("ServiceWithHeadersBlock", "MethodA", resp.StatusCode, string(body)) } } diff --git a/http/codegen/testdata/golden/client_encode_body-primitive-field-array-user.go.golden b/http/codegen/testdata/golden/client_encode_body-primitive-field-array-user.go.golden index 767d687dcb..0488f93ba2 100644 --- a/http/codegen/testdata/golden/client_encode_body-primitive-field-array-user.go.golden +++ b/http/codegen/testdata/golden/client_encode_body-primitive-field-array-user.go.golden @@ -7,9 +7,11 @@ func EncodeMethodBodyPrimitiveArrayUserRequest(encoder func(*http.Request) goaht if !ok { return goahttp.ErrInvalidType("ServiceBodyPrimitiveArrayUser", "MethodBodyPrimitiveArrayUser", "*servicebodyprimitivearrayuser.PayloadType", v) } - body := p.A - if err := encoder(req).Encode(&body); err != nil { - return goahttp.ErrEncodingError("ServiceBodyPrimitiveArrayUser", "MethodBodyPrimitiveArrayUser", err) + if p.A != nil { + body := p.A + if err := encoder(req).Encode(&body); err != nil { + return goahttp.ErrEncodingError("ServiceBodyPrimitiveArrayUser", "MethodBodyPrimitiveArrayUser", err) + } } return nil } diff --git a/http/codegen/testdata/golden/client_encode_body-user-nested.go.golden b/http/codegen/testdata/golden/client_encode_body-user-nested.go.golden new file mode 100644 index 0000000000..3e9a9c3f36 --- /dev/null +++ b/http/codegen/testdata/golden/client_encode_body-user-nested.go.golden @@ -0,0 +1,17 @@ +// EncodeMethodBodyUserRequest returns an encoder for requests sent to the +// ServiceBodyUser MethodBodyUser server. +func EncodeMethodBodyUserRequest(encoder func(*http.Request) goahttp.Encoder) func(*http.Request, any) error { + return func(req *http.Request, v any) error { + p, ok := v.(*servicebodyuser.PayloadType) + if !ok { + return goahttp.ErrInvalidType("ServiceBodyUser", "MethodBodyUser", "*servicebodyuser.PayloadType", v) + } + if p.Data != nil { + body := NewMethodBodyUserRequestBody(p) + if err := encoder(req).Encode(&body); err != nil { + return goahttp.ErrEncodingError("ServiceBodyUser", "MethodBodyUser", err) + } + } + return nil + } +} diff --git a/http/codegen/testdata/golden/client_encode_body-user-validate.go.golden b/http/codegen/testdata/golden/client_encode_body-user-validate.go.golden index cd65bd2d92..e18d33bebf 100644 --- a/http/codegen/testdata/golden/client_encode_body-user-validate.go.golden +++ b/http/codegen/testdata/golden/client_encode_body-user-validate.go.golden @@ -6,9 +6,11 @@ func EncodeMethodBodyUserValidateRequest(encoder func(*http.Request) goahttp.Enc if !ok { return goahttp.ErrInvalidType("ServiceBodyUserValidate", "MethodBodyUserValidate", "*servicebodyuservalidate.PayloadType", v) } - body := p.A - if err := encoder(req).Encode(&body); err != nil { - return goahttp.ErrEncodingError("ServiceBodyUserValidate", "MethodBodyUserValidate", err) + if p.A != nil { + body := *p.A + if err := encoder(req).Encode(&body); err != nil { + return goahttp.ErrEncodingError("ServiceBodyUserValidate", "MethodBodyUserValidate", err) + } } return nil } diff --git a/http/codegen/testdata/golden/client_encode_query-array-float32-validate.go.golden b/http/codegen/testdata/golden/client_encode_query-array-float32-validate.go.golden index bc8331e979..55f4313620 100644 --- a/http/codegen/testdata/golden/client_encode_query-array-float32-validate.go.golden +++ b/http/codegen/testdata/golden/client_encode_query-array-float32-validate.go.golden @@ -9,7 +9,7 @@ func EncodeMethodQueryArrayFloat32ValidateRequest(encoder func(*http.Request) go } values := req.URL.Query() for _, value := range p.Q { - valueStr := strconv.FormatFloat(float64(value), 'f', -1, 32) + valueStr := strconv.FormatFloat(float64(value), 'g', -1, 32) values.Add("q", valueStr) } req.URL.RawQuery = values.Encode() diff --git a/http/codegen/testdata/golden/client_encode_query-array-float32.go.golden b/http/codegen/testdata/golden/client_encode_query-array-float32.go.golden index 6589621437..c4c139da56 100644 --- a/http/codegen/testdata/golden/client_encode_query-array-float32.go.golden +++ b/http/codegen/testdata/golden/client_encode_query-array-float32.go.golden @@ -8,7 +8,7 @@ func EncodeMethodQueryArrayFloat32Request(encoder func(*http.Request) goahttp.En } values := req.URL.Query() for _, value := range p.Q { - valueStr := strconv.FormatFloat(float64(value), 'f', -1, 32) + valueStr := strconv.FormatFloat(float64(value), 'g', -1, 32) values.Add("q", valueStr) } req.URL.RawQuery = values.Encode() diff --git a/http/codegen/testdata/golden/client_encode_query-array-float64-validate.go.golden b/http/codegen/testdata/golden/client_encode_query-array-float64-validate.go.golden index b933344476..58d87c3625 100644 --- a/http/codegen/testdata/golden/client_encode_query-array-float64-validate.go.golden +++ b/http/codegen/testdata/golden/client_encode_query-array-float64-validate.go.golden @@ -9,7 +9,7 @@ func EncodeMethodQueryArrayFloat64ValidateRequest(encoder func(*http.Request) go } values := req.URL.Query() for _, value := range p.Q { - valueStr := strconv.FormatFloat(value, 'f', -1, 64) + valueStr := strconv.FormatFloat(value, 'g', -1, 64) values.Add("q", valueStr) } req.URL.RawQuery = values.Encode() diff --git a/http/codegen/testdata/golden/client_encode_query-array-float64.go.golden b/http/codegen/testdata/golden/client_encode_query-array-float64.go.golden index 7a44c90fc7..611043286d 100644 --- a/http/codegen/testdata/golden/client_encode_query-array-float64.go.golden +++ b/http/codegen/testdata/golden/client_encode_query-array-float64.go.golden @@ -8,7 +8,7 @@ func EncodeMethodQueryArrayFloat64Request(encoder func(*http.Request) goahttp.En } values := req.URL.Query() for _, value := range p.Q { - valueStr := strconv.FormatFloat(value, 'f', -1, 64) + valueStr := strconv.FormatFloat(value, 'g', -1, 64) values.Add("q", valueStr) } req.URL.RawQuery = values.Encode() diff --git a/http/codegen/testdata/golden/client_encode_query-array-nested-alias-validate.go.golden b/http/codegen/testdata/golden/client_encode_query-array-nested-alias-validate.go.golden index 263eee9ae4..5797d576e9 100644 --- a/http/codegen/testdata/golden/client_encode_query-array-nested-alias-validate.go.golden +++ b/http/codegen/testdata/golden/client_encode_query-array-nested-alias-validate.go.golden @@ -8,7 +8,7 @@ func EncodeMethodARequest(encoder func(*http.Request) goahttp.Encoder) func(*htt } values := req.URL.Query() for _, value := range p.Array { - valueStr := strconv.FormatFloat(float64(value), 'f', -1, 64) + valueStr := strconv.FormatFloat(float64(value), 'g', -1, 64) values.Add("array", valueStr) } req.URL.RawQuery = values.Encode() diff --git a/http/codegen/testdata/golden/client_encode_query-bool-validate.go.golden b/http/codegen/testdata/golden/client_encode_query-bool-validate.go.golden index 6d1cc0ac23..c5195f8124 100644 --- a/http/codegen/testdata/golden/client_encode_query-bool-validate.go.golden +++ b/http/codegen/testdata/golden/client_encode_query-bool-validate.go.golden @@ -7,7 +7,7 @@ func EncodeMethodQueryBoolValidateRequest(encoder func(*http.Request) goahttp.En return goahttp.ErrInvalidType("ServiceQueryBoolValidate", "MethodQueryBoolValidate", "*servicequeryboolvalidate.MethodQueryBoolValidatePayload", v) } values := req.URL.Query() - values.Add("q", fmt.Sprintf("%v", p.Q)) + values.Add("q", strconv.FormatBool(p.Q)) req.URL.RawQuery = values.Encode() return nil } diff --git a/http/codegen/testdata/golden/client_encode_query-bool.go.golden b/http/codegen/testdata/golden/client_encode_query-bool.go.golden index cdc6afae49..00936be1cd 100644 --- a/http/codegen/testdata/golden/client_encode_query-bool.go.golden +++ b/http/codegen/testdata/golden/client_encode_query-bool.go.golden @@ -8,7 +8,7 @@ func EncodeMethodQueryBoolRequest(encoder func(*http.Request) goahttp.Encoder) f } values := req.URL.Query() if p.Q != nil { - values.Add("q", fmt.Sprintf("%v", *p.Q)) + values.Add("q", strconv.FormatBool(*p.Q)) } req.URL.RawQuery = values.Encode() return nil diff --git a/http/codegen/testdata/golden/client_encode_query-float32-validate.go.golden b/http/codegen/testdata/golden/client_encode_query-float32-validate.go.golden index 72fc208076..3f4ad6f02d 100644 --- a/http/codegen/testdata/golden/client_encode_query-float32-validate.go.golden +++ b/http/codegen/testdata/golden/client_encode_query-float32-validate.go.golden @@ -7,7 +7,7 @@ func EncodeMethodQueryFloat32ValidateRequest(encoder func(*http.Request) goahttp return goahttp.ErrInvalidType("ServiceQueryFloat32Validate", "MethodQueryFloat32Validate", "*servicequeryfloat32validate.MethodQueryFloat32ValidatePayload", v) } values := req.URL.Query() - values.Add("q", fmt.Sprintf("%v", p.Q)) + values.Add("q", strconv.FormatFloat(float64(p.Q), 'g', -1, 32)) req.URL.RawQuery = values.Encode() return nil } diff --git a/http/codegen/testdata/golden/client_encode_query-float32.go.golden b/http/codegen/testdata/golden/client_encode_query-float32.go.golden index f75be80096..e7e1060fe7 100644 --- a/http/codegen/testdata/golden/client_encode_query-float32.go.golden +++ b/http/codegen/testdata/golden/client_encode_query-float32.go.golden @@ -8,7 +8,7 @@ func EncodeMethodQueryFloat32Request(encoder func(*http.Request) goahttp.Encoder } values := req.URL.Query() if p.Q != nil { - values.Add("q", fmt.Sprintf("%v", *p.Q)) + values.Add("q", strconv.FormatFloat(float64(*p.Q), 'g', -1, 32)) } req.URL.RawQuery = values.Encode() return nil diff --git a/http/codegen/testdata/golden/client_encode_query-float64-validate.go.golden b/http/codegen/testdata/golden/client_encode_query-float64-validate.go.golden index 40c55bf7cc..ae96242174 100644 --- a/http/codegen/testdata/golden/client_encode_query-float64-validate.go.golden +++ b/http/codegen/testdata/golden/client_encode_query-float64-validate.go.golden @@ -7,7 +7,7 @@ func EncodeMethodQueryFloat64ValidateRequest(encoder func(*http.Request) goahttp return goahttp.ErrInvalidType("ServiceQueryFloat64Validate", "MethodQueryFloat64Validate", "*servicequeryfloat64validate.MethodQueryFloat64ValidatePayload", v) } values := req.URL.Query() - values.Add("q", fmt.Sprintf("%v", p.Q)) + values.Add("q", strconv.FormatFloat(p.Q, 'g', -1, 64)) req.URL.RawQuery = values.Encode() return nil } diff --git a/http/codegen/testdata/golden/client_encode_query-float64.go.golden b/http/codegen/testdata/golden/client_encode_query-float64.go.golden index 7949ba85ee..532b403c57 100644 --- a/http/codegen/testdata/golden/client_encode_query-float64.go.golden +++ b/http/codegen/testdata/golden/client_encode_query-float64.go.golden @@ -8,7 +8,7 @@ func EncodeMethodQueryFloat64Request(encoder func(*http.Request) goahttp.Encoder } values := req.URL.Query() if p.Q != nil { - values.Add("q", fmt.Sprintf("%v", *p.Q)) + values.Add("q", strconv.FormatFloat(*p.Q, 'g', -1, 64)) } req.URL.RawQuery = values.Encode() return nil diff --git a/http/codegen/testdata/golden/client_encode_query-int-alias-validate.go.golden b/http/codegen/testdata/golden/client_encode_query-int-alias-validate.go.golden index a06d7c3a09..40e75667d2 100644 --- a/http/codegen/testdata/golden/client_encode_query-int-alias-validate.go.golden +++ b/http/codegen/testdata/golden/client_encode_query-int-alias-validate.go.golden @@ -8,13 +8,13 @@ func EncodeMethodARequest(encoder func(*http.Request) goahttp.Encoder) func(*htt } values := req.URL.Query() if p.Int != nil { - values.Add("int", fmt.Sprintf("%v", *p.Int)) + values.Add("int", strconv.Itoa(int(*p.Int))) } if p.Int32 != nil { - values.Add("int32", fmt.Sprintf("%v", *p.Int32)) + values.Add("int32", strconv.FormatInt(int64(*p.Int32), 10)) } if p.Int64 != nil { - values.Add("int64", fmt.Sprintf("%v", *p.Int64)) + values.Add("int64", strconv.FormatInt(int64(*p.Int64), 10)) } req.URL.RawQuery = values.Encode() return nil diff --git a/http/codegen/testdata/golden/client_encode_query-int-alias.go.golden b/http/codegen/testdata/golden/client_encode_query-int-alias.go.golden index e4033a47a7..84ef7aa3a5 100644 --- a/http/codegen/testdata/golden/client_encode_query-int-alias.go.golden +++ b/http/codegen/testdata/golden/client_encode_query-int-alias.go.golden @@ -8,13 +8,13 @@ func EncodeMethodARequest(encoder func(*http.Request) goahttp.Encoder) func(*htt } values := req.URL.Query() if p.Int != nil { - values.Add("int", fmt.Sprintf("%v", *p.Int)) + values.Add("int", strconv.Itoa(int(*p.Int))) } if p.Int32 != nil { - values.Add("int32", fmt.Sprintf("%v", *p.Int32)) + values.Add("int32", strconv.FormatInt(int64(*p.Int32), 10)) } if p.Int64 != nil { - values.Add("int64", fmt.Sprintf("%v", *p.Int64)) + values.Add("int64", strconv.FormatInt(int64(*p.Int64), 10)) } req.URL.RawQuery = values.Encode() return nil diff --git a/http/codegen/testdata/golden/client_encode_query-int-validate.go.golden b/http/codegen/testdata/golden/client_encode_query-int-validate.go.golden index 1d51b83c44..9b07ffec50 100644 --- a/http/codegen/testdata/golden/client_encode_query-int-validate.go.golden +++ b/http/codegen/testdata/golden/client_encode_query-int-validate.go.golden @@ -7,7 +7,7 @@ func EncodeMethodQueryIntValidateRequest(encoder func(*http.Request) goahttp.Enc return goahttp.ErrInvalidType("ServiceQueryIntValidate", "MethodQueryIntValidate", "*servicequeryintvalidate.MethodQueryIntValidatePayload", v) } values := req.URL.Query() - values.Add("q", fmt.Sprintf("%v", p.Q)) + values.Add("q", strconv.Itoa(p.Q)) req.URL.RawQuery = values.Encode() return nil } diff --git a/http/codegen/testdata/golden/client_encode_query-int.go.golden b/http/codegen/testdata/golden/client_encode_query-int.go.golden index cf129af0b3..66ad1cd02d 100644 --- a/http/codegen/testdata/golden/client_encode_query-int.go.golden +++ b/http/codegen/testdata/golden/client_encode_query-int.go.golden @@ -8,7 +8,7 @@ func EncodeMethodQueryIntRequest(encoder func(*http.Request) goahttp.Encoder) fu } values := req.URL.Query() if p.Q != nil { - values.Add("q", fmt.Sprintf("%v", *p.Q)) + values.Add("q", strconv.Itoa(*p.Q)) } req.URL.RawQuery = values.Encode() return nil diff --git a/http/codegen/testdata/golden/client_encode_query-int32-validate.go.golden b/http/codegen/testdata/golden/client_encode_query-int32-validate.go.golden index 6d1ee581d2..8e4e61624c 100644 --- a/http/codegen/testdata/golden/client_encode_query-int32-validate.go.golden +++ b/http/codegen/testdata/golden/client_encode_query-int32-validate.go.golden @@ -7,7 +7,7 @@ func EncodeMethodQueryInt32ValidateRequest(encoder func(*http.Request) goahttp.E return goahttp.ErrInvalidType("ServiceQueryInt32Validate", "MethodQueryInt32Validate", "*servicequeryint32validate.MethodQueryInt32ValidatePayload", v) } values := req.URL.Query() - values.Add("q", fmt.Sprintf("%v", p.Q)) + values.Add("q", strconv.FormatInt(int64(p.Q), 10)) req.URL.RawQuery = values.Encode() return nil } diff --git a/http/codegen/testdata/golden/client_encode_query-int32.go.golden b/http/codegen/testdata/golden/client_encode_query-int32.go.golden index 2da13e32cf..cda9da2969 100644 --- a/http/codegen/testdata/golden/client_encode_query-int32.go.golden +++ b/http/codegen/testdata/golden/client_encode_query-int32.go.golden @@ -8,7 +8,7 @@ func EncodeMethodQueryInt32Request(encoder func(*http.Request) goahttp.Encoder) } values := req.URL.Query() if p.Q != nil { - values.Add("q", fmt.Sprintf("%v", *p.Q)) + values.Add("q", strconv.FormatInt(int64(*p.Q), 10)) } req.URL.RawQuery = values.Encode() return nil diff --git a/http/codegen/testdata/golden/client_encode_query-int64-validate.go.golden b/http/codegen/testdata/golden/client_encode_query-int64-validate.go.golden index 9409b6b703..5b366b0879 100644 --- a/http/codegen/testdata/golden/client_encode_query-int64-validate.go.golden +++ b/http/codegen/testdata/golden/client_encode_query-int64-validate.go.golden @@ -7,7 +7,7 @@ func EncodeMethodQueryInt64ValidateRequest(encoder func(*http.Request) goahttp.E return goahttp.ErrInvalidType("ServiceQueryInt64Validate", "MethodQueryInt64Validate", "*servicequeryint64validate.MethodQueryInt64ValidatePayload", v) } values := req.URL.Query() - values.Add("q", fmt.Sprintf("%v", p.Q)) + values.Add("q", strconv.FormatInt(p.Q, 10)) req.URL.RawQuery = values.Encode() return nil } diff --git a/http/codegen/testdata/golden/client_encode_query-int64.go.golden b/http/codegen/testdata/golden/client_encode_query-int64.go.golden index 98bc713141..24e74a0c4e 100644 --- a/http/codegen/testdata/golden/client_encode_query-int64.go.golden +++ b/http/codegen/testdata/golden/client_encode_query-int64.go.golden @@ -8,7 +8,7 @@ func EncodeMethodQueryInt64Request(encoder func(*http.Request) goahttp.Encoder) } values := req.URL.Query() if p.Q != nil { - values.Add("q", fmt.Sprintf("%v", *p.Q)) + values.Add("q", strconv.FormatInt(*p.Q, 10)) } req.URL.RawQuery = values.Encode() return nil diff --git a/http/codegen/testdata/golden/client_encode_query-map-alias-validate.go.golden b/http/codegen/testdata/golden/client_encode_query-map-alias-validate.go.golden index 1bfa849733..19fe87a03c 100644 --- a/http/codegen/testdata/golden/client_encode_query-map-alias-validate.go.golden +++ b/http/codegen/testdata/golden/client_encode_query-map-alias-validate.go.golden @@ -8,7 +8,7 @@ func EncodeMethodARequest(encoder func(*http.Request) goahttp.Encoder) func(*htt } values := req.URL.Query() for kRaw, value := range p.Map { - k := strconv.FormatFloat(float64(kRaw), 'f', -1, 32) + k := strconv.FormatFloat(float64(kRaw), 'g', -1, 32) key := fmt.Sprintf("map[%s]", k) valueStr := strconv.FormatBool(value) values.Add(key, valueStr) diff --git a/http/codegen/testdata/golden/client_encode_query-map-alias.go.golden b/http/codegen/testdata/golden/client_encode_query-map-alias.go.golden index 0bae2e60fd..d40207f329 100644 --- a/http/codegen/testdata/golden/client_encode_query-map-alias.go.golden +++ b/http/codegen/testdata/golden/client_encode_query-map-alias.go.golden @@ -8,7 +8,7 @@ func EncodeMethodARequest(encoder func(*http.Request) goahttp.Encoder) func(*htt } values := req.URL.Query() for kRaw, value := range p.Map { - k := strconv.FormatFloat(float64(kRaw), 'f', -1, 32) + k := strconv.FormatFloat(float64(kRaw), 'g', -1, 32) key := fmt.Sprintf("map[%s]", k) valueStr := strconv.FormatBool(value) values.Add(key, valueStr) diff --git a/http/codegen/testdata/golden/client_encode_query-uint-validate.go.golden b/http/codegen/testdata/golden/client_encode_query-uint-validate.go.golden index 3784cc4fcc..603b7ff12e 100644 --- a/http/codegen/testdata/golden/client_encode_query-uint-validate.go.golden +++ b/http/codegen/testdata/golden/client_encode_query-uint-validate.go.golden @@ -7,7 +7,7 @@ func EncodeMethodQueryUIntValidateRequest(encoder func(*http.Request) goahttp.En return goahttp.ErrInvalidType("ServiceQueryUIntValidate", "MethodQueryUIntValidate", "*servicequeryuintvalidate.MethodQueryUIntValidatePayload", v) } values := req.URL.Query() - values.Add("q", fmt.Sprintf("%v", p.Q)) + values.Add("q", strconv.FormatUint(uint64(p.Q), 10)) req.URL.RawQuery = values.Encode() return nil } diff --git a/http/codegen/testdata/golden/client_encode_query-uint.go.golden b/http/codegen/testdata/golden/client_encode_query-uint.go.golden index 5541e09192..00f05680e9 100644 --- a/http/codegen/testdata/golden/client_encode_query-uint.go.golden +++ b/http/codegen/testdata/golden/client_encode_query-uint.go.golden @@ -8,7 +8,7 @@ func EncodeMethodQueryUIntRequest(encoder func(*http.Request) goahttp.Encoder) f } values := req.URL.Query() if p.Q != nil { - values.Add("q", fmt.Sprintf("%v", *p.Q)) + values.Add("q", strconv.FormatUint(uint64(*p.Q), 10)) } req.URL.RawQuery = values.Encode() return nil diff --git a/http/codegen/testdata/golden/client_encode_query-uint32-validate.go.golden b/http/codegen/testdata/golden/client_encode_query-uint32-validate.go.golden index 8d749033d1..d2edbd9dac 100644 --- a/http/codegen/testdata/golden/client_encode_query-uint32-validate.go.golden +++ b/http/codegen/testdata/golden/client_encode_query-uint32-validate.go.golden @@ -7,7 +7,7 @@ func EncodeMethodQueryUInt32ValidateRequest(encoder func(*http.Request) goahttp. return goahttp.ErrInvalidType("ServiceQueryUInt32Validate", "MethodQueryUInt32Validate", "*servicequeryuint32validate.MethodQueryUInt32ValidatePayload", v) } values := req.URL.Query() - values.Add("q", fmt.Sprintf("%v", p.Q)) + values.Add("q", strconv.FormatUint(uint64(p.Q), 10)) req.URL.RawQuery = values.Encode() return nil } diff --git a/http/codegen/testdata/golden/client_encode_query-uint32.go.golden b/http/codegen/testdata/golden/client_encode_query-uint32.go.golden index b97ad4710f..17710b93ee 100644 --- a/http/codegen/testdata/golden/client_encode_query-uint32.go.golden +++ b/http/codegen/testdata/golden/client_encode_query-uint32.go.golden @@ -8,7 +8,7 @@ func EncodeMethodQueryUInt32Request(encoder func(*http.Request) goahttp.Encoder) } values := req.URL.Query() if p.Q != nil { - values.Add("q", fmt.Sprintf("%v", *p.Q)) + values.Add("q", strconv.FormatUint(uint64(*p.Q), 10)) } req.URL.RawQuery = values.Encode() return nil diff --git a/http/codegen/testdata/golden/client_encode_query-uint64-validate.go.golden b/http/codegen/testdata/golden/client_encode_query-uint64-validate.go.golden index 42975304c4..0622c17f8f 100644 --- a/http/codegen/testdata/golden/client_encode_query-uint64-validate.go.golden +++ b/http/codegen/testdata/golden/client_encode_query-uint64-validate.go.golden @@ -7,7 +7,7 @@ func EncodeMethodQueryUInt64ValidateRequest(encoder func(*http.Request) goahttp. return goahttp.ErrInvalidType("ServiceQueryUInt64Validate", "MethodQueryUInt64Validate", "*servicequeryuint64validate.MethodQueryUInt64ValidatePayload", v) } values := req.URL.Query() - values.Add("q", fmt.Sprintf("%v", p.Q)) + values.Add("q", strconv.FormatUint(p.Q, 10)) req.URL.RawQuery = values.Encode() return nil } diff --git a/http/codegen/testdata/golden/client_encode_query-uint64.go.golden b/http/codegen/testdata/golden/client_encode_query-uint64.go.golden index 0aaa4166d5..fb2f305cbd 100644 --- a/http/codegen/testdata/golden/client_encode_query-uint64.go.golden +++ b/http/codegen/testdata/golden/client_encode_query-uint64.go.golden @@ -8,7 +8,7 @@ func EncodeMethodQueryUInt64Request(encoder func(*http.Request) goahttp.Encoder) } values := req.URL.Query() if p.Q != nil { - values.Add("q", fmt.Sprintf("%v", *p.Q)) + values.Add("q", strconv.FormatUint(*p.Q, 10)) } req.URL.RawQuery = values.Encode() return nil diff --git a/http/codegen/testdata/golden/client_encode_skip-request-body-header.go.golden b/http/codegen/testdata/golden/client_encode_skip-request-body-header.go.golden new file mode 100644 index 0000000000..754d23d39d --- /dev/null +++ b/http/codegen/testdata/golden/client_encode_skip-request-body-header.go.golden @@ -0,0 +1,16 @@ +// EncodeUploadRequest returns an encoder for requests sent to the +// SkipRequestBodyEncodeDecodeHeader Upload server. +func EncodeUploadRequest(encoder func(*http.Request) goahttp.Encoder) func(*http.Request, any) error { + return func(req *http.Request, v any) error { + data, ok := v.(*skiprequestbodyencodedecodeheader.UploadRequestData) + if !ok { + return goahttp.ErrInvalidType("SkipRequestBodyEncodeDecodeHeader", "Upload", "*skiprequestbodyencodedecodeheader.UploadRequestData", v) + } + p := data.Payload + if p.ContentType != nil { + head := *p.ContentType + req.Header.Set("Content-Type", head) + } + return nil + } +} diff --git a/http/codegen/testdata/golden/client_endpoint_response_body_lifecycle.go.golden b/http/codegen/testdata/golden/client_endpoint_response_body_lifecycle.go.golden new file mode 100644 index 0000000000..4f0cc348b5 --- /dev/null +++ b/http/codegen/testdata/golden/client_endpoint_response_body_lifecycle.go.golden @@ -0,0 +1,80 @@ +// Read returns an endpoint that makes HTTP requests to the body_lifecycle +// service read server. +func (c *Client) Read() goa.Endpoint { + var ( + decodeResponse = DecodeReadResponse(c.decoder, c.RestoreResponseBody) + ) + return func(ctx context.Context, v any) (any, error) { + req, err := c.BuildReadRequest(ctx, v) + if err != nil { + return nil, err + } + resp, err := c.ReadDoer.Do(req) + if err != nil { + return nil, goahttp.ErrRequestError("body_lifecycle", "read", err) + } + return decodeResponse(resp) + } +} + +// Raw returns an endpoint that makes HTTP requests to the body_lifecycle +// service raw server. +func (c *Client) Raw() goa.Endpoint { + var ( + decodeResponse = DecodeRawResponse(c.decoder, c.RestoreResponseBody) + ) + return func(ctx context.Context, v any) (any, error) { + req, err := c.BuildRawRequest(ctx, v) + if err != nil { + return nil, err + } + resp, err := c.RawDoer.Do(req) + if err != nil { + return nil, goahttp.ErrRequestError("body_lifecycle", "raw", err) + } + _, err = decodeResponse(resp) + if err != nil { + if closeErr := resp.Body.Close(); closeErr != nil { + return nil, errors.Join(err, goahttp.ErrDecodingError("body_lifecycle", "raw", closeErr)) + } + return nil, err + } + return &bodylifecycle.RawResponseData{Body: resp.Body}, nil + } +} + +// Watch returns an endpoint that makes HTTP requests to the body_lifecycle +// service watch server. +func (c *Client) Watch() goa.Endpoint { + var ( + decodeResponse = DecodeWatchResponse(c.decoder, c.RestoreResponseBody) + ) + return func(ctx context.Context, v any) (any, error) { + req, err := c.BuildWatchRequest(ctx, v) + if err != nil { + return nil, err + } + // For SSE endpoints, connect and return a stream + resp, err := c.WatchDoer.Do(req) + if err != nil { + return nil, goahttp.ErrRequestError("body_lifecycle", "watch", err) + } + + if resp.StatusCode != http.StatusOK { + // Decode designed errors (the decoder closes the response body). + return decodeResponse(resp) + } + + contentType := resp.Header.Get("Content-Type") + mediaType, _, mediaTypeErr := mime.ParseMediaType(contentType) + if mediaTypeErr != nil || mediaType != "text/event-stream" { + contentTypeErr := fmt.Errorf("unexpected content type: %s (expected text/event-stream)", contentType) + if err := resp.Body.Close(); err != nil { + return nil, errors.Join(contentTypeErr, goahttp.ErrDecodingError("body_lifecycle", "watch", err)) + } + return nil, contentTypeErr + } + + return NewWatchStream(resp, c.decoder), nil + } +} diff --git a/http/codegen/testdata/golden/client_type_file_multiple-services-same-payload-and-result_0.go.golden b/http/codegen/testdata/golden/client_type_file_multiple-services-same-payload-and-result_0.go.golden index ae60cad639..0160eb2162 100644 --- a/http/codegen/testdata/golden/client_type_file_multiple-services-same-payload-and-result_0.go.golden +++ b/http/codegen/testdata/golden/client_type_file_multiple-services-same-payload-and-result_0.go.golden @@ -76,7 +76,7 @@ func ValidateListResponseBody(body *ListResponseBody) (err error) { } // ValidateListSomethingWentWrongResponseBody runs the validations defined on -// list_something_went_wrong_response_body +// ListSomethingWentWrongResponseBody func ValidateListSomethingWentWrongResponseBody(body *ListSomethingWentWrongResponseBody) (err error) { if body.Name == nil { err = goa.MergeErrors(err, goa.MissingFieldError("name", "body")) diff --git a/http/codegen/testdata/golden/client_type_file_multiple-services-same-payload-and-result_1.go.golden b/http/codegen/testdata/golden/client_type_file_multiple-services-same-payload-and-result_1.go.golden index cd45f6401a..66d816b2f2 100644 --- a/http/codegen/testdata/golden/client_type_file_multiple-services-same-payload-and-result_1.go.golden +++ b/http/codegen/testdata/golden/client_type_file_multiple-services-same-payload-and-result_1.go.golden @@ -76,7 +76,7 @@ func ValidateListResponseBody(body *ListResponseBody) (err error) { } // ValidateListSomethingWentWrongResponseBody runs the validations defined on -// list_something_went_wrong_response_body +// ListSomethingWentWrongResponseBody func ValidateListSomethingWentWrongResponseBody(body *ListSomethingWentWrongResponseBody) (err error) { if body.Name == nil { err = goa.MergeErrors(err, goa.MissingFieldError("name", "body")) diff --git a/http/codegen/testdata/golden/client_types_client-multiple-methods-with-array-type-payloads.go.golden b/http/codegen/testdata/golden/client_types_client-multiple-methods-with-array-type-payloads.go.golden index 4c4bf9af70..1b8a0d841a 100644 --- a/http/codegen/testdata/golden/client_types_client-multiple-methods-with-array-type-payloads.go.golden +++ b/http/codegen/testdata/golden/client_types_client-multiple-methods-with-array-type-payloads.go.golden @@ -37,8 +37,7 @@ func NewPayloadBRequestBody(p []*servicemultiplemethods.PayloadB) []*PayloadBReq return body } -// ValidatePayloadARequestBody runs the validations defined on -// PayloadARequestBody +// ValidatePayloadARequestBody runs the validations defined on PayloadA func ValidatePayloadARequestBody(body *PayloadARequestBody) (err error) { if body.A != nil { err = goa.MergeErrors(err, goa.ValidatePattern("body.a", *body.A, "patterna")) @@ -46,8 +45,7 @@ func ValidatePayloadARequestBody(body *PayloadARequestBody) (err error) { return } -// ValidatePayloadBRequestBody runs the validations defined on -// PayloadBRequestBody +// ValidatePayloadBRequestBody runs the validations defined on PayloadB func ValidatePayloadBRequestBody(body *PayloadBRequestBody) (err error) { err = goa.MergeErrors(err, goa.ValidatePattern("body.a", body.A, "patterna")) err = goa.MergeErrors(err, goa.ValidatePattern("body.b", body.B, "patternb")) diff --git a/http/codegen/testdata/golden/client_types_client-multiple-methods.go.golden b/http/codegen/testdata/golden/client_types_client-multiple-methods.go.golden index 46d896109a..f6a9ffa29d 100644 --- a/http/codegen/testdata/golden/client_types_client-multiple-methods.go.golden +++ b/http/codegen/testdata/golden/client_types_client-multiple-methods.go.golden @@ -39,11 +39,19 @@ func NewMethodBRequestBody(p *servicemultiplemethods.PayloadType) *MethodBReques return body } -// ValidateAPayloadRequestBody runs the validations defined on -// APayloadRequestBody +// ValidateAPayloadRequestBody runs the validations defined on APayload func ValidateAPayloadRequestBody(body *APayloadRequestBody) (err error) { if body.A != nil { err = goa.MergeErrors(err, goa.ValidatePattern("body.a", *body.A, "patterna")) } return } + +// validateAPayloadRequestBody checks APayload and reports errors using the +// path supplied by its caller +func validateAPayloadRequestBody(body *APayloadRequestBody, path string) (err error) { + if body.A != nil { + err = goa.MergeErrors(err, goa.ValidatePattern(path+".a", *body.A, "patterna")) + } + return +} diff --git a/http/codegen/testdata/golden/client_types_client-required-primitive-arrays.go.golden b/http/codegen/testdata/golden/client_types_client-required-primitive-arrays.go.golden new file mode 100644 index 0000000000..5e75b8f1aa --- /dev/null +++ b/http/codegen/testdata/golden/client_types_client-required-primitive-arrays.go.golden @@ -0,0 +1,76 @@ +// StoreRequestBody is the type of the "RequiredArrays" service "Store" +// endpoint HTTP request body. +type StoreRequestBody struct { + Names []string `form:"names" json:"names" xml:"names"` + Aliases []string `form:"aliases" json:"aliases" xml:"aliases"` +} + +// StoreResponseBody is the type of the "RequiredArrays" service "Store" +// endpoint HTTP response body. +type StoreResponseBody struct { + Names []*string `form:"names,omitempty" json:"names,omitempty" xml:"names,omitempty"` + Aliases []*string `form:"aliases,omitempty" json:"aliases,omitempty" xml:"aliases,omitempty"` +} + +// NewStoreRequestBody builds the HTTP request body from the payload of the +// "Store" endpoint of the "RequiredArrays" service. +func NewStoreRequestBody(p *requiredarrays.StorePayload) *StoreRequestBody { + body := &StoreRequestBody{} + if p.Names != nil { + body.Names = make([]string, len(p.Names)) + for i, val := range p.Names { + body.Names[i] = val + } + } else { + body.Names = []string{} + } + if p.Aliases != nil { + body.Aliases = make([]string, len(p.Aliases)) + for i, val := range p.Aliases { + body.Aliases[i] = string(val) + } + } else { + body.Aliases = []string{} + } + return body +} + +// NewStoreResultOK builds a "RequiredArrays" service "Store" endpoint result +// from a HTTP "OK" response. +func NewStoreResultOK(body *StoreResponseBody) *requiredarrays.StoreResult { + v := &requiredarrays.StoreResult{} + v.Names = make([]string, len(body.Names)) + for i, val := range body.Names { + v.Names[i] = *val + } + v.Aliases = make([]requiredarrays.RequiredArrayAlias, len(body.Aliases)) + for i, val := range body.Aliases { + v.Aliases[i] = requiredarrays.RequiredArrayAlias(*val) + } + + return v +} + +// ValidateStoreResponseBody runs the validations defined on StoreResponseBody +func ValidateStoreResponseBody(body *StoreResponseBody) (err error) { + if body.Names == nil { + err = goa.MergeErrors(err, goa.MissingFieldError("names", "body")) + } + if body.Aliases == nil { + err = goa.MergeErrors(err, goa.MissingFieldError("aliases", "body")) + } + for _, e := range body.Names { + if e == nil { + err = goa.MergeErrors(err, goa.MissingFieldError("body.names", "[*]")) + } + } + for _, e := range body.Aliases { + if e == nil { + err = goa.MergeErrors(err, goa.MissingFieldError("body.aliases", "[*]")) + } + if e != nil { + err = goa.MergeErrors(err, goa.ValidatePattern("body.aliases[*]", *e, "^[a-z]+$")) + } + } + return +} diff --git a/http/codegen/testdata/golden/client_types_client-status-tag-required.go.golden b/http/codegen/testdata/golden/client_types_client-status-tag-required.go.golden new file mode 100644 index 0000000000..8e353f3f53 --- /dev/null +++ b/http/codegen/testdata/golden/client_types_client-status-tag-required.go.golden @@ -0,0 +1,58 @@ +// MethodStatusTagRequiredCreatedResponseBody is the type of the +// "ServiceStatusTagRequired" service "MethodStatusTagRequired" endpoint HTTP +// response body. +type MethodStatusTagRequiredCreatedResponseBody struct { + Message *string `form:"message,omitempty" json:"message,omitempty" xml:"message,omitempty"` +} + +// MethodStatusTagRequiredOKResponseBody is the type of the +// "ServiceStatusTagRequired" service "MethodStatusTagRequired" endpoint HTTP +// response body. +type MethodStatusTagRequiredOKResponseBody struct { + Message *string `form:"message,omitempty" json:"message,omitempty" xml:"message,omitempty"` + Outcome *string `form:"outcome,omitempty" json:"outcome,omitempty" xml:"outcome,omitempty"` +} + +// NewMethodStatusTagRequiredStatusTagResultCreated builds a +// "ServiceStatusTagRequired" service "MethodStatusTagRequired" endpoint result +// from a HTTP "Created" response. +func NewMethodStatusTagRequiredStatusTagResultCreated(body *MethodStatusTagRequiredCreatedResponseBody) *servicestatustagrequiredviews.StatusTagResultView { + v := &servicestatustagrequiredviews.StatusTagResultView{ + Message: body.Message, + } + + return v +} + +// NewMethodStatusTagRequiredStatusTagResultOK builds a +// "ServiceStatusTagRequired" service "MethodStatusTagRequired" endpoint result +// from a HTTP "OK" response. +func NewMethodStatusTagRequiredStatusTagResultOK(body *MethodStatusTagRequiredOKResponseBody) *servicestatustagrequiredviews.StatusTagResultView { + v := &servicestatustagrequiredviews.StatusTagResultView{ + Message: body.Message, + Outcome: body.Outcome, + } + + return v +} + +// ValidateMethodStatusTagRequiredCreatedResponseBody runs the validations +// defined on MethodStatusTagRequiredCreatedResponseBody +func ValidateMethodStatusTagRequiredCreatedResponseBody(body *MethodStatusTagRequiredCreatedResponseBody) (err error) { + if body.Message == nil { + err = goa.MergeErrors(err, goa.MissingFieldError("message", "body")) + } + return +} + +// ValidateMethodStatusTagRequiredOKResponseBody runs the validations defined +// on MethodStatusTagRequiredOKResponseBody +func ValidateMethodStatusTagRequiredOKResponseBody(body *MethodStatusTagRequiredOKResponseBody) (err error) { + if body.Message == nil { + err = goa.MergeErrors(err, goa.MissingFieldError("message", "body")) + } + if body.Outcome == nil { + err = goa.MergeErrors(err, goa.MissingFieldError("outcome", "body")) + } + return +} diff --git a/http/codegen/testdata/golden/client_types_client-with-error-custom-pkg.go.golden b/http/codegen/testdata/golden/client_types_client-with-error-custom-pkg.go.golden index 9d47f2ddc7..95cc3a5ee5 100644 --- a/http/codegen/testdata/golden/client_types_client-with-error-custom-pkg.go.golden +++ b/http/codegen/testdata/golden/client_types_client-with-error-custom-pkg.go.golden @@ -16,7 +16,7 @@ func NewMethodWithErrorCustomPkgErrorName(body *MethodWithErrorCustomPkgErrorNam } // ValidateMethodWithErrorCustomPkgErrorNameResponseBody runs the validations -// defined on MethodWithErrorCustomPkg_error_name_Response_Body +// defined on MethodWithErrorCustomPkgErrorNameResponseBody func ValidateMethodWithErrorCustomPkgErrorNameResponseBody(body *MethodWithErrorCustomPkgErrorNameResponseBody) (err error) { if body.Name == nil { err = goa.MergeErrors(err, goa.MissingFieldError("name", "body")) diff --git a/http/codegen/testdata/golden/client_types_client-with-result-collection.go.golden b/http/codegen/testdata/golden/client_types_client-with-result-collection.go.golden index 69eb2409af..cd791479fe 100644 --- a/http/codegen/testdata/golden/client_types_client-with-result-collection.go.golden +++ b/http/codegen/testdata/golden/client_types_client-with-result-collection.go.golden @@ -34,30 +34,39 @@ func NewMethodResultWithResultCollectionResultOK(body *MethodResultWithResultCol // defined on MethodResultWithResultCollectionResponseBody func ValidateMethodResultWithResultCollectionResponseBody(body *MethodResultWithResultCollectionResponseBody) (err error) { if body.A != nil { - if err2 := ValidateResulttypeResponseBody(body.A); err2 != nil { + if err2 := validateResulttypeResponseBody(body.A, "body.a"); err2 != nil { err = goa.MergeErrors(err, err2) } } return } -// ValidateResulttypeResponseBody runs the validations defined on -// ResulttypeResponseBody +// ValidateResulttypeResponseBody runs the validations defined on Resulttype func ValidateResulttypeResponseBody(body *ResulttypeResponseBody) (err error) { if body.X != nil { - if err2 := ValidateRtCollectionResponseBody(body.X); err2 != nil { + if err2 := validateRtCollectionResponseBody(body.X, "body.x"); err2 != nil { err = goa.MergeErrors(err, err2) } } return } -// ValidateRtCollectionResponseBody runs the validations defined on -// RtCollectionResponseBody +// validateResulttypeResponseBody checks Resulttype and reports errors using +// the path supplied by its caller +func validateResulttypeResponseBody(body *ResulttypeResponseBody, path string) (err error) { + if body.X != nil { + if err2 := validateRtCollectionResponseBody(body.X, path+".x"); err2 != nil { + err = goa.MergeErrors(err, err2) + } + } + return +} + +// ValidateRtCollectionResponseBody runs the validations defined on RtCollection func ValidateRtCollectionResponseBody(body RtCollectionResponseBody) (err error) { for _, e := range body { if e != nil { - if err2 := ValidateRtResponseBody(e); err2 != nil { + if err2 := validateRtResponseBody(e, "body[*]"); err2 != nil { err = goa.MergeErrors(err, err2) } } @@ -65,7 +74,20 @@ func ValidateRtCollectionResponseBody(body RtCollectionResponseBody) (err error) return } -// ValidateRtResponseBody runs the validations defined on RtResponseBody +// validateRtCollectionResponseBody checks RtCollection and reports errors +// using the path supplied by its caller +func validateRtCollectionResponseBody(body RtCollectionResponseBody, path string) (err error) { + for _, e := range body { + if e != nil { + if err2 := validateRtResponseBody(e, path+"[*]"); err2 != nil { + err = goa.MergeErrors(err, err2) + } + } + } + return +} + +// ValidateRtResponseBody runs the validations defined on Rt func ValidateRtResponseBody(body *RtResponseBody) (err error) { if body.X != nil { if utf8.RuneCountInString(*body.X) < 5 { @@ -74,3 +96,14 @@ func ValidateRtResponseBody(body *RtResponseBody) (err error) { } return } + +// validateRtResponseBody checks Rt and reports errors using the path supplied +// by its caller +func validateRtResponseBody(body *RtResponseBody, path string) (err error) { + if body.X != nil { + if utf8.RuneCountInString(*body.X) < 5 { + err = goa.MergeErrors(err, goa.InvalidLengthError(path+".x", *body.X, utf8.RuneCountInString(*body.X), 5, true)) + } + } + return +} diff --git a/http/codegen/testdata/golden/http_union_branch_order.go.golden b/http/codegen/testdata/golden/http_union_branch_order.go.golden new file mode 100644 index 0000000000..d22393a4fb --- /dev/null +++ b/http/codegen/testdata/golden/http_union_branch_order.go.golden @@ -0,0 +1,375 @@ +// WithEarlierSnapshotResponseBody is the type of the "values" service +// "with_earlier_snapshot" endpoint HTTP response body. +type WithEarlierSnapshotResponseBody struct { + Snapshot *SnapshotResponseBody `form:"snapshot,omitempty" json:"snapshot,omitempty" xml:"snapshot,omitempty"` + Edit *EditResponseBody `form:"edit,omitempty" json:"edit,omitempty" xml:"edit,omitempty"` +} + +// WithEditOnlyResponseBody is the type of the "values" service +// "with_edit_only" endpoint HTTP response body. +type WithEditOnlyResponseBody struct { + Edit *EditResponseBody `form:"edit,omitempty" json:"edit,omitempty" xml:"edit,omitempty"` +} + +// SnapshotResponseBody is used to define fields on response body types. +type SnapshotResponseBody struct { + ID *string `form:"id,omitempty" json:"id,omitempty" xml:"id,omitempty"` + Labels []*LabelResponseBody `form:"labels,omitempty" json:"labels,omitempty" xml:"labels,omitempty"` +} + +// LabelResponseBody is used to define fields on response body types. +type LabelResponseBody struct { + Value *string `form:"value,omitempty" json:"value,omitempty" xml:"value,omitempty"` +} + +// EditResponseBody is used to define fields on response body types. +type EditResponseBody struct { + Relationship *RelationshipResponseBody `form:"relationship,omitempty" json:"relationship,omitempty" xml:"relationship,omitempty"` +} + +// NoEditResponseBody is used to define fields on response body types. +type NoEditResponseBody struct { +} + +// EditableResponseBody is used to define fields on response body types. +type EditableResponseBody struct { + Snapshot *SnapshotResponseBody `form:"snapshot,omitempty" json:"snapshot,omitempty" xml:"snapshot,omitempty"` +} + +// RelationshipResponseBody holds exactly one of its branch values. +type RelationshipResponseBody struct { + kind RelationshipResponseBodyKind + none *NoEditResponseBody + editable *EditableResponseBody +} + +// RelationshipResponseBodyKind records which RelationshipResponseBody branch is selected. +type RelationshipResponseBodyKind string + +const ( + // RelationshipResponseBodyKindNone identifies the none branch. + RelationshipResponseBodyKindNone RelationshipResponseBodyKind = "none" + // RelationshipResponseBodyKindEditable identifies the editable branch. + RelationshipResponseBodyKindEditable RelationshipResponseBodyKind = "editable" +) + +// Kind returns the selected branch. +func (u RelationshipResponseBody) Kind() RelationshipResponseBodyKind { + return u.kind +} + +// NewRelationshipResponseBodyNone constructs RelationshipResponseBody with the none branch set. +func NewRelationshipResponseBodyNone(v *NoEditResponseBody) RelationshipResponseBody { + return RelationshipResponseBody{ + kind: RelationshipResponseBodyKindNone, + none: v, + } +} + +// AsNone returns the value when the none branch is selected. +func (u RelationshipResponseBody) AsNone() (_ *NoEditResponseBody, ok bool) { + if u.kind != RelationshipResponseBodyKindNone { + return + } + return u.none, true +} + +// SetNone selects the none branch and stores v. +func (u *RelationshipResponseBody) SetNone(v *NoEditResponseBody) { + *u = RelationshipResponseBody{ + kind: RelationshipResponseBodyKindNone, + none: v, + } +} + +// NewRelationshipResponseBodyEditable constructs RelationshipResponseBody with the editable branch set. +func NewRelationshipResponseBodyEditable(v *EditableResponseBody) RelationshipResponseBody { + return RelationshipResponseBody{ + kind: RelationshipResponseBodyKindEditable, + editable: v, + } +} + +// AsEditable returns the value when the editable branch is selected. +func (u RelationshipResponseBody) AsEditable() (_ *EditableResponseBody, ok bool) { + if u.kind != RelationshipResponseBodyKindEditable { + return + } + return u.editable, true +} + +// SetEditable selects the editable branch and stores v. +func (u *RelationshipResponseBody) SetEditable(v *EditableResponseBody) { + *u = RelationshipResponseBody{ + kind: RelationshipResponseBodyKindEditable, + editable: v, + } +} + +// Validate ensures exactly one valid branch is selected. +func (u RelationshipResponseBody) Validate() error { + switch u.kind { + case "": + return goa.InvalidEnumValueError("type", "", []any{ + string(RelationshipResponseBodyKindNone), + string(RelationshipResponseBodyKindEditable), + }) + case RelationshipResponseBodyKindNone: + if u.none == nil { + return goa.MissingFieldError("value", "RelationshipResponseBody") + } + return nil + case RelationshipResponseBodyKindEditable: + if u.editable == nil { + return goa.MissingFieldError("value", "RelationshipResponseBody") + } + return nil + default: + return goa.InvalidEnumValueError("type", u.kind, []any{ + string(RelationshipResponseBodyKindNone), + string(RelationshipResponseBodyKindEditable), + }) + } +} + +// MarshalJSON marshals the union into the canonical {type,value} JSON shape. +func (u RelationshipResponseBody) MarshalJSON() ([]byte, error) { + if err := u.Validate(); err != nil { + return nil, err + } + var ( + value any + ) + switch u.kind { + case RelationshipResponseBodyKindNone: + value = u.none + case RelationshipResponseBodyKindEditable: + value = u.editable + default: + return nil, fmt.Errorf("unexpected RelationshipResponseBody kind %q", u.kind) + } + return json.Marshal(struct { + Type string `json:"type"` + Value any `json:"value"` + }{ + Type: string(u.kind), + Value: value, + }) +} + +// UnmarshalJSON unmarshals the union from the canonical {type,value} JSON shape. +func (u *RelationshipResponseBody) UnmarshalJSON(data []byte) error { + var raw struct { + Type string `json:"type"` + Value json.RawMessage `json:"value"` + } + if err := json.Unmarshal(data, &raw); err != nil { + return err + } + if len(raw.Value) == 0 { + return goa.MissingFieldError("value", "RelationshipResponseBody") + } + if bytes.Equal(bytes.TrimSpace(raw.Value), []byte("null")) { + return goa.InvalidFieldTypeError("value", nil, "non-null JSON value") + } + switch raw.Type { + case string(RelationshipResponseBodyKindNone): + var v *NoEditResponseBody + if err := json.Unmarshal(raw.Value, &v); err != nil { + return err + } + u.SetNone(v) + case string(RelationshipResponseBodyKindEditable): + var v *EditableResponseBody + if err := json.Unmarshal(raw.Value, &v); err != nil { + return err + } + u.SetEditable(v) + default: + if raw.Type == "" { + return goa.MissingFieldError("type", "RelationshipResponseBody") + } + return goa.InvalidEnumValueError("type", raw.Type, []any{ + string(RelationshipResponseBodyKindNone), + string(RelationshipResponseBodyKindEditable), + }) + } + return nil +} + +// NewWithEarlierSnapshotWithEarlierSnapshotOK builds a "values" service +// "with_earlier_snapshot" endpoint result from a HTTP "OK" response. +func NewWithEarlierSnapshotWithEarlierSnapshotOK(body *WithEarlierSnapshotResponseBody) *values.WithEarlierSnapshot { + v := &values.WithEarlierSnapshot{} + v.Snapshot = unmarshalSnapshotResponseBodyToValuesSnapshot(body.Snapshot) + v.Edit = unmarshalEditResponseBodyToValuesEdit(body.Edit) + + return v +} + +// NewWithEditOnlyWithEditOnlyOK builds a "values" service "with_edit_only" +// endpoint result from a HTTP "OK" response. +func NewWithEditOnlyWithEditOnlyOK(body *WithEditOnlyResponseBody) *values.WithEditOnly { + v := &values.WithEditOnly{} + v.Edit = unmarshalEditResponseBodyToValuesEdit(body.Edit) + + return v +} + +// ValidateWithEarlierSnapshotResponseBody runs the validations defined on +// WithEarlierSnapshotResponseBody +func ValidateWithEarlierSnapshotResponseBody(body *WithEarlierSnapshotResponseBody) (err error) { + if body.Snapshot == nil { + err = goa.MergeErrors(err, goa.MissingFieldError("snapshot", "body")) + } + if body.Edit == nil { + err = goa.MergeErrors(err, goa.MissingFieldError("edit", "body")) + } + if body.Snapshot != nil { + if err2 := validateSnapshotResponseBody(body.Snapshot, "body.snapshot"); err2 != nil { + err = goa.MergeErrors(err, err2) + } + } + if body.Edit != nil { + if err2 := validateEditResponseBody(body.Edit, "body.edit"); err2 != nil { + err = goa.MergeErrors(err, err2) + } + } + return +} + +// ValidateWithEditOnlyResponseBody runs the validations defined on +// WithEditOnlyResponseBody +func ValidateWithEditOnlyResponseBody(body *WithEditOnlyResponseBody) (err error) { + if body.Edit == nil { + err = goa.MergeErrors(err, goa.MissingFieldError("edit", "body")) + } + if body.Edit != nil { + if err2 := validateEditResponseBody(body.Edit, "body.edit"); err2 != nil { + err = goa.MergeErrors(err, err2) + } + } + return +} + +// ValidateSnapshotResponseBody runs the validations defined on Snapshot +func ValidateSnapshotResponseBody(body *SnapshotResponseBody) (err error) { + if body.ID == nil { + err = goa.MergeErrors(err, goa.MissingFieldError("id", "body")) + } + if body.Labels == nil { + err = goa.MergeErrors(err, goa.MissingFieldError("labels", "body")) + } + for _, e := range body.Labels { + if e != nil { + if err2 := validateLabelResponseBody(e, "body.labels[*]"); err2 != nil { + err = goa.MergeErrors(err, err2) + } + } + } + return +} + +// validateSnapshotResponseBody checks Snapshot and reports errors using the +// path supplied by its caller +func validateSnapshotResponseBody(body *SnapshotResponseBody, path string) (err error) { + if body.ID == nil { + err = goa.MergeErrors(err, goa.MissingFieldError("id", path)) + } + if body.Labels == nil { + err = goa.MergeErrors(err, goa.MissingFieldError("labels", path)) + } + for _, e := range body.Labels { + if e != nil { + if err2 := validateLabelResponseBody(e, path+".labels[*]"); err2 != nil { + err = goa.MergeErrors(err, err2) + } + } + } + return +} + +// ValidateLabelResponseBody runs the validations defined on Label +func ValidateLabelResponseBody(body *LabelResponseBody) (err error) { + if body.Value == nil { + err = goa.MergeErrors(err, goa.MissingFieldError("value", "body")) + } + return +} + +// validateLabelResponseBody checks Label and reports errors using the path +// supplied by its caller +func validateLabelResponseBody(body *LabelResponseBody, path string) (err error) { + if body.Value == nil { + err = goa.MergeErrors(err, goa.MissingFieldError("value", path)) + } + return +} + +// ValidateEditResponseBody runs the validations defined on Edit +func ValidateEditResponseBody(body *EditResponseBody) (err error) { + if body.Relationship == nil { + err = goa.MergeErrors(err, goa.MissingFieldError("relationship", "body")) + } + if body.Relationship != nil { + switch string(body.Relationship.Kind()) { + case "editable": + actual, _ := body.Relationship.AsEditable() + if actual != nil { + if err2 := validateEditableResponseBody(actual, "body.relationship.value"); err2 != nil { + err = goa.MergeErrors(err, err2) + } + } + } + + } + return +} + +// validateEditResponseBody checks Edit and reports errors using the path +// supplied by its caller +func validateEditResponseBody(body *EditResponseBody, path string) (err error) { + if body.Relationship == nil { + err = goa.MergeErrors(err, goa.MissingFieldError("relationship", path)) + } + if body.Relationship != nil { + switch string(body.Relationship.Kind()) { + case "editable": + actual, _ := body.Relationship.AsEditable() + if actual != nil { + if err2 := validateEditableResponseBody(actual, path+".relationship.value"); err2 != nil { + err = goa.MergeErrors(err, err2) + } + } + } + + } + return +} + +// ValidateEditableResponseBody runs the validations defined on Editable +func ValidateEditableResponseBody(body *EditableResponseBody) (err error) { + if body.Snapshot == nil { + err = goa.MergeErrors(err, goa.MissingFieldError("snapshot", "body")) + } + if body.Snapshot != nil { + if err2 := validateSnapshotResponseBody(body.Snapshot, "body.snapshot"); err2 != nil { + err = goa.MergeErrors(err, err2) + } + } + return +} + +// validateEditableResponseBody checks Editable and reports errors using the +// path supplied by its caller +func validateEditableResponseBody(body *EditableResponseBody, path string) (err error) { + if body.Snapshot == nil { + err = goa.MergeErrors(err, goa.MissingFieldError("snapshot", path)) + } + if body.Snapshot != nil { + if err2 := validateSnapshotResponseBody(body.Snapshot, path+".snapshot"); err2 != nil { + err = goa.MergeErrors(err, err2) + } + } + return +} diff --git a/http/codegen/testdata/golden/planned_jsonrpc_validator_collisions.go.golden b/http/codegen/testdata/golden/planned_jsonrpc_validator_collisions.go.golden new file mode 100644 index 0000000000..3ef2477c0b --- /dev/null +++ b/http/codegen/testdata/golden/planned_jsonrpc_validator_collisions.go.golden @@ -0,0 +1,49 @@ +// ChooseRequestBody2 is the type of the "Names" service "Choose" endpoint HTTP +// request body. +type ChooseRequestBody2 struct { + Value *string `form:"value,omitempty" json:"value,omitempty" xml:"value,omitempty"` +} + +// ValidateChooseRequestBody2 runs the validations defined on ChooseRequestBody2 +func ValidateChooseRequestBody2(body *ChooseRequestBody2) (err error) { + if body.Value == nil { + err = goa.MergeErrors(err, goa.MissingFieldError("value", "body")) + } + if body.Value != nil { + if utf8.RuneCountInString(*body.Value) < 2 { + err = goa.MergeErrors(err, goa.InvalidLengthError("body.value", *body.Value, utf8.RuneCountInString(*body.Value), 2, true)) + } + } + return +} + +// DecodeChooseRequest2 returns a decoder for requests sent to the Names Choose +// endpoint. +func DecodeChooseRequest2(mux goahttp.Muxer, decoder func(*http.Request) goahttp.Decoder) func(*http.Request, *jsonrpc.RawRequest) (*names.ChoosePayload, error) { + return func(r *http.Request, req *jsonrpc.RawRequest) (*names.ChoosePayload, error) { + r.Body = io.NopCloser(bytes.NewReader(req.Params)) + var payload *names.ChoosePayload + var ( + body ChooseRequestBody2 + err error + ) + err = decoder(r).Decode(&body) + if err != nil { + if errors.Is(err, io.EOF) { + return payload, goa.MissingPayloadError() + } + var gerr *goa.ServiceError + if errors.As(err, &gerr) { + return payload, gerr + } + return payload, goa.DecodePayloadError(err.Error()) + } + err = ValidateChooseRequestBody2(&body) + if err != nil { + return payload, err + } + payload = NewChoosePayload(&body) + + return payload, nil + } +} diff --git a/http/codegen/testdata/golden/planned_name_collisions.go.golden b/http/codegen/testdata/golden/planned_name_collisions.go.golden new file mode 100644 index 0000000000..e463e477fb --- /dev/null +++ b/http/codegen/testdata/golden/planned_name_collisions.go.golden @@ -0,0 +1,189 @@ +// BuildCompleteRequest2 instantiates a HTTP request object with method and +// path set to call the "Names" service "Complete" endpoint +func (c *Client) BuildCompleteRequest2(ctx context.Context, v any) (*http.Request, error) { + u := &url.URL{Scheme: c.scheme, Host: c.host, Path: CompleteNamesPath()} + req, err := http.NewRequest("POST", u.String(), nil) + if err != nil { + return nil, goahttp.ErrInvalidURL("Names", "Complete", u.String(), err) + } + if ctx != nil { + req = req.WithContext(ctx) + } + + return req, nil +} + +// Complete returns an endpoint that makes HTTP requests to the Names service +// Complete server. +func (c *Client) Complete() goa.Endpoint { + var ( + encodeRequest = EncodeCompleteRequest(c.encoder) + decodeResponse = DecodeCompleteResponse(c.decoder, c.RestoreResponseBody) + ) + return func(ctx context.Context, v any) (any, error) { + req, err := c.BuildCompleteRequest2(ctx, v) + if err != nil { + return nil, err + } + err = encodeRequest(req, v) + if err != nil { + return nil, err + } + resp, err := c.CompleteDoer.Do(req) + if err != nil { + return nil, goahttp.ErrRequestError("Names", "Complete", err) + } + return decodeResponse(resp) + } +} + +// ChildPayloadRequestBody2 is used to define fields on request body types. +type ChildPayloadRequestBody2 struct { + Value *string `form:"value,omitempty" json:"value,omitempty" xml:"value,omitempty"` +} + +// ValidateCompleteRequestBody runs the validations defined on +// CompleteRequestBody +func ValidateCompleteRequestBody(body *CompleteRequestBody) (err error) { + if body.Child == nil { + err = goa.MergeErrors(err, goa.MissingFieldError("child", "body")) + } + if body.Child != nil { + if err2 := validateChildPayloadRequestBody2(body.Child, "body.child"); err2 != nil { + err = goa.MergeErrors(err, err2) + } + } + return +} + +// ValidateChildPayloadRequestBody2 runs the validations defined on ChildPayload +func ValidateChildPayloadRequestBody2(body *ChildPayloadRequestBody2) (err error) { + if body.Value == nil { + err = goa.MergeErrors(err, goa.MissingFieldError("value", "body")) + } + if body.Value != nil { + err = goa.MergeErrors(err, goa.ValidatePattern("body.value", *body.Value, "value")) + } + return +} + +// validateChildPayloadRequestBody2 checks ChildPayload and reports errors +// using the path supplied by its caller +func validateChildPayloadRequestBody2(body *ChildPayloadRequestBody2, path string) (err error) { + if body.Value == nil { + err = goa.MergeErrors(err, goa.MissingFieldError("value", path)) + } + if body.Value != nil { + err = goa.MergeErrors(err, goa.ValidatePattern(path+".value", *body.Value, "value")) + } + return +} + +// DecodeCompleteRequest returns a decoder for requests sent to the Names +// Complete endpoint. +func DecodeCompleteRequest(mux goahttp.Muxer, decoder func(*http.Request) goahttp.Decoder) func(*http.Request) (*names.CompletePayload, error) { + return func(r *http.Request) (*names.CompletePayload, error) { + var payload *names.CompletePayload + var ( + body CompleteRequestBody + err error + ) + err = decoder(r).Decode(&body) + if err != nil { + if errors.Is(err, io.EOF) { + return payload, goa.MissingPayloadError() + } + var gerr *goa.ServiceError + if errors.As(err, &gerr) { + return payload, gerr + } + return payload, goa.DecodePayloadError(err.Error()) + } + err = ValidateCompleteRequestBody(&body) + if err != nil { + return payload, err + } + payload = NewCompletePayload(&body) + + return payload, nil + } +} + +// SocketServerStream2 implements the names.SocketServerStream interface. +type SocketServerStream2 struct { + once sync.Once + // upgradeErr is the error returned by the websocket upgrade attempt. + upgradeErr error + // closeOnce makes repeated Close calls return the first close result without + // writing again. + closeOnce sync.Once + // closeErr is the result of the first Close call. + closeErr error + // upgrader is the websocket connection upgrader. + upgrader goahttp.Upgrader + // configurer is the websocket connection configurer. + configurer goahttp.ConnConfigureFunc + // cancel is the context cancellation function which cancels the request + // context when invoked. + cancel context.CancelFunc + // w is the HTTP response writer used in upgrading the connection. + w http.ResponseWriter + // r is the HTTP request. + r *http.Request + // conn is the underlying websocket connection. + conn *websocket.Conn +} + +// NewSocketHandler creates a HTTP handler which loads the HTTP request and +// calls the "Names" service "Socket" endpoint. +func NewSocketHandler( + endpoint goa.Endpoint, + mux goahttp.Muxer, + decoder func(*http.Request) goahttp.Decoder, + encoder func(context.Context, http.ResponseWriter) goahttp.Encoder, + errhandler func(context.Context, http.ResponseWriter, error), + formatter func(ctx context.Context, err error) goahttp.Statuser, + upgrader goahttp.Upgrader, + configurer goahttp.ConnConfigureFunc, +) http.Handler { + var ( + encodeError = goahttp.ErrorEncoder(encoder, formatter) + ) + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + ctx := context.WithValue(r.Context(), goahttp.AcceptTypeKey, r.Header.Get("Accept")) + ctx = context.WithValue(ctx, goa.MethodKey, "Socket") + ctx = context.WithValue(ctx, goa.ServiceKey, "Names") + var err error + var cancel context.CancelFunc + ctx, cancel = context.WithCancel(ctx) + v := &names.SocketEndpointInput{ + Stream: &SocketServerStream2{ + upgrader: upgrader, + configurer: configurer, + cancel: cancel, + w: w, + r: r, + }, + } + _, err = endpoint(ctx, v) + if err != nil { + var stream *SocketServerStream2 + if wrapper, ok := v.Stream.(interface{ Unwrap() any }); ok { + stream = wrapper.Unwrap().(*SocketServerStream2) + } else { + stream = v.Stream.(*SocketServerStream2) + } + if stream != nil && stream.conn != nil { + // Response writer has been hijacked, do not encode the error + if errhandler != nil { + errhandler(ctx, w, err) + } + return + } + if err := encodeError(ctx, w, err); err != nil && errhandler != nil { + errhandler(ctx, w, err) + } + return + } + }) +} diff --git a/http/codegen/testdata/golden/planned_service_name_uses.go.golden b/http/codegen/testdata/golden/planned_service_name_uses.go.golden new file mode 100644 index 0000000000..70f4003161 --- /dev/null +++ b/http/codegen/testdata/golden/planned_service_name_uses.go.golden @@ -0,0 +1,411 @@ +===== service method names definition ===== +// Service is the Collisions service interface. +type Service interface { + // Read implements Read. + Read(context.Context, *ReadPayload) (res string, err error) +} + +// APIName is the name of the API as defined in the design. +const APIName = "Name Test" + +// APIVersion is the version of the API as defined in the design. +const APIVersion = "0.0.1" + +// ServiceName is the name of the service as defined in the design. This is the +// same value that is set in the endpoint request contexts under the ServiceKey +// key. +const ServiceName = "Collisions" + +// MethodNames lists the service method names as defined in the design. These +// are the same values that are set in the endpoint request contexts under the +// MethodKey key. +var MethodNames2 = [1]string{"Read"} + +===== service endpoints definition ===== +// Endpoints2 wraps the "Collisions" service endpoints. +type Endpoints2 struct { + Read goa.Endpoint +} + +===== client interceptors definition ===== +// ClientInterceptors defines the interface for all client-side interceptors. +// Client interceptors execute after the payload is encoded and before the request +// is sent to the server. The implementation is responsible for calling next to +// complete the request. +type ClientInterceptors2 interface { + Trace(ctx context.Context, info TraceInfo, next goa.Endpoint) (any, error) +} + +===== client endpoint wrapper definition ===== +// WrapReadClientEndpoint2 wraps the Read endpoint with the client interceptors +// defined in the design. +func WrapReadClientEndpoint2(endpoint goa.Endpoint, i ClientInterceptors2) goa.Endpoint { + if i != nil { + endpoint = wrapClientReadTrace(endpoint, i) + } + return endpoint +} + +===== HTTP server endpoints use ===== +// New instantiates HTTP handlers for all the Collisions service endpoints +// using the provided encoder and decoder. The handlers are mounted on the +// given mux using the HTTP verb and path defined in the design. errhandler is +// called whenever a response fails to be encoded. formatter is used to format +// errors returned by the service methods prior to encoding. Both errhandler +// and formatter are optional and can be nil. +func New( + e *collisions.Endpoints2, + mux goahttp.Muxer, + decoder func(*http.Request) goahttp.Decoder, + encoder func(context.Context, http.ResponseWriter) goahttp.Encoder, + errhandler func(context.Context, http.ResponseWriter, error), + formatter func(ctx context.Context, err error) goahttp.Statuser, +) *Server { + return &Server{ + Mounts: []*MountPoint{ + {"Read", "POST", "/read"}, + }, + Read: NewReadHandler(e.Read, mux, decoder, encoder, errhandler, formatter), + } +} + +===== HTTP server method names use ===== +// MethodNames returns the methods served. +func (s *Server) MethodNames() []string { return collisions.MethodNames2[:] } + +===== HTTP command parser ===== +// cliStringFlag keeps an omitted command-line flag distinct from an explicitly empty flag. +type cliStringFlag struct { + value *string +} + +// String returns the flag text shown by the standard flag package. +func (f *cliStringFlag) String() string { + if f.value == nil { + return "" + } + return *f.value +} + +// Set records that the user supplied the flag, even when value is empty. +func (f *cliStringFlag) Set(value string) error { + f.value = &value + return nil +} + +// ParseEndpoint returns the endpoint and payload as specified on the command +// line. +func ParseEndpoint( + scheme, host string, + doer goahttp.Doer, + enc func(*http.Request) goahttp.Encoder, + dec func(*http.Response) goahttp.Decoder, + restore bool, + collisionsInter collisions.ClientInterceptors2, +) (goa.Endpoint, any, error) { + var ( + collisionsFlags = flag.NewFlagSet("collisions", flag.ContinueOnError) + + collisionsReadFlags = flag.NewFlagSet("read", flag.ExitOnError) + collisionsReadBodyFlag = new(cliStringFlag) + ) + collisionsReadFlags.Var(collisionsReadBodyFlag, "body", "") + + collisionsFlags.Usage = collisionsUsage + collisionsReadFlags.Usage = collisionsReadUsage + + if err := flag.CommandLine.Parse(os.Args[1:]); err != nil { + return nil, nil, err + } + + if flag.NArg() < 2 { // two non flag args are required: SERVICE and ENDPOINT (aka COMMAND) + return nil, nil, fmt.Errorf("not enough arguments") + } + + var ( + svcn string + svcf *flag.FlagSet + ) + { + svcn = flag.Arg(0) + switch svcn { + case "collisions": + svcf = collisionsFlags + default: + return nil, nil, fmt.Errorf("unknown service %q", svcn) + } + } + if err := svcf.Parse(flag.Args()[1:]); err != nil { + return nil, nil, err + } + + var ( + epn string + epf *flag.FlagSet + ) + { + epn = svcf.Arg(0) + switch svcn { + case "collisions": + switch epn { + case "read": + epf = collisionsReadFlags + + } + + } + } + if epf == nil { + return nil, nil, fmt.Errorf("unknown %q endpoint %q", svcn, epn) + } + + // Parse endpoint flags if any + if svcf.NArg() > 1 { + if err := epf.Parse(svcf.Args()[1:]); err != nil { + return nil, nil, err + } + } + + var ( + data any + endpoint goa.Endpoint + err error + ) + { + switch svcn { + case "collisions": + c := collisionsc.NewClient(scheme, host, doer, enc, dec, restore) + switch epn { + case "read": + endpoint = c.Read() + endpoint = collisions.WrapReadClientEndpoint2(endpoint, collisionsInter) + data, err = collisionsc.BuildReadPayload(collisionsReadBodyFlag.value) + } + } + } + if err != nil { + return nil, nil, err + } + + return endpoint, data, nil +} + +===== HTTP example client interceptor use ===== +func doHTTP(ctx context.Context, scheme, host string, timeout int, debug bool, stdout io.Writer) error { + var ( + doer goahttp.Doer + collisionsInterceptors collisions.ClientInterceptors2 + ) + { + doer = &http.Client{Timeout: time.Duration(timeout) * time.Second} + if debug { + doer = goahttp.NewDebugDoer(doer) + } + collisionsInterceptors = interceptors.NewCollisionsClientInterceptors() + } + + endpoint, payload, err := cli2.ParseEndpoint( + scheme, + host, + doer, + goahttp.RequestEncoder, + goahttp.ResponseDecoder, + debug, + collisionsInterceptors, + ) + if err != nil { + return fmt.Errorf("parse endpoint: %w", err) + } + + switch flag.Arg(0) { + case "collisions": + switch flag.Arg(1) { + case "read": + return writeEndpointResult(ctx, stdout, endpoint, payload) + } + } + panic("parsed HTTP command has no generated result writer") +} + +func httpUsageExamples() string { + return cli2.UsageExamples() +} + +===== gRPC server endpoints use ===== +// New instantiates the server struct with the Collisions service endpoints. +func New(e *collisions.Endpoints2, uh goagrpc.UnaryHandler) *Server { + return &Server{ + ReadH: NewReadHandler(e.Read, uh), + } +} + +===== gRPC example server endpoints use ===== +// handleGRPCServer starts configures and starts a gRPC server on the given +// URL. It shuts down the server if any error is received in the error channel. +func handleGRPCServer(ctx context.Context, u *url.URL, collisionsEndpoints *collisions.Endpoints2, wg *sync.WaitGroup, errc chan error, dbg bool) { + + // Wrap the endpoints with the transport specific layers. The generated + // server packages contains code generated from the design which maps + // the service input and output data structures to gRPC requests and + // responses. + var ( + collisionsServer *collisionssvr.Server + ) + { + collisionsServer = collisionssvr.New(collisionsEndpoints, nil) + } + + // Create interceptor which sets up the logger in each request context. + chain := grpc.ChainUnaryInterceptor(log.UnaryServerInterceptor(ctx)) + if dbg { + // Log request and response content if debug logs are enabled. + chain = grpc.ChainUnaryInterceptor(log.UnaryServerInterceptor(ctx), debug.UnaryServerInterceptor()) + } + + // Initialize gRPC server + srv := grpc.NewServer(chain) + + // Register the servers. + collisionspb.RegisterCollisionsServer(srv, collisionsServer) + log.Printf(ctx, "serving gRPC method %s", "collisions.Collisions/Read") + + // Register the server reflection service on the server. + // See https://grpc.github.io/grpc/core/md_doc_server-reflection.html. + reflection.Register(srv) + + (*wg).Add(1) + go func() { + defer (*wg).Done() + + // Start gRPC server in a separate goroutine. + go func() { + lis, err := net.Listen("tcp", u.Host) + if err != nil { + errc <- err + } + if lis == nil { + errc <- fmt.Errorf("failed to listen on %q", u.Host) + } + log.Printf(ctx, "gRPC server listening on %q", u.Host) + errc <- srv.Serve(lis) + }() + + <-ctx.Done() + log.Printf(ctx, "shutting down gRPC server at %q", u.Host) + srv.Stop() + }() +} + +===== gRPC command parser ===== +// cliStringFlag keeps an omitted command-line flag distinct from an explicitly empty flag. +type cliStringFlag struct { + value *string +} + +// String returns the flag text shown by the standard flag package. +func (f *cliStringFlag) String() string { + if f.value == nil { + return "" + } + return *f.value +} + +// Set records that the user supplied the flag, even when value is empty. +func (f *cliStringFlag) Set(value string) error { + f.value = &value + return nil +} + +// ParseEndpoint returns the endpoint and payload as specified on the command +// line. +func ParseEndpoint( + cc *grpc.ClientConn, + collisionsInter collisions.ClientInterceptors2, + opts ...grpc.CallOption, +) (goa.Endpoint, any, error) { + var ( + collisionsFlags = flag.NewFlagSet("collisions", flag.ContinueOnError) + + collisionsReadFlags = flag.NewFlagSet("read", flag.ExitOnError) + collisionsReadMessageFlag = new(cliStringFlag) + ) + collisionsReadFlags.Var(collisionsReadMessageFlag, "message", "") + + collisionsFlags.Usage = collisionsUsage + collisionsReadFlags.Usage = collisionsReadUsage + + if err := flag.CommandLine.Parse(os.Args[1:]); err != nil { + return nil, nil, err + } + + if flag.NArg() < 2 { // two non flag args are required: SERVICE and ENDPOINT (aka COMMAND) + return nil, nil, fmt.Errorf("not enough arguments") + } + + var ( + svcn string + svcf *flag.FlagSet + ) + { + svcn = flag.Arg(0) + switch svcn { + case "collisions": + svcf = collisionsFlags + default: + return nil, nil, fmt.Errorf("unknown service %q", svcn) + } + } + if err := svcf.Parse(flag.Args()[1:]); err != nil { + return nil, nil, err + } + + var ( + epn string + epf *flag.FlagSet + ) + { + epn = svcf.Arg(0) + switch svcn { + case "collisions": + switch epn { + case "read": + epf = collisionsReadFlags + + } + + } + } + if epf == nil { + return nil, nil, fmt.Errorf("unknown %q endpoint %q", svcn, epn) + } + + // Parse endpoint flags if any + if svcf.NArg() > 1 { + if err := epf.Parse(svcf.Args()[1:]); err != nil { + return nil, nil, err + } + } + + var ( + data any + endpoint goa.Endpoint + err error + ) + { + switch svcn { + case "collisions": + c := collisionsc.NewClient(cc, opts...) + switch epn { + case "read": + endpoint = c.Read() + endpoint = collisions.WrapReadClientEndpoint2(endpoint, collisionsInter) + data, err = collisionsc.BuildReadPayload(collisionsReadMessageFlag.value) + } + } + } + if err != nil { + return nil, nil, err + } + + return endpoint, data, nil +} diff --git a/http/codegen/testdata/golden/released_streaming_response_collection_client.go.golden b/http/codegen/testdata/golden/released_streaming_response_collection_client.go.golden new file mode 100644 index 0000000000..790a102d4e --- /dev/null +++ b/http/codegen/testdata/golden/released_streaming_response_collection_client.go.golden @@ -0,0 +1,5 @@ +// UsertypeResponseTinyCollection is the type of the +// "StreamingPayloadResultCollectionWithExplicitViewService" service +// "StreamingPayloadResultCollectionWithExplicitViewMethod" endpoint HTTP +// response body. +type UsertypeResponseTinyCollection []*UsertypeResponseTiny diff --git a/http/codegen/testdata/golden/released_streaming_response_collection_server.go.golden b/http/codegen/testdata/golden/released_streaming_response_collection_server.go.golden new file mode 100644 index 0000000000..ece6314b98 --- /dev/null +++ b/http/codegen/testdata/golden/released_streaming_response_collection_server.go.golden @@ -0,0 +1,21 @@ +// UsertypeResponseTinyCollection is the type of the +// "StreamingPayloadResultCollectionWithExplicitViewService" service +// "StreamingPayloadResultCollectionWithExplicitViewMethod" endpoint HTTP +// response body. +type UsertypeResponseTinyCollection []*UsertypeResponseTiny + +// NewUsertypeResponseTinyCollection builds the HTTP response body from the +// result of the "StreamingPayloadResultCollectionWithExplicitViewMethod" +// endpoint of the "StreamingPayloadResultCollectionWithExplicitViewService" +// service. +func NewUsertypeResponseTinyCollection(res streamingpayloadresultcollectionwithexplicitviewserviceviews.UsertypeCollectionView) UsertypeResponseTinyCollection { + body := make([]*UsertypeResponseTiny, len(res)) + for i, val := range res { + if val == nil { + body[i] = nil + continue + } + body[i] = marshalStreamingpayloadresultcollectionwithexplicitviewserviceviewsUsertypeViewToUsertypeResponseTiny(val) + } + return body +} diff --git a/http/codegen/testdata/golden/security_alias_client.go.golden b/http/codegen/testdata/golden/security_alias_client.go.golden new file mode 100644 index 0000000000..59c9c28353 --- /dev/null +++ b/http/codegen/testdata/golden/security_alias_client.go.golden @@ -0,0 +1,120 @@ +// EncodeBasicRequest returns an encoder for requests sent to the +// NamedSecurityTypes Basic server. +func EncodeBasicRequest(encoder func(*http.Request) goahttp.Encoder) func(*http.Request, any) error { + return func(req *http.Request, v any) error { + p, ok := v.(*namedsecuritytypes.BasicPayload) + if !ok { + return goahttp.ErrInvalidType("NamedSecurityTypes", "Basic", "*namedsecuritytypes.BasicPayload", v) + } + var pass string + if p.Password != nil { + pass = string(*p.Password) + } + req.SetBasicAuth(string(p.Username), pass) + return nil + } +} + +// EncodeBasicOptionalRequest returns an encoder for requests sent to the +// NamedSecurityTypes BasicOptional server. +func EncodeBasicOptionalRequest(encoder func(*http.Request) goahttp.Encoder) func(*http.Request, any) error { + return func(req *http.Request, v any) error { + p, ok := v.(*namedsecuritytypes.BasicOptionalPayload) + if !ok { + return goahttp.ErrInvalidType("NamedSecurityTypes", "BasicOptional", "*namedsecuritytypes.BasicOptionalPayload", v) + } + var user string + if p.Username != nil { + user = string(*p.Username) + } + var pass string + if p.Password != nil { + pass = string(*p.Password) + } + if p.Username != nil || p.Password != nil { + req.SetBasicAuth(user, pass) + } + return nil + } +} + +// EncodeBasicPasswordRequiredRequest returns an encoder for requests sent to +// the NamedSecurityTypes BasicPasswordRequired server. +func EncodeBasicPasswordRequiredRequest(encoder func(*http.Request) goahttp.Encoder) func(*http.Request, any) error { + return func(req *http.Request, v any) error { + p, ok := v.(*namedsecuritytypes.BasicPasswordRequiredPayload) + if !ok { + return goahttp.ErrInvalidType("NamedSecurityTypes", "BasicPasswordRequired", "*namedsecuritytypes.BasicPasswordRequiredPayload", v) + } + var user string + if p.Username != nil { + user = string(*p.Username) + } + req.SetBasicAuth(user, string(p.Password)) + return nil + } +} + +// EncodeAPIKeyRequest returns an encoder for requests sent to the +// NamedSecurityTypes APIKey server. +func EncodeAPIKeyRequest(encoder func(*http.Request) goahttp.Encoder) func(*http.Request, any) error { + return func(req *http.Request, v any) error { + p, ok := v.(*namedsecuritytypes.APIKeyPayload) + if !ok { + return goahttp.ErrInvalidType("NamedSecurityTypes", "APIKey", "*namedsecuritytypes.APIKeyPayload", v) + } + { + head := p.Key + req.Header.Set("X-API-Key", string(head)) + } + return nil + } +} + +// EncodeBearerRequest returns an encoder for requests sent to the +// NamedSecurityTypes Bearer server. +func EncodeBearerRequest(encoder func(*http.Request) goahttp.Encoder) func(*http.Request, any) error { + return func(req *http.Request, v any) error { + p, ok := v.(*namedsecuritytypes.BearerPayload) + if !ok { + return goahttp.ErrInvalidType("NamedSecurityTypes", "Bearer", "*namedsecuritytypes.BearerPayload", v) + } + if p.Token != nil { + head := *p.Token + req.Header.Set("X-Bearer", string(head)) + } + return nil + } +} + +// EncodeJWTRequest returns an encoder for requests sent to the +// NamedSecurityTypes JWT server. +func EncodeJWTRequest(encoder func(*http.Request) goahttp.Encoder) func(*http.Request, any) error { + return func(req *http.Request, v any) error { + p, ok := v.(*namedsecuritytypes.JWTPayload) + if !ok { + return goahttp.ErrInvalidType("NamedSecurityTypes", "JWT", "*namedsecuritytypes.JWTPayload", v) + } + { + head := p.Token + req.Header.Set("X-JWT", string(head)) + } + return nil + } +} + +// EncodeOAuth2Request returns an encoder for requests sent to the +// NamedSecurityTypes OAuth2 server. +func EncodeOAuth2Request(encoder func(*http.Request) goahttp.Encoder) func(*http.Request, any) error { + return func(req *http.Request, v any) error { + p, ok := v.(*namedsecuritytypes.OAuth2Payload) + if !ok { + return goahttp.ErrInvalidType("NamedSecurityTypes", "OAuth2", "*namedsecuritytypes.OAuth2Payload", v) + } + if p.Token != nil { + head := *p.Token + req.Header.Set("X-OAuth", string(head)) + } + return nil + } +} diff --git a/http/codegen/testdata/golden/security_alias_server.go.golden b/http/codegen/testdata/golden/security_alias_server.go.golden new file mode 100644 index 0000000000..cd4266fb2b --- /dev/null +++ b/http/codegen/testdata/golden/security_alias_server.go.golden @@ -0,0 +1,231 @@ +// DecodeBasicRequest returns a decoder for requests sent to the +// NamedSecurityTypes Basic endpoint. +func DecodeBasicRequest(mux goahttp.Muxer, decoder func(*http.Request) goahttp.Decoder) func(*http.Request) (*namedsecuritytypes.BasicPayload, error) { + return func(r *http.Request) (*namedsecuritytypes.BasicPayload, error) { + var payload *namedsecuritytypes.BasicPayload + user, pass, ok := r.BasicAuth() + if !ok { + return payload, goa.MissingFieldError("Authorization", "header") + } + passPtr := &pass + payload = NewBasicPayload(user, passPtr) + + return payload, nil + } +} + +// DecodeBasicOptionalRequest returns a decoder for requests sent to the +// NamedSecurityTypes BasicOptional endpoint. +func DecodeBasicOptionalRequest(mux goahttp.Muxer, decoder func(*http.Request) goahttp.Decoder) func(*http.Request) (*namedsecuritytypes.BasicOptionalPayload, error) { + return func(r *http.Request) (*namedsecuritytypes.BasicOptionalPayload, error) { + var payload *namedsecuritytypes.BasicOptionalPayload + user, pass, ok := r.BasicAuth() + var userPtr, passPtr *string + if ok { + userPtr = &user + passPtr = &pass + } + payload = NewBasicOptionalPayload(userPtr, passPtr) + + return payload, nil + } +} + +// DecodeBasicPasswordRequiredRequest returns a decoder for requests sent to +// the NamedSecurityTypes BasicPasswordRequired endpoint. +func DecodeBasicPasswordRequiredRequest(mux goahttp.Muxer, decoder func(*http.Request) goahttp.Decoder) func(*http.Request) (*namedsecuritytypes.BasicPasswordRequiredPayload, error) { + return func(r *http.Request) (*namedsecuritytypes.BasicPasswordRequiredPayload, error) { + var payload *namedsecuritytypes.BasicPasswordRequiredPayload + user, pass, ok := r.BasicAuth() + if !ok { + return payload, goa.MissingFieldError("Authorization", "header") + } + userPtr := &user + payload = NewBasicPasswordRequiredPayload(userPtr, pass) + + return payload, nil + } +} + +// DecodeAPIKeyRequest returns a decoder for requests sent to the +// NamedSecurityTypes APIKey endpoint. +func DecodeAPIKeyRequest(mux goahttp.Muxer, decoder func(*http.Request) goahttp.Decoder) func(*http.Request) (*namedsecuritytypes.APIKeyPayload, error) { + return func(r *http.Request) (*namedsecuritytypes.APIKeyPayload, error) { + var payload *namedsecuritytypes.APIKeyPayload + var ( + key string + err error + ) + key = r.Header.Get("X-API-Key") + if key == "" { + err = goa.MergeErrors(err, goa.MissingFieldError("key", "header")) + } + if err != nil { + return payload, err + } + payload = NewAPIKeyPayload(key) + if index := strings.IndexByte(string(payload.Key), ' '); index >= 0 { + // Remove authorization scheme prefix (e.g. "Bearer") + payload.Key = payload.Key[index+1:] + } + + return payload, nil + } +} + +// DecodeBearerRequest returns a decoder for requests sent to the +// NamedSecurityTypes Bearer endpoint. +func DecodeBearerRequest(mux goahttp.Muxer, decoder func(*http.Request) goahttp.Decoder) func(*http.Request) (*namedsecuritytypes.BearerPayload, error) { + return func(r *http.Request) (*namedsecuritytypes.BearerPayload, error) { + var payload *namedsecuritytypes.BearerPayload + var ( + token *string + ) + tokenRaw := r.Header.Get("X-Bearer") + if tokenRaw != "" { + token = &tokenRaw + } + payload = NewBearerPayload(token) + if payload.Token != nil { + cred := *payload.Token + if index := strings.IndexByte(string(cred), ' '); index >= 0 { + // Remove authorization scheme prefix (e.g. "Bearer") + cred = cred[index+1:] + payload.Token = &cred + } + } + + return payload, nil + } +} + +// DecodeJWTRequest returns a decoder for requests sent to the +// NamedSecurityTypes JWT endpoint. +func DecodeJWTRequest(mux goahttp.Muxer, decoder func(*http.Request) goahttp.Decoder) func(*http.Request) (*namedsecuritytypes.JWTPayload, error) { + return func(r *http.Request) (*namedsecuritytypes.JWTPayload, error) { + var payload *namedsecuritytypes.JWTPayload + var ( + token string + err error + ) + token = r.Header.Get("X-JWT") + if token == "" { + err = goa.MergeErrors(err, goa.MissingFieldError("token", "header")) + } + if err != nil { + return payload, err + } + payload = NewJWTPayload(token) + if index := strings.IndexByte(string(payload.Token), ' '); index >= 0 { + // Remove authorization scheme prefix (e.g. "Bearer") + payload.Token = payload.Token[index+1:] + } + + return payload, nil + } +} + +// DecodeOAuth2Request returns a decoder for requests sent to the +// NamedSecurityTypes OAuth2 endpoint. +func DecodeOAuth2Request(mux goahttp.Muxer, decoder func(*http.Request) goahttp.Decoder) func(*http.Request) (*namedsecuritytypes.OAuth2Payload, error) { + return func(r *http.Request) (*namedsecuritytypes.OAuth2Payload, error) { + var payload *namedsecuritytypes.OAuth2Payload + var ( + token *string + ) + tokenRaw := r.Header.Get("X-OAuth") + if tokenRaw != "" { + token = &tokenRaw + } + payload = NewOAuth2Payload(token) + if payload.Token != nil { + cred := *payload.Token + if index := strings.IndexByte(string(cred), ' '); index >= 0 { + // Remove authorization scheme prefix (e.g. "Bearer") + cred = cred[index+1:] + payload.Token = &cred + } + } + + return payload, nil + } +} + +// NewBasicPayload builds a NamedSecurityTypes service Basic endpoint payload. +func NewBasicPayload(username string, password *string) *namedsecuritytypes.BasicPayload { + v := &namedsecuritytypes.BasicPayload{} + v.Username = namedsecuritytypes.Credential(username) + if password != nil { + tmppassword := namedsecuritytypes.Credential(*password) + v.Password = &tmppassword + } + + return v +} + +// NewBasicOptionalPayload builds a NamedSecurityTypes service BasicOptional +// endpoint payload. +func NewBasicOptionalPayload(username *string, password *string) *namedsecuritytypes.BasicOptionalPayload { + v := &namedsecuritytypes.BasicOptionalPayload{} + if username != nil { + tmpusername := namedsecuritytypes.Credential(*username) + v.Username = &tmpusername + } + if password != nil { + tmppassword := namedsecuritytypes.Credential(*password) + v.Password = &tmppassword + } + + return v +} + +// NewBasicPasswordRequiredPayload builds a NamedSecurityTypes service +// BasicPasswordRequired endpoint payload. +func NewBasicPasswordRequiredPayload(username *string, password string) *namedsecuritytypes.BasicPasswordRequiredPayload { + v := &namedsecuritytypes.BasicPasswordRequiredPayload{} + if username != nil { + tmpusername := namedsecuritytypes.Credential(*username) + v.Username = &tmpusername + } + v.Password = namedsecuritytypes.Credential(password) + + return v +} + +// NewAPIKeyPayload builds a NamedSecurityTypes service APIKey endpoint payload. +func NewAPIKeyPayload(key string) *namedsecuritytypes.APIKeyPayload { + v := &namedsecuritytypes.APIKeyPayload{} + v.Key = namedsecuritytypes.Credential(key) + + return v +} + +// NewBearerPayload builds a NamedSecurityTypes service Bearer endpoint payload. +func NewBearerPayload(token *string) *namedsecuritytypes.BearerPayload { + v := &namedsecuritytypes.BearerPayload{} + if token != nil { + tmptoken := namedsecuritytypes.Credential(*token) + v.Token = &tmptoken + } + + return v +} + +// NewJWTPayload builds a NamedSecurityTypes service JWT endpoint payload. +func NewJWTPayload(token string) *namedsecuritytypes.JWTPayload { + v := &namedsecuritytypes.JWTPayload{} + v.Token = namedsecuritytypes.Credential(token) + + return v +} + +// NewOAuth2Payload builds a NamedSecurityTypes service OAuth2 endpoint payload. +func NewOAuth2Payload(token *string) *namedsecuritytypes.OAuth2Payload { + v := &namedsecuritytypes.OAuth2Payload{} + if token != nil { + tmptoken := namedsecuritytypes.Credential(*token) + v.Token = &tmptoken + } + + return v +} diff --git a/http/codegen/testdata/golden/server-colliding-service-names.golden b/http/codegen/testdata/golden/server-colliding-service-names.golden new file mode 100644 index 0000000000..53673ba81f --- /dev/null +++ b/http/codegen/testdata/golden/server-colliding-service-names.golden @@ -0,0 +1,93 @@ +// handleHTTPServer starts configures and starts a HTTP server on the given +// URL. It shuts down the server if any error is received in the error channel. +func handleHTTPServer(ctx context.Context, u *url.URL, readValue2Endpoints *readvalue2.Endpoints, readValueEndpoints *readvalue.Endpoints, wg *sync.WaitGroup, errc chan error, dbg bool) { + + // Provide the transport specific request decoder and response encoder. + // The goa http package has built-in support for JSON, XML and gob. + // Other encodings can be used by providing the corresponding functions, + // see goa.design/implement/encoding. + var ( + dec = goahttp.RequestDecoder + enc = goahttp.ResponseEncoder + ) + + // Build the service HTTP request multiplexer and mount debug and profiler + // endpoints in debug mode. + var mux goahttp.Muxer + { + mux = goahttp.NewMuxer() + if dbg { + // Mount pprof handlers for memory profiling under /debug/pprof. + debug.MountPprofHandlers(debug.Adapt(mux)) + // Mount /debug endpoint to enable or disable debug logs at runtime. + debug.MountDebugLogEnabler(debug.Adapt(mux)) + } + } + + // Wrap the endpoints with the transport specific layers. The generated + // server packages contains code generated from the design which maps + // the service input and output data structures to HTTP requests and + // responses. + var ( + readValue2Server *readvaluesvr2.Server + readValueServer *readvaluesvr.Server + ) + { + eh := errorHandler(ctx) + readValue2Server = readvaluesvr2.New(readValue2Endpoints, mux, dec, enc, eh, nil) + readValueServer = readvaluesvr.New(readValueEndpoints, mux, dec, enc, eh, nil) + } + + // Configure the mux. + readvaluesvr2.Mount(mux, readValue2Server) + readvaluesvr.Mount(mux, readValueServer) + + var handler http.Handler = mux + if dbg { + // Log query and response bodies if debug logs are enabled. + handler = debug.HTTP()(handler) + } + handler = log.HTTP(ctx)(handler) + + // Start HTTP server using default configuration, change the code to + // configure the server as required by your service. + srv := &http.Server{Addr: u.Host, Handler: handler, ReadHeaderTimeout: time.Second * 60} + for _, m := range readValue2Server.Mounts { + log.Printf(ctx, "HTTP %q mounted on %s %s", m.Method, m.Verb, m.Pattern) + } + for _, m := range readValueServer.Mounts { + log.Printf(ctx, "HTTP %q mounted on %s %s", m.Method, m.Verb, m.Pattern) + } + + (*wg).Add(1) + go func() { + defer (*wg).Done() + + // Start HTTP server in a separate goroutine. + go func() { + log.Printf(ctx, "HTTP server listening on %q", u.Host) + errc <- srv.ListenAndServe() + }() + + <-ctx.Done() + log.Printf(ctx, "shutting down HTTP server at %q", u.Host) + + // Shutdown gracefully with a 30s timeout. + shutdownCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 30*time.Second) + defer cancel() + + err := srv.Shutdown(shutdownCtx) + if err != nil { + log.Printf(shutdownCtx, "failed to shutdown: %v", err) + } + }() +} + +// errorHandler returns a function that writes and logs the given error. +// The function also writes and logs the error unique ID so that it's possible +// to correlate. +func errorHandler(logCtx context.Context) func(context.Context, http.ResponseWriter, error) { + return func(ctx context.Context, w http.ResponseWriter, err error) { + log.Printf(logCtx, "ERROR: %s", err.Error()) + } +} diff --git a/http/codegen/testdata/golden/server-multipart-array.golden b/http/codegen/testdata/golden/server-multipart-array.golden new file mode 100644 index 0000000000..44780f3455 --- /dev/null +++ b/http/codegen/testdata/golden/server-multipart-array.golden @@ -0,0 +1,22 @@ +import ( + "mime/multipart" + + servicemultipartarraytypesvr "generated.local/gen/http/service_multipart_array_type/server" + servicemultipartarraytype "generated.local/gen/service_multipart_array_type" +) + +// ServiceMultipartArrayTypeMethodMultipartArrayTypeDecoderFunc reads the +// multipart request body for service "ServiceMultipartArrayType" endpoint +// "MethodMultipartArrayType" into body. +func ServiceMultipartArrayTypeMethodMultipartArrayTypeDecoderFunc(mr *multipart.Reader, body *[]*servicemultipartarraytypesvr.PayloadTypeRequestBody) error { + // Add multipart request decoder logic here + return nil +} + +// ServiceMultipartArrayTypeMethodMultipartArrayTypeEncoderFunc implements the +// multipart encoder for service "ServiceMultipartArrayType" endpoint +// "MethodMultipartArrayType". +func ServiceMultipartArrayTypeMethodMultipartArrayTypeEncoderFunc(mw *multipart.Writer, p []*servicemultipartarraytype.PayloadType) error { + // Add multipart request encoder logic here + return nil +} diff --git a/http/codegen/testdata/golden/server-multipart-map.golden b/http/codegen/testdata/golden/server-multipart-map.golden new file mode 100644 index 0000000000..31289d9360 --- /dev/null +++ b/http/codegen/testdata/golden/server-multipart-map.golden @@ -0,0 +1,19 @@ +import ( + "mime/multipart" +) + +// ServiceMultipartMapTypeMethodMultipartMapTypeDecoderFunc reads the multipart +// request body for service "ServiceMultipartMapType" endpoint +// "MethodMultipartMapType" into body. +func ServiceMultipartMapTypeMethodMultipartMapTypeDecoderFunc(mr *multipart.Reader, body *map[string]int) error { + // Add multipart request decoder logic here + return nil +} + +// ServiceMultipartMapTypeMethodMultipartMapTypeEncoderFunc implements the +// multipart encoder for service "ServiceMultipartMapType" endpoint +// "MethodMultipartMapType". +func ServiceMultipartMapTypeMethodMultipartMapTypeEncoderFunc(mw *multipart.Writer, p map[string]int) error { + // Add multipart request encoder logic here + return nil +} diff --git a/http/codegen/testdata/golden/server-multipart-object.golden b/http/codegen/testdata/golden/server-multipart-object.golden new file mode 100644 index 0000000000..778678dd79 --- /dev/null +++ b/http/codegen/testdata/golden/server-multipart-object.golden @@ -0,0 +1,22 @@ +import ( + "mime/multipart" + + servicemultipartvalidationsvr "generated.local/gen/http/service_multipart_validation/server" + servicemultipartvalidation "generated.local/gen/service_multipart_validation" +) + +// ServiceMultipartValidationMethodMultipartValidationDecoderFunc reads the +// multipart request body for service "ServiceMultipartValidation" endpoint +// "MethodMultipartValidation" into body. +func ServiceMultipartValidationMethodMultipartValidationDecoderFunc(mr *multipart.Reader, body *servicemultipartvalidationsvr.MethodMultipartValidationRequestBody) error { + // Add multipart request decoder logic here + return nil +} + +// ServiceMultipartValidationMethodMultipartValidationEncoderFunc implements +// the multipart encoder for service "ServiceMultipartValidation" endpoint +// "MethodMultipartValidation". +func ServiceMultipartValidationMethodMultipartValidationEncoderFunc(mw *multipart.Writer, p *servicemultipartvalidation.MethodMultipartValidationPayload) error { + // Add multipart request encoder logic here + return nil +} diff --git a/http/codegen/testdata/golden/server_decode_decode-body-extend-primitive-field-string.go.golden b/http/codegen/testdata/golden/server_decode_decode-body-extend-primitive-field-string.go.golden index 509e45cd42..2cf9ba0b26 100644 --- a/http/codegen/testdata/golden/server_decode_decode-body-extend-primitive-field-string.go.golden +++ b/http/codegen/testdata/golden/server_decode_decode-body-extend-primitive-field-string.go.golden @@ -5,7 +5,7 @@ func DecodeMethodBodyPrimitiveArrayUserRequest(mux goahttp.Muxer, decoder func(* return func(r *http.Request) (*servicebodyprimitivearrayuser.PayloadType, error) { var payload *servicebodyprimitivearrayuser.PayloadType var ( - body string + body *string err error ) err = decoder(r).Decode(&body) diff --git a/http/codegen/testdata/golden/server_decode_decode-body-primitive-array-user-required.go.golden b/http/codegen/testdata/golden/server_decode_decode-body-primitive-array-user-required.go.golden index 2c63d024f3..6aee8eac3d 100644 --- a/http/codegen/testdata/golden/server_decode_decode-body-primitive-array-user-required.go.golden +++ b/http/codegen/testdata/golden/server_decode_decode-body-primitive-array-user-required.go.golden @@ -21,7 +21,7 @@ func DecodeMethodBodyPrimitiveArrayUserRequiredRequest(mux goahttp.Muxer, decode } for _, e := range body { if e != nil { - if err2 := ValidatePayloadTypeRequestBody(e); err2 != nil { + if err2 := validatePayloadTypeRequestBody(e, "body[*]"); err2 != nil { err = goa.MergeErrors(err, err2) } } diff --git a/http/codegen/testdata/golden/server_decode_decode-body-primitive-array-user-validate.go.golden b/http/codegen/testdata/golden/server_decode_decode-body-primitive-array-user-validate.go.golden index dfa38dfb4f..49a33dbde9 100644 --- a/http/codegen/testdata/golden/server_decode_decode-body-primitive-array-user-validate.go.golden +++ b/http/codegen/testdata/golden/server_decode_decode-body-primitive-array-user-validate.go.golden @@ -24,7 +24,7 @@ func DecodeMethodBodyPrimitiveArrayUserValidateRequest(mux goahttp.Muxer, decode } for _, e := range body { if e != nil { - if err2 := ValidatePayloadTypeRequestBody(e); err2 != nil { + if err2 := validatePayloadTypeRequestBody(e, "body[*]"); err2 != nil { err = goa.MergeErrors(err, err2) } } diff --git a/http/codegen/testdata/golden/server_decode_decode-body-required-primitive-arrays.go.golden b/http/codegen/testdata/golden/server_decode_decode-body-required-primitive-arrays.go.golden new file mode 100644 index 0000000000..191f32022d --- /dev/null +++ b/http/codegen/testdata/golden/server_decode_decode-body-required-primitive-arrays.go.golden @@ -0,0 +1,29 @@ +// DecodeStoreRequest returns a decoder for requests sent to the RequiredArrays +// Store endpoint. +func DecodeStoreRequest(mux goahttp.Muxer, decoder func(*http.Request) goahttp.Decoder) func(*http.Request) (*requiredarrays.StorePayload, error) { + return func(r *http.Request) (*requiredarrays.StorePayload, error) { + var payload *requiredarrays.StorePayload + var ( + body StoreRequestBody + err error + ) + err = decoder(r).Decode(&body) + if err != nil { + if errors.Is(err, io.EOF) { + return payload, goa.MissingPayloadError() + } + var gerr *goa.ServiceError + if errors.As(err, &gerr) { + return payload, gerr + } + return payload, goa.DecodePayloadError(err.Error()) + } + err = ValidateStoreRequestBody(&body) + if err != nil { + return payload, err + } + payload = NewStorePayload(&body) + + return payload, nil + } +} diff --git a/http/codegen/testdata/golden/server_decode_decode-body-user-nested.go.golden b/http/codegen/testdata/golden/server_decode_decode-body-user-nested.go.golden index 7f797ad350..5af0a150b3 100644 --- a/http/codegen/testdata/golden/server_decode_decode-body-user-nested.go.golden +++ b/http/codegen/testdata/golden/server_decode_decode-body-user-nested.go.golden @@ -4,7 +4,7 @@ func DecodeMethodBodyUserRequest(mux goahttp.Muxer, decoder func(*http.Request) return func(r *http.Request) (*servicebodyuser.PayloadType, error) { var payload *servicebodyuser.PayloadType var ( - body MethodBodyUserRequestBody + body *MethodBodyUserRequestBody err error ) err = decoder(r).Decode(&body) @@ -19,11 +19,13 @@ func DecodeMethodBodyUserRequest(mux goahttp.Muxer, decoder func(*http.Request) return payload, goa.DecodePayloadError(err.Error()) } } - err = ValidateMethodBodyUserRequestBody(&body) - if err != nil { - return payload, err + if body != nil { + err = ValidateMethodBodyUserRequestBody(body) + if err != nil { + return payload, err + } } - payload = NewMethodBodyUserPayloadType(&body) + payload = NewMethodBodyUserPayloadType(body) return payload, nil } diff --git a/http/codegen/testdata/golden/server_decode_decode-body-user-validate.go.golden b/http/codegen/testdata/golden/server_decode_decode-body-user-validate.go.golden index 064adc369b..fe22ebb05d 100644 --- a/http/codegen/testdata/golden/server_decode_decode-body-user-validate.go.golden +++ b/http/codegen/testdata/golden/server_decode_decode-body-user-validate.go.golden @@ -4,7 +4,7 @@ func DecodeMethodBodyUserValidateRequest(mux goahttp.Muxer, decoder func(*http.R return func(r *http.Request) (*servicebodyuservalidate.PayloadType, error) { var payload *servicebodyuservalidate.PayloadType var ( - body string + body *string err error ) err = decoder(r).Decode(&body) @@ -19,9 +19,11 @@ func DecodeMethodBodyUserValidateRequest(mux goahttp.Muxer, decoder func(*http.R return payload, goa.DecodePayloadError(err.Error()) } } - err = goa.MergeErrors(err, goa.ValidatePattern("body", body, "apattern")) - if err != nil { - return payload, err + if body != nil { + err = goa.MergeErrors(err, goa.ValidatePattern("body", *body, "apattern")) + if err != nil { + return payload, err + } } payload = NewMethodBodyUserValidatePayloadType(body) diff --git a/http/codegen/testdata/golden/server_decode_decode-deep-user.go.golden b/http/codegen/testdata/golden/server_decode_decode-deep-user.go.golden index 649c70dede..ab3baa601e 100644 --- a/http/codegen/testdata/golden/server_decode_decode-deep-user.go.golden +++ b/http/codegen/testdata/golden/server_decode_decode-deep-user.go.golden @@ -2,9 +2,6 @@ // builds a value of type *ImmediatechildextenderResponseBody from a value of // type *servicedeepuserviews.ImmediatechildextenderView. func marshalServicedeepuserviewsImmediatechildextenderViewToImmediatechildextenderResponseBody(v *servicedeepuserviews.ImmediatechildextenderView) *ImmediatechildextenderResponseBody { - if v == nil { - return nil - } res := &ImmediatechildextenderResponseBody{} if v.DeepChild != nil { res.DeepChild = marshalServicedeepuserviewsDeepchildViewToDeepchildResponseBody(v.DeepChild) diff --git a/http/codegen/testdata/golden/server_decode_decode-map-query-primitive-array.go.golden b/http/codegen/testdata/golden/server_decode_decode-map-query-primitive-array.go.golden index b98e11f887..0036765930 100644 --- a/http/codegen/testdata/golden/server_decode_decode-map-query-primitive-array.go.golden +++ b/http/codegen/testdata/golden/server_decode_decode-map-query-primitive-array.go.golden @@ -12,34 +12,24 @@ func DecodeMapQueryPrimitiveArrayRequest(mux goahttp.Muxer, decoder func(*http.R if len(queryRaw) == 0 { err = goa.MergeErrors(err, goa.MissingFieldError("query", "query string")) } + if query == nil { + query = make(map[string][]uint) + } for keyRaw, valRaw := range queryRaw { - if strings.HasPrefix(keyRaw, "query[") { - if query == nil { - query = make(map[string][]uint) - } - var keya string - { - openIdx := strings.IndexRune(keyRaw, '[') - closeIdx := strings.IndexRune(keyRaw, ']') - if openIdx == -1 || closeIdx == -1 || closeIdx <= openIdx { - err = goa.MergeErrors(err, goa.DecodePayloadError("invalid query string: malformed brackets")) - } else { - keya = keyRaw[openIdx+1 : closeIdx] - } - } - var val []uint - { - val = make([]uint, len(valRaw)) - for i, rv := range valRaw { - v, err2 := strconv.ParseUint(rv, 10, strconv.IntSize) - if err2 != nil { - err = goa.MergeErrors(err, goa.InvalidFieldTypeError("query", valRaw, "array of unsigned integers")) - } - val[i] = uint(v) + var key string + key = keyRaw + var val []uint + { + val = make([]uint, len(valRaw)) + for i, rv := range valRaw { + v, err2 := strconv.ParseUint(rv, 10, strconv.IntSize) + if err2 != nil { + err = goa.MergeErrors(err, goa.InvalidFieldTypeError("query", valRaw, "array of unsigned integers")) } + val[i] = uint(v) } - query[keya] = val } + query[key] = val } } if err != nil { diff --git a/http/codegen/testdata/golden/server_decode_decode-map-query-primitive-primitive.go.golden b/http/codegen/testdata/golden/server_decode_decode-map-query-primitive-primitive.go.golden index 52b194560c..49e38038bb 100644 --- a/http/codegen/testdata/golden/server_decode_decode-map-query-primitive-primitive.go.golden +++ b/http/codegen/testdata/golden/server_decode_decode-map-query-primitive-primitive.go.golden @@ -12,23 +12,13 @@ func DecodeMapQueryPrimitivePrimitiveRequest(mux goahttp.Muxer, decoder func(*ht if len(queryRaw) == 0 { err = goa.MergeErrors(err, goa.MissingFieldError("query", "query string")) } + if query == nil { + query = make(map[string]string) + } for keyRaw, valRaw := range queryRaw { - if strings.HasPrefix(keyRaw, "query[") { - if query == nil { - query = make(map[string]string) - } - var keya string - { - openIdx := strings.IndexRune(keyRaw, '[') - closeIdx := strings.IndexRune(keyRaw, ']') - if openIdx == -1 || closeIdx == -1 || closeIdx <= openIdx { - err = goa.MergeErrors(err, goa.DecodePayloadError("invalid query string: malformed brackets")) - } else { - keya = keyRaw[openIdx+1 : closeIdx] - } - } - query[keya] = valRaw[0] - } + var key string + key = keyRaw + query[key] = valRaw[0] } } if err != nil { diff --git a/http/codegen/testdata/golden/server_decode_decode-multipart-body-array-type.go.golden b/http/codegen/testdata/golden/server_decode_decode-multipart-body-array-type.go.golden index 44258c1cb8..89e4cfc3b4 100644 --- a/http/codegen/testdata/golden/server_decode_decode-multipart-body-array-type.go.golden +++ b/http/codegen/testdata/golden/server_decode_decode-multipart-body-array-type.go.golden @@ -3,13 +3,32 @@ func DecodeMethodMultipartArrayTypeRequest(mux goahttp.Muxer, decoder func(*http.Request) goahttp.Decoder) func(*http.Request) ([]*servicemultipartarraytype.PayloadType, error) { return func(r *http.Request) ([]*servicemultipartarraytype.PayloadType, error) { var payload []*servicemultipartarraytype.PayloadType - if err := decoder(r).Decode(&payload); err != nil { + var ( + body []*PayloadTypeRequestBody + err error + ) + err = decoder(r).Decode(&body) + if err != nil { + if errors.Is(err, io.EOF) { + return payload, goa.MissingPayloadError() + } var gerr *goa.ServiceError if errors.As(err, &gerr) { return payload, gerr } return payload, goa.DecodePayloadError(err.Error()) } + for _, e := range body { + if e != nil { + if err2 := validatePayloadTypeRequestBody(e, "body[*]"); err2 != nil { + err = goa.MergeErrors(err, err2) + } + } + } + if err != nil { + return payload, err + } + payload = NewMethodMultipartArrayTypePayloadType(body) return payload, nil } diff --git a/http/codegen/testdata/golden/server_decode_decode-multipart-body-map-type.go.golden b/http/codegen/testdata/golden/server_decode_decode-multipart-body-map-type.go.golden index 37f333f73d..4cc86de67b 100644 --- a/http/codegen/testdata/golden/server_decode_decode-multipart-body-map-type.go.golden +++ b/http/codegen/testdata/golden/server_decode_decode-multipart-body-map-type.go.golden @@ -3,13 +3,22 @@ func DecodeMethodMultipartMapTypeRequest(mux goahttp.Muxer, decoder func(*http.Request) goahttp.Decoder) func(*http.Request) (map[string]int, error) { return func(r *http.Request) (map[string]int, error) { var payload map[string]int - if err := decoder(r).Decode(&payload); err != nil { + var ( + body map[string]int + err error + ) + err = decoder(r).Decode(&body) + if err != nil { + if errors.Is(err, io.EOF) { + return payload, goa.MissingPayloadError() + } var gerr *goa.ServiceError if errors.As(err, &gerr) { return payload, gerr } return payload, goa.DecodePayloadError(err.Error()) } + payload = body return payload, nil } diff --git a/http/codegen/testdata/golden/server_decode_decode-multipart-body-primitive.go.golden b/http/codegen/testdata/golden/server_decode_decode-multipart-body-primitive.go.golden index d763025157..44e8ad610c 100644 --- a/http/codegen/testdata/golden/server_decode_decode-multipart-body-primitive.go.golden +++ b/http/codegen/testdata/golden/server_decode_decode-multipart-body-primitive.go.golden @@ -3,13 +3,22 @@ func DecodeMethodMultipartPrimitiveRequest(mux goahttp.Muxer, decoder func(*http.Request) goahttp.Decoder) func(*http.Request) (string, error) { return func(r *http.Request) (string, error) { var payload string - if err := decoder(r).Decode(&payload); err != nil { + var ( + body string + err error + ) + err = decoder(r).Decode(&body) + if err != nil { + if errors.Is(err, io.EOF) { + return payload, goa.MissingPayloadError() + } var gerr *goa.ServiceError if errors.As(err, &gerr) { return payload, gerr } return payload, goa.DecodePayloadError(err.Error()) } + payload = body return payload, nil } diff --git a/http/codegen/testdata/golden/server_decode_decode-multipart-body-user-type.go.golden b/http/codegen/testdata/golden/server_decode_decode-multipart-body-user-type.go.golden index 032f70ee31..d431496263 100644 --- a/http/codegen/testdata/golden/server_decode_decode-multipart-body-user-type.go.golden +++ b/http/codegen/testdata/golden/server_decode_decode-multipart-body-user-type.go.golden @@ -3,13 +3,26 @@ func DecodeMethodMultipartUserTypeRequest(mux goahttp.Muxer, decoder func(*http.Request) goahttp.Decoder) func(*http.Request) (*servicemultipartusertype.MethodMultipartUserTypePayload, error) { return func(r *http.Request) (*servicemultipartusertype.MethodMultipartUserTypePayload, error) { var payload *servicemultipartusertype.MethodMultipartUserTypePayload - if err := decoder(r).Decode(&payload); err != nil { + var ( + body MethodMultipartUserTypeRequestBody + err error + ) + err = decoder(r).Decode(&body) + if err != nil { + if errors.Is(err, io.EOF) { + return payload, goa.MissingPayloadError() + } var gerr *goa.ServiceError if errors.As(err, &gerr) { return payload, gerr } return payload, goa.DecodePayloadError(err.Error()) } + err = ValidateMethodMultipartUserTypeRequestBody(&body) + if err != nil { + return payload, err + } + payload = NewMethodMultipartUserTypePayload(&body) return payload, nil } diff --git a/http/codegen/testdata/golden/server_decode_decode-multipart-body-validation.go.golden b/http/codegen/testdata/golden/server_decode_decode-multipart-body-validation.go.golden new file mode 100644 index 0000000000..87397a5509 --- /dev/null +++ b/http/codegen/testdata/golden/server_decode_decode-multipart-body-validation.go.golden @@ -0,0 +1,29 @@ +// DecodeMethodMultipartValidationRequest returns a decoder for requests sent +// to the ServiceMultipartValidation MethodMultipartValidation endpoint. +func DecodeMethodMultipartValidationRequest(mux goahttp.Muxer, decoder func(*http.Request) goahttp.Decoder) func(*http.Request) (*servicemultipartvalidation.MethodMultipartValidationPayload, error) { + return func(r *http.Request) (*servicemultipartvalidation.MethodMultipartValidationPayload, error) { + var payload *servicemultipartvalidation.MethodMultipartValidationPayload + var ( + body MethodMultipartValidationRequestBody + err error + ) + err = decoder(r).Decode(&body) + if err != nil { + if errors.Is(err, io.EOF) { + return payload, goa.MissingPayloadError() + } + var gerr *goa.ServiceError + if errors.As(err, &gerr) { + return payload, gerr + } + return payload, goa.DecodePayloadError(err.Error()) + } + err = ValidateMethodMultipartValidationRequestBody(&body) + if err != nil { + return payload, err + } + payload = NewMethodMultipartValidationPayload(&body) + + return payload, nil + } +} diff --git a/http/codegen/testdata/golden/server_decode_decode-multipart-with-param.go.golden b/http/codegen/testdata/golden/server_decode_decode-multipart-with-param.go.golden new file mode 100644 index 0000000000..043170b1f3 --- /dev/null +++ b/http/codegen/testdata/golden/server_decode_decode-multipart-with-param.go.golden @@ -0,0 +1,65 @@ +// DecodeMethodMultipartWithParamRequest returns a decoder for requests sent to +// the ServiceMultipartWithParam MethodMultipartWithParam endpoint. +func DecodeMethodMultipartWithParamRequest(mux goahttp.Muxer, decoder func(*http.Request) goahttp.Decoder) func(*http.Request) (*servicemultipartwithparam.PayloadType, error) { + return func(r *http.Request) (*servicemultipartwithparam.PayloadType, error) { + var payload *servicemultipartwithparam.PayloadType + var ( + body MethodMultipartWithParamRequestBody + err error + ) + err = decoder(r).Decode(&body) + if err != nil { + if errors.Is(err, io.EOF) { + return payload, goa.MissingPayloadError() + } + var gerr *goa.ServiceError + if errors.As(err, &gerr) { + return payload, gerr + } + return payload, goa.DecodePayloadError(err.Error()) + } + err = ValidateMethodMultipartWithParamRequestBody(&body) + if err != nil { + return payload, err + } + + var ( + c2 map[int][]string + ) + { + c2Raw := r.URL.Query() + if len(c2Raw) == 0 { + err = goa.MergeErrors(err, goa.MissingFieldError("c", "query string")) + } + for keyRaw, valRaw := range c2Raw { + if strings.HasPrefix(keyRaw, "c[") { + if c2 == nil { + c2 = make(map[int][]string) + } + var keya int + { + openIdx := strings.IndexRune(keyRaw, '[') + closeIdx := strings.IndexRune(keyRaw, ']') + if openIdx == -1 || closeIdx == -1 || closeIdx <= openIdx { + err = goa.MergeErrors(err, goa.DecodePayloadError("invalid query string: malformed brackets")) + } else { + keyaRaw := keyRaw[openIdx+1 : closeIdx] + v, err2 := strconv.ParseInt(keyaRaw, 10, strconv.IntSize) + if err2 != nil { + err = goa.MergeErrors(err, goa.InvalidFieldTypeError("query", keyaRaw, "integer")) + } + keya = int(v) + } + } + c2[keya] = valRaw + } + } + } + if err != nil { + return payload, err + } + payload = NewMethodMultipartWithParamPayloadType(&body, c2) + + return payload, nil + } +} diff --git a/http/codegen/testdata/golden/server_decode_decode-multipart-with-params-and-headers.go.golden b/http/codegen/testdata/golden/server_decode_decode-multipart-with-params-and-headers.go.golden new file mode 100644 index 0000000000..2de7af9d40 --- /dev/null +++ b/http/codegen/testdata/golden/server_decode_decode-multipart-with-params-and-headers.go.golden @@ -0,0 +1,79 @@ +// DecodeMethodMultipartWithParamsAndHeadersRequest returns a decoder for +// requests sent to the ServiceMultipartWithParamsAndHeaders +// MethodMultipartWithParamsAndHeaders endpoint. +func DecodeMethodMultipartWithParamsAndHeadersRequest(mux goahttp.Muxer, decoder func(*http.Request) goahttp.Decoder) func(*http.Request) (*servicemultipartwithparamsandheaders.PayloadType, error) { + return func(r *http.Request) (*servicemultipartwithparamsandheaders.PayloadType, error) { + var payload *servicemultipartwithparamsandheaders.PayloadType + var ( + body MethodMultipartWithParamsAndHeadersRequestBody + err error + ) + err = decoder(r).Decode(&body) + if err != nil { + if errors.Is(err, io.EOF) { + return payload, goa.MissingPayloadError() + } + var gerr *goa.ServiceError + if errors.As(err, &gerr) { + return payload, gerr + } + return payload, goa.DecodePayloadError(err.Error()) + } + err = ValidateMethodMultipartWithParamsAndHeadersRequestBody(&body) + if err != nil { + return payload, err + } + + var ( + a string + c2 map[int][]string + b *string + + params = mux.Vars(r) + ) + a = params["a"] + err = goa.MergeErrors(err, goa.ValidatePattern("a", a, "patterna")) + { + c2Raw := r.URL.Query() + if len(c2Raw) == 0 { + err = goa.MergeErrors(err, goa.MissingFieldError("c", "query string")) + } + for keyRaw, valRaw := range c2Raw { + if strings.HasPrefix(keyRaw, "c[") { + if c2 == nil { + c2 = make(map[int][]string) + } + var keya int + { + openIdx := strings.IndexRune(keyRaw, '[') + closeIdx := strings.IndexRune(keyRaw, ']') + if openIdx == -1 || closeIdx == -1 || closeIdx <= openIdx { + err = goa.MergeErrors(err, goa.DecodePayloadError("invalid query string: malformed brackets")) + } else { + keyaRaw := keyRaw[openIdx+1 : closeIdx] + v, err2 := strconv.ParseInt(keyaRaw, 10, strconv.IntSize) + if err2 != nil { + err = goa.MergeErrors(err, goa.InvalidFieldTypeError("query", keyaRaw, "integer")) + } + keya = int(v) + } + } + c2[keya] = valRaw + } + } + } + bRaw := r.Header.Get("Authorization") + if bRaw != "" { + b = &bRaw + } + if b != nil { + err = goa.MergeErrors(err, goa.ValidatePattern("b", *b, "patternb")) + } + if err != nil { + return payload, err + } + payload = NewMethodMultipartWithParamsAndHeadersPayloadType(&body, a, c2, b) + + return payload, nil + } +} diff --git a/http/codegen/testdata/golden/server_encode_default-response-before-tags.go.golden b/http/codegen/testdata/golden/server_encode_default-response-before-tags.go.golden new file mode 100644 index 0000000000..030d136423 --- /dev/null +++ b/http/codegen/testdata/golden/server_encode_default-response-before-tags.go.golden @@ -0,0 +1,23 @@ +// EncodeMethodResponseOrderResponse returns an encoder for responses returned +// by the ServiceResponseOrder MethodResponseOrder endpoint. +func EncodeMethodResponseOrderResponse(encoder func(context.Context, http.ResponseWriter) goahttp.Encoder) func(context.Context, http.ResponseWriter, any) error { + return func(ctx context.Context, w http.ResponseWriter, v any) error { + res, _ := v.(*serviceresponseorder.MethodResponseOrderResult) + if res.First != nil && *res.First == "match" { + enc := encoder(ctx, w) + body := NewMethodResponseOrderAcceptedResponseBody(res) + w.WriteHeader(http.StatusAccepted) + return enc.Encode(body) + } + if res.Second != nil && *res.Second == "match" { + enc := encoder(ctx, w) + body := NewMethodResponseOrderCreatedResponseBody(res) + w.WriteHeader(http.StatusCreated) + return enc.Encode(body) + } + enc := encoder(ctx, w) + body := NewMethodResponseOrderOKResponseBody(res) + w.WriteHeader(http.StatusOK) + return enc.Encode(body) + } +} diff --git a/http/codegen/testdata/golden/server_encode_explicit-body-result-collection.go.golden b/http/codegen/testdata/golden/server_encode_explicit-body-result-collection.go.golden index 43ba6e30a8..b681ede57e 100644 --- a/http/codegen/testdata/golden/server_encode_explicit-body-result-collection.go.golden +++ b/http/codegen/testdata/golden/server_encode_explicit-body-result-collection.go.golden @@ -5,7 +5,7 @@ func EncodeMethodExplicitBodyResultCollectionResponse(encoder func(context.Conte return func(ctx context.Context, w http.ResponseWriter, v any) error { res, _ := v.(*serviceexplicitbodyresultcollection.MethodExplicitBodyResultCollectionResult) enc := encoder(ctx, w) - body := NewResulttypeCollection(res) + body := NewResulttypeResponseCollection(res) w.WriteHeader(http.StatusOK) return enc.Encode(body) } diff --git a/http/codegen/testdata/golden/server_encode_marshal_embedded-custom-pkg-type_section0.go.golden b/http/codegen/testdata/golden/server_encode_marshal_embedded-custom-pkg-type_section0.go.golden index c79024ee92..118b6e632b 100644 --- a/http/codegen/testdata/golden/server_encode_marshal_embedded-custom-pkg-type_section0.go.golden +++ b/http/codegen/testdata/golden/server_encode_marshal_embedded-custom-pkg-type_section0.go.golden @@ -1,9 +1,6 @@ // unmarshalFooRequestBodyToFooFoo builds a value of type *foo.Foo from a value // of type *FooRequestBody. func unmarshalFooRequestBodyToFooFoo(v *FooRequestBody) *foo.Foo { - if v == nil { - return nil - } res := &foo.Foo{ Bar: v.Bar, } diff --git a/http/codegen/testdata/golden/server_encode_marshal_embedded-custom-pkg-type_section1.go.golden b/http/codegen/testdata/golden/server_encode_marshal_embedded-custom-pkg-type_section1.go.golden index 4f3e2bf6df..274fece55f 100644 --- a/http/codegen/testdata/golden/server_encode_marshal_embedded-custom-pkg-type_section1.go.golden +++ b/http/codegen/testdata/golden/server_encode_marshal_embedded-custom-pkg-type_section1.go.golden @@ -1,9 +1,6 @@ // marshalFooFooToFooResponseBody builds a value of type *FooResponseBody from // a value of type *foo.Foo. func marshalFooFooToFooResponseBody(v *foo.Foo) *FooResponseBody { - if v == nil { - return nil - } res := &FooResponseBody{ Bar: v.Bar, } diff --git a/http/codegen/testdata/golden/server_encode_marshal_extension-with-alias_section0.go.golden b/http/codegen/testdata/golden/server_encode_marshal_extension-with-alias_section0.go.golden index ca22a60a55..c8f16f9a7f 100644 --- a/http/codegen/testdata/golden/server_encode_marshal_extension-with-alias_section0.go.golden +++ b/http/codegen/testdata/golden/server_encode_marshal_extension-with-alias_section0.go.golden @@ -1,9 +1,6 @@ // unmarshalExtensionRequestBodyToFooserviceExtension builds a value of type // *fooservice.Extension from a value of type *ExtensionRequestBody. func unmarshalExtensionRequestBodyToFooserviceExtension(v *ExtensionRequestBody) *fooservice.Extension { - if v == nil { - return nil - } res := &fooservice.Extension{} if v.Bar != nil { res.Bar = unmarshalBarRequestBodyToFooserviceBar(v.Bar) diff --git a/http/codegen/testdata/golden/server_encode_marshal_extension-with-alias_section1.go.golden b/http/codegen/testdata/golden/server_encode_marshal_extension-with-alias_section1.go.golden index 32ec2f62e4..aa2546294b 100644 --- a/http/codegen/testdata/golden/server_encode_marshal_extension-with-alias_section1.go.golden +++ b/http/codegen/testdata/golden/server_encode_marshal_extension-with-alias_section1.go.golden @@ -1,9 +1,6 @@ // unmarshalBarRequestBodyToFooserviceBar builds a value of type // *fooservice.Bar from a value of type *BarRequestBody. func unmarshalBarRequestBodyToFooserviceBar(v *BarRequestBody) *fooservice.Bar { - if v == nil { - return nil - } res := &fooservice.Bar{ Bar: *v.Bar, } diff --git a/http/codegen/testdata/golden/server_encode_marshal_extension-with-alias_section3.go.golden b/http/codegen/testdata/golden/server_encode_marshal_extension-with-alias_section3.go.golden index 730ae35211..24ad0a3fbe 100644 --- a/http/codegen/testdata/golden/server_encode_marshal_extension-with-alias_section3.go.golden +++ b/http/codegen/testdata/golden/server_encode_marshal_extension-with-alias_section3.go.golden @@ -1,9 +1,6 @@ // marshalFooserviceExtensionToExtensionResponse builds a value of type // *ExtensionResponse from a value of type *fooservice.Extension. func marshalFooserviceExtensionToExtensionResponse(v *fooservice.Extension) *ExtensionResponse { - if v == nil { - return nil - } res := &ExtensionResponse{} if v.Bar != nil { res.Bar = marshalFooserviceBarToBarResponse(v.Bar) diff --git a/http/codegen/testdata/golden/server_encode_marshal_extension-with-alias_section4.go.golden b/http/codegen/testdata/golden/server_encode_marshal_extension-with-alias_section4.go.golden index 97c3aeb895..6dda650a43 100644 --- a/http/codegen/testdata/golden/server_encode_marshal_extension-with-alias_section4.go.golden +++ b/http/codegen/testdata/golden/server_encode_marshal_extension-with-alias_section4.go.golden @@ -1,9 +1,6 @@ // marshalFooserviceBarToBarResponse builds a value of type *BarResponse from a // value of type *fooservice.Bar. func marshalFooserviceBarToBarResponse(v *fooservice.Bar) *BarResponse { - if v == nil { - return nil - } res := &BarResponse{ Bar: v.Bar, } diff --git a/http/codegen/testdata/golden/server_error_encoder_error-body-attribute-response.go.golden b/http/codegen/testdata/golden/server_error_encoder_error-body-attribute-response.go.golden new file mode 100644 index 0000000000..6e7d4e80c7 --- /dev/null +++ b/http/codegen/testdata/golden/server_error_encoder_error-body-attribute-response.go.golden @@ -0,0 +1,24 @@ +// EncodeMethodErrorBodyAttributeResponseError returns an encoder for errors +// returned by the MethodErrorBodyAttributeResponse +// ServiceErrorBodyAttributeResponse endpoint. +func EncodeMethodErrorBodyAttributeResponseError(encoder func(context.Context, http.ResponseWriter) goahttp.Encoder, formatter func(ctx context.Context, err error) goahttp.Statuser) func(context.Context, http.ResponseWriter, error) error { + encodeError := goahttp.ErrorEncoder(encoder, formatter) + return func(ctx context.Context, w http.ResponseWriter, v error) error { + var en goa.GoaErrorNamer + if !errors.As(v, &en) { + return encodeError(ctx, w, v) + } + switch en.GoaErrorName() { + case "selected": + var res *serviceerrorbodyattributeresponse.SelectedError + errors.As(v, &res) + enc := encoder(ctx, w) + body := res.Detail + w.Header().Set("goa-error", res.GoaErrorName()) + w.WriteHeader(http.StatusBadRequest) + return enc.Encode(body) + default: + return encodeError(ctx, w, v) + } + } +} diff --git a/http/codegen/testdata/golden/server_extensions_endpoint_helper.go.golden b/http/codegen/testdata/golden/server_extensions_endpoint_helper.go.golden new file mode 100644 index 0000000000..31864b5c21 --- /dev/null +++ b/http/codegen/testdata/golden/server_extensions_endpoint_helper.go.golden @@ -0,0 +1,12 @@ +// MountReadHandler configures the mux to serve the "Files" service "Read" +// endpoint. +func MountReadHandler(mux goahttp.Muxer, h http.Handler) { + h = First(Second(wrapEndpoint(h))) + f, ok := h.(http.HandlerFunc) + if !ok { + f = func(w http.ResponseWriter, r *http.Request) { + h.ServeHTTP(w, r) + } + } + mux.Handle("GET", "/items/{id}", f) +} diff --git a/http/codegen/testdata/golden/server_extensions_escaping.go.golden b/http/codegen/testdata/golden/server_extensions_escaping.go.golden new file mode 100644 index 0000000000..cb391c4ced --- /dev/null +++ b/http/codegen/testdata/golden/server_extensions_escaping.go.golden @@ -0,0 +1,22 @@ +// New instantiates HTTP handlers for all the Escape service endpoints using +// the provided encoder and decoder. The handlers are mounted on the given mux +// using the HTTP verb and path defined in the design. errhandler is called +// whenever a response fails to be encoded. formatter is used to format errors +// returned by the service methods prior to encoding. Both errhandler and +// formatter are optional and can be nil. +func New( + e *escape.Endpoints, + mux goahttp.Muxer, + decoder func(*http.Request) goahttp.Decoder, + encoder func(context.Context, http.ResponseWriter) goahttp.Encoder, + errhandler func(context.Context, http.ResponseWriter, error), + formatter func(ctx context.Context, err error) goahttp.Statuser, +) *Server { + return &Server{ + Mounts: []*MountPoint{ + {"Ping", "GET", "/"}, + {"Quoted \"method\"\nnext", "CUSTOM\\VERB", "/quoted/\"value\"\\next\nline"}, + }, + Ping: NewPingHandler(e.Ping, mux, decoder, encoder, errhandler, formatter), + } +} diff --git a/http/codegen/testdata/golden/server_extensions_file_helper.go.golden b/http/codegen/testdata/golden/server_extensions_file_helper.go.golden new file mode 100644 index 0000000000..98ca985187 --- /dev/null +++ b/http/codegen/testdata/golden/server_extensions_file_helper.go.golden @@ -0,0 +1,6 @@ +// MountAssets configures the mux to serve GET request made to "/assets". +func MountAssets(mux goahttp.Muxer, h http.Handler) { + h = First(Second(h)) + mux.Handle("GET", "/assets/", h.ServeHTTP) + mux.Handle("GET", "/assets/{*path}", h.ServeHTTP) +} diff --git a/http/codegen/testdata/golden/server_extensions_init.go.golden b/http/codegen/testdata/golden/server_extensions_init.go.golden new file mode 100644 index 0000000000..a0711e108c --- /dev/null +++ b/http/codegen/testdata/golden/server_extensions_init.go.golden @@ -0,0 +1,37 @@ +// New instantiates HTTP handlers for all the Files service endpoints using the +// provided encoder and decoder. The handlers are mounted on the given mux +// using the HTTP verb and path defined in the design. errhandler is called +// whenever a response fails to be encoded. formatter is used to format errors +// returned by the service methods prior to encoding. Both errhandler and +// formatter are optional and can be nil. +func New( + e *files.Endpoints, + mux goahttp.Muxer, + decoder func(*http.Request) goahttp.Decoder, + encoder func(context.Context, http.ResponseWriter) goahttp.Encoder, + errhandler func(context.Context, http.ResponseWriter, error), + formatter func(ctx context.Context, err error) goahttp.Statuser, + fileSystemAssets http.FileSystem, + fileSystemOldHTML http.FileSystem, +) *Server { + if fileSystemAssets == nil { + fileSystemAssets = http.Dir(".") + } + fileSystemAssets = appendPrefix(fileSystemAssets, "/assets") + if fileSystemOldHTML == nil { + fileSystemOldHTML = http.Dir(".") + } + fileSystemOldHTML = appendPrefix(fileSystemOldHTML, "/") + return &Server{ + Mounts: []*MountPoint{ + {"Read", "GET", "/items/{id}"}, + {"Serve assets", "GET", "/assets"}, + {"Serve old.html", "GET", "/old"}, + {"Preflight item", "OPTIONS", "/items/{id}"}, + {"Preflight assets", "OPTIONS", "/assets/{*path}"}, + }, + Read: NewReadHandler(e.Read, mux, decoder, encoder, errhandler, formatter), + Assets: http.FileServer(fileSystemAssets), + OldHTML: http.FileServer(fileSystemOldHTML), + } +} diff --git a/http/codegen/testdata/golden/server_extensions_mount.go.golden b/http/codegen/testdata/golden/server_extensions_mount.go.golden new file mode 100644 index 0000000000..20098b2236 --- /dev/null +++ b/http/codegen/testdata/golden/server_extensions_mount.go.golden @@ -0,0 +1,14 @@ +// Mount configures the mux to serve the Files endpoints. +func Mount(mux goahttp.Muxer, h *Server) { + MountReadHandler(mux, h.Read) + MountAssets(mux, http.StripPrefix("/assets", h.Assets)) + MountOldHTML(mux, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Redirect(w, r, "/new", http.StatusMovedPermanently) + })) + MountPreflight(mux) +} + +// Mount configures the mux to serve the Files endpoints. +func (s *Server) Mount(mux goahttp.Muxer) { + Mount(mux, s) +} diff --git a/http/codegen/testdata/golden/server_extensions_redirect_helper.go.golden b/http/codegen/testdata/golden/server_extensions_redirect_helper.go.golden new file mode 100644 index 0000000000..a4d72ff7ba --- /dev/null +++ b/http/codegen/testdata/golden/server_extensions_redirect_helper.go.golden @@ -0,0 +1,5 @@ +// MountOldHTML configures the mux to serve GET request made to "/old". +func MountOldHTML(mux goahttp.Muxer, h http.Handler) { + h = First(Second(h)) + mux.Handle("GET", "/old", h.ServeHTTP) +} diff --git a/http/codegen/testdata/golden/server_multipart_multipart-body-array-type.go.golden b/http/codegen/testdata/golden/server_multipart_multipart-body-array-type.go.golden index 3d2bf2b323..1f2519773e 100644 --- a/http/codegen/testdata/golden/server_multipart_multipart-body-array-type.go.golden +++ b/http/codegen/testdata/golden/server_multipart_multipart-body-array-type.go.golden @@ -1,4 +1,4 @@ // ServiceMultipartArrayTypeMethodMultipartArrayTypeDecoderFunc is the type to // decode multipart request for the "ServiceMultipartArrayType" service // "MethodMultipartArrayType" endpoint. -type ServiceMultipartArrayTypeMethodMultipartArrayTypeDecoderFunc func(*multipart.Reader, *[]*servicemultipartarraytype.PayloadType) error +type ServiceMultipartArrayTypeMethodMultipartArrayTypeDecoderFunc func(*multipart.Reader, *[]*PayloadTypeRequestBody) error diff --git a/http/codegen/testdata/golden/server_multipart_multipart-body-user-type.go.golden b/http/codegen/testdata/golden/server_multipart_multipart-body-user-type.go.golden index d7dce056e7..83adb6442b 100644 --- a/http/codegen/testdata/golden/server_multipart_multipart-body-user-type.go.golden +++ b/http/codegen/testdata/golden/server_multipart_multipart-body-user-type.go.golden @@ -1,4 +1,4 @@ // ServiceMultipartUserTypeMethodMultipartUserTypeDecoderFunc is the type to // decode multipart request for the "ServiceMultipartUserType" service // "MethodMultipartUserType" endpoint. -type ServiceMultipartUserTypeMethodMultipartUserTypeDecoderFunc func(*multipart.Reader, **servicemultipartusertype.MethodMultipartUserTypePayload) error +type ServiceMultipartUserTypeMethodMultipartUserTypeDecoderFunc func(*multipart.Reader, *MethodMultipartUserTypeRequestBody) error diff --git a/http/codegen/testdata/golden/server_multipart_multipart-body-validation.go.golden b/http/codegen/testdata/golden/server_multipart_multipart-body-validation.go.golden new file mode 100644 index 0000000000..3aaa0eebcf --- /dev/null +++ b/http/codegen/testdata/golden/server_multipart_multipart-body-validation.go.golden @@ -0,0 +1,4 @@ +// ServiceMultipartValidationMethodMultipartValidationDecoderFunc is the type +// to decode multipart request for the "ServiceMultipartValidation" service +// "MethodMultipartValidation" endpoint. +type ServiceMultipartValidationMethodMultipartValidationDecoderFunc func(*multipart.Reader, *MethodMultipartValidationRequestBody) error diff --git a/http/codegen/testdata/golden/server_multipart_server-multipart-body-array-type.go.golden b/http/codegen/testdata/golden/server_multipart_server-multipart-body-array-type.go.golden index 80d15bab46..5d1eca1a42 100644 --- a/http/codegen/testdata/golden/server_multipart_server-multipart-body-array-type.go.golden +++ b/http/codegen/testdata/golden/server_multipart_server-multipart-body-array-type.go.golden @@ -1,15 +1,15 @@ // NewServiceMultipartArrayTypeMethodMultipartArrayTypeDecoder returns a // decoder to decode the multipart request for the "ServiceMultipartArrayType" // service "MethodMultipartArrayType" endpoint. -func NewServiceMultipartArrayTypeMethodMultipartArrayTypeDecoder(mux goahttp.Muxer, serviceMultipartArrayTypeMethodMultipartArrayTypeDecoderFn ServiceMultipartArrayTypeMethodMultipartArrayTypeDecoderFunc) func(r *http.Request) goahttp.Decoder { +func NewServiceMultipartArrayTypeMethodMultipartArrayTypeDecoder(_ goahttp.Muxer, serviceMultipartArrayTypeMethodMultipartArrayTypeDecoderFn ServiceMultipartArrayTypeMethodMultipartArrayTypeDecoderFunc) func(r *http.Request) goahttp.Decoder { return func(r *http.Request) goahttp.Decoder { return goahttp.EncodingFunc(func(v any) error { mr, merr := r.MultipartReader() if merr != nil { return merr } - p := v.(*[]*servicemultipartarraytype.PayloadType) - if err := serviceMultipartArrayTypeMethodMultipartArrayTypeDecoderFn(mr, p); err != nil { + body := v.(*[]*PayloadTypeRequestBody) + if err := serviceMultipartArrayTypeMethodMultipartArrayTypeDecoderFn(mr, body); err != nil { return err } return nil diff --git a/http/codegen/testdata/golden/server_multipart_server-multipart-body-map-type.go.golden b/http/codegen/testdata/golden/server_multipart_server-multipart-body-map-type.go.golden index 604f597f90..d3540d9071 100644 --- a/http/codegen/testdata/golden/server_multipart_server-multipart-body-map-type.go.golden +++ b/http/codegen/testdata/golden/server_multipart_server-multipart-body-map-type.go.golden @@ -1,15 +1,15 @@ // NewServiceMultipartMapTypeMethodMultipartMapTypeDecoder returns a decoder to // decode the multipart request for the "ServiceMultipartMapType" service // "MethodMultipartMapType" endpoint. -func NewServiceMultipartMapTypeMethodMultipartMapTypeDecoder(mux goahttp.Muxer, serviceMultipartMapTypeMethodMultipartMapTypeDecoderFn ServiceMultipartMapTypeMethodMultipartMapTypeDecoderFunc) func(r *http.Request) goahttp.Decoder { +func NewServiceMultipartMapTypeMethodMultipartMapTypeDecoder(_ goahttp.Muxer, serviceMultipartMapTypeMethodMultipartMapTypeDecoderFn ServiceMultipartMapTypeMethodMultipartMapTypeDecoderFunc) func(r *http.Request) goahttp.Decoder { return func(r *http.Request) goahttp.Decoder { return goahttp.EncodingFunc(func(v any) error { mr, merr := r.MultipartReader() if merr != nil { return merr } - p := v.(*map[string]int) - if err := serviceMultipartMapTypeMethodMultipartMapTypeDecoderFn(mr, p); err != nil { + body := v.(*map[string]int) + if err := serviceMultipartMapTypeMethodMultipartMapTypeDecoderFn(mr, body); err != nil { return err } return nil diff --git a/http/codegen/testdata/golden/server_multipart_server-multipart-body-primitive.go.golden b/http/codegen/testdata/golden/server_multipart_server-multipart-body-primitive.go.golden index 1b90418bbc..f6774d6fec 100644 --- a/http/codegen/testdata/golden/server_multipart_server-multipart-body-primitive.go.golden +++ b/http/codegen/testdata/golden/server_multipart_server-multipart-body-primitive.go.golden @@ -1,15 +1,15 @@ // NewServiceMultipartPrimitiveMethodMultipartPrimitiveDecoder returns a // decoder to decode the multipart request for the "ServiceMultipartPrimitive" // service "MethodMultipartPrimitive" endpoint. -func NewServiceMultipartPrimitiveMethodMultipartPrimitiveDecoder(mux goahttp.Muxer, serviceMultipartPrimitiveMethodMultipartPrimitiveDecoderFn ServiceMultipartPrimitiveMethodMultipartPrimitiveDecoderFunc) func(r *http.Request) goahttp.Decoder { +func NewServiceMultipartPrimitiveMethodMultipartPrimitiveDecoder(_ goahttp.Muxer, serviceMultipartPrimitiveMethodMultipartPrimitiveDecoderFn ServiceMultipartPrimitiveMethodMultipartPrimitiveDecoderFunc) func(r *http.Request) goahttp.Decoder { return func(r *http.Request) goahttp.Decoder { return goahttp.EncodingFunc(func(v any) error { mr, merr := r.MultipartReader() if merr != nil { return merr } - p := v.(*string) - if err := serviceMultipartPrimitiveMethodMultipartPrimitiveDecoderFn(mr, p); err != nil { + body := v.(*string) + if err := serviceMultipartPrimitiveMethodMultipartPrimitiveDecoderFn(mr, body); err != nil { return err } return nil diff --git a/http/codegen/testdata/golden/server_multipart_server-multipart-body-user-type.go.golden b/http/codegen/testdata/golden/server_multipart_server-multipart-body-user-type.go.golden index 55e5dee3f3..cc61211248 100644 --- a/http/codegen/testdata/golden/server_multipart_server-multipart-body-user-type.go.golden +++ b/http/codegen/testdata/golden/server_multipart_server-multipart-body-user-type.go.golden @@ -1,15 +1,15 @@ // NewServiceMultipartUserTypeMethodMultipartUserTypeDecoder returns a decoder // to decode the multipart request for the "ServiceMultipartUserType" service // "MethodMultipartUserType" endpoint. -func NewServiceMultipartUserTypeMethodMultipartUserTypeDecoder(mux goahttp.Muxer, serviceMultipartUserTypeMethodMultipartUserTypeDecoderFn ServiceMultipartUserTypeMethodMultipartUserTypeDecoderFunc) func(r *http.Request) goahttp.Decoder { +func NewServiceMultipartUserTypeMethodMultipartUserTypeDecoder(_ goahttp.Muxer, serviceMultipartUserTypeMethodMultipartUserTypeDecoderFn ServiceMultipartUserTypeMethodMultipartUserTypeDecoderFunc) func(r *http.Request) goahttp.Decoder { return func(r *http.Request) goahttp.Decoder { return goahttp.EncodingFunc(func(v any) error { mr, merr := r.MultipartReader() if merr != nil { return merr } - p := v.(**servicemultipartusertype.MethodMultipartUserTypePayload) - if err := serviceMultipartUserTypeMethodMultipartUserTypeDecoderFn(mr, p); err != nil { + body := v.(*MethodMultipartUserTypeRequestBody) + if err := serviceMultipartUserTypeMethodMultipartUserTypeDecoderFn(mr, body); err != nil { return err } return nil diff --git a/http/codegen/testdata/golden/server_multipart_server-multipart-body-validation.go.golden b/http/codegen/testdata/golden/server_multipart_server-multipart-body-validation.go.golden new file mode 100644 index 0000000000..c1bdd3f3d4 --- /dev/null +++ b/http/codegen/testdata/golden/server_multipart_server-multipart-body-validation.go.golden @@ -0,0 +1,18 @@ +// NewServiceMultipartValidationMethodMultipartValidationDecoder returns a +// decoder to decode the multipart request for the "ServiceMultipartValidation" +// service "MethodMultipartValidation" endpoint. +func NewServiceMultipartValidationMethodMultipartValidationDecoder(_ goahttp.Muxer, serviceMultipartValidationMethodMultipartValidationDecoderFn ServiceMultipartValidationMethodMultipartValidationDecoderFunc) func(r *http.Request) goahttp.Decoder { + return func(r *http.Request) goahttp.Decoder { + return goahttp.EncodingFunc(func(v any) error { + mr, merr := r.MultipartReader() + if merr != nil { + return merr + } + body := v.(*MethodMultipartValidationRequestBody) + if err := serviceMultipartValidationMethodMultipartValidationDecoderFn(mr, body); err != nil { + return err + } + return nil + }) + } +} diff --git a/http/codegen/testdata/golden/server_multipart_server-multipart-with-param.go.golden b/http/codegen/testdata/golden/server_multipart_server-multipart-with-param.go.golden index 673a61e26b..4a5a39c414 100644 --- a/http/codegen/testdata/golden/server_multipart_server-multipart-with-param.go.golden +++ b/http/codegen/testdata/golden/server_multipart_server-multipart-with-param.go.golden @@ -1,55 +1,17 @@ // NewServiceMultipartWithParamMethodMultipartWithParamDecoder returns a // decoder to decode the multipart request for the "ServiceMultipartWithParam" // service "MethodMultipartWithParam" endpoint. -func NewServiceMultipartWithParamMethodMultipartWithParamDecoder(mux goahttp.Muxer, serviceMultipartWithParamMethodMultipartWithParamDecoderFn ServiceMultipartWithParamMethodMultipartWithParamDecoderFunc) func(r *http.Request) goahttp.Decoder { +func NewServiceMultipartWithParamMethodMultipartWithParamDecoder(_ goahttp.Muxer, serviceMultipartWithParamMethodMultipartWithParamDecoderFn ServiceMultipartWithParamMethodMultipartWithParamDecoderFunc) func(r *http.Request) goahttp.Decoder { return func(r *http.Request) goahttp.Decoder { return goahttp.EncodingFunc(func(v any) error { mr, merr := r.MultipartReader() if merr != nil { return merr } - p := v.(**servicemultipartwithparam.PayloadType) - if err := serviceMultipartWithParamMethodMultipartWithParamDecoderFn(mr, p); err != nil { + body := v.(*MethodMultipartWithParamRequestBody) + if err := serviceMultipartWithParamMethodMultipartWithParamDecoderFn(mr, body); err != nil { return err } - - var ( - c2 map[int][]string - err error - ) - { - c2Raw := r.URL.Query() - if len(c2Raw) == 0 { - err = goa.MergeErrors(err, goa.MissingFieldError("c", "query string")) - } - for keyRaw, valRaw := range c2Raw { - if strings.HasPrefix(keyRaw, "c[") { - if c2 == nil { - c2 = make(map[int][]string) - } - var keya int - { - openIdx := strings.IndexRune(keyRaw, '[') - closeIdx := strings.IndexRune(keyRaw, ']') - if openIdx == -1 || closeIdx == -1 || closeIdx <= openIdx { - err = goa.MergeErrors(err, goa.DecodePayloadError("invalid query string: malformed brackets")) - } else { - keyaRaw := keyRaw[openIdx+1 : closeIdx] - v, err2 := strconv.ParseInt(keyaRaw, 10, strconv.IntSize) - if err2 != nil { - err = goa.MergeErrors(err, goa.InvalidFieldTypeError("query", keyaRaw, "integer")) - } - keya = int(v) - } - } - c2[keya] = valRaw - } - } - } - if err != nil { - return err - } - (*p).C = c2 return nil }) } diff --git a/http/codegen/testdata/golden/server_multipart_server-multipart-with-params-and-headers.go.golden b/http/codegen/testdata/golden/server_multipart_server-multipart-with-params-and-headers.go.golden index 0fae06d72e..804bfb47a1 100644 --- a/http/codegen/testdata/golden/server_multipart_server-multipart-with-params-and-headers.go.golden +++ b/http/codegen/testdata/golden/server_multipart_server-multipart-with-params-and-headers.go.golden @@ -2,69 +2,17 @@ // returns a decoder to decode the multipart request for the // "ServiceMultipartWithParamsAndHeaders" service // "MethodMultipartWithParamsAndHeaders" endpoint. -func NewServiceMultipartWithParamsAndHeadersMethodMultipartWithParamsAndHeadersDecoder(mux goahttp.Muxer, serviceMultipartWithParamsAndHeadersMethodMultipartWithParamsAndHeadersDecoderFn ServiceMultipartWithParamsAndHeadersMethodMultipartWithParamsAndHeadersDecoderFunc) func(r *http.Request) goahttp.Decoder { +func NewServiceMultipartWithParamsAndHeadersMethodMultipartWithParamsAndHeadersDecoder(_ goahttp.Muxer, serviceMultipartWithParamsAndHeadersMethodMultipartWithParamsAndHeadersDecoderFn ServiceMultipartWithParamsAndHeadersMethodMultipartWithParamsAndHeadersDecoderFunc) func(r *http.Request) goahttp.Decoder { return func(r *http.Request) goahttp.Decoder { return goahttp.EncodingFunc(func(v any) error { mr, merr := r.MultipartReader() if merr != nil { return merr } - p := v.(**servicemultipartwithparamsandheaders.PayloadType) - if err := serviceMultipartWithParamsAndHeadersMethodMultipartWithParamsAndHeadersDecoderFn(mr, p); err != nil { + body := v.(*MethodMultipartWithParamsAndHeadersRequestBody) + if err := serviceMultipartWithParamsAndHeadersMethodMultipartWithParamsAndHeadersDecoderFn(mr, body); err != nil { return err } - var ( - a string - c2 map[int][]string - b *string - err error - - params = mux.Vars(r) - ) - a = params["a"] - err = goa.MergeErrors(err, goa.ValidatePattern("a", a, "patterna")) - { - c2Raw := r.URL.Query() - if len(c2Raw) == 0 { - err = goa.MergeErrors(err, goa.MissingFieldError("c", "query string")) - } - for keyRaw, valRaw := range c2Raw { - if strings.HasPrefix(keyRaw, "c[") { - if c2 == nil { - c2 = make(map[int][]string) - } - var keya int - { - openIdx := strings.IndexRune(keyRaw, '[') - closeIdx := strings.IndexRune(keyRaw, ']') - if openIdx == -1 || closeIdx == -1 || closeIdx <= openIdx { - err = goa.MergeErrors(err, goa.DecodePayloadError("invalid query string: malformed brackets")) - } else { - keyaRaw := keyRaw[openIdx+1 : closeIdx] - v, err2 := strconv.ParseInt(keyaRaw, 10, strconv.IntSize) - if err2 != nil { - err = goa.MergeErrors(err, goa.InvalidFieldTypeError("query", keyaRaw, "integer")) - } - keya = int(v) - } - } - c2[keya] = valRaw - } - } - } - bRaw := r.Header.Get("Authorization") - if bRaw != "" { - b = &bRaw - } - if b != nil { - err = goa.MergeErrors(err, goa.ValidatePattern("b", *b, "patternb")) - } - if err != nil { - return err - } - (*p).A = a - (*p).C = c2 - (*p).B = b return nil }) } diff --git a/http/codegen/testdata/golden/server_payload_types_body-inline-map-user.go.golden b/http/codegen/testdata/golden/server_payload_types_body-inline-map-user.go.golden index a92096c3cf..b2152d20f1 100644 --- a/http/codegen/testdata/golden/server_payload_types_body-inline-map-user.go.golden +++ b/http/codegen/testdata/golden/server_payload_types_body-inline-map-user.go.golden @@ -3,7 +3,7 @@ func NewMethodBodyInlineMapUserMapKeyTypeElemType(body map[*KeyTypeRequestBody]*ElemTypeRequestBody) map[*servicebodyinlinemapuser.KeyType]*servicebodyinlinemapuser.ElemType { v := make(map[*servicebodyinlinemapuser.KeyType]*servicebodyinlinemapuser.ElemType, len(body)) for key, val := range body { - tk := unmarshalKeyTypeRequestBodyToServicebodyinlinemapuserKeyType(val) + tk := unmarshalKeyTypeRequestBodyToServicebodyinlinemapuserKeyType(key) if val == nil { v[tk] = nil continue diff --git a/http/codegen/testdata/golden/server_payload_types_body-union.go.golden b/http/codegen/testdata/golden/server_payload_types_body-union.go.golden index 2858a747ac..acf260a119 100644 --- a/http/codegen/testdata/golden/server_payload_types_body-union.go.golden +++ b/http/codegen/testdata/golden/server_payload_types_body-union.go.golden @@ -6,17 +6,17 @@ func NewMethodBodyUnionUnion(body *MethodBodyUnionRequestBody) *servicebodyunion switch string(body.Values.Kind()) { case "String": actual, _ := body.Values.AsString() - obj := servicebodyunion.ValuesString(actual) + obj := servicebodyunion.ValuesBranchString(actual) u := v.Values - u.SetString((servicebodyunion.ValuesString)(obj)) + u.SetString((servicebodyunion.ValuesBranchString)(obj)) v.Values = u case "Int": actual, _ := body.Values.AsInt() - obj := servicebodyunion.ValuesInt(actual) + obj := servicebodyunion.ValuesBranchInt(actual) u := v.Values - u.SetInt((servicebodyunion.ValuesInt)(obj)) + u.SetInt((servicebodyunion.ValuesBranchInt)(obj)) v.Values = u } } diff --git a/http/codegen/testdata/golden/server_payload_types_body-user-inner-origin.go.golden b/http/codegen/testdata/golden/server_payload_types_body-user-inner-origin.go.golden index 11b1cee35b..d02bcb92dd 100644 --- a/http/codegen/testdata/golden/server_payload_types_body-user-inner-origin.go.golden +++ b/http/codegen/testdata/golden/server_payload_types_body-user-inner-origin.go.golden @@ -1,11 +1,12 @@ // NewMethodBodyUserOriginDefaultPayload builds a ServiceBodyUserOriginDefault // service MethodBodyUserOriginDefault endpoint payload. func NewMethodBodyUserOriginDefaultPayload(body *MethodBodyUserOriginDefaultRequestBody) *servicebodyuserorigindefault.MethodBodyUserOriginDefaultPayload { - v := &servicebodyuserorigindefault.PayloadType{ - A: *body.A, - } - res := &servicebodyuserorigindefault.MethodBodyUserOriginDefaultPayload{ - Body: v, + res := &servicebodyuserorigindefault.MethodBodyUserOriginDefaultPayload{} + if body != nil { + v := &servicebodyuserorigindefault.PayloadType{ + A: *body.A, + } + res.Body = v } return res diff --git a/http/codegen/testdata/golden/server_payload_types_selected-body-bytes-default.go.golden b/http/codegen/testdata/golden/server_payload_types_selected-body-bytes-default.go.golden new file mode 100644 index 0000000000..9f2df27ff2 --- /dev/null +++ b/http/codegen/testdata/golden/server_payload_types_selected-body-bytes-default.go.golden @@ -0,0 +1,13 @@ +// NewBytesPayload builds a SelectedBytesBodyDefault service Bytes endpoint +// payload. +func NewBytesPayload(body []byte) *selectedbytesbodydefault.BytesPayload { + res := &selectedbytesbodydefault.BytesPayload{} + if body != nil { + v := body + res.Value = v + } else { + res.Value = []byte("safe") + } + + return res +} diff --git a/http/codegen/testdata/golden/server_payload_types_selected-body-defaults.go.golden b/http/codegen/testdata/golden/server_payload_types_selected-body-defaults.go.golden new file mode 100644 index 0000000000..56600a1c4d --- /dev/null +++ b/http/codegen/testdata/golden/server_payload_types_selected-body-defaults.go.golden @@ -0,0 +1,30 @@ +// NewUnionPayload builds a SelectedBodyDefaults service Union endpoint payload. +func NewUnionPayload(body *SelectedDefaultValueRequestBody) *selectedbodydefaults.UnionPayload { + res := &selectedbodydefaults.UnionPayload{} + if body != nil { + var v *selectedbodydefaults.SelectedDefaultValue + switch string(body.Kind()) { + case "name": + actual, _ := body.AsName() + obj := selectedbodydefaults.SelectedDefaultValueBranchName(actual) + + var u selectedbodydefaults.SelectedDefaultValue + u.SetName((selectedbodydefaults.SelectedDefaultValueBranchName)(obj)) + v = &u + case "inactive": + actual, _ := body.AsInactive() + var obj *selectedbodydefaults.SelectedDefaultEmpty + if actual != nil { + obj = unmarshalSelectedDefaultEmptyRequestBodyToSelectedbodydefaultsSelectedDefaultEmpty(actual) + } + var u selectedbodydefaults.SelectedDefaultValue + u.SetInactive((*selectedbodydefaults.SelectedDefaultEmpty)(obj)) + v = &u + } + res.Value = *v + } else { + res.Value = selectedbodydefaults.NewSelectedDefaultValueInactive(&selectedbodydefaults.SelectedDefaultEmpty{}) + } + + return res +} diff --git a/http/codegen/testdata/golden/server_payload_types_selected-body-map-default.go.golden b/http/codegen/testdata/golden/server_payload_types_selected-body-map-default.go.golden new file mode 100644 index 0000000000..1b95f58c0c --- /dev/null +++ b/http/codegen/testdata/golden/server_payload_types_selected-body-map-default.go.golden @@ -0,0 +1,17 @@ +// NewMapPayload builds a SelectedMapBodyDefault service Map endpoint payload. +func NewMapPayload(body map[string]string) *selectedmapbodydefault.MapPayload { + res := &selectedmapbodydefault.MapPayload{} + if body != nil { + v := make(map[string]string, len(body)) + for key, val := range body { + tk := key + tv := val + v[tk] = tv + } + res.Value = v + } else { + res.Value = selectedmapbodydefault.SelectedMapDefault{"mode": "safe"} + } + + return res +} diff --git a/http/codegen/testdata/golden/server_payload_types_selected-body-object-default.go.golden b/http/codegen/testdata/golden/server_payload_types_selected-body-object-default.go.golden new file mode 100644 index 0000000000..b46f792e46 --- /dev/null +++ b/http/codegen/testdata/golden/server_payload_types_selected-body-object-default.go.golden @@ -0,0 +1,15 @@ +// NewObjectPayload builds a SelectedObjectBodyDefault service Object endpoint +// payload. +func NewObjectPayload(body *ObjectRequestBody) *selectedobjectbodydefault.ObjectPayload { + res := &selectedobjectbodydefault.ObjectPayload{} + if body != nil { + v := &selectedobjectbodydefault.SelectedObjectDefault{ + Name: *body.Name, + } + res.Value = v + } else { + res.Value = &selectedobjectbodydefault.SelectedObjectDefault{Name: "safe"} + } + + return res +} diff --git a/http/codegen/testdata/golden/server_types_server-mixed-payload-attrs.go.golden b/http/codegen/testdata/golden/server_types_server-mixed-payload-attrs.go.golden index 4f49ae0eca..2be970c692 100644 --- a/http/codegen/testdata/golden/server_types_server-mixed-payload-attrs.go.golden +++ b/http/codegen/testdata/golden/server_types_server-mixed-payload-attrs.go.golden @@ -49,23 +49,31 @@ func ValidateMethodARequestBody(body *MethodARequestBody) (err error) { err = goa.MergeErrors(err, goa.MissingFieldError("object", "body")) } if body.Object != nil { - if err2 := ValidateBPayloadRequestBody(body.Object); err2 != nil { + if err2 := validateBPayloadRequestBody(body.Object, "body.object"); err2 != nil { err = goa.MergeErrors(err, err2) } } if body.DupObj != nil { - if err2 := ValidateBPayloadRequestBody(body.DupObj); err2 != nil { + if err2 := validateBPayloadRequestBody(body.DupObj, "body.dup_obj"); err2 != nil { err = goa.MergeErrors(err, err2) } } return } -// ValidateBPayloadRequestBody runs the validations defined on -// BPayloadRequestBody +// ValidateBPayloadRequestBody runs the validations defined on BPayload func ValidateBPayloadRequestBody(body *BPayloadRequestBody) (err error) { if body.Int == nil { err = goa.MergeErrors(err, goa.MissingFieldError("int", "body")) } return } + +// validateBPayloadRequestBody checks BPayload and reports errors using the +// path supplied by its caller +func validateBPayloadRequestBody(body *BPayloadRequestBody, path string) (err error) { + if body.Int == nil { + err = goa.MergeErrors(err, goa.MissingFieldError("int", path)) + } + return +} diff --git a/http/codegen/testdata/golden/server_types_server-multipart-validation.go.golden b/http/codegen/testdata/golden/server_types_server-multipart-validation.go.golden new file mode 100644 index 0000000000..7db641c70f --- /dev/null +++ b/http/codegen/testdata/golden/server_types_server-multipart-validation.go.golden @@ -0,0 +1,64 @@ +// MethodMultipartValidationRequestBody is the type of the +// "ServiceMultipartValidation" service "MethodMultipartValidation" endpoint +// HTTP request body. +type MethodMultipartValidationRequestBody struct { + Name *string `form:"name,omitempty" json:"name,omitempty" xml:"name,omitempty"` + Part *MultipartPartRequestBody `form:"part,omitempty" json:"part,omitempty" xml:"part,omitempty"` +} + +// MultipartPartRequestBody is used to define fields on request body types. +type MultipartPartRequestBody struct { + Code *string `form:"code,omitempty" json:"code,omitempty" xml:"code,omitempty"` +} + +// NewMethodMultipartValidationPayload builds a ServiceMultipartValidation +// service MethodMultipartValidation endpoint payload. +func NewMethodMultipartValidationPayload(body *MethodMultipartValidationRequestBody) *servicemultipartvalidation.MethodMultipartValidationPayload { + v := &servicemultipartvalidation.MethodMultipartValidationPayload{ + Name: *body.Name, + } + v.Part = unmarshalMultipartPartRequestBodyToServicemultipartvalidationMultipartPart(body.Part) + + return v +} + +// ValidateMethodMultipartValidationRequestBody runs the validations defined on +// MethodMultipartValidationRequestBody +func ValidateMethodMultipartValidationRequestBody(body *MethodMultipartValidationRequestBody) (err error) { + if body.Name == nil { + err = goa.MergeErrors(err, goa.MissingFieldError("name", "body")) + } + if body.Part == nil { + err = goa.MergeErrors(err, goa.MissingFieldError("part", "body")) + } + if body.Part != nil { + if err2 := validateMultipartPartRequestBody(body.Part, "body.part"); err2 != nil { + err = goa.MergeErrors(err, err2) + } + } + return +} + +// ValidateMultipartPartRequestBody runs the validations defined on +// MultipartPart +func ValidateMultipartPartRequestBody(body *MultipartPartRequestBody) (err error) { + if body.Code == nil { + err = goa.MergeErrors(err, goa.MissingFieldError("code", "body")) + } + if body.Code != nil { + err = goa.MergeErrors(err, goa.ValidatePattern("body.code", *body.Code, "^[a-z]+$")) + } + return +} + +// validateMultipartPartRequestBody checks MultipartPart and reports errors +// using the path supplied by its caller +func validateMultipartPartRequestBody(body *MultipartPartRequestBody, path string) (err error) { + if body.Code == nil { + err = goa.MergeErrors(err, goa.MissingFieldError("code", path)) + } + if body.Code != nil { + err = goa.MergeErrors(err, goa.ValidatePattern(path+".code", *body.Code, "^[a-z]+$")) + } + return +} diff --git a/http/codegen/testdata/golden/server_types_server-multiple-methods.go.golden b/http/codegen/testdata/golden/server_types_server-multiple-methods.go.golden index b6a9ca3eaa..0e266e10ea 100644 --- a/http/codegen/testdata/golden/server_types_server-multiple-methods.go.golden +++ b/http/codegen/testdata/golden/server_types_server-multiple-methods.go.golden @@ -62,18 +62,26 @@ func ValidateMethodBRequestBody(body *MethodBRequestBody) (err error) { err = goa.MergeErrors(err, goa.ValidatePattern("body.b", *body.B, "patternb")) } if body.C != nil { - if err2 := ValidateAPayloadRequestBody(body.C); err2 != nil { + if err2 := validateAPayloadRequestBody(body.C, "body.c"); err2 != nil { err = goa.MergeErrors(err, err2) } } return } -// ValidateAPayloadRequestBody runs the validations defined on -// APayloadRequestBody +// ValidateAPayloadRequestBody runs the validations defined on APayload func ValidateAPayloadRequestBody(body *APayloadRequestBody) (err error) { if body.A != nil { err = goa.MergeErrors(err, goa.ValidatePattern("body.a", *body.A, "patterna")) } return } + +// validateAPayloadRequestBody checks APayload and reports errors using the +// path supplied by its caller +func validateAPayloadRequestBody(body *APayloadRequestBody, path string) (err error) { + if body.A != nil { + err = goa.MergeErrors(err, goa.ValidatePattern(path+".a", *body.A, "patterna")) + } + return +} diff --git a/http/codegen/testdata/golden/server_types_server-payload-with-validated-alias.go.golden b/http/codegen/testdata/golden/server_types_server-payload-with-validated-alias.go.golden index cdb65103b2..05882b271c 100644 --- a/http/codegen/testdata/golden/server_types_server-payload-with-validated-alias.go.golden +++ b/http/codegen/testdata/golden/server_types_server-payload-with-validated-alias.go.golden @@ -24,8 +24,6 @@ func NewMethodStreamingBody(body *MethodStreamingBody) *servicepayloadvalidateda func ValidateMethodStreamingBody(body *MethodStreamingBody) (err error) { if body.Name != nil { err = goa.MergeErrors(err, goa.ValidatePattern("body.name", string(*body.Name), "^[a-zA-Z]+$")) - } - if body.Name != nil { if utf8.RuneCountInString(string(*body.Name)) < 10 { err = goa.MergeErrors(err, goa.InvalidLengthError("body.name", string(*body.Name), utf8.RuneCountInString(string(*body.Name)), 10, true)) } diff --git a/http/codegen/testdata/golden/server_types_server-required-primitive-arrays.go.golden b/http/codegen/testdata/golden/server_types_server-required-primitive-arrays.go.golden new file mode 100644 index 0000000000..7911355343 --- /dev/null +++ b/http/codegen/testdata/golden/server_types_server-required-primitive-arrays.go.golden @@ -0,0 +1,75 @@ +// StoreRequestBody is the type of the "RequiredArrays" service "Store" +// endpoint HTTP request body. +type StoreRequestBody struct { + Names []*string `form:"names,omitempty" json:"names,omitempty" xml:"names,omitempty"` + Aliases []*string `form:"aliases,omitempty" json:"aliases,omitempty" xml:"aliases,omitempty"` +} + +// StoreResponseBody is the type of the "RequiredArrays" service "Store" +// endpoint HTTP response body. +type StoreResponseBody struct { + Names []string `form:"names" json:"names" xml:"names"` + Aliases []string `form:"aliases" json:"aliases" xml:"aliases"` +} + +// NewStoreResponseBody builds the HTTP response body from the result of the +// "Store" endpoint of the "RequiredArrays" service. +func NewStoreResponseBody(res *requiredarrays.StoreResult) *StoreResponseBody { + body := &StoreResponseBody{} + if res.Names != nil { + body.Names = make([]string, len(res.Names)) + for i, val := range res.Names { + body.Names[i] = val + } + } else { + body.Names = []string{} + } + if res.Aliases != nil { + body.Aliases = make([]string, len(res.Aliases)) + for i, val := range res.Aliases { + body.Aliases[i] = string(val) + } + } else { + body.Aliases = []string{} + } + return body +} + +// NewStorePayload builds a RequiredArrays service Store endpoint payload. +func NewStorePayload(body *StoreRequestBody) *requiredarrays.StorePayload { + v := &requiredarrays.StorePayload{} + v.Names = make([]string, len(body.Names)) + for i, val := range body.Names { + v.Names[i] = *val + } + v.Aliases = make([]requiredarrays.RequiredArrayAlias, len(body.Aliases)) + for i, val := range body.Aliases { + v.Aliases[i] = requiredarrays.RequiredArrayAlias(*val) + } + + return v +} + +// ValidateStoreRequestBody runs the validations defined on StoreRequestBody +func ValidateStoreRequestBody(body *StoreRequestBody) (err error) { + if body.Names == nil { + err = goa.MergeErrors(err, goa.MissingFieldError("names", "body")) + } + if body.Aliases == nil { + err = goa.MergeErrors(err, goa.MissingFieldError("aliases", "body")) + } + for _, e := range body.Names { + if e == nil { + err = goa.MergeErrors(err, goa.MissingFieldError("body.names", "[*]")) + } + } + for _, e := range body.Aliases { + if e == nil { + err = goa.MergeErrors(err, goa.MissingFieldError("body.aliases", "[*]")) + } + if e != nil { + err = goa.MergeErrors(err, goa.ValidatePattern("body.aliases[*]", *e, "^[a-z]+$")) + } + } + return +} diff --git a/http/codegen/testdata/golden/server_types_server-status-tag-required.go.golden b/http/codegen/testdata/golden/server_types_server-status-tag-required.go.golden new file mode 100644 index 0000000000..53422ff6e2 --- /dev/null +++ b/http/codegen/testdata/golden/server_types_server-status-tag-required.go.golden @@ -0,0 +1,35 @@ +// MethodStatusTagRequiredCreatedResponseBody is the type of the +// "ServiceStatusTagRequired" service "MethodStatusTagRequired" endpoint HTTP +// response body. +type MethodStatusTagRequiredCreatedResponseBody struct { + Message string `form:"message" json:"message" xml:"message"` +} + +// MethodStatusTagRequiredOKResponseBody is the type of the +// "ServiceStatusTagRequired" service "MethodStatusTagRequired" endpoint HTTP +// response body. +type MethodStatusTagRequiredOKResponseBody struct { + Message string `form:"message" json:"message" xml:"message"` + Outcome string `form:"outcome" json:"outcome" xml:"outcome"` +} + +// NewMethodStatusTagRequiredCreatedResponseBody builds the HTTP response body +// from the result of the "MethodStatusTagRequired" endpoint of the +// "ServiceStatusTagRequired" service. +func NewMethodStatusTagRequiredCreatedResponseBody(res *servicestatustagrequiredviews.StatusTagResultView) *MethodStatusTagRequiredCreatedResponseBody { + body := &MethodStatusTagRequiredCreatedResponseBody{ + Message: *res.Message, + } + return body +} + +// NewMethodStatusTagRequiredOKResponseBody builds the HTTP response body from +// the result of the "MethodStatusTagRequired" endpoint of the +// "ServiceStatusTagRequired" service. +func NewMethodStatusTagRequiredOKResponseBody(res *servicestatustagrequiredviews.StatusTagResultView) *MethodStatusTagRequiredOKResponseBody { + body := &MethodStatusTagRequiredOKResponseBody{ + Message: *res.Message, + Outcome: *res.Outcome, + } + return body +} diff --git a/http/codegen/testdata/golden/server_types_server-streaming-payload-required-fields.go.golden b/http/codegen/testdata/golden/server_types_server-streaming-payload-required-fields.go.golden index 411849e600..169ff45382 100644 --- a/http/codegen/testdata/golden/server_types_server-streaming-payload-required-fields.go.golden +++ b/http/codegen/testdata/golden/server_types_server-streaming-payload-required-fields.go.golden @@ -65,7 +65,7 @@ func ValidateBidirectionalStreamStreamingBody(body *BidirectionalStreamStreaming } // ValidateStreamingRequestStreamingBody runs the validations defined on -// StreamingRequestStreamingBody +// StreamingRequest func ValidateStreamingRequestStreamingBody(body *StreamingRequestStreamingBody) (err error) { if body.Required == nil { err = goa.MergeErrors(err, goa.MissingFieldError("required", "body")) @@ -75,3 +75,15 @@ func ValidateStreamingRequestStreamingBody(body *StreamingRequestStreamingBody) } return } + +// validateStreamingRequestStreamingBody checks StreamingRequest and reports +// errors using the path supplied by its caller +func validateStreamingRequestStreamingBody(body *StreamingRequestStreamingBody, path string) (err error) { + if body.Required == nil { + err = goa.MergeErrors(err, goa.MissingFieldError("required", path)) + } + if body.BaseRequired == nil { + err = goa.MergeErrors(err, goa.MissingFieldError("baseRequired", path)) + } + return +} diff --git a/http/codegen/testdata/golden/server_types_server-with-result-required-nested-array.go.golden b/http/codegen/testdata/golden/server_types_server-with-result-required-nested-array.go.golden new file mode 100644 index 0000000000..11df32f690 --- /dev/null +++ b/http/codegen/testdata/golden/server_types_server-with-result-required-nested-array.go.golden @@ -0,0 +1,34 @@ +// MethodResultRequiredNestedArrayResponseBody is the type of the +// "ServiceResultRequiredNestedArray" service "MethodResultRequiredNestedArray" +// endpoint HTTP response body. +type MethodResultRequiredNestedArrayResponseBody struct { + Items [][]*NestedElemResponseBody `form:"items" json:"items" xml:"items"` +} + +// NestedElemResponseBody is used to define fields on response body types. +type NestedElemResponseBody struct { + A *string `form:"a,omitempty" json:"a,omitempty" xml:"a,omitempty"` +} + +// NewMethodResultRequiredNestedArrayResponseBody builds the HTTP response body +// from the result of the "MethodResultRequiredNestedArray" endpoint of the +// "ServiceResultRequiredNestedArray" service. +func NewMethodResultRequiredNestedArrayResponseBody(res *serviceresultrequirednestedarrayviews.GoaRequiredNestedArrayView) *MethodResultRequiredNestedArrayResponseBody { + body := &MethodResultRequiredNestedArrayResponseBody{} + if res.Items != nil { + body.Items = make([][]*NestedElemResponseBody, len(res.Items)) + for i, val := range res.Items { + body.Items[i] = make([]*NestedElemResponseBody, len(val)) + for j, val := range val { + if val == nil { + body.Items[i][j] = nil + continue + } + body.Items[i][j] = marshalServiceresultrequirednestedarrayviewsNestedElemViewToNestedElemResponseBody(val) + } + } + } else { + body.Items = [][]*NestedElemResponseBody{} + } + return body +} diff --git a/http/codegen/testdata/golden/server_types_server-with-result-required-user-type-array.go.golden b/http/codegen/testdata/golden/server_types_server-with-result-required-user-type-array.go.golden new file mode 100644 index 0000000000..ef0b36f4bc --- /dev/null +++ b/http/codegen/testdata/golden/server_types_server-with-result-required-user-type-array.go.golden @@ -0,0 +1,31 @@ +// MethodResultRequiredUserTypeArrayResponseBody is the type of the +// "ServiceResultRequiredUserTypeArray" service +// "MethodResultRequiredUserTypeArray" endpoint HTTP response body. +type MethodResultRequiredUserTypeArrayResponseBody struct { + Items []*ElemResponseBody `form:"items" json:"items" xml:"items"` +} + +// ElemResponseBody is used to define fields on response body types. +type ElemResponseBody struct { + A *string `form:"a,omitempty" json:"a,omitempty" xml:"a,omitempty"` +} + +// NewMethodResultRequiredUserTypeArrayResponseBody builds the HTTP response +// body from the result of the "MethodResultRequiredUserTypeArray" endpoint of +// the "ServiceResultRequiredUserTypeArray" service. +func NewMethodResultRequiredUserTypeArrayResponseBody(res *serviceresultrequiredusertypearrayviews.GoaRequiredArrayView) *MethodResultRequiredUserTypeArrayResponseBody { + body := &MethodResultRequiredUserTypeArrayResponseBody{} + if res.Items != nil { + body.Items = make([]*ElemResponseBody, len(res.Items)) + for i, val := range res.Items { + if val == nil { + body.Items[i] = nil + continue + } + body.Items[i] = marshalServiceresultrequiredusertypearrayviewsElemViewToElemResponseBody(val) + } + } else { + body.Items = []*ElemResponseBody{} + } + return body +} diff --git a/http/codegen/testdata/golden/server_types_server-with-result-viewed-required-array.go.golden b/http/codegen/testdata/golden/server_types_server-with-result-viewed-required-array.go.golden new file mode 100644 index 0000000000..e2bd573dfd --- /dev/null +++ b/http/codegen/testdata/golden/server_types_server-with-result-viewed-required-array.go.golden @@ -0,0 +1,61 @@ +// MethodResultViewedRequiredArrayResponseBody is the type of the +// "ServiceResultViewedRequiredArray" service "MethodResultViewedRequiredArray" +// endpoint HTTP response body. +type MethodResultViewedRequiredArrayResponseBody struct { + Items []*ViewedElemResponseBody `form:"items" json:"items" xml:"items"` + Name *string `form:"name,omitempty" json:"name,omitempty" xml:"name,omitempty"` +} + +// MethodResultViewedRequiredArrayResponseBodyTiny is the type of the +// "ServiceResultViewedRequiredArray" service "MethodResultViewedRequiredArray" +// endpoint HTTP response body. +type MethodResultViewedRequiredArrayResponseBodyTiny struct { + Items []*ViewedElemResponseBody `form:"items" json:"items" xml:"items"` +} + +// ViewedElemResponseBody is used to define fields on response body types. +type ViewedElemResponseBody struct { + A *string `form:"a,omitempty" json:"a,omitempty" xml:"a,omitempty"` +} + +// NewMethodResultViewedRequiredArrayResponseBody builds the HTTP response body +// from the result of the "MethodResultViewedRequiredArray" endpoint of the +// "ServiceResultViewedRequiredArray" service. +func NewMethodResultViewedRequiredArrayResponseBody(res *serviceresultviewedrequiredarrayviews.GoaViewedRequiredArrayView) *MethodResultViewedRequiredArrayResponseBody { + body := &MethodResultViewedRequiredArrayResponseBody{ + Name: res.Name, + } + if res.Items != nil { + body.Items = make([]*ViewedElemResponseBody, len(res.Items)) + for i, val := range res.Items { + if val == nil { + body.Items[i] = nil + continue + } + body.Items[i] = marshalServiceresultviewedrequiredarrayviewsViewedElemViewToViewedElemResponseBody(val) + } + } else { + body.Items = []*ViewedElemResponseBody{} + } + return body +} + +// NewMethodResultViewedRequiredArrayResponseBodyTiny builds the HTTP response +// body from the result of the "MethodResultViewedRequiredArray" endpoint of +// the "ServiceResultViewedRequiredArray" service. +func NewMethodResultViewedRequiredArrayResponseBodyTiny(res *serviceresultviewedrequiredarrayviews.GoaViewedRequiredArrayView) *MethodResultViewedRequiredArrayResponseBodyTiny { + body := &MethodResultViewedRequiredArrayResponseBodyTiny{} + if res.Items != nil { + body.Items = make([]*ViewedElemResponseBody, len(res.Items)) + for i, val := range res.Items { + if val == nil { + body.Items[i] = nil + continue + } + body.Items[i] = marshalServiceresultviewedrequiredarrayviewsViewedElemViewToViewedElemResponseBody(val) + } + } else { + body.Items = []*ViewedElemResponseBody{} + } + return body +} diff --git a/http/codegen/testdata/golden/sse-all-fields.golden b/http/codegen/testdata/golden/sse-all-fields.golden index 9c3b36535f..9a8534927a 100644 --- a/http/codegen/testdata/golden/sse-all-fields.golden +++ b/http/codegen/testdata/golden/sse-all-fields.golden @@ -8,19 +8,12 @@ type SSEAllFieldsMethodServerStream struct { w http.ResponseWriter // r is the HTTP request. r *http.Request + // attempted is true after this stream writes the HTTP success status. + attempted bool } -// Send Send streams instances of -// "sseallfieldsservice.SSEAllFieldsMethodResult" to the "SSEAllFieldsMethod" -// endpoint SSE connection. -func (s *SSEAllFieldsMethodServerStream) Send(v *sseallfieldsservice.SSEAllFieldsMethodResult) error { - return s.SendWithContext(context.Background(), v) -} - -// SendWithContext SendWithContext streams instances of -// "sseallfieldsservice.SSEAllFieldsMethodResult" to the "SSEAllFieldsMethod" -// endpoint SSE connection with context. -func (s *SSEAllFieldsMethodServerStream) SendWithContext(ctx context.Context, v *sseallfieldsservice.SSEAllFieldsMethodResult) error { +// start writes the headers that identify a successful SSE response. +func (s *SSEAllFieldsMethodServerStream) start() { s.once.Do(func() { header := s.w.Header() if header.Get("Content-Type") == "" { @@ -33,75 +26,108 @@ func (s *SSEAllFieldsMethodServerStream) SendWithContext(ctx context.Context, v header.Set("Connection", "keep-alive") } s.w.WriteHeader(http.StatusOK) + s.attempted = true }) +} + +// finish writes an empty successful SSE response when the service sent no +// events. +func (s *SSEAllFieldsMethodServerStream) finish() error { + if s.attempted { + return nil + } + s.start() + return nil +} + +// Send Send streams instances of +// "sseallfieldsservice.SSEAllFieldsMethodResult" to the "SSEAllFieldsMethod" +// endpoint SSE connection. +func (s *SSEAllFieldsMethodServerStream) Send(v *sseallfieldsservice.SSEAllFieldsMethodResult) error { + return s.SendWithContext(context.Background(), v) +} + +// SendWithContext SendWithContext streams instances of +// "sseallfieldsservice.SSEAllFieldsMethodResult" to the "SSEAllFieldsMethod" +// endpoint SSE connection with context. +func (s *SSEAllFieldsMethodServerStream) SendWithContext(ctx context.Context, v *sseallfieldsservice.SSEAllFieldsMethodResult) error { res := v - if id := res.ID; id != "" { - fmt.Fprintf(s.w, "id: %s\n", id) + var data string + body := NewSSEAllFieldsMethodResponseBody(res) + + byts, err := json.Marshal(body.Data) + if err != nil { + return err } - if event := res.Event; event != "" { - fmt.Fprintf(s.w, "event: %s\n", event) + data = string(byts) + id := res.ID + if id != nil { + for i := 0; i < len(*id); i++ { + if (*id)[i] == '\r' || (*id)[i] == '\n' || (*id)[i] == 0 { + return fmt.Errorf("SSE event ID contains a character that cannot appear on an id line") + } + } + } + event := res.Event + if event != nil { + for i := 0; i < len(*event); i++ { + if (*event)[i] == '\r' || (*event)[i] == '\n' { + return fmt.Errorf("SSE event type contains a line break") + } + } } - if retry := res.Retry; retry > 0 { - fmt.Fprintf(s.w, "retry: %d\n", retry) + retry := res.Retry + if retry != nil && *retry < 0 { + return fmt.Errorf("SSE retry value cannot be negative") } + s.start() - var data string - var payload any - body := NewSSEAllFieldsMethodResponseBody(res) - payload = body.Data - switch v := payload.(type) { - case nil: - data = "null" - case string: - data = v - case []byte: - data = string(v) - case bool: - if v { - data = "true" - } else { - data = "false" + if id != nil { + if _, err := fmt.Fprintf(s.w, "id: %s\n", *id); err != nil { + return err } - case int: - data = fmt.Sprintf("%d", v) - case int8: - data = fmt.Sprintf("%d", v) - case int16: - data = fmt.Sprintf("%d", v) - case int32: - data = fmt.Sprintf("%d", v) - case int64: - data = fmt.Sprintf("%d", v) - case uint: - data = fmt.Sprintf("%d", v) - case uint8: - data = fmt.Sprintf("%d", v) - case uint16: - data = fmt.Sprintf("%d", v) - case uint32: - data = fmt.Sprintf("%d", v) - case uint64: - data = fmt.Sprintf("%d", v) - case float32: - data = fmt.Sprintf("%g", v) - case float64: - data = fmt.Sprintf("%g", v) - default: - byts, err := json.Marshal(payload) - if err != nil { + } + if event != nil { + if _, err := fmt.Fprintf(s.w, "event: %s\n", *event); err != nil { + return err + } + } + if retry != nil { + if _, err := fmt.Fprintf(s.w, "retry: %d\n", *retry); err != nil { + return err + } + } + remaining := data + for { + lineEnd := 0 + for lineEnd < len(remaining) && remaining[lineEnd] != '\r' && remaining[lineEnd] != '\n' { + lineEnd++ + } + if _, err := fmt.Fprintf(s.w, "data: %s\n", remaining[:lineEnd]); err != nil { return err } - data = string(byts) + if lineEnd == len(remaining) { + break + } + next := lineEnd + 1 + if remaining[lineEnd] == '\r' && next < len(remaining) && remaining[next] == '\n' { + next++ + } + remaining = remaining[next:] + } + if _, err := fmt.Fprintln(s.w); err != nil { + return err } - fmt.Fprintf(s.w, "data: %s\n\n", data) - http.NewResponseController(s.w).Flush() + if err := http.NewResponseController(s.w).Flush(); err != nil { + return err + } return nil } -// Close is a no-op for SSE. We keep the method for compatibility with other -// stream types. +// Close does nothing because an SSE stream closes with its HTTP response. The +// common stream interface still requires this method. func (s *SSEAllFieldsMethodServerStream) Close() error { return nil } diff --git a/http/codegen/testdata/golden/sse-bool.golden b/http/codegen/testdata/golden/sse-bool.golden index 4d88c2f936..64d6457a71 100644 --- a/http/codegen/testdata/golden/sse-bool.golden +++ b/http/codegen/testdata/golden/sse-bool.golden @@ -7,17 +7,12 @@ type SSEBoolMethodServerStream struct { w http.ResponseWriter // r is the HTTP request. r *http.Request + // attempted is true after this stream writes the HTTP success status. + attempted bool } -// Send Send streams instances of "bool" to the "SSEBoolMethod" endpoint SSE -// connection. -func (s *SSEBoolMethodServerStream) Send(v bool) error { - return s.SendWithContext(context.Background(), v) -} - -// SendWithContext SendWithContext streams instances of "bool" to the -// "SSEBoolMethod" endpoint SSE connection with context. -func (s *SSEBoolMethodServerStream) SendWithContext(ctx context.Context, v bool) error { +// start writes the headers that identify a successful SSE response. +func (s *SSEBoolMethodServerStream) start() { s.once.Do(func() { header := s.w.Header() if header.Get("Content-Type") == "" { @@ -30,65 +25,71 @@ func (s *SSEBoolMethodServerStream) SendWithContext(ctx context.Context, v bool) header.Set("Connection", "keep-alive") } s.w.WriteHeader(http.StatusOK) + s.attempted = true }) +} + +// finish writes an empty successful SSE response when the service sent no +// events. +func (s *SSEBoolMethodServerStream) finish() error { + if s.attempted { + return nil + } + s.start() + return nil +} + +// Send Send streams instances of "bool" to the "SSEBoolMethod" endpoint SSE +// connection. +func (s *SSEBoolMethodServerStream) Send(v bool) error { + return s.SendWithContext(context.Background(), v) +} + +// SendWithContext SendWithContext streams instances of "bool" to the +// "SSEBoolMethod" endpoint SSE connection with context. +func (s *SSEBoolMethodServerStream) SendWithContext(ctx context.Context, v bool) error { res := v var data string - var payload any - body := NewSSEBoolMethodResponseBody(res) - payload = body - switch v := payload.(type) { - case nil: - data = "null" - case string: - data = v - case []byte: - data = string(v) - case bool: - if v { - data = "true" - } else { - data = "false" + body := res + + if body { + data = "true" + } else { + data = "false" + } + s.start() + + remaining := data + for { + lineEnd := 0 + for lineEnd < len(remaining) && remaining[lineEnd] != '\r' && remaining[lineEnd] != '\n' { + lineEnd++ } - case int: - data = fmt.Sprintf("%d", v) - case int8: - data = fmt.Sprintf("%d", v) - case int16: - data = fmt.Sprintf("%d", v) - case int32: - data = fmt.Sprintf("%d", v) - case int64: - data = fmt.Sprintf("%d", v) - case uint: - data = fmt.Sprintf("%d", v) - case uint8: - data = fmt.Sprintf("%d", v) - case uint16: - data = fmt.Sprintf("%d", v) - case uint32: - data = fmt.Sprintf("%d", v) - case uint64: - data = fmt.Sprintf("%d", v) - case float32: - data = fmt.Sprintf("%g", v) - case float64: - data = fmt.Sprintf("%g", v) - default: - byts, err := json.Marshal(payload) - if err != nil { + if _, err := fmt.Fprintf(s.w, "data: %s\n", remaining[:lineEnd]); err != nil { return err } - data = string(byts) + if lineEnd == len(remaining) { + break + } + next := lineEnd + 1 + if remaining[lineEnd] == '\r' && next < len(remaining) && remaining[next] == '\n' { + next++ + } + remaining = remaining[next:] + } + if _, err := fmt.Fprintln(s.w); err != nil { + return err } - fmt.Fprintf(s.w, "data: %s\n\n", data) - http.NewResponseController(s.w).Flush() + if err := http.NewResponseController(s.w).Flush(); err != nil { + return err + } return nil } -// Close is a no-op for SSE. We keep the method for compatibility with other -// stream types. +// Close does nothing because an SSE stream closes with its HTTP response. The +// common stream interface still requires this method. func (s *SSEBoolMethodServerStream) Close() error { return nil } diff --git a/http/codegen/testdata/golden/sse-client-all-fields.golden b/http/codegen/testdata/golden/sse-client-all-fields.golden index 9ceaf9cb32..e188416d50 100644 --- a/http/codegen/testdata/golden/sse-client-all-fields.golden +++ b/http/codegen/testdata/golden/sse-client-all-fields.golden @@ -14,8 +14,14 @@ type ( resp *http.Response decoder func(*http.Response) goahttp.Decoder buffer []byte // Buffer for unprocessed data - lock sync.Mutex - closed bool + // started records whether the first stream line was read. + started bool + // lastEventID is the last valid id field read from the stream. + lastEventID string + // hasLastEventID distinguishes no id from an explicitly empty id. + hasLastEventID bool + lock sync.Mutex + closed bool } ) @@ -42,25 +48,28 @@ func (s *SSEAllFieldsMethodStreamImpl) Recv() (*sseallfieldsservice.SSEAllFields // RecvWithContext reads and returns the next event from the SSE stream, respecting context cancellation. func (s *SSEAllFieldsMethodStreamImpl) RecvWithContext(ctx context.Context) (event *sseallfieldsservice.SSEAllFieldsMethodResult, err error) { - var byts []byte - byts, err = s.readEvent(ctx) - if err != nil { - if errors.Is(err, io.EOF) || errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) { - // Clean up on EOF or context cancellation. io.EOF - // propagates to the caller to signal end of stream. - s.Close() + for { + var byts []byte + byts, err = s.readEvent(ctx) + if err != nil { + if errors.Is(err, io.EOF) || errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) { + // Clean up on EOF or context cancellation. io.EOF + // propagates to the caller to signal end of stream. + s.Close() + } + return + } + var hasData bool + event, hasData, err = s.processEvent(byts) + if err != nil || hasData { + return } - return } - return s.processEvent(byts) } // readEvent reads a single SSE event from the stream, respecting context -// cancellation. It first checks the internal buffer for a complete event -// (delimited by double newlines). If no complete event is found, it reads from -// the HTTP response body until it either finds an event boundary, reaches EOF, -// or encounters an error. Any data after the event boundary is saved in the -// buffer for the next call. +// cancellation. It accepts CR, LF, and CRLF line endings. A blank line ends an +// event; bytes after that line remain buffered for the next call. func (s *SSEAllFieldsMethodStreamImpl) readEvent(ctx context.Context) ([]byte, error) { const bufSize = 4096 // 4KB buffer size @@ -70,9 +79,7 @@ func (s *SSEAllFieldsMethodStreamImpl) readEvent(ctx context.Context) ([]byte, e return event, nil } - // Initialize with any data from buffer eventData := event - wasNewline := len(eventData) > 0 && eventData[len(eventData)-1] == '\n' buf := make([]byte, bufSize) // Read data in chunks until we find an event or hit EOF. A stream that @@ -103,27 +110,14 @@ func (s *SSEAllFieldsMethodStreamImpl) readEvent(ctx context.Context) ([]byte, e return nil, err } - // Process data if we got any if n > 0 { - // Look for event boundary in this chunk - for i := 0; i < n; i++ { - b := buf[i] - eventData = append(eventData, b) - - // Check for double newlines (event boundary) - if b == '\n' && wasNewline { - // Save any remaining data for next read - if i+1 < n { - s.lock.Lock() - s.buffer = append(s.buffer[:0], buf[i+1:n]...) - s.lock.Unlock() - } - return eventData, nil - } - - // Update newline tracking - wasNewline = (b == '\n') - } + eventData = append(eventData, buf[:n]...) + } + if end := s.eventEnd(eventData, errors.Is(err, io.EOF)); end >= 0 { + s.lock.Lock() + s.buffer = append(s.buffer[:0], eventData[end:]...) + s.lock.Unlock() + return eventData[:end], nil } // Discard any partial frame at EOF: an event that ends before its @@ -134,11 +128,8 @@ func (s *SSEAllFieldsMethodStreamImpl) readEvent(ctx context.Context) ([]byte, e } } -// checkBuffer examines the internal buffer for a complete SSE event (delimited -// by double newlines). It returns two values: the event data (or all buffer -// contents if no complete event is found), and a boolean indicating whether a -// complete event was found. If a complete event is found, any remaining data -// after the event is kept in the buffer for the next call. +// checkBuffer returns the first complete event already read from the response. +// When no blank line is present, it returns a copy that readEvent can extend. func (s *SSEAllFieldsMethodStreamImpl) checkBuffer() ([]byte, bool) { s.lock.Lock() defer s.lock.Unlock() @@ -148,25 +139,11 @@ func (s *SSEAllFieldsMethodStreamImpl) checkBuffer() ([]byte, bool) { return nil, false } - // Look for double newline in buffer - for i := 0; i < len(s.buffer)-1; i++ { - if s.buffer[i] == '\n' && s.buffer[i+1] == '\n' { - // Found complete event. Copy it out: compacting the buffer - // below would otherwise overwrite the returned bytes, since - // both slices share the same backing array. - eventEnd := i + 2 // Include both newlines - eventData := make([]byte, eventEnd) - copy(eventData, s.buffer[:eventEnd]) - - // Save remaining data for next time - if eventEnd < len(s.buffer) { - s.buffer = append(s.buffer[:0], s.buffer[eventEnd:]...) - } else { - s.buffer = s.buffer[:0] - } - - return eventData, true - } + if end := s.eventEnd(s.buffer, false); end >= 0 { + eventData := make([]byte, end) + copy(eventData, s.buffer[:end]) + s.buffer = append(s.buffer[:0], s.buffer[end:]...) + return eventData, true } // No complete event found, return a copy of the buffer contents: the @@ -178,6 +155,54 @@ func (s *SSEAllFieldsMethodStreamImpl) checkBuffer() ([]byte, bool) { return eventData, false } +// eventEnd returns the byte after the blank line that ends the first event. +// A CR at the end waits for the next byte unless the response has ended. +func (s *SSEAllFieldsMethodStreamImpl) eventEnd(data []byte, atEOF bool) int { + lineStart := 0 + for i := 0; i < len(data); { + if data[i] != '\r' && data[i] != '\n' { + i++ + continue + } + lineEnd := i + 1 + if data[i] == '\r' { + if lineEnd == len(data) && !atEOF { + return -1 + } + if lineEnd < len(data) && data[lineEnd] == '\n' { + lineEnd++ + } + } + if i == lineStart { + return lineEnd + } + lineStart = lineEnd + i = lineEnd + } + return -1 +} + +// eventLines splits one complete event using every line ending allowed by SSE. +func (s *SSEAllFieldsMethodStreamImpl) eventLines(data []byte) [][]byte { + var lines [][]byte + for len(data) > 0 { + lineEnd := 0 + for lineEnd < len(data) && data[lineEnd] != '\r' && data[lineEnd] != '\n' { + lineEnd++ + } + lines = append(lines, data[:lineEnd]) + if lineEnd == len(data) { + break + } + next := lineEnd + 1 + if data[lineEnd] == '\r' && next < len(data) && data[next] == '\n' { + next++ + } + data = data[next:] + } + return lines +} + // Close closes the SSE stream and releases any associated resources. func (s *SSEAllFieldsMethodStreamImpl) Close() error { s.lock.Lock() @@ -190,56 +215,77 @@ func (s *SSEAllFieldsMethodStreamImpl) Close() error { } // processEvent processes a raw SSE event into the expected type -func (s *SSEAllFieldsMethodStreamImpl) processEvent(eventData []byte) (event *sseallfieldsservice.SSEAllFieldsMethodResult, err error) { - event = new(sseallfieldsservice.SSEAllFieldsMethodResult) +func (s *SSEAllFieldsMethodStreamImpl) processEvent(eventData []byte) (event *sseallfieldsservice.SSEAllFieldsMethodResult, hasData bool, err error) { + var body SSEAllFieldsMethodResponseBody var dataLines []string - for _, line := range bytes.Split(eventData, []byte("\n")) { + for _, line := range s.eventLines(eventData) { + if !s.started { + line = bytes.TrimPrefix(line, []byte{0xef, 0xbb, 0xbf}) + s.started = true + } if len(line) == 0 { continue } - if bytes.HasPrefix(line, []byte("data:")) { - dataLines = append(dataLines, s.trimHeader(len("data:"), line)) - continue + field, value, _ := bytes.Cut(line, []byte(":")) + if len(value) > 0 && value[0] == ' ' { + value = value[1:] } - if bytes.HasPrefix(line, []byte("id:")) { - event.ID = s.trimHeader(len("id:"), line) + if bytes.Equal(field, []byte("data")) { + dataLines = append(dataLines, string(value)) continue } - if bytes.HasPrefix(line, []byte("event:")) { - event.Event = s.trimHeader(len("event:"), line) + if bytes.Equal(field, []byte("id")) { + if !bytes.ContainsRune(value, '\x00') { + s.lastEventID = string(value) + s.hasLastEventID = true + } continue } - if bytes.HasPrefix(line, []byte("retry:")) { - // Note: retry value parsing depends on the field type; client currently expects integer-like types. - // We deliberately leave conversion to a future enhancement that includes the field type reference. - // For now this branch is kept for completeness; services using RetryField should be handled server-side. + if bytes.Equal(field, []byte("event")) { + eventContent := string(value) + body.Event = &eventContent continue } - } - if len(dataLines) > 0 { - dataContent := strings.Join(dataLines, "\n") + if bytes.Equal(field, []byte("retry")) { + // An invalid retry field does not replace an earlier valid field. + validRetry := len(value) > 0 + for i := 0; validRetry && i < len(value); i++ { + validRetry = value[i] >= '0' && value[i] <= '9' + } + if !validRetry { + continue + } + retryContent := string(value) - // Use user-provided decoder for complex types - respBody := &http.Response{ - StatusCode: http.StatusOK, - Body: io.NopCloser(bytes.NewReader([]byte(dataContent))), - } - err = s.decoder(respBody).Decode(&event.Data) - if err != nil { - return + var val int64 + val, err = strconv.ParseInt(retryContent, 10, 0) + if err != nil { + return + } + value := int(val) + body.Retry = &value + continue } } - return -} + if len(dataLines) == 0 { + return event, false, nil + } + if s.hasLastEventID { + idContent := s.lastEventID + body.ID = &idContent + } + dataContent := strings.Join(dataLines, "\n") -// trimHeader removes the header prefix and optional leading space -func (s *SSEAllFieldsMethodStreamImpl) trimHeader(size int, data []byte) string { - if len(data) < size { - return string(data) + // The configured decoder handles structured event data. + respBody := &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(bytes.NewReader([]byte(dataContent))), } - data = data[size:] - if len(data) > 0 && data[0] == ' ' { - data = data[1:] + err = s.decoder(respBody).Decode(&body.Data) + if err != nil { + return } - return string(data) + event = NewSSEAllFieldsMethodResultOK(&body) + hasData = true + return } diff --git a/http/codegen/testdata/golden/sse-client-bool.golden b/http/codegen/testdata/golden/sse-client-bool.golden index c2e5a00a96..5acc7feef6 100644 --- a/http/codegen/testdata/golden/sse-client-bool.golden +++ b/http/codegen/testdata/golden/sse-client-bool.golden @@ -14,6 +14,8 @@ type ( resp *http.Response decoder func(*http.Response) goahttp.Decoder buffer []byte // Buffer for unprocessed data + // started records whether the first stream line was read. + started bool lock sync.Mutex closed bool } @@ -42,25 +44,28 @@ func (s *SSEBoolMethodStreamImpl) Recv() (bool, error) { // RecvWithContext reads and returns the next event from the SSE stream, respecting context cancellation. func (s *SSEBoolMethodStreamImpl) RecvWithContext(ctx context.Context) (event bool, err error) { - var byts []byte - byts, err = s.readEvent(ctx) - if err != nil { - if errors.Is(err, io.EOF) || errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) { - // Clean up on EOF or context cancellation. io.EOF - // propagates to the caller to signal end of stream. - s.Close() + for { + var byts []byte + byts, err = s.readEvent(ctx) + if err != nil { + if errors.Is(err, io.EOF) || errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) { + // Clean up on EOF or context cancellation. io.EOF + // propagates to the caller to signal end of stream. + s.Close() + } + return + } + var hasData bool + event, hasData, err = s.processEvent(byts) + if err != nil || hasData { + return } - return } - return s.processEvent(byts) } // readEvent reads a single SSE event from the stream, respecting context -// cancellation. It first checks the internal buffer for a complete event -// (delimited by double newlines). If no complete event is found, it reads from -// the HTTP response body until it either finds an event boundary, reaches EOF, -// or encounters an error. Any data after the event boundary is saved in the -// buffer for the next call. +// cancellation. It accepts CR, LF, and CRLF line endings. A blank line ends an +// event; bytes after that line remain buffered for the next call. func (s *SSEBoolMethodStreamImpl) readEvent(ctx context.Context) ([]byte, error) { const bufSize = 4096 // 4KB buffer size @@ -70,9 +75,7 @@ func (s *SSEBoolMethodStreamImpl) readEvent(ctx context.Context) ([]byte, error) return event, nil } - // Initialize with any data from buffer eventData := event - wasNewline := len(eventData) > 0 && eventData[len(eventData)-1] == '\n' buf := make([]byte, bufSize) // Read data in chunks until we find an event or hit EOF. A stream that @@ -103,27 +106,14 @@ func (s *SSEBoolMethodStreamImpl) readEvent(ctx context.Context) ([]byte, error) return nil, err } - // Process data if we got any if n > 0 { - // Look for event boundary in this chunk - for i := 0; i < n; i++ { - b := buf[i] - eventData = append(eventData, b) - - // Check for double newlines (event boundary) - if b == '\n' && wasNewline { - // Save any remaining data for next read - if i+1 < n { - s.lock.Lock() - s.buffer = append(s.buffer[:0], buf[i+1:n]...) - s.lock.Unlock() - } - return eventData, nil - } - - // Update newline tracking - wasNewline = (b == '\n') - } + eventData = append(eventData, buf[:n]...) + } + if end := s.eventEnd(eventData, errors.Is(err, io.EOF)); end >= 0 { + s.lock.Lock() + s.buffer = append(s.buffer[:0], eventData[end:]...) + s.lock.Unlock() + return eventData[:end], nil } // Discard any partial frame at EOF: an event that ends before its @@ -134,11 +124,8 @@ func (s *SSEBoolMethodStreamImpl) readEvent(ctx context.Context) ([]byte, error) } } -// checkBuffer examines the internal buffer for a complete SSE event (delimited -// by double newlines). It returns two values: the event data (or all buffer -// contents if no complete event is found), and a boolean indicating whether a -// complete event was found. If a complete event is found, any remaining data -// after the event is kept in the buffer for the next call. +// checkBuffer returns the first complete event already read from the response. +// When no blank line is present, it returns a copy that readEvent can extend. func (s *SSEBoolMethodStreamImpl) checkBuffer() ([]byte, bool) { s.lock.Lock() defer s.lock.Unlock() @@ -148,25 +135,11 @@ func (s *SSEBoolMethodStreamImpl) checkBuffer() ([]byte, bool) { return nil, false } - // Look for double newline in buffer - for i := 0; i < len(s.buffer)-1; i++ { - if s.buffer[i] == '\n' && s.buffer[i+1] == '\n' { - // Found complete event. Copy it out: compacting the buffer - // below would otherwise overwrite the returned bytes, since - // both slices share the same backing array. - eventEnd := i + 2 // Include both newlines - eventData := make([]byte, eventEnd) - copy(eventData, s.buffer[:eventEnd]) - - // Save remaining data for next time - if eventEnd < len(s.buffer) { - s.buffer = append(s.buffer[:0], s.buffer[eventEnd:]...) - } else { - s.buffer = s.buffer[:0] - } - - return eventData, true - } + if end := s.eventEnd(s.buffer, false); end >= 0 { + eventData := make([]byte, end) + copy(eventData, s.buffer[:end]) + s.buffer = append(s.buffer[:0], s.buffer[end:]...) + return eventData, true } // No complete event found, return a copy of the buffer contents: the @@ -178,6 +151,54 @@ func (s *SSEBoolMethodStreamImpl) checkBuffer() ([]byte, bool) { return eventData, false } +// eventEnd returns the byte after the blank line that ends the first event. +// A CR at the end waits for the next byte unless the response has ended. +func (s *SSEBoolMethodStreamImpl) eventEnd(data []byte, atEOF bool) int { + lineStart := 0 + for i := 0; i < len(data); { + if data[i] != '\r' && data[i] != '\n' { + i++ + continue + } + lineEnd := i + 1 + if data[i] == '\r' { + if lineEnd == len(data) && !atEOF { + return -1 + } + if lineEnd < len(data) && data[lineEnd] == '\n' { + lineEnd++ + } + } + if i == lineStart { + return lineEnd + } + lineStart = lineEnd + i = lineEnd + } + return -1 +} + +// eventLines splits one complete event using every line ending allowed by SSE. +func (s *SSEBoolMethodStreamImpl) eventLines(data []byte) [][]byte { + var lines [][]byte + for len(data) > 0 { + lineEnd := 0 + for lineEnd < len(data) && data[lineEnd] != '\r' && data[lineEnd] != '\n' { + lineEnd++ + } + lines = append(lines, data[:lineEnd]) + if lineEnd == len(data) { + break + } + next := lineEnd + 1 + if data[lineEnd] == '\r' && next < len(data) && data[next] == '\n' { + next++ + } + data = data[next:] + } + return lines +} + // Close closes the SSE stream and releases any associated resources. func (s *SSEBoolMethodStreamImpl) Close() error { s.lock.Lock() @@ -190,41 +211,38 @@ func (s *SSEBoolMethodStreamImpl) Close() error { } // processEvent processes a raw SSE event into the expected type -func (s *SSEBoolMethodStreamImpl) processEvent(eventData []byte) (event bool, err error) { +func (s *SSEBoolMethodStreamImpl) processEvent(eventData []byte) (event bool, hasData bool, err error) { + var body bool var dataLines []string - for _, line := range bytes.Split(eventData, []byte("\n")) { + for _, line := range s.eventLines(eventData) { + if !s.started { + line = bytes.TrimPrefix(line, []byte{0xef, 0xbb, 0xbf}) + s.started = true + } if len(line) == 0 { continue } - if bytes.HasPrefix(line, []byte("data:")) { - dataLines = append(dataLines, s.trimHeader(len("data:"), line)) + field, value, _ := bytes.Cut(line, []byte(":")) + if len(value) > 0 && value[0] == ' ' { + value = value[1:] + } + if bytes.Equal(field, []byte("data")) { + dataLines = append(dataLines, string(value)) continue } } - if len(dataLines) > 0 { - dataContent := strings.Join(dataLines, "\n") - - // Use user-provided decoder for complex types - respBody := &http.Response{ - StatusCode: http.StatusOK, - Body: io.NopCloser(bytes.NewReader([]byte(dataContent))), - } - err = s.decoder(respBody).Decode(&event) - if err != nil { - return - } + if len(dataLines) == 0 { + return event, false, nil } - return -} + dataContent := strings.Join(dataLines, "\n") -// trimHeader removes the header prefix and optional leading space -func (s *SSEBoolMethodStreamImpl) trimHeader(size int, data []byte) string { - if len(data) < size { - return string(data) - } - data = data[size:] - if len(data) > 0 && data[0] == ' ' { - data = data[1:] + var val bool + val, err = strconv.ParseBool(dataContent) + if err != nil { + return } - return string(data) + body = val + event = body + hasData = true + return } diff --git a/http/codegen/testdata/golden/sse-client-data-field.golden b/http/codegen/testdata/golden/sse-client-data-field.golden index 830c91bc51..0e0914c9ba 100644 --- a/http/codegen/testdata/golden/sse-client-data-field.golden +++ b/http/codegen/testdata/golden/sse-client-data-field.golden @@ -14,6 +14,8 @@ type ( resp *http.Response decoder func(*http.Response) goahttp.Decoder buffer []byte // Buffer for unprocessed data + // started records whether the first stream line was read. + started bool lock sync.Mutex closed bool } @@ -42,25 +44,28 @@ func (s *SSEDataFieldMethodStreamImpl) Recv() (*ssedatafieldservice.SSEDataField // RecvWithContext reads and returns the next event from the SSE stream, respecting context cancellation. func (s *SSEDataFieldMethodStreamImpl) RecvWithContext(ctx context.Context) (event *ssedatafieldservice.SSEDataFieldMethodResult, err error) { - var byts []byte - byts, err = s.readEvent(ctx) - if err != nil { - if errors.Is(err, io.EOF) || errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) { - // Clean up on EOF or context cancellation. io.EOF - // propagates to the caller to signal end of stream. - s.Close() - } - return + for { + var byts []byte + byts, err = s.readEvent(ctx) + if err != nil { + if errors.Is(err, io.EOF) || errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) { + // Clean up on EOF or context cancellation. io.EOF + // propagates to the caller to signal end of stream. + s.Close() + } + return + } + var hasData bool + event, hasData, err = s.processEvent(byts) + if err != nil || hasData { + return + } } - return s.processEvent(byts) } // readEvent reads a single SSE event from the stream, respecting context -// cancellation. It first checks the internal buffer for a complete event -// (delimited by double newlines). If no complete event is found, it reads from -// the HTTP response body until it either finds an event boundary, reaches EOF, -// or encounters an error. Any data after the event boundary is saved in the -// buffer for the next call. +// cancellation. It accepts CR, LF, and CRLF line endings. A blank line ends an +// event; bytes after that line remain buffered for the next call. func (s *SSEDataFieldMethodStreamImpl) readEvent(ctx context.Context) ([]byte, error) { const bufSize = 4096 // 4KB buffer size @@ -70,9 +75,7 @@ func (s *SSEDataFieldMethodStreamImpl) readEvent(ctx context.Context) ([]byte, e return event, nil } - // Initialize with any data from buffer eventData := event - wasNewline := len(eventData) > 0 && eventData[len(eventData)-1] == '\n' buf := make([]byte, bufSize) // Read data in chunks until we find an event or hit EOF. A stream that @@ -103,27 +106,14 @@ func (s *SSEDataFieldMethodStreamImpl) readEvent(ctx context.Context) ([]byte, e return nil, err } - // Process data if we got any if n > 0 { - // Look for event boundary in this chunk - for i := 0; i < n; i++ { - b := buf[i] - eventData = append(eventData, b) - - // Check for double newlines (event boundary) - if b == '\n' && wasNewline { - // Save any remaining data for next read - if i+1 < n { - s.lock.Lock() - s.buffer = append(s.buffer[:0], buf[i+1:n]...) - s.lock.Unlock() - } - return eventData, nil - } - - // Update newline tracking - wasNewline = (b == '\n') - } + eventData = append(eventData, buf[:n]...) + } + if end := s.eventEnd(eventData, errors.Is(err, io.EOF)); end >= 0 { + s.lock.Lock() + s.buffer = append(s.buffer[:0], eventData[end:]...) + s.lock.Unlock() + return eventData[:end], nil } // Discard any partial frame at EOF: an event that ends before its @@ -134,11 +124,8 @@ func (s *SSEDataFieldMethodStreamImpl) readEvent(ctx context.Context) ([]byte, e } } -// checkBuffer examines the internal buffer for a complete SSE event (delimited -// by double newlines). It returns two values: the event data (or all buffer -// contents if no complete event is found), and a boolean indicating whether a -// complete event was found. If a complete event is found, any remaining data -// after the event is kept in the buffer for the next call. +// checkBuffer returns the first complete event already read from the response. +// When no blank line is present, it returns a copy that readEvent can extend. func (s *SSEDataFieldMethodStreamImpl) checkBuffer() ([]byte, bool) { s.lock.Lock() defer s.lock.Unlock() @@ -148,25 +135,11 @@ func (s *SSEDataFieldMethodStreamImpl) checkBuffer() ([]byte, bool) { return nil, false } - // Look for double newline in buffer - for i := 0; i < len(s.buffer)-1; i++ { - if s.buffer[i] == '\n' && s.buffer[i+1] == '\n' { - // Found complete event. Copy it out: compacting the buffer - // below would otherwise overwrite the returned bytes, since - // both slices share the same backing array. - eventEnd := i + 2 // Include both newlines - eventData := make([]byte, eventEnd) - copy(eventData, s.buffer[:eventEnd]) - - // Save remaining data for next time - if eventEnd < len(s.buffer) { - s.buffer = append(s.buffer[:0], s.buffer[eventEnd:]...) - } else { - s.buffer = s.buffer[:0] - } - - return eventData, true - } + if end := s.eventEnd(s.buffer, false); end >= 0 { + eventData := make([]byte, end) + copy(eventData, s.buffer[:end]) + s.buffer = append(s.buffer[:0], s.buffer[end:]...) + return eventData, true } // No complete event found, return a copy of the buffer contents: the @@ -178,6 +151,54 @@ func (s *SSEDataFieldMethodStreamImpl) checkBuffer() ([]byte, bool) { return eventData, false } +// eventEnd returns the byte after the blank line that ends the first event. +// A CR at the end waits for the next byte unless the response has ended. +func (s *SSEDataFieldMethodStreamImpl) eventEnd(data []byte, atEOF bool) int { + lineStart := 0 + for i := 0; i < len(data); { + if data[i] != '\r' && data[i] != '\n' { + i++ + continue + } + lineEnd := i + 1 + if data[i] == '\r' { + if lineEnd == len(data) && !atEOF { + return -1 + } + if lineEnd < len(data) && data[lineEnd] == '\n' { + lineEnd++ + } + } + if i == lineStart { + return lineEnd + } + lineStart = lineEnd + i = lineEnd + } + return -1 +} + +// eventLines splits one complete event using every line ending allowed by SSE. +func (s *SSEDataFieldMethodStreamImpl) eventLines(data []byte) [][]byte { + var lines [][]byte + for len(data) > 0 { + lineEnd := 0 + for lineEnd < len(data) && data[lineEnd] != '\r' && data[lineEnd] != '\n' { + lineEnd++ + } + lines = append(lines, data[:lineEnd]) + if lineEnd == len(data) { + break + } + next := lineEnd + 1 + if data[lineEnd] == '\r' && next < len(data) && data[next] == '\n' { + next++ + } + data = data[next:] + } + return lines +} + // Close closes the SSE stream and releases any associated resources. func (s *SSEDataFieldMethodStreamImpl) Close() error { s.lock.Lock() @@ -190,34 +211,34 @@ func (s *SSEDataFieldMethodStreamImpl) Close() error { } // processEvent processes a raw SSE event into the expected type -func (s *SSEDataFieldMethodStreamImpl) processEvent(eventData []byte) (event *ssedatafieldservice.SSEDataFieldMethodResult, err error) { - event = new(ssedatafieldservice.SSEDataFieldMethodResult) +func (s *SSEDataFieldMethodStreamImpl) processEvent(eventData []byte) (event *ssedatafieldservice.SSEDataFieldMethodResult, hasData bool, err error) { + var body SSEDataFieldMethodResponseBody var dataLines []string - for _, line := range bytes.Split(eventData, []byte("\n")) { + for _, line := range s.eventLines(eventData) { + if !s.started { + line = bytes.TrimPrefix(line, []byte{0xef, 0xbb, 0xbf}) + s.started = true + } if len(line) == 0 { continue } - if bytes.HasPrefix(line, []byte("data:")) { - dataLines = append(dataLines, s.trimHeader(len("data:"), line)) + field, value, _ := bytes.Cut(line, []byte(":")) + if len(value) > 0 && value[0] == ' ' { + value = value[1:] + } + if bytes.Equal(field, []byte("data")) { + dataLines = append(dataLines, string(value)) continue } } - if len(dataLines) > 0 { - dataContent := strings.Join(dataLines, "\n") - - event.Data = dataContent + if len(dataLines) == 0 { + return event, false, nil } - return -} + dataContent := strings.Join(dataLines, "\n") -// trimHeader removes the header prefix and optional leading space -func (s *SSEDataFieldMethodStreamImpl) trimHeader(size int, data []byte) string { - if len(data) < size { - return string(data) - } - data = data[size:] - if len(data) > 0 && data[0] == ' ' { - data = data[1:] - } - return string(data) + value := dataContent + body.Data = &value + event = NewSSEDataFieldMethodResultOK(&body) + hasData = true + return } diff --git a/http/codegen/testdata/golden/sse-client-data-id-field.golden b/http/codegen/testdata/golden/sse-client-data-id-field.golden index cbb7acf1f3..98c1fdeee2 100644 --- a/http/codegen/testdata/golden/sse-client-data-id-field.golden +++ b/http/codegen/testdata/golden/sse-client-data-id-field.golden @@ -14,8 +14,14 @@ type ( resp *http.Response decoder func(*http.Response) goahttp.Decoder buffer []byte // Buffer for unprocessed data - lock sync.Mutex - closed bool + // started records whether the first stream line was read. + started bool + // lastEventID is the last valid id field read from the stream. + lastEventID string + // hasLastEventID distinguishes no id from an explicitly empty id. + hasLastEventID bool + lock sync.Mutex + closed bool } ) @@ -42,25 +48,28 @@ func (s *SSEDataIDFieldMethodStreamImpl) Recv() (*ssedataidfieldservice.SSEDataI // RecvWithContext reads and returns the next event from the SSE stream, respecting context cancellation. func (s *SSEDataIDFieldMethodStreamImpl) RecvWithContext(ctx context.Context) (event *ssedataidfieldservice.SSEDataIDFieldMethodResult, err error) { - var byts []byte - byts, err = s.readEvent(ctx) - if err != nil { - if errors.Is(err, io.EOF) || errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) { - // Clean up on EOF or context cancellation. io.EOF - // propagates to the caller to signal end of stream. - s.Close() - } - return + for { + var byts []byte + byts, err = s.readEvent(ctx) + if err != nil { + if errors.Is(err, io.EOF) || errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) { + // Clean up on EOF or context cancellation. io.EOF + // propagates to the caller to signal end of stream. + s.Close() + } + return + } + var hasData bool + event, hasData, err = s.processEvent(byts) + if err != nil || hasData { + return + } } - return s.processEvent(byts) } // readEvent reads a single SSE event from the stream, respecting context -// cancellation. It first checks the internal buffer for a complete event -// (delimited by double newlines). If no complete event is found, it reads from -// the HTTP response body until it either finds an event boundary, reaches EOF, -// or encounters an error. Any data after the event boundary is saved in the -// buffer for the next call. +// cancellation. It accepts CR, LF, and CRLF line endings. A blank line ends an +// event; bytes after that line remain buffered for the next call. func (s *SSEDataIDFieldMethodStreamImpl) readEvent(ctx context.Context) ([]byte, error) { const bufSize = 4096 // 4KB buffer size @@ -70,9 +79,7 @@ func (s *SSEDataIDFieldMethodStreamImpl) readEvent(ctx context.Context) ([]byte, return event, nil } - // Initialize with any data from buffer eventData := event - wasNewline := len(eventData) > 0 && eventData[len(eventData)-1] == '\n' buf := make([]byte, bufSize) // Read data in chunks until we find an event or hit EOF. A stream that @@ -103,27 +110,14 @@ func (s *SSEDataIDFieldMethodStreamImpl) readEvent(ctx context.Context) ([]byte, return nil, err } - // Process data if we got any if n > 0 { - // Look for event boundary in this chunk - for i := 0; i < n; i++ { - b := buf[i] - eventData = append(eventData, b) - - // Check for double newlines (event boundary) - if b == '\n' && wasNewline { - // Save any remaining data for next read - if i+1 < n { - s.lock.Lock() - s.buffer = append(s.buffer[:0], buf[i+1:n]...) - s.lock.Unlock() - } - return eventData, nil - } - - // Update newline tracking - wasNewline = (b == '\n') - } + eventData = append(eventData, buf[:n]...) + } + if end := s.eventEnd(eventData, errors.Is(err, io.EOF)); end >= 0 { + s.lock.Lock() + s.buffer = append(s.buffer[:0], eventData[end:]...) + s.lock.Unlock() + return eventData[:end], nil } // Discard any partial frame at EOF: an event that ends before its @@ -134,11 +128,8 @@ func (s *SSEDataIDFieldMethodStreamImpl) readEvent(ctx context.Context) ([]byte, } } -// checkBuffer examines the internal buffer for a complete SSE event (delimited -// by double newlines). It returns two values: the event data (or all buffer -// contents if no complete event is found), and a boolean indicating whether a -// complete event was found. If a complete event is found, any remaining data -// after the event is kept in the buffer for the next call. +// checkBuffer returns the first complete event already read from the response. +// When no blank line is present, it returns a copy that readEvent can extend. func (s *SSEDataIDFieldMethodStreamImpl) checkBuffer() ([]byte, bool) { s.lock.Lock() defer s.lock.Unlock() @@ -148,25 +139,11 @@ func (s *SSEDataIDFieldMethodStreamImpl) checkBuffer() ([]byte, bool) { return nil, false } - // Look for double newline in buffer - for i := 0; i < len(s.buffer)-1; i++ { - if s.buffer[i] == '\n' && s.buffer[i+1] == '\n' { - // Found complete event. Copy it out: compacting the buffer - // below would otherwise overwrite the returned bytes, since - // both slices share the same backing array. - eventEnd := i + 2 // Include both newlines - eventData := make([]byte, eventEnd) - copy(eventData, s.buffer[:eventEnd]) - - // Save remaining data for next time - if eventEnd < len(s.buffer) { - s.buffer = append(s.buffer[:0], s.buffer[eventEnd:]...) - } else { - s.buffer = s.buffer[:0] - } - - return eventData, true - } + if end := s.eventEnd(s.buffer, false); end >= 0 { + eventData := make([]byte, end) + copy(eventData, s.buffer[:end]) + s.buffer = append(s.buffer[:0], s.buffer[end:]...) + return eventData, true } // No complete event found, return a copy of the buffer contents: the @@ -178,6 +155,54 @@ func (s *SSEDataIDFieldMethodStreamImpl) checkBuffer() ([]byte, bool) { return eventData, false } +// eventEnd returns the byte after the blank line that ends the first event. +// A CR at the end waits for the next byte unless the response has ended. +func (s *SSEDataIDFieldMethodStreamImpl) eventEnd(data []byte, atEOF bool) int { + lineStart := 0 + for i := 0; i < len(data); { + if data[i] != '\r' && data[i] != '\n' { + i++ + continue + } + lineEnd := i + 1 + if data[i] == '\r' { + if lineEnd == len(data) && !atEOF { + return -1 + } + if lineEnd < len(data) && data[lineEnd] == '\n' { + lineEnd++ + } + } + if i == lineStart { + return lineEnd + } + lineStart = lineEnd + i = lineEnd + } + return -1 +} + +// eventLines splits one complete event using every line ending allowed by SSE. +func (s *SSEDataIDFieldMethodStreamImpl) eventLines(data []byte) [][]byte { + var lines [][]byte + for len(data) > 0 { + lineEnd := 0 + for lineEnd < len(data) && data[lineEnd] != '\r' && data[lineEnd] != '\n' { + lineEnd++ + } + lines = append(lines, data[:lineEnd]) + if lineEnd == len(data) { + break + } + next := lineEnd + 1 + if data[lineEnd] == '\r' && next < len(data) && data[next] == '\n' { + next++ + } + data = data[next:] + } + return lines +} + // Close closes the SSE stream and releases any associated resources. func (s *SSEDataIDFieldMethodStreamImpl) Close() error { s.lock.Lock() @@ -190,38 +215,45 @@ func (s *SSEDataIDFieldMethodStreamImpl) Close() error { } // processEvent processes a raw SSE event into the expected type -func (s *SSEDataIDFieldMethodStreamImpl) processEvent(eventData []byte) (event *ssedataidfieldservice.SSEDataIDFieldMethodResult, err error) { - event = new(ssedataidfieldservice.SSEDataIDFieldMethodResult) +func (s *SSEDataIDFieldMethodStreamImpl) processEvent(eventData []byte) (event *ssedataidfieldservice.SSEDataIDFieldMethodResult, hasData bool, err error) { + var body SSEDataIDFieldMethodResponseBody var dataLines []string - for _, line := range bytes.Split(eventData, []byte("\n")) { + for _, line := range s.eventLines(eventData) { + if !s.started { + line = bytes.TrimPrefix(line, []byte{0xef, 0xbb, 0xbf}) + s.started = true + } if len(line) == 0 { continue } - if bytes.HasPrefix(line, []byte("data:")) { - dataLines = append(dataLines, s.trimHeader(len("data:"), line)) + field, value, _ := bytes.Cut(line, []byte(":")) + if len(value) > 0 && value[0] == ' ' { + value = value[1:] + } + if bytes.Equal(field, []byte("data")) { + dataLines = append(dataLines, string(value)) continue } - if bytes.HasPrefix(line, []byte("id:")) { - event.ID = s.trimHeader(len("id:"), line) + if bytes.Equal(field, []byte("id")) { + if !bytes.ContainsRune(value, '\x00') { + s.lastEventID = string(value) + s.hasLastEventID = true + } continue } } - if len(dataLines) > 0 { - dataContent := strings.Join(dataLines, "\n") - - event.Data = dataContent - } - return -} - -// trimHeader removes the header prefix and optional leading space -func (s *SSEDataIDFieldMethodStreamImpl) trimHeader(size int, data []byte) string { - if len(data) < size { - return string(data) + if len(dataLines) == 0 { + return event, false, nil } - data = data[size:] - if len(data) > 0 && data[0] == ' ' { - data = data[1:] + if s.hasLastEventID { + idContent := s.lastEventID + body.ID = &idContent } - return string(data) + dataContent := strings.Join(dataLines, "\n") + + value := dataContent + body.Data = &value + event = NewSSEDataIDFieldMethodResultOK(&body) + hasData = true + return } diff --git a/http/codegen/testdata/golden/sse-client-int.golden b/http/codegen/testdata/golden/sse-client-int.golden index e4786f790c..8d24c57e5e 100644 --- a/http/codegen/testdata/golden/sse-client-int.golden +++ b/http/codegen/testdata/golden/sse-client-int.golden @@ -14,6 +14,8 @@ type ( resp *http.Response decoder func(*http.Response) goahttp.Decoder buffer []byte // Buffer for unprocessed data + // started records whether the first stream line was read. + started bool lock sync.Mutex closed bool } @@ -42,25 +44,28 @@ func (s *SSEIntMethodStreamImpl) Recv() (int, error) { // RecvWithContext reads and returns the next event from the SSE stream, respecting context cancellation. func (s *SSEIntMethodStreamImpl) RecvWithContext(ctx context.Context) (event int, err error) { - var byts []byte - byts, err = s.readEvent(ctx) - if err != nil { - if errors.Is(err, io.EOF) || errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) { - // Clean up on EOF or context cancellation. io.EOF - // propagates to the caller to signal end of stream. - s.Close() + for { + var byts []byte + byts, err = s.readEvent(ctx) + if err != nil { + if errors.Is(err, io.EOF) || errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) { + // Clean up on EOF or context cancellation. io.EOF + // propagates to the caller to signal end of stream. + s.Close() + } + return + } + var hasData bool + event, hasData, err = s.processEvent(byts) + if err != nil || hasData { + return } - return } - return s.processEvent(byts) } // readEvent reads a single SSE event from the stream, respecting context -// cancellation. It first checks the internal buffer for a complete event -// (delimited by double newlines). If no complete event is found, it reads from -// the HTTP response body until it either finds an event boundary, reaches EOF, -// or encounters an error. Any data after the event boundary is saved in the -// buffer for the next call. +// cancellation. It accepts CR, LF, and CRLF line endings. A blank line ends an +// event; bytes after that line remain buffered for the next call. func (s *SSEIntMethodStreamImpl) readEvent(ctx context.Context) ([]byte, error) { const bufSize = 4096 // 4KB buffer size @@ -70,9 +75,7 @@ func (s *SSEIntMethodStreamImpl) readEvent(ctx context.Context) ([]byte, error) return event, nil } - // Initialize with any data from buffer eventData := event - wasNewline := len(eventData) > 0 && eventData[len(eventData)-1] == '\n' buf := make([]byte, bufSize) // Read data in chunks until we find an event or hit EOF. A stream that @@ -103,27 +106,14 @@ func (s *SSEIntMethodStreamImpl) readEvent(ctx context.Context) ([]byte, error) return nil, err } - // Process data if we got any if n > 0 { - // Look for event boundary in this chunk - for i := 0; i < n; i++ { - b := buf[i] - eventData = append(eventData, b) - - // Check for double newlines (event boundary) - if b == '\n' && wasNewline { - // Save any remaining data for next read - if i+1 < n { - s.lock.Lock() - s.buffer = append(s.buffer[:0], buf[i+1:n]...) - s.lock.Unlock() - } - return eventData, nil - } - - // Update newline tracking - wasNewline = (b == '\n') - } + eventData = append(eventData, buf[:n]...) + } + if end := s.eventEnd(eventData, errors.Is(err, io.EOF)); end >= 0 { + s.lock.Lock() + s.buffer = append(s.buffer[:0], eventData[end:]...) + s.lock.Unlock() + return eventData[:end], nil } // Discard any partial frame at EOF: an event that ends before its @@ -134,11 +124,8 @@ func (s *SSEIntMethodStreamImpl) readEvent(ctx context.Context) ([]byte, error) } } -// checkBuffer examines the internal buffer for a complete SSE event (delimited -// by double newlines). It returns two values: the event data (or all buffer -// contents if no complete event is found), and a boolean indicating whether a -// complete event was found. If a complete event is found, any remaining data -// after the event is kept in the buffer for the next call. +// checkBuffer returns the first complete event already read from the response. +// When no blank line is present, it returns a copy that readEvent can extend. func (s *SSEIntMethodStreamImpl) checkBuffer() ([]byte, bool) { s.lock.Lock() defer s.lock.Unlock() @@ -148,25 +135,11 @@ func (s *SSEIntMethodStreamImpl) checkBuffer() ([]byte, bool) { return nil, false } - // Look for double newline in buffer - for i := 0; i < len(s.buffer)-1; i++ { - if s.buffer[i] == '\n' && s.buffer[i+1] == '\n' { - // Found complete event. Copy it out: compacting the buffer - // below would otherwise overwrite the returned bytes, since - // both slices share the same backing array. - eventEnd := i + 2 // Include both newlines - eventData := make([]byte, eventEnd) - copy(eventData, s.buffer[:eventEnd]) - - // Save remaining data for next time - if eventEnd < len(s.buffer) { - s.buffer = append(s.buffer[:0], s.buffer[eventEnd:]...) - } else { - s.buffer = s.buffer[:0] - } - - return eventData, true - } + if end := s.eventEnd(s.buffer, false); end >= 0 { + eventData := make([]byte, end) + copy(eventData, s.buffer[:end]) + s.buffer = append(s.buffer[:0], s.buffer[end:]...) + return eventData, true } // No complete event found, return a copy of the buffer contents: the @@ -178,6 +151,54 @@ func (s *SSEIntMethodStreamImpl) checkBuffer() ([]byte, bool) { return eventData, false } +// eventEnd returns the byte after the blank line that ends the first event. +// A CR at the end waits for the next byte unless the response has ended. +func (s *SSEIntMethodStreamImpl) eventEnd(data []byte, atEOF bool) int { + lineStart := 0 + for i := 0; i < len(data); { + if data[i] != '\r' && data[i] != '\n' { + i++ + continue + } + lineEnd := i + 1 + if data[i] == '\r' { + if lineEnd == len(data) && !atEOF { + return -1 + } + if lineEnd < len(data) && data[lineEnd] == '\n' { + lineEnd++ + } + } + if i == lineStart { + return lineEnd + } + lineStart = lineEnd + i = lineEnd + } + return -1 +} + +// eventLines splits one complete event using every line ending allowed by SSE. +func (s *SSEIntMethodStreamImpl) eventLines(data []byte) [][]byte { + var lines [][]byte + for len(data) > 0 { + lineEnd := 0 + for lineEnd < len(data) && data[lineEnd] != '\r' && data[lineEnd] != '\n' { + lineEnd++ + } + lines = append(lines, data[:lineEnd]) + if lineEnd == len(data) { + break + } + next := lineEnd + 1 + if data[lineEnd] == '\r' && next < len(data) && data[next] == '\n' { + next++ + } + data = data[next:] + } + return lines +} + // Close closes the SSE stream and releases any associated resources. func (s *SSEIntMethodStreamImpl) Close() error { s.lock.Lock() @@ -190,38 +211,38 @@ func (s *SSEIntMethodStreamImpl) Close() error { } // processEvent processes a raw SSE event into the expected type -func (s *SSEIntMethodStreamImpl) processEvent(eventData []byte) (event int, err error) { +func (s *SSEIntMethodStreamImpl) processEvent(eventData []byte) (event int, hasData bool, err error) { + var body int var dataLines []string - for _, line := range bytes.Split(eventData, []byte("\n")) { + for _, line := range s.eventLines(eventData) { + if !s.started { + line = bytes.TrimPrefix(line, []byte{0xef, 0xbb, 0xbf}) + s.started = true + } if len(line) == 0 { continue } - if bytes.HasPrefix(line, []byte("data:")) { - dataLines = append(dataLines, s.trimHeader(len("data:"), line)) + field, value, _ := bytes.Cut(line, []byte(":")) + if len(value) > 0 && value[0] == ' ' { + value = value[1:] + } + if bytes.Equal(field, []byte("data")) { + dataLines = append(dataLines, string(value)) continue } } - if len(dataLines) > 0 { - dataContent := strings.Join(dataLines, "\n") - - var val int64 - val, err = strconv.ParseInt(dataContent, 10, 0) - if err != nil { - return - } - event = int(val) + if len(dataLines) == 0 { + return event, false, nil } - return -} + dataContent := strings.Join(dataLines, "\n") -// trimHeader removes the header prefix and optional leading space -func (s *SSEIntMethodStreamImpl) trimHeader(size int, data []byte) string { - if len(data) < size { - return string(data) - } - data = data[size:] - if len(data) > 0 && data[0] == ' ' { - data = data[1:] + var val int64 + val, err = strconv.ParseInt(dataContent, 10, 0) + if err != nil { + return } - return string(data) + body = int(val) + event = body + hasData = true + return } diff --git a/http/codegen/testdata/golden/sse-client-object.golden b/http/codegen/testdata/golden/sse-client-object.golden index a664f71f48..c28715232b 100644 --- a/http/codegen/testdata/golden/sse-client-object.golden +++ b/http/codegen/testdata/golden/sse-client-object.golden @@ -14,6 +14,8 @@ type ( resp *http.Response decoder func(*http.Response) goahttp.Decoder buffer []byte // Buffer for unprocessed data + // started records whether the first stream line was read. + started bool lock sync.Mutex closed bool } @@ -42,25 +44,28 @@ func (s *SSEObjectMethodStreamImpl) Recv() (*sseobjectservice.SSEObjectMethodRes // RecvWithContext reads and returns the next event from the SSE stream, respecting context cancellation. func (s *SSEObjectMethodStreamImpl) RecvWithContext(ctx context.Context) (event *sseobjectservice.SSEObjectMethodResult, err error) { - var byts []byte - byts, err = s.readEvent(ctx) - if err != nil { - if errors.Is(err, io.EOF) || errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) { - // Clean up on EOF or context cancellation. io.EOF - // propagates to the caller to signal end of stream. - s.Close() + for { + var byts []byte + byts, err = s.readEvent(ctx) + if err != nil { + if errors.Is(err, io.EOF) || errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) { + // Clean up on EOF or context cancellation. io.EOF + // propagates to the caller to signal end of stream. + s.Close() + } + return + } + var hasData bool + event, hasData, err = s.processEvent(byts) + if err != nil || hasData { + return } - return } - return s.processEvent(byts) } // readEvent reads a single SSE event from the stream, respecting context -// cancellation. It first checks the internal buffer for a complete event -// (delimited by double newlines). If no complete event is found, it reads from -// the HTTP response body until it either finds an event boundary, reaches EOF, -// or encounters an error. Any data after the event boundary is saved in the -// buffer for the next call. +// cancellation. It accepts CR, LF, and CRLF line endings. A blank line ends an +// event; bytes after that line remain buffered for the next call. func (s *SSEObjectMethodStreamImpl) readEvent(ctx context.Context) ([]byte, error) { const bufSize = 4096 // 4KB buffer size @@ -70,9 +75,7 @@ func (s *SSEObjectMethodStreamImpl) readEvent(ctx context.Context) ([]byte, erro return event, nil } - // Initialize with any data from buffer eventData := event - wasNewline := len(eventData) > 0 && eventData[len(eventData)-1] == '\n' buf := make([]byte, bufSize) // Read data in chunks until we find an event or hit EOF. A stream that @@ -103,27 +106,14 @@ func (s *SSEObjectMethodStreamImpl) readEvent(ctx context.Context) ([]byte, erro return nil, err } - // Process data if we got any if n > 0 { - // Look for event boundary in this chunk - for i := 0; i < n; i++ { - b := buf[i] - eventData = append(eventData, b) - - // Check for double newlines (event boundary) - if b == '\n' && wasNewline { - // Save any remaining data for next read - if i+1 < n { - s.lock.Lock() - s.buffer = append(s.buffer[:0], buf[i+1:n]...) - s.lock.Unlock() - } - return eventData, nil - } - - // Update newline tracking - wasNewline = (b == '\n') - } + eventData = append(eventData, buf[:n]...) + } + if end := s.eventEnd(eventData, errors.Is(err, io.EOF)); end >= 0 { + s.lock.Lock() + s.buffer = append(s.buffer[:0], eventData[end:]...) + s.lock.Unlock() + return eventData[:end], nil } // Discard any partial frame at EOF: an event that ends before its @@ -134,11 +124,8 @@ func (s *SSEObjectMethodStreamImpl) readEvent(ctx context.Context) ([]byte, erro } } -// checkBuffer examines the internal buffer for a complete SSE event (delimited -// by double newlines). It returns two values: the event data (or all buffer -// contents if no complete event is found), and a boolean indicating whether a -// complete event was found. If a complete event is found, any remaining data -// after the event is kept in the buffer for the next call. +// checkBuffer returns the first complete event already read from the response. +// When no blank line is present, it returns a copy that readEvent can extend. func (s *SSEObjectMethodStreamImpl) checkBuffer() ([]byte, bool) { s.lock.Lock() defer s.lock.Unlock() @@ -148,25 +135,11 @@ func (s *SSEObjectMethodStreamImpl) checkBuffer() ([]byte, bool) { return nil, false } - // Look for double newline in buffer - for i := 0; i < len(s.buffer)-1; i++ { - if s.buffer[i] == '\n' && s.buffer[i+1] == '\n' { - // Found complete event. Copy it out: compacting the buffer - // below would otherwise overwrite the returned bytes, since - // both slices share the same backing array. - eventEnd := i + 2 // Include both newlines - eventData := make([]byte, eventEnd) - copy(eventData, s.buffer[:eventEnd]) - - // Save remaining data for next time - if eventEnd < len(s.buffer) { - s.buffer = append(s.buffer[:0], s.buffer[eventEnd:]...) - } else { - s.buffer = s.buffer[:0] - } - - return eventData, true - } + if end := s.eventEnd(s.buffer, false); end >= 0 { + eventData := make([]byte, end) + copy(eventData, s.buffer[:end]) + s.buffer = append(s.buffer[:0], s.buffer[end:]...) + return eventData, true } // No complete event found, return a copy of the buffer contents: the @@ -178,6 +151,54 @@ func (s *SSEObjectMethodStreamImpl) checkBuffer() ([]byte, bool) { return eventData, false } +// eventEnd returns the byte after the blank line that ends the first event. +// A CR at the end waits for the next byte unless the response has ended. +func (s *SSEObjectMethodStreamImpl) eventEnd(data []byte, atEOF bool) int { + lineStart := 0 + for i := 0; i < len(data); { + if data[i] != '\r' && data[i] != '\n' { + i++ + continue + } + lineEnd := i + 1 + if data[i] == '\r' { + if lineEnd == len(data) && !atEOF { + return -1 + } + if lineEnd < len(data) && data[lineEnd] == '\n' { + lineEnd++ + } + } + if i == lineStart { + return lineEnd + } + lineStart = lineEnd + i = lineEnd + } + return -1 +} + +// eventLines splits one complete event using every line ending allowed by SSE. +func (s *SSEObjectMethodStreamImpl) eventLines(data []byte) [][]byte { + var lines [][]byte + for len(data) > 0 { + lineEnd := 0 + for lineEnd < len(data) && data[lineEnd] != '\r' && data[lineEnd] != '\n' { + lineEnd++ + } + lines = append(lines, data[:lineEnd]) + if lineEnd == len(data) { + break + } + next := lineEnd + 1 + if data[lineEnd] == '\r' && next < len(data) && data[next] == '\n' { + next++ + } + data = data[next:] + } + return lines +} + // Close closes the SSE stream and releases any associated resources. func (s *SSEObjectMethodStreamImpl) Close() error { s.lock.Lock() @@ -190,41 +211,41 @@ func (s *SSEObjectMethodStreamImpl) Close() error { } // processEvent processes a raw SSE event into the expected type -func (s *SSEObjectMethodStreamImpl) processEvent(eventData []byte) (event *sseobjectservice.SSEObjectMethodResult, err error) { - event = new(sseobjectservice.SSEObjectMethodResult) +func (s *SSEObjectMethodStreamImpl) processEvent(eventData []byte) (event *sseobjectservice.SSEObjectMethodResult, hasData bool, err error) { + var body SSEObjectMethodResponseBody var dataLines []string - for _, line := range bytes.Split(eventData, []byte("\n")) { + for _, line := range s.eventLines(eventData) { + if !s.started { + line = bytes.TrimPrefix(line, []byte{0xef, 0xbb, 0xbf}) + s.started = true + } if len(line) == 0 { continue } - if bytes.HasPrefix(line, []byte("data:")) { - dataLines = append(dataLines, s.trimHeader(len("data:"), line)) + field, value, _ := bytes.Cut(line, []byte(":")) + if len(value) > 0 && value[0] == ' ' { + value = value[1:] + } + if bytes.Equal(field, []byte("data")) { + dataLines = append(dataLines, string(value)) continue } } - if len(dataLines) > 0 { - dataContent := strings.Join(dataLines, "\n") - // Decode JSON into the struct pointer directly - respBody := &http.Response{ - StatusCode: http.StatusOK, - Body: io.NopCloser(bytes.NewReader([]byte(dataContent))), - } - err = s.decoder(respBody).Decode(event) - if err != nil { - return - } + if len(dataLines) == 0 { + return event, false, nil } - return -} - -// trimHeader removes the header prefix and optional leading space -func (s *SSEObjectMethodStreamImpl) trimHeader(size int, data []byte) string { - if len(data) < size { - return string(data) + dataContent := strings.Join(dataLines, "\n") + // Decode the event data into the HTTP response type so required + // primitive fields retain their presence for validation. + respBody := &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(bytes.NewReader([]byte(dataContent))), } - data = data[size:] - if len(data) > 0 && data[0] == ' ' { - data = data[1:] + err = s.decoder(respBody).Decode(&body) + if err != nil { + return } - return string(data) + event = NewSSEObjectMethodResultOK(&body) + hasData = true + return } diff --git a/http/codegen/testdata/golden/sse-client-request-id.golden b/http/codegen/testdata/golden/sse-client-request-id.golden index cb31a59bde..2f30f45b9e 100644 --- a/http/codegen/testdata/golden/sse-client-request-id.golden +++ b/http/codegen/testdata/golden/sse-client-request-id.golden @@ -14,6 +14,8 @@ type ( resp *http.Response decoder func(*http.Response) goahttp.Decoder buffer []byte // Buffer for unprocessed data + // started records whether the first stream line was read. + started bool lock sync.Mutex closed bool } @@ -42,25 +44,28 @@ func (s *SSERequestIDMethodStreamImpl) Recv() (string, error) { // RecvWithContext reads and returns the next event from the SSE stream, respecting context cancellation. func (s *SSERequestIDMethodStreamImpl) RecvWithContext(ctx context.Context) (event string, err error) { - var byts []byte - byts, err = s.readEvent(ctx) - if err != nil { - if errors.Is(err, io.EOF) || errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) { - // Clean up on EOF or context cancellation. io.EOF - // propagates to the caller to signal end of stream. - s.Close() - } - return + for { + var byts []byte + byts, err = s.readEvent(ctx) + if err != nil { + if errors.Is(err, io.EOF) || errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) { + // Clean up on EOF or context cancellation. io.EOF + // propagates to the caller to signal end of stream. + s.Close() + } + return + } + var hasData bool + event, hasData, err = s.processEvent(byts) + if err != nil || hasData { + return + } } - return s.processEvent(byts) } // readEvent reads a single SSE event from the stream, respecting context -// cancellation. It first checks the internal buffer for a complete event -// (delimited by double newlines). If no complete event is found, it reads from -// the HTTP response body until it either finds an event boundary, reaches EOF, -// or encounters an error. Any data after the event boundary is saved in the -// buffer for the next call. +// cancellation. It accepts CR, LF, and CRLF line endings. A blank line ends an +// event; bytes after that line remain buffered for the next call. func (s *SSERequestIDMethodStreamImpl) readEvent(ctx context.Context) ([]byte, error) { const bufSize = 4096 // 4KB buffer size @@ -70,9 +75,7 @@ func (s *SSERequestIDMethodStreamImpl) readEvent(ctx context.Context) ([]byte, e return event, nil } - // Initialize with any data from buffer eventData := event - wasNewline := len(eventData) > 0 && eventData[len(eventData)-1] == '\n' buf := make([]byte, bufSize) // Read data in chunks until we find an event or hit EOF. A stream that @@ -103,27 +106,14 @@ func (s *SSERequestIDMethodStreamImpl) readEvent(ctx context.Context) ([]byte, e return nil, err } - // Process data if we got any if n > 0 { - // Look for event boundary in this chunk - for i := 0; i < n; i++ { - b := buf[i] - eventData = append(eventData, b) - - // Check for double newlines (event boundary) - if b == '\n' && wasNewline { - // Save any remaining data for next read - if i+1 < n { - s.lock.Lock() - s.buffer = append(s.buffer[:0], buf[i+1:n]...) - s.lock.Unlock() - } - return eventData, nil - } - - // Update newline tracking - wasNewline = (b == '\n') - } + eventData = append(eventData, buf[:n]...) + } + if end := s.eventEnd(eventData, errors.Is(err, io.EOF)); end >= 0 { + s.lock.Lock() + s.buffer = append(s.buffer[:0], eventData[end:]...) + s.lock.Unlock() + return eventData[:end], nil } // Discard any partial frame at EOF: an event that ends before its @@ -134,11 +124,8 @@ func (s *SSERequestIDMethodStreamImpl) readEvent(ctx context.Context) ([]byte, e } } -// checkBuffer examines the internal buffer for a complete SSE event (delimited -// by double newlines). It returns two values: the event data (or all buffer -// contents if no complete event is found), and a boolean indicating whether a -// complete event was found. If a complete event is found, any remaining data -// after the event is kept in the buffer for the next call. +// checkBuffer returns the first complete event already read from the response. +// When no blank line is present, it returns a copy that readEvent can extend. func (s *SSERequestIDMethodStreamImpl) checkBuffer() ([]byte, bool) { s.lock.Lock() defer s.lock.Unlock() @@ -148,25 +135,11 @@ func (s *SSERequestIDMethodStreamImpl) checkBuffer() ([]byte, bool) { return nil, false } - // Look for double newline in buffer - for i := 0; i < len(s.buffer)-1; i++ { - if s.buffer[i] == '\n' && s.buffer[i+1] == '\n' { - // Found complete event. Copy it out: compacting the buffer - // below would otherwise overwrite the returned bytes, since - // both slices share the same backing array. - eventEnd := i + 2 // Include both newlines - eventData := make([]byte, eventEnd) - copy(eventData, s.buffer[:eventEnd]) - - // Save remaining data for next time - if eventEnd < len(s.buffer) { - s.buffer = append(s.buffer[:0], s.buffer[eventEnd:]...) - } else { - s.buffer = s.buffer[:0] - } - - return eventData, true - } + if end := s.eventEnd(s.buffer, false); end >= 0 { + eventData := make([]byte, end) + copy(eventData, s.buffer[:end]) + s.buffer = append(s.buffer[:0], s.buffer[end:]...) + return eventData, true } // No complete event found, return a copy of the buffer contents: the @@ -178,6 +151,54 @@ func (s *SSERequestIDMethodStreamImpl) checkBuffer() ([]byte, bool) { return eventData, false } +// eventEnd returns the byte after the blank line that ends the first event. +// A CR at the end waits for the next byte unless the response has ended. +func (s *SSERequestIDMethodStreamImpl) eventEnd(data []byte, atEOF bool) int { + lineStart := 0 + for i := 0; i < len(data); { + if data[i] != '\r' && data[i] != '\n' { + i++ + continue + } + lineEnd := i + 1 + if data[i] == '\r' { + if lineEnd == len(data) && !atEOF { + return -1 + } + if lineEnd < len(data) && data[lineEnd] == '\n' { + lineEnd++ + } + } + if i == lineStart { + return lineEnd + } + lineStart = lineEnd + i = lineEnd + } + return -1 +} + +// eventLines splits one complete event using every line ending allowed by SSE. +func (s *SSERequestIDMethodStreamImpl) eventLines(data []byte) [][]byte { + var lines [][]byte + for len(data) > 0 { + lineEnd := 0 + for lineEnd < len(data) && data[lineEnd] != '\r' && data[lineEnd] != '\n' { + lineEnd++ + } + lines = append(lines, data[:lineEnd]) + if lineEnd == len(data) { + break + } + next := lineEnd + 1 + if data[lineEnd] == '\r' && next < len(data) && data[next] == '\n' { + next++ + } + data = data[next:] + } + return lines +} + // Close closes the SSE stream and releases any associated resources. func (s *SSERequestIDMethodStreamImpl) Close() error { s.lock.Lock() @@ -190,33 +211,33 @@ func (s *SSERequestIDMethodStreamImpl) Close() error { } // processEvent processes a raw SSE event into the expected type -func (s *SSERequestIDMethodStreamImpl) processEvent(eventData []byte) (event string, err error) { +func (s *SSERequestIDMethodStreamImpl) processEvent(eventData []byte) (event string, hasData bool, err error) { + var body string var dataLines []string - for _, line := range bytes.Split(eventData, []byte("\n")) { + for _, line := range s.eventLines(eventData) { + if !s.started { + line = bytes.TrimPrefix(line, []byte{0xef, 0xbb, 0xbf}) + s.started = true + } if len(line) == 0 { continue } - if bytes.HasPrefix(line, []byte("data:")) { - dataLines = append(dataLines, s.trimHeader(len("data:"), line)) + field, value, _ := bytes.Cut(line, []byte(":")) + if len(value) > 0 && value[0] == ' ' { + value = value[1:] + } + if bytes.Equal(field, []byte("data")) { + dataLines = append(dataLines, string(value)) continue } } - if len(dataLines) > 0 { - dataContent := strings.Join(dataLines, "\n") - - event = dataContent + if len(dataLines) == 0 { + return event, false, nil } - return -} + dataContent := strings.Join(dataLines, "\n") -// trimHeader removes the header prefix and optional leading space -func (s *SSERequestIDMethodStreamImpl) trimHeader(size int, data []byte) string { - if len(data) < size { - return string(data) - } - data = data[size:] - if len(data) > 0 && data[0] == ' ' { - data = data[1:] - } - return string(data) + body = dataContent + event = body + hasData = true + return } diff --git a/http/codegen/testdata/golden/sse-client-string.golden b/http/codegen/testdata/golden/sse-client-string.golden index b418aa697b..850638a6f5 100644 --- a/http/codegen/testdata/golden/sse-client-string.golden +++ b/http/codegen/testdata/golden/sse-client-string.golden @@ -14,6 +14,8 @@ type ( resp *http.Response decoder func(*http.Response) goahttp.Decoder buffer []byte // Buffer for unprocessed data + // started records whether the first stream line was read. + started bool lock sync.Mutex closed bool } @@ -42,25 +44,28 @@ func (s *SSEStringMethodStreamImpl) Recv() (string, error) { // RecvWithContext reads and returns the next event from the SSE stream, respecting context cancellation. func (s *SSEStringMethodStreamImpl) RecvWithContext(ctx context.Context) (event string, err error) { - var byts []byte - byts, err = s.readEvent(ctx) - if err != nil { - if errors.Is(err, io.EOF) || errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) { - // Clean up on EOF or context cancellation. io.EOF - // propagates to the caller to signal end of stream. - s.Close() - } - return + for { + var byts []byte + byts, err = s.readEvent(ctx) + if err != nil { + if errors.Is(err, io.EOF) || errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) { + // Clean up on EOF or context cancellation. io.EOF + // propagates to the caller to signal end of stream. + s.Close() + } + return + } + var hasData bool + event, hasData, err = s.processEvent(byts) + if err != nil || hasData { + return + } } - return s.processEvent(byts) } // readEvent reads a single SSE event from the stream, respecting context -// cancellation. It first checks the internal buffer for a complete event -// (delimited by double newlines). If no complete event is found, it reads from -// the HTTP response body until it either finds an event boundary, reaches EOF, -// or encounters an error. Any data after the event boundary is saved in the -// buffer for the next call. +// cancellation. It accepts CR, LF, and CRLF line endings. A blank line ends an +// event; bytes after that line remain buffered for the next call. func (s *SSEStringMethodStreamImpl) readEvent(ctx context.Context) ([]byte, error) { const bufSize = 4096 // 4KB buffer size @@ -70,9 +75,7 @@ func (s *SSEStringMethodStreamImpl) readEvent(ctx context.Context) ([]byte, erro return event, nil } - // Initialize with any data from buffer eventData := event - wasNewline := len(eventData) > 0 && eventData[len(eventData)-1] == '\n' buf := make([]byte, bufSize) // Read data in chunks until we find an event or hit EOF. A stream that @@ -103,27 +106,14 @@ func (s *SSEStringMethodStreamImpl) readEvent(ctx context.Context) ([]byte, erro return nil, err } - // Process data if we got any if n > 0 { - // Look for event boundary in this chunk - for i := 0; i < n; i++ { - b := buf[i] - eventData = append(eventData, b) - - // Check for double newlines (event boundary) - if b == '\n' && wasNewline { - // Save any remaining data for next read - if i+1 < n { - s.lock.Lock() - s.buffer = append(s.buffer[:0], buf[i+1:n]...) - s.lock.Unlock() - } - return eventData, nil - } - - // Update newline tracking - wasNewline = (b == '\n') - } + eventData = append(eventData, buf[:n]...) + } + if end := s.eventEnd(eventData, errors.Is(err, io.EOF)); end >= 0 { + s.lock.Lock() + s.buffer = append(s.buffer[:0], eventData[end:]...) + s.lock.Unlock() + return eventData[:end], nil } // Discard any partial frame at EOF: an event that ends before its @@ -134,11 +124,8 @@ func (s *SSEStringMethodStreamImpl) readEvent(ctx context.Context) ([]byte, erro } } -// checkBuffer examines the internal buffer for a complete SSE event (delimited -// by double newlines). It returns two values: the event data (or all buffer -// contents if no complete event is found), and a boolean indicating whether a -// complete event was found. If a complete event is found, any remaining data -// after the event is kept in the buffer for the next call. +// checkBuffer returns the first complete event already read from the response. +// When no blank line is present, it returns a copy that readEvent can extend. func (s *SSEStringMethodStreamImpl) checkBuffer() ([]byte, bool) { s.lock.Lock() defer s.lock.Unlock() @@ -148,25 +135,11 @@ func (s *SSEStringMethodStreamImpl) checkBuffer() ([]byte, bool) { return nil, false } - // Look for double newline in buffer - for i := 0; i < len(s.buffer)-1; i++ { - if s.buffer[i] == '\n' && s.buffer[i+1] == '\n' { - // Found complete event. Copy it out: compacting the buffer - // below would otherwise overwrite the returned bytes, since - // both slices share the same backing array. - eventEnd := i + 2 // Include both newlines - eventData := make([]byte, eventEnd) - copy(eventData, s.buffer[:eventEnd]) - - // Save remaining data for next time - if eventEnd < len(s.buffer) { - s.buffer = append(s.buffer[:0], s.buffer[eventEnd:]...) - } else { - s.buffer = s.buffer[:0] - } - - return eventData, true - } + if end := s.eventEnd(s.buffer, false); end >= 0 { + eventData := make([]byte, end) + copy(eventData, s.buffer[:end]) + s.buffer = append(s.buffer[:0], s.buffer[end:]...) + return eventData, true } // No complete event found, return a copy of the buffer contents: the @@ -178,6 +151,54 @@ func (s *SSEStringMethodStreamImpl) checkBuffer() ([]byte, bool) { return eventData, false } +// eventEnd returns the byte after the blank line that ends the first event. +// A CR at the end waits for the next byte unless the response has ended. +func (s *SSEStringMethodStreamImpl) eventEnd(data []byte, atEOF bool) int { + lineStart := 0 + for i := 0; i < len(data); { + if data[i] != '\r' && data[i] != '\n' { + i++ + continue + } + lineEnd := i + 1 + if data[i] == '\r' { + if lineEnd == len(data) && !atEOF { + return -1 + } + if lineEnd < len(data) && data[lineEnd] == '\n' { + lineEnd++ + } + } + if i == lineStart { + return lineEnd + } + lineStart = lineEnd + i = lineEnd + } + return -1 +} + +// eventLines splits one complete event using every line ending allowed by SSE. +func (s *SSEStringMethodStreamImpl) eventLines(data []byte) [][]byte { + var lines [][]byte + for len(data) > 0 { + lineEnd := 0 + for lineEnd < len(data) && data[lineEnd] != '\r' && data[lineEnd] != '\n' { + lineEnd++ + } + lines = append(lines, data[:lineEnd]) + if lineEnd == len(data) { + break + } + next := lineEnd + 1 + if data[lineEnd] == '\r' && next < len(data) && data[next] == '\n' { + next++ + } + data = data[next:] + } + return lines +} + // Close closes the SSE stream and releases any associated resources. func (s *SSEStringMethodStreamImpl) Close() error { s.lock.Lock() @@ -190,33 +211,33 @@ func (s *SSEStringMethodStreamImpl) Close() error { } // processEvent processes a raw SSE event into the expected type -func (s *SSEStringMethodStreamImpl) processEvent(eventData []byte) (event string, err error) { +func (s *SSEStringMethodStreamImpl) processEvent(eventData []byte) (event string, hasData bool, err error) { + var body string var dataLines []string - for _, line := range bytes.Split(eventData, []byte("\n")) { + for _, line := range s.eventLines(eventData) { + if !s.started { + line = bytes.TrimPrefix(line, []byte{0xef, 0xbb, 0xbf}) + s.started = true + } if len(line) == 0 { continue } - if bytes.HasPrefix(line, []byte("data:")) { - dataLines = append(dataLines, s.trimHeader(len("data:"), line)) + field, value, _ := bytes.Cut(line, []byte(":")) + if len(value) > 0 && value[0] == ' ' { + value = value[1:] + } + if bytes.Equal(field, []byte("data")) { + dataLines = append(dataLines, string(value)) continue } } - if len(dataLines) > 0 { - dataContent := strings.Join(dataLines, "\n") - - event = dataContent + if len(dataLines) == 0 { + return event, false, nil } - return -} + dataContent := strings.Join(dataLines, "\n") -// trimHeader removes the header prefix and optional leading space -func (s *SSEStringMethodStreamImpl) trimHeader(size int, data []byte) string { - if len(data) < size { - return string(data) - } - data = data[size:] - if len(data) > 0 && data[0] == ' ' { - data = data[1:] - } - return string(data) + body = dataContent + event = body + hasData = true + return } diff --git a/http/codegen/testdata/golden/sse-data-field.golden b/http/codegen/testdata/golden/sse-data-field.golden index f61cb95147..a1dd8b2c40 100644 --- a/http/codegen/testdata/golden/sse-data-field.golden +++ b/http/codegen/testdata/golden/sse-data-field.golden @@ -8,19 +8,12 @@ type SSEDataFieldMethodServerStream struct { w http.ResponseWriter // r is the HTTP request. r *http.Request + // attempted is true after this stream writes the HTTP success status. + attempted bool } -// Send Send streams instances of -// "ssedatafieldservice.SSEDataFieldMethodResult" to the "SSEDataFieldMethod" -// endpoint SSE connection. -func (s *SSEDataFieldMethodServerStream) Send(v *ssedatafieldservice.SSEDataFieldMethodResult) error { - return s.SendWithContext(context.Background(), v) -} - -// SendWithContext SendWithContext streams instances of -// "ssedatafieldservice.SSEDataFieldMethodResult" to the "SSEDataFieldMethod" -// endpoint SSE connection with context. -func (s *SSEDataFieldMethodServerStream) SendWithContext(ctx context.Context, v *ssedatafieldservice.SSEDataFieldMethodResult) error { +// start writes the headers that identify a successful SSE response. +func (s *SSEDataFieldMethodServerStream) start() { s.once.Do(func() { header := s.w.Header() if header.Get("Content-Type") == "" { @@ -33,65 +26,76 @@ func (s *SSEDataFieldMethodServerStream) SendWithContext(ctx context.Context, v header.Set("Connection", "keep-alive") } s.w.WriteHeader(http.StatusOK) + s.attempted = true }) +} + +// finish writes an empty successful SSE response when the service sent no +// events. +func (s *SSEDataFieldMethodServerStream) finish() error { + if s.attempted { + return nil + } + s.start() + return nil +} + +// Send Send streams instances of +// "ssedatafieldservice.SSEDataFieldMethodResult" to the "SSEDataFieldMethod" +// endpoint SSE connection. +func (s *SSEDataFieldMethodServerStream) Send(v *ssedatafieldservice.SSEDataFieldMethodResult) error { + return s.SendWithContext(context.Background(), v) +} + +// SendWithContext SendWithContext streams instances of +// "ssedatafieldservice.SSEDataFieldMethodResult" to the "SSEDataFieldMethod" +// endpoint SSE connection with context. +func (s *SSEDataFieldMethodServerStream) SendWithContext(ctx context.Context, v *ssedatafieldservice.SSEDataFieldMethodResult) error { res := v var data string - var payload any + hasData := true body := NewSSEDataFieldMethodResponseBody(res) - payload = body.Data - switch v := payload.(type) { - case nil: - data = "null" - case string: - data = v - case []byte: - data = string(v) - case bool: - if v { - data = "true" - } else { - data = "false" - } - case int: - data = fmt.Sprintf("%d", v) - case int8: - data = fmt.Sprintf("%d", v) - case int16: - data = fmt.Sprintf("%d", v) - case int32: - data = fmt.Sprintf("%d", v) - case int64: - data = fmt.Sprintf("%d", v) - case uint: - data = fmt.Sprintf("%d", v) - case uint8: - data = fmt.Sprintf("%d", v) - case uint16: - data = fmt.Sprintf("%d", v) - case uint32: - data = fmt.Sprintf("%d", v) - case uint64: - data = fmt.Sprintf("%d", v) - case float32: - data = fmt.Sprintf("%g", v) - case float64: - data = fmt.Sprintf("%g", v) - default: - byts, err := json.Marshal(payload) - if err != nil { - return err + + if body.Data != nil { + data = string(*body.Data) + } else { + hasData = false + } + s.start() + + if hasData { + remaining := data + for { + lineEnd := 0 + for lineEnd < len(remaining) && remaining[lineEnd] != '\r' && remaining[lineEnd] != '\n' { + lineEnd++ + } + if _, err := fmt.Fprintf(s.w, "data: %s\n", remaining[:lineEnd]); err != nil { + return err + } + if lineEnd == len(remaining) { + break + } + next := lineEnd + 1 + if remaining[lineEnd] == '\r' && next < len(remaining) && remaining[next] == '\n' { + next++ + } + remaining = remaining[next:] } - data = string(byts) } - fmt.Fprintf(s.w, "data: %s\n\n", data) + if _, err := fmt.Fprintln(s.w); err != nil { + return err + } - http.NewResponseController(s.w).Flush() + if err := http.NewResponseController(s.w).Flush(); err != nil { + return err + } return nil } -// Close is a no-op for SSE. We keep the method for compatibility with other -// stream types. +// Close does nothing because an SSE stream closes with its HTTP response. The +// common stream interface still requires this method. func (s *SSEDataFieldMethodServerStream) Close() error { return nil } diff --git a/http/codegen/testdata/golden/sse-data-id-field.golden b/http/codegen/testdata/golden/sse-data-id-field.golden index 3e7004aef2..5f68174760 100644 --- a/http/codegen/testdata/golden/sse-data-id-field.golden +++ b/http/codegen/testdata/golden/sse-data-id-field.golden @@ -8,19 +8,12 @@ type SSEDataIDFieldMethodServerStream struct { w http.ResponseWriter // r is the HTTP request. r *http.Request + // attempted is true after this stream writes the HTTP success status. + attempted bool } -// Send Send streams instances of -// "ssedataidfieldservice.SSEDataIDFieldMethodResult" to the -// "SSEDataIDFieldMethod" endpoint SSE connection. -func (s *SSEDataIDFieldMethodServerStream) Send(v *ssedataidfieldservice.SSEDataIDFieldMethodResult) error { - return s.SendWithContext(context.Background(), v) -} - -// SendWithContext SendWithContext streams instances of -// "ssedataidfieldservice.SSEDataIDFieldMethodResult" to the -// "SSEDataIDFieldMethod" endpoint SSE connection with context. -func (s *SSEDataIDFieldMethodServerStream) SendWithContext(ctx context.Context, v *ssedataidfieldservice.SSEDataIDFieldMethodResult) error { +// start writes the headers that identify a successful SSE response. +func (s *SSEDataIDFieldMethodServerStream) start() { s.once.Do(func() { header := s.w.Header() if header.Get("Content-Type") == "" { @@ -33,69 +26,89 @@ func (s *SSEDataIDFieldMethodServerStream) SendWithContext(ctx context.Context, header.Set("Connection", "keep-alive") } s.w.WriteHeader(http.StatusOK) + s.attempted = true }) - res := v +} - if id := res.ID; id != "" { - fmt.Fprintf(s.w, "id: %s\n", id) +// finish writes an empty successful SSE response when the service sent no +// events. +func (s *SSEDataIDFieldMethodServerStream) finish() error { + if s.attempted { + return nil } + s.start() + return nil +} + +// Send Send streams instances of +// "ssedataidfieldservice.SSEDataIDFieldMethodResult" to the +// "SSEDataIDFieldMethod" endpoint SSE connection. +func (s *SSEDataIDFieldMethodServerStream) Send(v *ssedataidfieldservice.SSEDataIDFieldMethodResult) error { + return s.SendWithContext(context.Background(), v) +} + +// SendWithContext SendWithContext streams instances of +// "ssedataidfieldservice.SSEDataIDFieldMethodResult" to the +// "SSEDataIDFieldMethod" endpoint SSE connection with context. +func (s *SSEDataIDFieldMethodServerStream) SendWithContext(ctx context.Context, v *ssedataidfieldservice.SSEDataIDFieldMethodResult) error { + res := v var data string - var payload any + hasData := true body := NewSSEDataIDFieldMethodResponseBody(res) - payload = body.Data - switch v := payload.(type) { - case nil: - data = "null" - case string: - data = v - case []byte: - data = string(v) - case bool: - if v { - data = "true" - } else { - data = "false" + + if body.Data != nil { + data = string(*body.Data) + } else { + hasData = false + } + id := res.ID + if id != nil { + for i := 0; i < len(*id); i++ { + if (*id)[i] == '\r' || (*id)[i] == '\n' || (*id)[i] == 0 { + return fmt.Errorf("SSE event ID contains a character that cannot appear on an id line") + } } - case int: - data = fmt.Sprintf("%d", v) - case int8: - data = fmt.Sprintf("%d", v) - case int16: - data = fmt.Sprintf("%d", v) - case int32: - data = fmt.Sprintf("%d", v) - case int64: - data = fmt.Sprintf("%d", v) - case uint: - data = fmt.Sprintf("%d", v) - case uint8: - data = fmt.Sprintf("%d", v) - case uint16: - data = fmt.Sprintf("%d", v) - case uint32: - data = fmt.Sprintf("%d", v) - case uint64: - data = fmt.Sprintf("%d", v) - case float32: - data = fmt.Sprintf("%g", v) - case float64: - data = fmt.Sprintf("%g", v) - default: - byts, err := json.Marshal(payload) - if err != nil { + } + s.start() + + if id != nil { + if _, err := fmt.Fprintf(s.w, "id: %s\n", *id); err != nil { return err } - data = string(byts) } - fmt.Fprintf(s.w, "data: %s\n\n", data) + if hasData { + remaining := data + for { + lineEnd := 0 + for lineEnd < len(remaining) && remaining[lineEnd] != '\r' && remaining[lineEnd] != '\n' { + lineEnd++ + } + if _, err := fmt.Fprintf(s.w, "data: %s\n", remaining[:lineEnd]); err != nil { + return err + } + if lineEnd == len(remaining) { + break + } + next := lineEnd + 1 + if remaining[lineEnd] == '\r' && next < len(remaining) && remaining[next] == '\n' { + next++ + } + remaining = remaining[next:] + } + } + if _, err := fmt.Fprintln(s.w); err != nil { + return err + } - http.NewResponseController(s.w).Flush() + if err := http.NewResponseController(s.w).Flush(); err != nil { + return err + } return nil } -// Close is a no-op for SSE. We keep the method for compatibility with other -// stream types. +// Close does nothing because an SSE stream closes with its HTTP response. The +// common stream interface still requires this method. func (s *SSEDataIDFieldMethodServerStream) Close() error { return nil } diff --git a/http/codegen/testdata/golden/sse-int.golden b/http/codegen/testdata/golden/sse-int.golden index 952688803c..ba77ebfc8c 100644 --- a/http/codegen/testdata/golden/sse-int.golden +++ b/http/codegen/testdata/golden/sse-int.golden @@ -7,17 +7,12 @@ type SSEIntMethodServerStream struct { w http.ResponseWriter // r is the HTTP request. r *http.Request + // attempted is true after this stream writes the HTTP success status. + attempted bool } -// Send Send streams instances of "int" to the "SSEIntMethod" endpoint SSE -// connection. -func (s *SSEIntMethodServerStream) Send(v int) error { - return s.SendWithContext(context.Background(), v) -} - -// SendWithContext SendWithContext streams instances of "int" to the -// "SSEIntMethod" endpoint SSE connection with context. -func (s *SSEIntMethodServerStream) SendWithContext(ctx context.Context, v int) error { +// start writes the headers that identify a successful SSE response. +func (s *SSEIntMethodServerStream) start() { s.once.Do(func() { header := s.w.Header() if header.Get("Content-Type") == "" { @@ -30,65 +25,67 @@ func (s *SSEIntMethodServerStream) SendWithContext(ctx context.Context, v int) e header.Set("Connection", "keep-alive") } s.w.WriteHeader(http.StatusOK) + s.attempted = true }) +} + +// finish writes an empty successful SSE response when the service sent no +// events. +func (s *SSEIntMethodServerStream) finish() error { + if s.attempted { + return nil + } + s.start() + return nil +} + +// Send Send streams instances of "int" to the "SSEIntMethod" endpoint SSE +// connection. +func (s *SSEIntMethodServerStream) Send(v int) error { + return s.SendWithContext(context.Background(), v) +} + +// SendWithContext SendWithContext streams instances of "int" to the +// "SSEIntMethod" endpoint SSE connection with context. +func (s *SSEIntMethodServerStream) SendWithContext(ctx context.Context, v int) error { res := v var data string - var payload any - body := NewSSEIntMethodResponseBody(res) - payload = body - switch v := payload.(type) { - case nil: - data = "null" - case string: - data = v - case []byte: - data = string(v) - case bool: - if v { - data = "true" - } else { - data = "false" + body := res + + data = fmt.Sprintf("%d", body) + s.start() + + remaining := data + for { + lineEnd := 0 + for lineEnd < len(remaining) && remaining[lineEnd] != '\r' && remaining[lineEnd] != '\n' { + lineEnd++ } - case int: - data = fmt.Sprintf("%d", v) - case int8: - data = fmt.Sprintf("%d", v) - case int16: - data = fmt.Sprintf("%d", v) - case int32: - data = fmt.Sprintf("%d", v) - case int64: - data = fmt.Sprintf("%d", v) - case uint: - data = fmt.Sprintf("%d", v) - case uint8: - data = fmt.Sprintf("%d", v) - case uint16: - data = fmt.Sprintf("%d", v) - case uint32: - data = fmt.Sprintf("%d", v) - case uint64: - data = fmt.Sprintf("%d", v) - case float32: - data = fmt.Sprintf("%g", v) - case float64: - data = fmt.Sprintf("%g", v) - default: - byts, err := json.Marshal(payload) - if err != nil { + if _, err := fmt.Fprintf(s.w, "data: %s\n", remaining[:lineEnd]); err != nil { return err } - data = string(byts) + if lineEnd == len(remaining) { + break + } + next := lineEnd + 1 + if remaining[lineEnd] == '\r' && next < len(remaining) && remaining[next] == '\n' { + next++ + } + remaining = remaining[next:] + } + if _, err := fmt.Fprintln(s.w); err != nil { + return err } - fmt.Fprintf(s.w, "data: %s\n\n", data) - http.NewResponseController(s.w).Flush() + if err := http.NewResponseController(s.w).Flush(); err != nil { + return err + } return nil } -// Close is a no-op for SSE. We keep the method for compatibility with other -// stream types. +// Close does nothing because an SSE stream closes with its HTTP response. The +// common stream interface still requires this method. func (s *SSEIntMethodServerStream) Close() error { return nil } diff --git a/http/codegen/testdata/golden/sse-object.golden b/http/codegen/testdata/golden/sse-object.golden index 14e24ddc88..6e32a5094d 100644 --- a/http/codegen/testdata/golden/sse-object.golden +++ b/http/codegen/testdata/golden/sse-object.golden @@ -8,18 +8,12 @@ type SSEObjectMethodServerStream struct { w http.ResponseWriter // r is the HTTP request. r *http.Request + // attempted is true after this stream writes the HTTP success status. + attempted bool } -// Send Send streams instances of "sseobjectservice.SSEObjectMethodResult" to -// the "SSEObjectMethod" endpoint SSE connection. -func (s *SSEObjectMethodServerStream) Send(v *sseobjectservice.SSEObjectMethodResult) error { - return s.SendWithContext(context.Background(), v) -} - -// SendWithContext SendWithContext streams instances of -// "sseobjectservice.SSEObjectMethodResult" to the "SSEObjectMethod" endpoint -// SSE connection with context. -func (s *SSEObjectMethodServerStream) SendWithContext(ctx context.Context, v *sseobjectservice.SSEObjectMethodResult) error { +// start writes the headers that identify a successful SSE response. +func (s *SSEObjectMethodServerStream) start() { s.once.Do(func() { header := s.w.Header() if header.Get("Content-Type") == "" { @@ -32,65 +26,72 @@ func (s *SSEObjectMethodServerStream) SendWithContext(ctx context.Context, v *ss header.Set("Connection", "keep-alive") } s.w.WriteHeader(http.StatusOK) + s.attempted = true }) +} + +// finish writes an empty successful SSE response when the service sent no +// events. +func (s *SSEObjectMethodServerStream) finish() error { + if s.attempted { + return nil + } + s.start() + return nil +} + +// Send Send streams instances of "sseobjectservice.SSEObjectMethodResult" to +// the "SSEObjectMethod" endpoint SSE connection. +func (s *SSEObjectMethodServerStream) Send(v *sseobjectservice.SSEObjectMethodResult) error { + return s.SendWithContext(context.Background(), v) +} + +// SendWithContext SendWithContext streams instances of +// "sseobjectservice.SSEObjectMethodResult" to the "SSEObjectMethod" endpoint +// SSE connection with context. +func (s *SSEObjectMethodServerStream) SendWithContext(ctx context.Context, v *sseobjectservice.SSEObjectMethodResult) error { res := v var data string - var payload any body := NewSSEObjectMethodResponseBody(res) - payload = body - switch v := payload.(type) { - case nil: - data = "null" - case string: - data = v - case []byte: - data = string(v) - case bool: - if v { - data = "true" - } else { - data = "false" + + byts, err := json.Marshal(body) + if err != nil { + return err + } + data = string(byts) + s.start() + + remaining := data + for { + lineEnd := 0 + for lineEnd < len(remaining) && remaining[lineEnd] != '\r' && remaining[lineEnd] != '\n' { + lineEnd++ } - case int: - data = fmt.Sprintf("%d", v) - case int8: - data = fmt.Sprintf("%d", v) - case int16: - data = fmt.Sprintf("%d", v) - case int32: - data = fmt.Sprintf("%d", v) - case int64: - data = fmt.Sprintf("%d", v) - case uint: - data = fmt.Sprintf("%d", v) - case uint8: - data = fmt.Sprintf("%d", v) - case uint16: - data = fmt.Sprintf("%d", v) - case uint32: - data = fmt.Sprintf("%d", v) - case uint64: - data = fmt.Sprintf("%d", v) - case float32: - data = fmt.Sprintf("%g", v) - case float64: - data = fmt.Sprintf("%g", v) - default: - byts, err := json.Marshal(payload) - if err != nil { + if _, err := fmt.Fprintf(s.w, "data: %s\n", remaining[:lineEnd]); err != nil { return err } - data = string(byts) + if lineEnd == len(remaining) { + break + } + next := lineEnd + 1 + if remaining[lineEnd] == '\r' && next < len(remaining) && remaining[next] == '\n' { + next++ + } + remaining = remaining[next:] + } + if _, err := fmt.Fprintln(s.w); err != nil { + return err } - fmt.Fprintf(s.w, "data: %s\n\n", data) - http.NewResponseController(s.w).Flush() + if err := http.NewResponseController(s.w).Flush(); err != nil { + return err + } return nil } -// Close is a no-op for SSE. We keep the method for compatibility with other -// stream types. +// Close does nothing because an SSE stream closes with its HTTP response. The +// common stream interface still requires this method. func (s *SSEObjectMethodServerStream) Close() error { return nil } diff --git a/http/codegen/testdata/golden/sse-request-id.golden b/http/codegen/testdata/golden/sse-request-id.golden index ce1e310295..43bb6986f5 100644 --- a/http/codegen/testdata/golden/sse-request-id.golden +++ b/http/codegen/testdata/golden/sse-request-id.golden @@ -8,17 +8,12 @@ type SSERequestIDMethodServerStream struct { w http.ResponseWriter // r is the HTTP request. r *http.Request + // attempted is true after this stream writes the HTTP success status. + attempted bool } -// Send Send streams instances of "string" to the "SSERequestIDMethod" endpoint -// SSE connection. -func (s *SSERequestIDMethodServerStream) Send(v string) error { - return s.SendWithContext(context.Background(), v) -} - -// SendWithContext SendWithContext streams instances of "string" to the -// "SSERequestIDMethod" endpoint SSE connection with context. -func (s *SSERequestIDMethodServerStream) SendWithContext(ctx context.Context, v string) error { +// start writes the headers that identify a successful SSE response. +func (s *SSERequestIDMethodServerStream) start() { s.once.Do(func() { header := s.w.Header() if header.Get("Content-Type") == "" { @@ -31,65 +26,67 @@ func (s *SSERequestIDMethodServerStream) SendWithContext(ctx context.Context, v header.Set("Connection", "keep-alive") } s.w.WriteHeader(http.StatusOK) + s.attempted = true }) +} + +// finish writes an empty successful SSE response when the service sent no +// events. +func (s *SSERequestIDMethodServerStream) finish() error { + if s.attempted { + return nil + } + s.start() + return nil +} + +// Send Send streams instances of "string" to the "SSERequestIDMethod" endpoint +// SSE connection. +func (s *SSERequestIDMethodServerStream) Send(v string) error { + return s.SendWithContext(context.Background(), v) +} + +// SendWithContext SendWithContext streams instances of "string" to the +// "SSERequestIDMethod" endpoint SSE connection with context. +func (s *SSERequestIDMethodServerStream) SendWithContext(ctx context.Context, v string) error { res := v var data string - var payload any - body := NewSSERequestIDMethodResponseBody(res) - payload = body - switch v := payload.(type) { - case nil: - data = "null" - case string: - data = v - case []byte: - data = string(v) - case bool: - if v { - data = "true" - } else { - data = "false" + body := res + + data = string(body) + s.start() + + remaining := data + for { + lineEnd := 0 + for lineEnd < len(remaining) && remaining[lineEnd] != '\r' && remaining[lineEnd] != '\n' { + lineEnd++ } - case int: - data = fmt.Sprintf("%d", v) - case int8: - data = fmt.Sprintf("%d", v) - case int16: - data = fmt.Sprintf("%d", v) - case int32: - data = fmt.Sprintf("%d", v) - case int64: - data = fmt.Sprintf("%d", v) - case uint: - data = fmt.Sprintf("%d", v) - case uint8: - data = fmt.Sprintf("%d", v) - case uint16: - data = fmt.Sprintf("%d", v) - case uint32: - data = fmt.Sprintf("%d", v) - case uint64: - data = fmt.Sprintf("%d", v) - case float32: - data = fmt.Sprintf("%g", v) - case float64: - data = fmt.Sprintf("%g", v) - default: - byts, err := json.Marshal(payload) - if err != nil { + if _, err := fmt.Fprintf(s.w, "data: %s\n", remaining[:lineEnd]); err != nil { return err } - data = string(byts) + if lineEnd == len(remaining) { + break + } + next := lineEnd + 1 + if remaining[lineEnd] == '\r' && next < len(remaining) && remaining[next] == '\n' { + next++ + } + remaining = remaining[next:] + } + if _, err := fmt.Fprintln(s.w); err != nil { + return err } - fmt.Fprintf(s.w, "data: %s\n\n", data) - http.NewResponseController(s.w).Flush() + if err := http.NewResponseController(s.w).Flush(); err != nil { + return err + } return nil } -// Close is a no-op for SSE. We keep the method for compatibility with other -// stream types. +// Close does nothing because an SSE stream closes with its HTTP response. The +// common stream interface still requires this method. func (s *SSERequestIDMethodServerStream) Close() error { return nil } diff --git a/http/codegen/testdata/golden/sse-string.golden b/http/codegen/testdata/golden/sse-string.golden index 4f5e7b9ed7..223db48244 100644 --- a/http/codegen/testdata/golden/sse-string.golden +++ b/http/codegen/testdata/golden/sse-string.golden @@ -8,17 +8,12 @@ type SSEStringMethodServerStream struct { w http.ResponseWriter // r is the HTTP request. r *http.Request + // attempted is true after this stream writes the HTTP success status. + attempted bool } -// Send Send streams instances of "string" to the "SSEStringMethod" endpoint -// SSE connection. -func (s *SSEStringMethodServerStream) Send(v string) error { - return s.SendWithContext(context.Background(), v) -} - -// SendWithContext SendWithContext streams instances of "string" to the -// "SSEStringMethod" endpoint SSE connection with context. -func (s *SSEStringMethodServerStream) SendWithContext(ctx context.Context, v string) error { +// start writes the headers that identify a successful SSE response. +func (s *SSEStringMethodServerStream) start() { s.once.Do(func() { header := s.w.Header() if header.Get("Content-Type") == "" { @@ -31,65 +26,67 @@ func (s *SSEStringMethodServerStream) SendWithContext(ctx context.Context, v str header.Set("Connection", "keep-alive") } s.w.WriteHeader(http.StatusOK) + s.attempted = true }) +} + +// finish writes an empty successful SSE response when the service sent no +// events. +func (s *SSEStringMethodServerStream) finish() error { + if s.attempted { + return nil + } + s.start() + return nil +} + +// Send Send streams instances of "string" to the "SSEStringMethod" endpoint +// SSE connection. +func (s *SSEStringMethodServerStream) Send(v string) error { + return s.SendWithContext(context.Background(), v) +} + +// SendWithContext SendWithContext streams instances of "string" to the +// "SSEStringMethod" endpoint SSE connection with context. +func (s *SSEStringMethodServerStream) SendWithContext(ctx context.Context, v string) error { res := v var data string - var payload any - body := NewSSEStringMethodResponseBody(res) - payload = body - switch v := payload.(type) { - case nil: - data = "null" - case string: - data = v - case []byte: - data = string(v) - case bool: - if v { - data = "true" - } else { - data = "false" + body := res + + data = string(body) + s.start() + + remaining := data + for { + lineEnd := 0 + for lineEnd < len(remaining) && remaining[lineEnd] != '\r' && remaining[lineEnd] != '\n' { + lineEnd++ } - case int: - data = fmt.Sprintf("%d", v) - case int8: - data = fmt.Sprintf("%d", v) - case int16: - data = fmt.Sprintf("%d", v) - case int32: - data = fmt.Sprintf("%d", v) - case int64: - data = fmt.Sprintf("%d", v) - case uint: - data = fmt.Sprintf("%d", v) - case uint8: - data = fmt.Sprintf("%d", v) - case uint16: - data = fmt.Sprintf("%d", v) - case uint32: - data = fmt.Sprintf("%d", v) - case uint64: - data = fmt.Sprintf("%d", v) - case float32: - data = fmt.Sprintf("%g", v) - case float64: - data = fmt.Sprintf("%g", v) - default: - byts, err := json.Marshal(payload) - if err != nil { + if _, err := fmt.Fprintf(s.w, "data: %s\n", remaining[:lineEnd]); err != nil { return err } - data = string(byts) + if lineEnd == len(remaining) { + break + } + next := lineEnd + 1 + if remaining[lineEnd] == '\r' && next < len(remaining) && remaining[next] == '\n' { + next++ + } + remaining = remaining[next:] + } + if _, err := fmt.Fprintln(s.w); err != nil { + return err } - fmt.Fprintf(s.w, "data: %s\n\n", data) - http.NewResponseController(s.w).Flush() + if err := http.NewResponseController(s.w).Flush(); err != nil { + return err + } return nil } -// Close is a no-op for SSE. We keep the method for compatibility with other -// stream types. +// Close does nothing because an SSE stream closes with its HTTP response. The +// common stream interface still requires this method. func (s *SSEStringMethodServerStream) Close() error { return nil } diff --git a/http/codegen/testdata/golden/transform_helper_bidirectional-client.go.golden b/http/codegen/testdata/golden/transform_helper_bidirectional-client.go.golden new file mode 100644 index 0000000000..25e12439dc --- /dev/null +++ b/http/codegen/testdata/golden/transform_helper_bidirectional-client.go.golden @@ -0,0 +1,35 @@ +// marshalServicebodyuserinnerdefaultInnerTypeToInnerTypeRequestBody builds a +// value of type *InnerTypeRequestBody from a value of type +// *servicebodyuserinnerdefault.InnerType. +func marshalServicebodyuserinnerdefaultInnerTypeToInnerTypeRequestBody(v *servicebodyuserinnerdefault.InnerType) *InnerTypeRequestBody { + res := &InnerTypeRequestBody{ + A: v.A, + B: v.B, + } + { + var zero string + if res.B == zero { + res.B = "patternb" + } + } + + return res +} + +// marshalInnerTypeRequestBodyToServicebodyuserinnerdefaultInnerType builds a +// value of type *servicebodyuserinnerdefault.InnerType from a value of type +// *InnerTypeRequestBody. +func marshalInnerTypeRequestBodyToServicebodyuserinnerdefaultInnerType(v *InnerTypeRequestBody) *servicebodyuserinnerdefault.InnerType { + res := &servicebodyuserinnerdefault.InnerType{ + A: v.A, + B: v.B, + } + { + var zero string + if res.B == zero { + res.B = "patternb" + } + } + + return res +} diff --git a/http/codegen/testdata/golden/transform_helper_shared-declarations.go.golden b/http/codegen/testdata/golden/transform_helper_shared-declarations.go.golden new file mode 100644 index 0000000000..32ab17a74c --- /dev/null +++ b/http/codegen/testdata/golden/transform_helper_shared-declarations.go.golden @@ -0,0 +1,9 @@ +// unmarshalSharedChildRequestBodyToSharedhelpersSharedChild builds a value of +// type *sharedhelpers.SharedChild from a value of type *SharedChildRequestBody. +func unmarshalSharedChildRequestBodyToSharedhelpersSharedChild(v *SharedChildRequestBody) *sharedhelpers.SharedChild { + res := &sharedhelpers.SharedChild{ + Value: *v.Value, + } + + return res +} diff --git a/http/codegen/testdata/golden/transform_helper_sibling-declarations.go.golden b/http/codegen/testdata/golden/transform_helper_sibling-declarations.go.golden new file mode 100644 index 0000000000..5f0d8b353b --- /dev/null +++ b/http/codegen/testdata/golden/transform_helper_sibling-declarations.go.golden @@ -0,0 +1,10 @@ +// marshalServiceresultusertypesiblingviewsUserTypeViewToUserTypeResponseBody +// builds a value of type *UserTypeResponseBody from a value of type +// *serviceresultusertypesiblingviews.UserTypeView. +func marshalServiceresultusertypesiblingviewsUserTypeViewToUserTypeResponseBody(v *serviceresultusertypesiblingviews.UserTypeView) *UserTypeResponseBody { + res := &UserTypeResponseBody{ + U: v.U, + } + + return res +} diff --git a/http/codegen/testdata/golden/validation_imports_client_codec.go.golden b/http/codegen/testdata/golden/validation_imports_client_codec.go.golden new file mode 100644 index 0000000000..75acd267f5 --- /dev/null +++ b/http/codegen/testdata/golden/validation_imports_client_codec.go.golden @@ -0,0 +1,105 @@ +// Code generated by goa, DO NOT EDIT. +// +// Validation Imports HTTP client encoders and decoders +// +// Command: +// goa + +package client + +import ( + "bytes" + "context" + "errors" + "io" + "net/http" + "net/url" + "unicode/utf8" + + validationimports "generated.local/gen/validation_imports" + goahttp "goa.design/goa/v3/http" + goa "goa.design/goa/v3/pkg" +) + +// BuildCheckRequest instantiates a HTTP request object with method and path +// set to call the "Validation Imports" service "Check" endpoint +func (c *Client) BuildCheckRequest(ctx context.Context, v any) (*http.Request, error) { + u := &url.URL{Scheme: c.scheme, Host: c.host, Path: CheckValidationImportsPath()} + req, err := http.NewRequest("GET", u.String(), nil) + if err != nil { + return nil, goahttp.ErrInvalidURL("Validation Imports", "Check", u.String(), err) + } + if ctx != nil { + req = req.WithContext(ctx) + } + + return req, nil +} + +// EncodeCheckRequest returns an encoder for requests sent to the Validation +// Imports Check server. +func EncodeCheckRequest(encoder func(*http.Request) goahttp.Encoder) func(*http.Request, any) error { + return func(req *http.Request, v any) error { + p, ok := v.(*validationimports.CheckPayload) + if !ok { + return goahttp.ErrInvalidType("Validation Imports", "Check", "*validationimports.CheckPayload", v) + } + { + head := p.Request + req.Header.Set("X-Request", head) + } + return nil + } +} + +// DecodeCheckResponse returns a decoder for responses returned by the +// Validation Imports Check endpoint. restoreBody controls whether the response +// body should be restored after having been read. +func DecodeCheckResponse(decoder func(*http.Response) goahttp.Decoder, restoreBody bool) func(*http.Response) (any, error) { + return func(resp *http.Response) (result any, decodeErr error) { + responseBody := resp.Body + if restoreBody { + b, readErr := io.ReadAll(responseBody) + closeErr := responseBody.Close() + if err := errors.Join(readErr, closeErr); err != nil { + return nil, goahttp.ErrDecodingError("Validation Imports", "Check", err) + } + resp.Body = io.NopCloser(bytes.NewBuffer(b)) + defer func() { + resp.Body = io.NopCloser(bytes.NewBuffer(b)) + }() + } else { + defer func() { + if err := responseBody.Close(); err != nil { + decodeErr = errors.Join(decodeErr, goahttp.ErrDecodingError("Validation Imports", "Check", err)) + } + }() + } + switch resp.StatusCode { + case http.StatusOK: + var ( + response string + err error + ) + responseRaw := resp.Header.Get("X-Response") + if responseRaw == "" { + err = goa.MergeErrors(err, goa.MissingFieldError("response", "header")) + } + response = responseRaw + if utf8.RuneCountInString(response) < 3 { + err = goa.MergeErrors(err, goa.InvalidLengthError("response", response, utf8.RuneCountInString(response), 3, true)) + } + if err != nil { + return nil, goahttp.ErrValidationError("Validation Imports", "Check", err) + } + res := NewCheckResultOK(response) + return res, nil + default: + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, goahttp.ErrDecodingError("Validation Imports", "Check", err) + } + return nil, goahttp.ErrInvalidResponse("Validation Imports", "Check", resp.StatusCode, string(body)) + } + } +} diff --git a/http/codegen/testdata/golden/validation_imports_server_codec.go.golden b/http/codegen/testdata/golden/validation_imports_server_codec.go.golden new file mode 100644 index 0000000000..bcc002385c --- /dev/null +++ b/http/codegen/testdata/golden/validation_imports_server_codec.go.golden @@ -0,0 +1,54 @@ +// Code generated by goa, DO NOT EDIT. +// +// Validation Imports HTTP server encoders and decoders +// +// Command: +// goa + +package server + +import ( + "context" + "net/http" + "unicode/utf8" + + validationimports "generated.local/gen/validation_imports" + goahttp "goa.design/goa/v3/http" + goa "goa.design/goa/v3/pkg" +) + +// EncodeCheckResponse returns an encoder for responses returned by the +// Validation Imports Check endpoint. +func EncodeCheckResponse(encoder func(context.Context, http.ResponseWriter) goahttp.Encoder) func(context.Context, http.ResponseWriter, any) error { + return func(ctx context.Context, w http.ResponseWriter, v any) error { + res, _ := v.(*validationimports.CheckResult) + w.Header().Set("X-Response", res.Response) + w.WriteHeader(http.StatusOK) + return nil + } +} + +// DecodeCheckRequest returns a decoder for requests sent to the Validation +// Imports Check endpoint. +func DecodeCheckRequest(mux goahttp.Muxer, decoder func(*http.Request) goahttp.Decoder) func(*http.Request) (*validationimports.CheckPayload, error) { + return func(r *http.Request) (*validationimports.CheckPayload, error) { + var payload *validationimports.CheckPayload + var ( + request string + err error + ) + request = r.Header.Get("X-Request") + if request == "" { + err = goa.MergeErrors(err, goa.MissingFieldError("request", "header")) + } + if utf8.RuneCountInString(request) < 2 { + err = goa.MergeErrors(err, goa.InvalidLengthError("request", request, utf8.RuneCountInString(request), 2, true)) + } + if err != nil { + return payload, err + } + payload = NewCheckPayload(request) + + return payload, nil + } +} diff --git a/http/codegen/testdata/golden/viewed_result_constructor_names.go.golden b/http/codegen/testdata/golden/viewed_result_constructor_names.go.golden new file mode 100644 index 0000000000..6a008187d3 --- /dev/null +++ b/http/codegen/testdata/golden/viewed_result_constructor_names.go.golden @@ -0,0 +1,48 @@ +// ArchiveResponseBody is the type of the "archiver" service "archive" endpoint +// HTTP response body. +type ArchiveResponseBody struct { + Status *int `form:"status,omitempty" json:"status,omitempty" xml:"status,omitempty"` +} + +// ReadResponseBody is the type of the "archiver" service "read" endpoint HTTP +// response body. +type ReadResponseBody struct { + Status *int `form:"status,omitempty" json:"status,omitempty" xml:"status,omitempty"` +} + +// NewArchiveMediaViewOK builds a "archiver" service "archive" endpoint result +// from a HTTP "OK" response. +func NewArchiveMediaViewOK(body *ArchiveResponseBody) *archiverviews.ArchiveMediaView { + v := &archiverviews.ArchiveMediaView{ + Status: body.Status, + } + + return v +} + +// NewReadArchiveMediaOK builds a "archiver" service "read" endpoint result +// from a HTTP "OK" response. +func NewReadArchiveMediaOK(body *ReadResponseBody) *archiverviews.ArchiveMediaView { + v := &archiverviews.ArchiveMediaView{ + Status: body.Status, + } + + return v +} + +// ValidateArchiveResponseBody runs the validations defined on +// ArchiveResponseBody +func ValidateArchiveResponseBody(body *ArchiveResponseBody) (err error) { + if body.Status == nil { + err = goa.MergeErrors(err, goa.MissingFieldError("status", "body")) + } + return +} + +// ValidateReadResponseBody runs the validations defined on ReadResponseBody +func ValidateReadResponseBody(body *ReadResponseBody) (err error) { + if body.Status == nil { + err = goa.MergeErrors(err, goa.MissingFieldError("status", "body")) + } + return +} diff --git a/http/codegen/testdata/golden/websocket/websocket-bidirectional-streaming-complex-client.golden b/http/codegen/testdata/golden/websocket/websocket-bidirectional-streaming-complex-client.golden index a3ceeae6d6..654be71931 100644 --- a/http/codegen/testdata/golden/websocket/websocket-bidirectional-streaming-complex-client.golden +++ b/http/codegen/testdata/golden/websocket/websocket-bidirectional-streaming-complex-client.golden @@ -8,9 +8,8 @@ package client import ( - testservice "/test_service" - testserviceviews "/test_service/views" "context" + testservice "generated.local/gen/test_service" "github.com/gorilla/websocket" goahttp "goa.design/goa/v3/http" goa "goa.design/goa/v3/pkg" diff --git a/http/codegen/testdata/golden/websocket/websocket-bidirectional-streaming-complex.golden b/http/codegen/testdata/golden/websocket/websocket-bidirectional-streaming-complex.golden index f35627f687..379e652a51 100644 --- a/http/codegen/testdata/golden/websocket/websocket-bidirectional-streaming-complex.golden +++ b/http/codegen/testdata/golden/websocket/websocket-bidirectional-streaming-complex.golden @@ -8,8 +8,8 @@ package server import ( - testservice "/test_service" "context" + testservice "generated.local/gen/test_service" "github.com/gorilla/websocket" goahttp "goa.design/goa/v3/http" goa "goa.design/goa/v3/pkg" @@ -31,6 +31,11 @@ type BidirectionalComplexServerStream struct { once sync.Once // upgradeErr is the error returned by the websocket upgrade attempt. upgradeErr error + // closeOnce makes repeated Close calls return the first close result without + // writing again. + closeOnce sync.Once + // closeErr is the result of the first Close call. + closeErr error // upgrader is the websocket connection upgrader. upgrader goahttp.Upgrader // configurer is the websocket connection configurer. @@ -135,9 +140,33 @@ func (s *BidirectionalComplexServerStream) RecvWithContext(ctx context.Context) // Close closes the "BidirectionalComplex" endpoint websocket connection. func (s *BidirectionalComplexServerStream) Close() error { + s.closeOnce.Do(func() { + s.closeErr = s.close() + }) + return s.closeErr +} + +// close opens the websocket connection when needed, sends its normal close +// message, and closes it. +func (s *BidirectionalComplexServerStream) close() error { var err error - if s.conn == nil { - return nil + // Upgrade the HTTP connection to a websocket connection only once. Connection + // upgrade is done here so that authorization logic in the endpoint is executed + // before calling the actual service method which may call Close(). + s.once.Do(func() { + var conn *websocket.Conn + conn, err = s.upgrader.Upgrade(s.w, s.r, nil) + if err != nil { + s.upgradeErr = err + return + } + if s.configurer != nil { + conn = s.configurer(conn, s.cancel) + } + s.conn = conn + }) + if s.upgradeErr != nil { + return s.upgradeErr } if err = s.conn.WriteControl( websocket.CloseMessage, diff --git a/http/codegen/testdata/golden/websocket/websocket-bidirectional-streaming-primitive-client.golden b/http/codegen/testdata/golden/websocket/websocket-bidirectional-streaming-primitive-client.golden index faadb67dc6..34aa73153b 100644 --- a/http/codegen/testdata/golden/websocket/websocket-bidirectional-streaming-primitive-client.golden +++ b/http/codegen/testdata/golden/websocket/websocket-bidirectional-streaming-primitive-client.golden @@ -8,9 +8,8 @@ package client import ( - testservice "/test_service" - testserviceviews "/test_service/views" "context" + testservice "generated.local/gen/test_service" "github.com/gorilla/websocket" goahttp "goa.design/goa/v3/http" goa "goa.design/goa/v3/pkg" diff --git a/http/codegen/testdata/golden/websocket/websocket-bidirectional-streaming-primitive.golden b/http/codegen/testdata/golden/websocket/websocket-bidirectional-streaming-primitive.golden index ba5d1bfda4..6ba33149e9 100644 --- a/http/codegen/testdata/golden/websocket/websocket-bidirectional-streaming-primitive.golden +++ b/http/codegen/testdata/golden/websocket/websocket-bidirectional-streaming-primitive.golden @@ -8,8 +8,8 @@ package server import ( - testservice "/test_service" "context" + testservice "generated.local/gen/test_service" "github.com/gorilla/websocket" goahttp "goa.design/goa/v3/http" goa "goa.design/goa/v3/pkg" @@ -31,6 +31,11 @@ type BidirectionalPrimitiveServerStream struct { once sync.Once // upgradeErr is the error returned by the websocket upgrade attempt. upgradeErr error + // closeOnce makes repeated Close calls return the first close result without + // writing again. + closeOnce sync.Once + // closeErr is the result of the first Close call. + closeErr error // upgrader is the websocket connection upgrader. upgrader goahttp.Upgrader // configurer is the websocket connection configurer. @@ -129,9 +134,33 @@ func (s *BidirectionalPrimitiveServerStream) RecvWithContext(ctx context.Context // Close closes the "BidirectionalPrimitive" endpoint websocket connection. func (s *BidirectionalPrimitiveServerStream) Close() error { + s.closeOnce.Do(func() { + s.closeErr = s.close() + }) + return s.closeErr +} + +// close opens the websocket connection when needed, sends its normal close +// message, and closes it. +func (s *BidirectionalPrimitiveServerStream) close() error { var err error - if s.conn == nil { - return nil + // Upgrade the HTTP connection to a websocket connection only once. Connection + // upgrade is done here so that authorization logic in the endpoint is executed + // before calling the actual service method which may call Close(). + s.once.Do(func() { + var conn *websocket.Conn + conn, err = s.upgrader.Upgrade(s.w, s.r, nil) + if err != nil { + s.upgradeErr = err + return + } + if s.configurer != nil { + conn = s.configurer(conn, s.cancel) + } + s.conn = conn + }) + if s.upgradeErr != nil { + return s.upgradeErr } if err = s.conn.WriteControl( websocket.CloseMessage, diff --git a/http/codegen/testdata/golden/websocket/websocket-bidirectional-streaming-with-views-client.golden b/http/codegen/testdata/golden/websocket/websocket-bidirectional-streaming-with-views-client.golden index f619e4ac03..4a59066841 100644 --- a/http/codegen/testdata/golden/websocket/websocket-bidirectional-streaming-with-views-client.golden +++ b/http/codegen/testdata/golden/websocket/websocket-bidirectional-streaming-with-views-client.golden @@ -8,9 +8,9 @@ package client import ( - testservice "/test_service" - testserviceviews "/test_service/views" "context" + testservice "generated.local/gen/test_service" + testserviceviews "generated.local/gen/test_service/views" "github.com/gorilla/websocket" goahttp "goa.design/goa/v3/http" goa "goa.design/goa/v3/pkg" @@ -31,9 +31,6 @@ type ConnConfigurer struct { type BidirectionalWithViewsClientStream struct { // conn is the underlying websocket connection. conn *websocket.Conn - // view is the view to render testservice.Request result type before sending to - // the websocket connection. - view string } // NewConnConfigurer initializes the websocket connection configurer function @@ -49,7 +46,7 @@ func NewConnConfigurer(fn goahttp.ConnConfigureFunc) *ConnConfigurer { func (s *BidirectionalWithViewsClientStream) Recv() (*testservice.Response, error) { var ( rv *testservice.Response - body BidirectionalWithViewsResponseBody + body BidirectionalWithViewsResponseBodyMinimal err error ) err = s.conn.ReadJSON(&body) @@ -59,8 +56,12 @@ func (s *BidirectionalWithViewsClientStream) Recv() (*testservice.Response, erro if err != nil { return rv, err } + err = ValidateBidirectionalWithViewsResponseBodyMinimal(&body) + if err != nil { + return rv, err + } res := NewBidirectionalWithViewsResponseOK(&body) - vres := &testserviceviews.Response{Projected: res, View: s.view} + vres := &testserviceviews.Response{Projected: res, View: "minimal"} if err := testserviceviews.ValidateResponse(vres); err != nil { return rv, goahttp.ErrValidationError("TestService", "BidirectionalWithViews", err) } @@ -95,9 +96,3 @@ func (s *BidirectionalWithViewsClientStream) Close() error { } return s.conn.Close() } - -// SetView sets the view to render the testservice.Request type before sending -// to the "BidirectionalWithViews" endpoint websocket connection. -func (s *BidirectionalWithViewsClientStream) SetView(view string) { - s.view = view -} diff --git a/http/codegen/testdata/golden/websocket/websocket-bidirectional-streaming-with-views.golden b/http/codegen/testdata/golden/websocket/websocket-bidirectional-streaming-with-views.golden index 53df220cd9..41a86bab81 100644 --- a/http/codegen/testdata/golden/websocket/websocket-bidirectional-streaming-with-views.golden +++ b/http/codegen/testdata/golden/websocket/websocket-bidirectional-streaming-with-views.golden @@ -8,8 +8,8 @@ package server import ( - testservice "/test_service" "context" + testservice "generated.local/gen/test_service" "github.com/gorilla/websocket" goahttp "goa.design/goa/v3/http" goa "goa.design/goa/v3/pkg" @@ -31,6 +31,11 @@ type BidirectionalWithViewsServerStream struct { once sync.Once // upgradeErr is the error returned by the websocket upgrade attempt. upgradeErr error + // closeOnce makes repeated Close calls return the first close result without + // writing again. + closeOnce sync.Once + // closeErr is the result of the first Close call. + closeErr error // upgrader is the websocket connection upgrader. upgrader goahttp.Upgrader // configurer is the websocket connection configurer. @@ -44,9 +49,6 @@ type BidirectionalWithViewsServerStream struct { r *http.Request // conn is the underlying websocket connection. conn *websocket.Conn - // view is the view to render testservice.Response result type before sending - // to the websocket connection. - view string } // NewConnConfigurer initializes the websocket connection configurer function @@ -65,10 +67,8 @@ func (s *BidirectionalWithViewsServerStream) Send(v *testservice.Response) error // upgrade is done here so that authorization logic in the endpoint is executed // before calling the actual service method which may call Send(). s.once.Do(func() { - respHdr := make(http.Header) - respHdr.Add("goa-view", s.view) var conn *websocket.Conn - conn, err = s.upgrader.Upgrade(s.w, s.r, respHdr) + conn, err = s.upgrader.Upgrade(s.w, s.r, nil) if err != nil { s.upgradeErr = err return @@ -81,14 +81,8 @@ func (s *BidirectionalWithViewsServerStream) Send(v *testservice.Response) error if s.upgradeErr != nil { return s.upgradeErr } - res := testservice.NewViewedResponse(v, s.view) - var body any - switch s.view { - case "default", "": - body = NewBidirectionalWithViewsResponseBody(res.Projected) - case "minimal": - body = NewBidirectionalWithViewsResponseBodyMinimal(res.Projected) - } + res := testservice.NewViewedResponse(v, "minimal") + body := NewBidirectionalWithViewsResponseBodyMinimal(res.Projected) return s.conn.WriteJSON(body) } @@ -146,9 +140,33 @@ func (s *BidirectionalWithViewsServerStream) RecvWithContext(ctx context.Context // Close closes the "BidirectionalWithViews" endpoint websocket connection. func (s *BidirectionalWithViewsServerStream) Close() error { + s.closeOnce.Do(func() { + s.closeErr = s.close() + }) + return s.closeErr +} + +// close opens the websocket connection when needed, sends its normal close +// message, and closes it. +func (s *BidirectionalWithViewsServerStream) close() error { var err error - if s.conn == nil { - return nil + // Upgrade the HTTP connection to a websocket connection only once. Connection + // upgrade is done here so that authorization logic in the endpoint is executed + // before calling the actual service method which may call Close(). + s.once.Do(func() { + var conn *websocket.Conn + conn, err = s.upgrader.Upgrade(s.w, s.r, nil) + if err != nil { + s.upgradeErr = err + return + } + if s.configurer != nil { + conn = s.configurer(conn, s.cancel) + } + s.conn = conn + }) + if s.upgradeErr != nil { + return s.upgradeErr } if err = s.conn.WriteControl( websocket.CloseMessage, @@ -159,9 +177,3 @@ func (s *BidirectionalWithViewsServerStream) Close() error { } return s.conn.Close() } - -// SetView sets the view to render the testservice.Response type before sending -// to the "BidirectionalWithViews" endpoint websocket connection. -func (s *BidirectionalWithViewsServerStream) SetView(view string) { - s.view = view -} diff --git a/http/codegen/testdata/golden/websocket/websocket-client-streaming-array.golden b/http/codegen/testdata/golden/websocket/websocket-client-streaming-array.golden index 2ce9547f11..d324bed471 100644 --- a/http/codegen/testdata/golden/websocket/websocket-client-streaming-array.golden +++ b/http/codegen/testdata/golden/websocket/websocket-client-streaming-array.golden @@ -8,9 +8,8 @@ package client import ( - testservice "/test_service" - testserviceviews "/test_service/views" "context" + testservice "generated.local/gen/test_service" "github.com/gorilla/websocket" goahttp "goa.design/goa/v3/http" goa "goa.design/goa/v3/pkg" diff --git a/http/codegen/testdata/golden/websocket/websocket-client-streaming-object.golden b/http/codegen/testdata/golden/websocket/websocket-client-streaming-object.golden index 094974849d..d01e872d1c 100644 --- a/http/codegen/testdata/golden/websocket/websocket-client-streaming-object.golden +++ b/http/codegen/testdata/golden/websocket/websocket-client-streaming-object.golden @@ -8,9 +8,8 @@ package client import ( - testservice "/test_service" - testserviceviews "/test_service/views" "context" + testservice "generated.local/gen/test_service" "github.com/gorilla/websocket" goahttp "goa.design/goa/v3/http" goa "goa.design/goa/v3/pkg" diff --git a/http/codegen/testdata/golden/websocket/websocket-client-streaming-primitive.golden b/http/codegen/testdata/golden/websocket/websocket-client-streaming-primitive.golden index 8f75c0c388..9a07b8b207 100644 --- a/http/codegen/testdata/golden/websocket/websocket-client-streaming-primitive.golden +++ b/http/codegen/testdata/golden/websocket/websocket-client-streaming-primitive.golden @@ -8,9 +8,8 @@ package client import ( - testservice "/test_service" - testserviceviews "/test_service/views" "context" + testservice "generated.local/gen/test_service" "github.com/gorilla/websocket" goahttp "goa.design/goa/v3/http" goa "goa.design/goa/v3/pkg" diff --git a/http/codegen/testdata/golden/websocket/websocket-client-streaming-user-type.golden b/http/codegen/testdata/golden/websocket/websocket-client-streaming-user-type.golden index ac7c52aea4..03c405d894 100644 --- a/http/codegen/testdata/golden/websocket/websocket-client-streaming-user-type.golden +++ b/http/codegen/testdata/golden/websocket/websocket-client-streaming-user-type.golden @@ -8,9 +8,8 @@ package client import ( - testservice "/test_service" - testserviceviews "/test_service/views" "context" + testservice "generated.local/gen/test_service" "github.com/gorilla/websocket" goahttp "goa.design/goa/v3/http" goa "goa.design/goa/v3/pkg" diff --git a/http/codegen/testdata/golden/websocket/websocket-client-streaming-with-validation.golden b/http/codegen/testdata/golden/websocket/websocket-client-streaming-with-validation.golden index 220ad3505f..9b4d32ab20 100644 --- a/http/codegen/testdata/golden/websocket/websocket-client-streaming-with-validation.golden +++ b/http/codegen/testdata/golden/websocket/websocket-client-streaming-with-validation.golden @@ -8,9 +8,8 @@ package client import ( - testservice "/test_service" - testserviceviews "/test_service/views" "context" + testservice "generated.local/gen/test_service" "github.com/gorilla/websocket" goahttp "goa.design/goa/v3/http" goa "goa.design/goa/v3/pkg" diff --git a/http/codegen/testdata/golden/websocket/websocket-conn-configurer-client.golden b/http/codegen/testdata/golden/websocket/websocket-conn-configurer-client.golden index f4c0398268..7671bc270d 100644 --- a/http/codegen/testdata/golden/websocket/websocket-conn-configurer-client.golden +++ b/http/codegen/testdata/golden/websocket/websocket-conn-configurer-client.golden @@ -8,9 +8,8 @@ package client import ( - testservice "/test_service" - testserviceviews "/test_service/views" "context" + testservice "generated.local/gen/test_service" "github.com/gorilla/websocket" goahttp "goa.design/goa/v3/http" goa "goa.design/goa/v3/pkg" diff --git a/http/codegen/testdata/golden/websocket/websocket-conn-configurer.golden b/http/codegen/testdata/golden/websocket/websocket-conn-configurer.golden index 529ce34bcc..b966c516ff 100644 --- a/http/codegen/testdata/golden/websocket/websocket-conn-configurer.golden +++ b/http/codegen/testdata/golden/websocket/websocket-conn-configurer.golden @@ -8,8 +8,8 @@ package server import ( - testservice "/test_service" "context" + testservice "generated.local/gen/test_service" "github.com/gorilla/websocket" goahttp "goa.design/goa/v3/http" goa "goa.design/goa/v3/pkg" @@ -31,6 +31,11 @@ type ConfigurableStreamServerStream struct { once sync.Once // upgradeErr is the error returned by the websocket upgrade attempt. upgradeErr error + // closeOnce makes repeated Close calls return the first close result without + // writing again. + closeOnce sync.Once + // closeErr is the result of the first Close call. + closeErr error // upgrader is the websocket connection upgrader. upgrader goahttp.Upgrader // configurer is the websocket connection configurer. @@ -88,9 +93,33 @@ func (s *ConfigurableStreamServerStream) SendWithContext(ctx context.Context, v // Close closes the "ConfigurableStream" endpoint websocket connection. func (s *ConfigurableStreamServerStream) Close() error { + s.closeOnce.Do(func() { + s.closeErr = s.close() + }) + return s.closeErr +} + +// close opens the websocket connection when needed, sends its normal close +// message, and closes it. +func (s *ConfigurableStreamServerStream) close() error { var err error - if s.conn == nil { - return nil + // Upgrade the HTTP connection to a websocket connection only once. Connection + // upgrade is done here so that authorization logic in the endpoint is executed + // before calling the actual service method which may call Close(). + s.once.Do(func() { + var conn *websocket.Conn + conn, err = s.upgrader.Upgrade(s.w, s.r, nil) + if err != nil { + s.upgradeErr = err + return + } + if s.configurer != nil { + conn = s.configurer(conn, s.cancel) + } + s.conn = conn + }) + if s.upgradeErr != nil { + return s.upgradeErr } if err = s.conn.WriteControl( websocket.CloseMessage, diff --git a/http/codegen/testdata/golden/websocket/websocket-mixed-endpoints-client.golden b/http/codegen/testdata/golden/websocket/websocket-mixed-endpoints-client.golden index 2dd48cf59f..16aded6d89 100644 --- a/http/codegen/testdata/golden/websocket/websocket-mixed-endpoints-client.golden +++ b/http/codegen/testdata/golden/websocket/websocket-mixed-endpoints-client.golden @@ -8,9 +8,8 @@ package client import ( - testservice "/test_service" - testserviceviews "/test_service/views" "context" + testservice "generated.local/gen/test_service" "github.com/gorilla/websocket" goahttp "goa.design/goa/v3/http" goa "goa.design/goa/v3/pkg" diff --git a/http/codegen/testdata/golden/websocket/websocket-mixed-endpoints.golden b/http/codegen/testdata/golden/websocket/websocket-mixed-endpoints.golden index f03105fd78..77067640f8 100644 --- a/http/codegen/testdata/golden/websocket/websocket-mixed-endpoints.golden +++ b/http/codegen/testdata/golden/websocket/websocket-mixed-endpoints.golden @@ -8,8 +8,8 @@ package server import ( - testservice "/test_service" "context" + testservice "generated.local/gen/test_service" "github.com/gorilla/websocket" goahttp "goa.design/goa/v3/http" goa "goa.design/goa/v3/pkg" @@ -31,6 +31,11 @@ type StreamingEndpointServerStream struct { once sync.Once // upgradeErr is the error returned by the websocket upgrade attempt. upgradeErr error + // closeOnce makes repeated Close calls return the first close result without + // writing again. + closeOnce sync.Once + // closeErr is the result of the first Close call. + closeErr error // upgrader is the websocket connection upgrader. upgrader goahttp.Upgrader // configurer is the websocket connection configurer. @@ -88,9 +93,33 @@ func (s *StreamingEndpointServerStream) SendWithContext(ctx context.Context, v s // Close closes the "StreamingEndpoint" endpoint websocket connection. func (s *StreamingEndpointServerStream) Close() error { + s.closeOnce.Do(func() { + s.closeErr = s.close() + }) + return s.closeErr +} + +// close opens the websocket connection when needed, sends its normal close +// message, and closes it. +func (s *StreamingEndpointServerStream) close() error { var err error - if s.conn == nil { - return nil + // Upgrade the HTTP connection to a websocket connection only once. Connection + // upgrade is done here so that authorization logic in the endpoint is executed + // before calling the actual service method which may call Close(). + s.once.Do(func() { + var conn *websocket.Conn + conn, err = s.upgrader.Upgrade(s.w, s.r, nil) + if err != nil { + s.upgradeErr = err + return + } + if s.configurer != nil { + conn = s.configurer(conn, s.cancel) + } + s.conn = conn + }) + if s.upgradeErr != nil { + return s.upgradeErr } if err = s.conn.WriteControl( websocket.CloseMessage, diff --git a/http/codegen/testdata/golden/websocket/websocket-no-payload-streaming.golden b/http/codegen/testdata/golden/websocket/websocket-no-payload-streaming.golden index cd1c4bf4b1..22fe380582 100644 --- a/http/codegen/testdata/golden/websocket/websocket-no-payload-streaming.golden +++ b/http/codegen/testdata/golden/websocket/websocket-no-payload-streaming.golden @@ -8,8 +8,8 @@ package server import ( - testservice "/test_service" "context" + testservice "generated.local/gen/test_service" "github.com/gorilla/websocket" goahttp "goa.design/goa/v3/http" goa "goa.design/goa/v3/pkg" @@ -31,6 +31,11 @@ type NoPayloadStreamServerStream struct { once sync.Once // upgradeErr is the error returned by the websocket upgrade attempt. upgradeErr error + // closeOnce makes repeated Close calls return the first close result without + // writing again. + closeOnce sync.Once + // closeErr is the result of the first Close call. + closeErr error // upgrader is the websocket connection upgrader. upgrader goahttp.Upgrader // configurer is the websocket connection configurer. @@ -88,9 +93,33 @@ func (s *NoPayloadStreamServerStream) SendWithContext(ctx context.Context, v str // Close closes the "NoPayloadStream" endpoint websocket connection. func (s *NoPayloadStreamServerStream) Close() error { + s.closeOnce.Do(func() { + s.closeErr = s.close() + }) + return s.closeErr +} + +// close opens the websocket connection when needed, sends its normal close +// message, and closes it. +func (s *NoPayloadStreamServerStream) close() error { var err error - if s.conn == nil { - return nil + // Upgrade the HTTP connection to a websocket connection only once. Connection + // upgrade is done here so that authorization logic in the endpoint is executed + // before calling the actual service method which may call Close(). + s.once.Do(func() { + var conn *websocket.Conn + conn, err = s.upgrader.Upgrade(s.w, s.r, nil) + if err != nil { + s.upgradeErr = err + return + } + if s.configurer != nil { + conn = s.configurer(conn, s.cancel) + } + s.conn = conn + }) + if s.upgradeErr != nil { + return s.upgradeErr } if err = s.conn.WriteControl( websocket.CloseMessage, diff --git a/http/codegen/testdata/golden/websocket/websocket-no-result-streaming.golden b/http/codegen/testdata/golden/websocket/websocket-no-result-streaming.golden index 764e00d6e6..083b5f4f88 100644 --- a/http/codegen/testdata/golden/websocket/websocket-no-result-streaming.golden +++ b/http/codegen/testdata/golden/websocket/websocket-no-result-streaming.golden @@ -8,8 +8,8 @@ package server import ( - testservice "/test_service" "context" + testservice "generated.local/gen/test_service" "github.com/gorilla/websocket" goahttp "goa.design/goa/v3/http" goa "goa.design/goa/v3/pkg" @@ -31,6 +31,11 @@ type NoResultStreamServerStream struct { once sync.Once // upgradeErr is the error returned by the websocket upgrade attempt. upgradeErr error + // closeOnce makes repeated Close calls return the first close result without + // writing again. + closeOnce sync.Once + // closeErr is the result of the first Close call. + closeErr error // upgrader is the websocket connection upgrader. upgrader goahttp.Upgrader // configurer is the websocket connection configurer. @@ -97,9 +102,33 @@ func (s *NoResultStreamServerStream) RecvWithContext(ctx context.Context) (strin // Close closes the "NoResultStream" endpoint websocket connection. func (s *NoResultStreamServerStream) Close() error { + s.closeOnce.Do(func() { + s.closeErr = s.close() + }) + return s.closeErr +} + +// close opens the websocket connection when needed, sends its normal close +// message, and closes it. +func (s *NoResultStreamServerStream) close() error { var err error - if s.conn == nil { - return nil + // Upgrade the HTTP connection to a websocket connection only once. Connection + // upgrade is done here so that authorization logic in the endpoint is executed + // before calling the actual service method which may call Close(). + s.once.Do(func() { + var conn *websocket.Conn + conn, err = s.upgrader.Upgrade(s.w, s.r, nil) + if err != nil { + s.upgradeErr = err + return + } + if s.configurer != nil { + conn = s.configurer(conn, s.cancel) + } + s.conn = conn + }) + if s.upgradeErr != nil { + return s.upgradeErr } if err = s.conn.WriteControl( websocket.CloseMessage, diff --git a/http/codegen/testdata/golden/websocket/websocket-server-streaming-array.golden b/http/codegen/testdata/golden/websocket/websocket-server-streaming-array.golden index 06109588a3..1f247e64d2 100644 --- a/http/codegen/testdata/golden/websocket/websocket-server-streaming-array.golden +++ b/http/codegen/testdata/golden/websocket/websocket-server-streaming-array.golden @@ -8,8 +8,8 @@ package server import ( - testservice "/test_service" "context" + testservice "generated.local/gen/test_service" "github.com/gorilla/websocket" goahttp "goa.design/goa/v3/http" goa "goa.design/goa/v3/pkg" @@ -31,6 +31,11 @@ type StreamArrayServerStream struct { once sync.Once // upgradeErr is the error returned by the websocket upgrade attempt. upgradeErr error + // closeOnce makes repeated Close calls return the first close result without + // writing again. + closeOnce sync.Once + // closeErr is the result of the first Close call. + closeErr error // upgrader is the websocket connection upgrader. upgrader goahttp.Upgrader // configurer is the websocket connection configurer. @@ -88,9 +93,33 @@ func (s *StreamArrayServerStream) SendWithContext(ctx context.Context, v []strin // Close closes the "StreamArray" endpoint websocket connection. func (s *StreamArrayServerStream) Close() error { + s.closeOnce.Do(func() { + s.closeErr = s.close() + }) + return s.closeErr +} + +// close opens the websocket connection when needed, sends its normal close +// message, and closes it. +func (s *StreamArrayServerStream) close() error { var err error - if s.conn == nil { - return nil + // Upgrade the HTTP connection to a websocket connection only once. Connection + // upgrade is done here so that authorization logic in the endpoint is executed + // before calling the actual service method which may call Close(). + s.once.Do(func() { + var conn *websocket.Conn + conn, err = s.upgrader.Upgrade(s.w, s.r, nil) + if err != nil { + s.upgradeErr = err + return + } + if s.configurer != nil { + conn = s.configurer(conn, s.cancel) + } + s.conn = conn + }) + if s.upgradeErr != nil { + return s.upgradeErr } if err = s.conn.WriteControl( websocket.CloseMessage, diff --git a/http/codegen/testdata/golden/websocket/websocket-server-streaming-collection-with-views-client.golden b/http/codegen/testdata/golden/websocket/websocket-server-streaming-collection-with-views-client.golden new file mode 100644 index 0000000000..5872133c7a --- /dev/null +++ b/http/codegen/testdata/golden/websocket/websocket-server-streaming-collection-with-views-client.golden @@ -0,0 +1,127 @@ +// Code generated by goa, DO NOT EDIT. +// +// StreamingResultCollectionWithViewsService WebSocket client streaming +// +// Command: +// goa + +package client + +import ( + "context" + streamingresultcollectionwithviewsservice "generated.local/gen/streaming_result_collection_with_views_service" + streamingresultcollectionwithviewsserviceviews "generated.local/gen/streaming_result_collection_with_views_service/views" + "github.com/gorilla/websocket" + goahttp "goa.design/goa/v3/http" + goa "goa.design/goa/v3/pkg" + "io" + "net/http" + "sync" + "time" +) + +// ConnConfigurer holds the websocket connection configurer functions for the +// streaming endpoints in "StreamingResultCollectionWithViewsService" service. +type ConnConfigurer struct { + StreamingResultCollectionWithViewsMethodFn goahttp.ConnConfigureFunc +} + +// StreamingResultCollectionWithViewsMethodClientStream implements the +// streamingresultcollectionwithviewsservice.StreamingResultCollectionWithViewsMethodClientStream +// interface. +type StreamingResultCollectionWithViewsMethodClientStream struct { + // conn is the underlying websocket connection. + conn *websocket.Conn + // view is the result view used to decode values received from the websocket + // connection. + view string +} + +// NewConnConfigurer initializes the websocket connection configurer function +// with fn for all the streaming endpoints in +// "StreamingResultCollectionWithViewsService" service. +func NewConnConfigurer(fn goahttp.ConnConfigureFunc) *ConnConfigurer { + return &ConnConfigurer{ + StreamingResultCollectionWithViewsMethodFn: fn, + } +} + +// Recv reads instances of +// "streamingresultcollectionwithviewsservice.UsertypeCollection" from the +// "StreamingResultCollectionWithViewsMethod" endpoint websocket connection. +func (s *StreamingResultCollectionWithViewsMethodClientStream) Recv() (streamingresultcollectionwithviewsservice.UsertypeCollection, error) { + var ( + rv streamingresultcollectionwithviewsservice.UsertypeCollection + err error + ) + view := s.view + if view == "" { + view = "default" + } + switch view { + case "tiny": + var body UsertypeResponseTinyCollection + err = s.conn.ReadJSON(&body) + if websocket.IsCloseError(err, websocket.CloseNormalClosure) { + s.conn.Close() + return rv, io.EOF + } + if err != nil { + return rv, err + } + res := NewStreamingResultCollectionWithViewsMethodResultTinyOK(body) + vres := streamingresultcollectionwithviewsserviceviews.UsertypeCollection{Projected: res, View: view} + if err := streamingresultcollectionwithviewsserviceviews.ValidateUsertypeCollection(vres); err != nil { + return rv, goahttp.ErrValidationError("StreamingResultCollectionWithViewsService", "StreamingResultCollectionWithViewsMethod", err) + } + return streamingresultcollectionwithviewsservice.NewUsertypeCollection(vres), nil + case "extended": + var body UsertypeResponseExtendedCollection + err = s.conn.ReadJSON(&body) + if websocket.IsCloseError(err, websocket.CloseNormalClosure) { + s.conn.Close() + return rv, io.EOF + } + if err != nil { + return rv, err + } + res := NewStreamingResultCollectionWithViewsMethodResultExtendedOK(body) + vres := streamingresultcollectionwithviewsserviceviews.UsertypeCollection{Projected: res, View: view} + if err := streamingresultcollectionwithviewsserviceviews.ValidateUsertypeCollection(vres); err != nil { + return rv, goahttp.ErrValidationError("StreamingResultCollectionWithViewsService", "StreamingResultCollectionWithViewsMethod", err) + } + return streamingresultcollectionwithviewsservice.NewUsertypeCollection(vres), nil + case "default": + var body UsertypeResponseCollection + err = s.conn.ReadJSON(&body) + if websocket.IsCloseError(err, websocket.CloseNormalClosure) { + s.conn.Close() + return rv, io.EOF + } + if err != nil { + return rv, err + } + res := NewStreamingResultCollectionWithViewsMethodResultDefaultOK(body) + vres := streamingresultcollectionwithviewsserviceviews.UsertypeCollection{Projected: res, View: view} + if err := streamingresultcollectionwithviewsserviceviews.ValidateUsertypeCollection(vres); err != nil { + return rv, goahttp.ErrValidationError("StreamingResultCollectionWithViewsService", "StreamingResultCollectionWithViewsMethod", err) + } + return streamingresultcollectionwithviewsservice.NewUsertypeCollection(vres), nil + default: + return rv, goahttp.ErrValidationError("StreamingResultCollectionWithViewsService", "StreamingResultCollectionWithViewsMethod", goa.InvalidEnumValueError("view", view, []any{"tiny", "extended", "default"})) + } +} + +// RecvWithContext reads instances of +// "streamingresultcollectionwithviewsservice.UsertypeCollection" from the +// "StreamingResultCollectionWithViewsMethod" endpoint websocket connection +// with context. +func (s *StreamingResultCollectionWithViewsMethodClientStream) RecvWithContext(ctx context.Context) (streamingresultcollectionwithviewsservice.UsertypeCollection, error) { + return s.Recv() +} + +// SetView sets the result view used to decode values received from the +// "StreamingResultCollectionWithViewsMethod" endpoint websocket connection. +func (s *StreamingResultCollectionWithViewsMethodClientStream) SetView(view string) { + s.view = view +} diff --git a/http/codegen/testdata/golden/websocket/websocket-server-streaming-object.golden b/http/codegen/testdata/golden/websocket/websocket-server-streaming-object.golden index e718e89ff1..d53ff69fab 100644 --- a/http/codegen/testdata/golden/websocket/websocket-server-streaming-object.golden +++ b/http/codegen/testdata/golden/websocket/websocket-server-streaming-object.golden @@ -8,8 +8,8 @@ package server import ( - testservice "/test_service" "context" + testservice "generated.local/gen/test_service" "github.com/gorilla/websocket" goahttp "goa.design/goa/v3/http" goa "goa.design/goa/v3/pkg" @@ -31,6 +31,11 @@ type StreamObjectServerStream struct { once sync.Once // upgradeErr is the error returned by the websocket upgrade attempt. upgradeErr error + // closeOnce makes repeated Close calls return the first close result without + // writing again. + closeOnce sync.Once + // closeErr is the result of the first Close call. + closeErr error // upgrader is the websocket connection upgrader. upgrader goahttp.Upgrader // configurer is the websocket connection configurer. @@ -89,9 +94,33 @@ func (s *StreamObjectServerStream) SendWithContext(ctx context.Context, v *tests // Close closes the "StreamObject" endpoint websocket connection. func (s *StreamObjectServerStream) Close() error { + s.closeOnce.Do(func() { + s.closeErr = s.close() + }) + return s.closeErr +} + +// close opens the websocket connection when needed, sends its normal close +// message, and closes it. +func (s *StreamObjectServerStream) close() error { var err error - if s.conn == nil { - return nil + // Upgrade the HTTP connection to a websocket connection only once. Connection + // upgrade is done here so that authorization logic in the endpoint is executed + // before calling the actual service method which may call Close(). + s.once.Do(func() { + var conn *websocket.Conn + conn, err = s.upgrader.Upgrade(s.w, s.r, nil) + if err != nil { + s.upgradeErr = err + return + } + if s.configurer != nil { + conn = s.configurer(conn, s.cancel) + } + s.conn = conn + }) + if s.upgradeErr != nil { + return s.upgradeErr } if err = s.conn.WriteControl( websocket.CloseMessage, diff --git a/http/codegen/testdata/golden/websocket/websocket-server-streaming-primitive.golden b/http/codegen/testdata/golden/websocket/websocket-server-streaming-primitive.golden index 2d098070c4..06f2727173 100644 --- a/http/codegen/testdata/golden/websocket/websocket-server-streaming-primitive.golden +++ b/http/codegen/testdata/golden/websocket/websocket-server-streaming-primitive.golden @@ -8,8 +8,8 @@ package server import ( - testservice "/test_service" "context" + testservice "generated.local/gen/test_service" "github.com/gorilla/websocket" goahttp "goa.design/goa/v3/http" goa "goa.design/goa/v3/pkg" @@ -31,6 +31,11 @@ type StreamPrimitiveServerStream struct { once sync.Once // upgradeErr is the error returned by the websocket upgrade attempt. upgradeErr error + // closeOnce makes repeated Close calls return the first close result without + // writing again. + closeOnce sync.Once + // closeErr is the result of the first Close call. + closeErr error // upgrader is the websocket connection upgrader. upgrader goahttp.Upgrader // configurer is the websocket connection configurer. @@ -88,9 +93,33 @@ func (s *StreamPrimitiveServerStream) SendWithContext(ctx context.Context, v str // Close closes the "StreamPrimitive" endpoint websocket connection. func (s *StreamPrimitiveServerStream) Close() error { + s.closeOnce.Do(func() { + s.closeErr = s.close() + }) + return s.closeErr +} + +// close opens the websocket connection when needed, sends its normal close +// message, and closes it. +func (s *StreamPrimitiveServerStream) close() error { var err error - if s.conn == nil { - return nil + // Upgrade the HTTP connection to a websocket connection only once. Connection + // upgrade is done here so that authorization logic in the endpoint is executed + // before calling the actual service method which may call Close(). + s.once.Do(func() { + var conn *websocket.Conn + conn, err = s.upgrader.Upgrade(s.w, s.r, nil) + if err != nil { + s.upgradeErr = err + return + } + if s.configurer != nil { + conn = s.configurer(conn, s.cancel) + } + s.conn = conn + }) + if s.upgradeErr != nil { + return s.upgradeErr } if err = s.conn.WriteControl( websocket.CloseMessage, diff --git a/http/codegen/testdata/golden/websocket/websocket-server-streaming-user-type.golden b/http/codegen/testdata/golden/websocket/websocket-server-streaming-user-type.golden index 70b1cf03c7..d03ea3a7f6 100644 --- a/http/codegen/testdata/golden/websocket/websocket-server-streaming-user-type.golden +++ b/http/codegen/testdata/golden/websocket/websocket-server-streaming-user-type.golden @@ -8,8 +8,8 @@ package server import ( - testservice "/test_service" "context" + testservice "generated.local/gen/test_service" "github.com/gorilla/websocket" goahttp "goa.design/goa/v3/http" goa "goa.design/goa/v3/pkg" @@ -31,6 +31,11 @@ type StreamUserServerStream struct { once sync.Once // upgradeErr is the error returned by the websocket upgrade attempt. upgradeErr error + // closeOnce makes repeated Close calls return the first close result without + // writing again. + closeOnce sync.Once + // closeErr is the result of the first Close call. + closeErr error // upgrader is the websocket connection upgrader. upgrader goahttp.Upgrader // configurer is the websocket connection configurer. @@ -89,9 +94,33 @@ func (s *StreamUserServerStream) SendWithContext(ctx context.Context, v *testser // Close closes the "StreamUser" endpoint websocket connection. func (s *StreamUserServerStream) Close() error { + s.closeOnce.Do(func() { + s.closeErr = s.close() + }) + return s.closeErr +} + +// close opens the websocket connection when needed, sends its normal close +// message, and closes it. +func (s *StreamUserServerStream) close() error { var err error - if s.conn == nil { - return nil + // Upgrade the HTTP connection to a websocket connection only once. Connection + // upgrade is done here so that authorization logic in the endpoint is executed + // before calling the actual service method which may call Close(). + s.once.Do(func() { + var conn *websocket.Conn + conn, err = s.upgrader.Upgrade(s.w, s.r, nil) + if err != nil { + s.upgradeErr = err + return + } + if s.configurer != nil { + conn = s.configurer(conn, s.cancel) + } + s.conn = conn + }) + if s.upgradeErr != nil { + return s.upgradeErr } if err = s.conn.WriteControl( websocket.CloseMessage, diff --git a/http/codegen/testdata/golden/websocket/websocket-server-streaming-with-explicit-body-client.golden b/http/codegen/testdata/golden/websocket/websocket-server-streaming-with-explicit-body-client.golden new file mode 100644 index 0000000000..037f54e8a0 --- /dev/null +++ b/http/codegen/testdata/golden/websocket/websocket-server-streaming-with-explicit-body-client.golden @@ -0,0 +1,89 @@ +// Code generated by goa, DO NOT EDIT. +// +// TestService WebSocket client streaming +// +// Command: +// goa + +package client + +import ( + "context" + testservice "generated.local/gen/test_service" + testserviceviews "generated.local/gen/test_service/views" + "github.com/gorilla/websocket" + goahttp "goa.design/goa/v3/http" + goa "goa.design/goa/v3/pkg" + "io" + "net/http" + "sync" + "time" +) + +// ConnConfigurer holds the websocket connection configurer functions for the +// streaming endpoints in "TestService" service. +type ConnConfigurer struct { + StreamUserIDFn goahttp.ConnConfigureFunc +} + +// StreamUserIDClientStream implements the testservice.StreamUserIDClientStream +// interface. +type StreamUserIDClientStream struct { + // conn is the underlying websocket connection. + conn *websocket.Conn + // view is the result view used to decode values received from the websocket + // connection. + view string +} + +// NewConnConfigurer initializes the websocket connection configurer function +// with fn for all the streaming endpoints in "TestService" service. +func NewConnConfigurer(fn goahttp.ConnConfigureFunc) *ConnConfigurer { + return &ConnConfigurer{ + StreamUserIDFn: fn, + } +} + +// Recv reads instances of "testservice.User" from the "StreamUserID" endpoint +// websocket connection. +func (s *StreamUserIDClientStream) Recv() (*testservice.User, error) { + var ( + rv *testservice.User + body StreamUserIDResponseBody + err error + ) + view := s.view + if view == "" { + view = "default" + } + err = s.conn.ReadJSON(&body) + if websocket.IsCloseError(err, websocket.CloseNormalClosure) { + s.conn.Close() + return rv, io.EOF + } + if err != nil { + return rv, err + } + err = ValidateStreamUserIDResponseBody(&body) + if err != nil { + return rv, err + } + res := NewStreamUserIDUserOK(&body) + vres := &testserviceviews.User{Projected: res, View: view} + if err := testserviceviews.ValidateUser(vres); err != nil { + return rv, goahttp.ErrValidationError("TestService", "StreamUserID", err) + } + return testservice.NewUser(vres), nil +} + +// RecvWithContext reads instances of "testservice.User" from the +// "StreamUserID" endpoint websocket connection with context. +func (s *StreamUserIDClientStream) RecvWithContext(ctx context.Context) (*testservice.User, error) { + return s.Recv() +} + +// SetView sets the result view used to decode values received from the +// "StreamUserID" endpoint websocket connection. +func (s *StreamUserIDClientStream) SetView(view string) { + s.view = view +} diff --git a/http/codegen/testdata/golden/websocket/websocket-server-streaming-with-views-client.golden b/http/codegen/testdata/golden/websocket/websocket-server-streaming-with-views-client.golden new file mode 100644 index 0000000000..b87e49b134 --- /dev/null +++ b/http/codegen/testdata/golden/websocket/websocket-server-streaming-with-views-client.golden @@ -0,0 +1,114 @@ +// Code generated by goa, DO NOT EDIT. +// +// TestService WebSocket client streaming +// +// Command: +// goa + +package client + +import ( + "context" + testservice "generated.local/gen/test_service" + testserviceviews "generated.local/gen/test_service/views" + "github.com/gorilla/websocket" + goahttp "goa.design/goa/v3/http" + goa "goa.design/goa/v3/pkg" + "io" + "net/http" + "sync" + "time" +) + +// ConnConfigurer holds the websocket connection configurer functions for the +// streaming endpoints in "TestService" service. +type ConnConfigurer struct { + StreamUserWithViewsFn goahttp.ConnConfigureFunc +} + +// StreamUserWithViewsClientStream implements the +// testservice.StreamUserWithViewsClientStream interface. +type StreamUserWithViewsClientStream struct { + // conn is the underlying websocket connection. + conn *websocket.Conn + // view is the result view used to decode values received from the websocket + // connection. + view string +} + +// NewConnConfigurer initializes the websocket connection configurer function +// with fn for all the streaming endpoints in "TestService" service. +func NewConnConfigurer(fn goahttp.ConnConfigureFunc) *ConnConfigurer { + return &ConnConfigurer{ + StreamUserWithViewsFn: fn, + } +} + +// Recv reads instances of "testservice.User" from the "StreamUserWithViews" +// endpoint websocket connection. +func (s *StreamUserWithViewsClientStream) Recv() (*testservice.User, error) { + var ( + rv *testservice.User + err error + ) + view := s.view + if view == "" { + view = "default" + } + switch view { + case "default": + var body StreamUserWithViewsResponseBody + err = s.conn.ReadJSON(&body) + if websocket.IsCloseError(err, websocket.CloseNormalClosure) { + s.conn.Close() + return rv, io.EOF + } + if err != nil { + return rv, err + } + err = ValidateStreamUserWithViewsResponseBody(&body) + if err != nil { + return rv, err + } + res := NewStreamUserWithViewsResultDefaultOK(&body) + vres := &testserviceviews.User{Projected: res, View: view} + if err := testserviceviews.ValidateUser(vres); err != nil { + return rv, goahttp.ErrValidationError("TestService", "StreamUserWithViews", err) + } + return testservice.NewUser(vres), nil + case "tiny": + var body StreamUserWithViewsResponseBodyTiny + err = s.conn.ReadJSON(&body) + if websocket.IsCloseError(err, websocket.CloseNormalClosure) { + s.conn.Close() + return rv, io.EOF + } + if err != nil { + return rv, err + } + err = ValidateStreamUserWithViewsResponseBodyTiny(&body) + if err != nil { + return rv, err + } + res := NewStreamUserWithViewsResultTinyOK(&body) + vres := &testserviceviews.User{Projected: res, View: view} + if err := testserviceviews.ValidateUser(vres); err != nil { + return rv, goahttp.ErrValidationError("TestService", "StreamUserWithViews", err) + } + return testservice.NewUser(vres), nil + default: + return rv, goahttp.ErrValidationError("TestService", "StreamUserWithViews", goa.InvalidEnumValueError("view", view, []any{"default", "tiny"})) + } +} + +// RecvWithContext reads instances of "testservice.User" from the +// "StreamUserWithViews" endpoint websocket connection with context. +func (s *StreamUserWithViewsClientStream) RecvWithContext(ctx context.Context) (*testservice.User, error) { + return s.Recv() +} + +// SetView sets the result view used to decode values received from the +// "StreamUserWithViews" endpoint websocket connection. +func (s *StreamUserWithViewsClientStream) SetView(view string) { + s.view = view +} diff --git a/http/codegen/testdata/golden/websocket/websocket-server-streaming-with-views.golden b/http/codegen/testdata/golden/websocket/websocket-server-streaming-with-views.golden index af646086a5..e34e31fc65 100644 --- a/http/codegen/testdata/golden/websocket/websocket-server-streaming-with-views.golden +++ b/http/codegen/testdata/golden/websocket/websocket-server-streaming-with-views.golden @@ -8,8 +8,8 @@ package server import ( - testservice "/test_service" "context" + testservice "generated.local/gen/test_service" "github.com/gorilla/websocket" goahttp "goa.design/goa/v3/http" goa "goa.design/goa/v3/pkg" @@ -31,6 +31,11 @@ type StreamUserWithViewsServerStream struct { once sync.Once // upgradeErr is the error returned by the websocket upgrade attempt. upgradeErr error + // closeOnce makes repeated Close calls return the first close result without + // writing again. + closeOnce sync.Once + // closeErr is the result of the first Close call. + closeErr error // upgrader is the websocket connection upgrader. upgrader goahttp.Upgrader // configurer is the websocket connection configurer. @@ -47,6 +52,9 @@ type StreamUserWithViewsServerStream struct { // view is the view to render testservice.User result type before sending to // the websocket connection. view string + // sentView is the result view named during the WebSocket upgrade. Later sends + // must use the same view. + sentView string } // NewConnConfigurer initializes the websocket connection configurer function @@ -60,13 +68,26 @@ func NewConnConfigurer(fn goahttp.ConnConfigureFunc) *ConnConfigurer { // Send streams instances of "testservice.User" to the "StreamUserWithViews" // endpoint websocket connection. func (s *StreamUserWithViewsServerStream) Send(v *testservice.User) error { + view := s.view + if view == "" { + view = "default" + } + if s.sentView != "" && view != s.sentView { + return goa.InvalidEnumValueError("view", view, []any{s.sentView}) + } + switch view { + case "default": + case "tiny": + default: + return goa.InvalidEnumValueError("view", view, []any{"default", "tiny"}) + } var err error // Upgrade the HTTP connection to a websocket connection only once. Connection // upgrade is done here so that authorization logic in the endpoint is executed // before calling the actual service method which may call Send(). s.once.Do(func() { respHdr := make(http.Header) - respHdr.Add("goa-view", s.view) + respHdr.Add("goa-view", view) var conn *websocket.Conn conn, err = s.upgrader.Upgrade(s.w, s.r, respHdr) if err != nil { @@ -81,15 +102,19 @@ func (s *StreamUserWithViewsServerStream) Send(v *testservice.User) error { if s.upgradeErr != nil { return s.upgradeErr } - res := testservice.NewViewedUser(v, s.view) - var body any - switch s.view { + if s.sentView == "" { + s.sentView = view + } + switch view { case "default", "": - body = NewStreamUserWithViewsResponseBody(res.Projected) + res := testservice.NewViewedUser(v, "default") + return s.conn.WriteJSON(NewStreamUserWithViewsResponseBody(res.Projected)) case "tiny": - body = NewStreamUserWithViewsResponseBodyTiny(res.Projected) + res := testservice.NewViewedUser(v, "tiny") + return s.conn.WriteJSON(NewStreamUserWithViewsResponseBodyTiny(res.Projected)) + default: + return goa.InvalidEnumValueError("view", view, []any{"default", "tiny"}) } - return s.conn.WriteJSON(body) } // SendWithContext streams instances of "testservice.User" to the @@ -100,9 +125,45 @@ func (s *StreamUserWithViewsServerStream) SendWithContext(ctx context.Context, v // Close closes the "StreamUserWithViews" endpoint websocket connection. func (s *StreamUserWithViewsServerStream) Close() error { + s.closeOnce.Do(func() { + s.closeErr = s.close() + }) + return s.closeErr +} + +// close opens the websocket connection when needed, sends its normal close +// message, and closes it. +func (s *StreamUserWithViewsServerStream) close() error { var err error - if s.conn == nil { - return nil + view := s.view + if view == "" { + view = "default" + } + switch view { + case "default": + case "tiny": + default: + return goa.InvalidEnumValueError("view", view, []any{"default", "tiny"}) + } + // Upgrade the HTTP connection to a websocket connection only once. Connection + // upgrade is done here so that authorization logic in the endpoint is executed + // before calling the actual service method which may call Close(). + s.once.Do(func() { + respHdr := make(http.Header) + respHdr.Add("goa-view", view) + var conn *websocket.Conn + conn, err = s.upgrader.Upgrade(s.w, s.r, respHdr) + if err != nil { + s.upgradeErr = err + return + } + if s.configurer != nil { + conn = s.configurer(conn, s.cancel) + } + s.conn = conn + }) + if s.upgradeErr != nil { + return s.upgradeErr } if err = s.conn.WriteControl( websocket.CloseMessage, diff --git a/http/codegen/testdata/golden/websocket/websocket-struct-types-client.golden b/http/codegen/testdata/golden/websocket/websocket-struct-types-client.golden index 4758960f20..d605273824 100644 --- a/http/codegen/testdata/golden/websocket/websocket-struct-types-client.golden +++ b/http/codegen/testdata/golden/websocket/websocket-struct-types-client.golden @@ -8,9 +8,8 @@ package client import ( - testservice "/test_service" - testserviceviews "/test_service/views" "context" + testservice "generated.local/gen/test_service" "github.com/gorilla/websocket" goahttp "goa.design/goa/v3/http" goa "goa.design/goa/v3/pkg" diff --git a/http/codegen/testdata/golden/websocket/websocket-struct-types.golden b/http/codegen/testdata/golden/websocket/websocket-struct-types.golden index ff88ca3357..11b40e43c0 100644 --- a/http/codegen/testdata/golden/websocket/websocket-struct-types.golden +++ b/http/codegen/testdata/golden/websocket/websocket-struct-types.golden @@ -8,8 +8,8 @@ package server import ( - testservice "/test_service" "context" + testservice "generated.local/gen/test_service" "github.com/gorilla/websocket" goahttp "goa.design/goa/v3/http" goa "goa.design/goa/v3/pkg" @@ -31,6 +31,11 @@ type StructStreamServerStream struct { once sync.Once // upgradeErr is the error returned by the websocket upgrade attempt. upgradeErr error + // closeOnce makes repeated Close calls return the first close result without + // writing again. + closeOnce sync.Once + // closeErr is the result of the first Close call. + closeErr error // upgrader is the websocket connection upgrader. upgrader goahttp.Upgrader // configurer is the websocket connection configurer. @@ -131,9 +136,33 @@ func (s *StructStreamServerStream) RecvWithContext(ctx context.Context) (*testse // Close closes the "StructStream" endpoint websocket connection. func (s *StructStreamServerStream) Close() error { + s.closeOnce.Do(func() { + s.closeErr = s.close() + }) + return s.closeErr +} + +// close opens the websocket connection when needed, sends its normal close +// message, and closes it. +func (s *StructStreamServerStream) close() error { var err error - if s.conn == nil { - return nil + // Upgrade the HTTP connection to a websocket connection only once. Connection + // upgrade is done here so that authorization logic in the endpoint is executed + // before calling the actual service method which may call Close(). + s.once.Do(func() { + var conn *websocket.Conn + conn, err = s.upgrader.Upgrade(s.w, s.r, nil) + if err != nil { + s.upgradeErr = err + return + } + if s.configurer != nil { + conn = s.configurer(conn, s.cancel) + } + s.conn = conn + }) + if s.upgradeErr != nil { + return s.upgradeErr } if err = s.conn.WriteControl( websocket.CloseMessage, diff --git a/http/codegen/testdata/openapi_dsls.go b/http/codegen/testdata/openapi_dsls.go index e58211fdfe..1ab813a98c 100644 --- a/http/codegen/testdata/openapi_dsls.go +++ b/http/codegen/testdata/openapi_dsls.go @@ -31,6 +31,28 @@ var SimpleDSL = func() { }) } +// BytesExampleDSL defines a response whose OpenAPI example must remain a +// string in both JSON and YAML documents. +var BytesExampleDSL = func() { + var _ = API("bytes", func() { + Server("bytes", func() { + Host("localhost", func() { + URI("https://goa.design") + }) + }) + }) + Service("bytes", func() { + Method("download", func() { + Result(Bytes, func() { + Example([]byte("hello")) + }) + HTTP(func() { + GET("/download") + }) + }) + }) +} + var MultipleServicesDSL = func() { var PayloadT = Type("Payload", func() { Attribute("string", String, func() { @@ -146,6 +168,49 @@ var ExplicitViewDSL = func() { }) } +// ReleasedResponseCollectionNamesDSL exercises the public OpenAPI component +// names for response collections whose elements use fixed views. +var ReleasedResponseCollectionNamesDSL = func() { + var StoredBottle = ResultType("application/vnd.stored-bottle", func() { + TypeName("StoredBottle") + Attributes(func() { + Attribute("name", String, func() { + Example("Blue's Cuvee") + }) + Attribute("vintage", UInt32, func() { + Example(2003) + }) + Required("name", "vintage") + }) + View("default", func() { + Attribute("name") + Attribute("vintage") + }) + View("tiny", func() { + Attribute("name") + }) + }) + + Service("storage", func() { + Method("list_default", func() { + Result(CollectionOf(StoredBottle), func() { + View("default") + }) + HTTP(func() { + GET("/default") + }) + }) + Method("list_tiny", func() { + Result(CollectionOf(StoredBottle), func() { + View("tiny") + }) + HTTP(func() { + GET("/tiny") + }) + }) + }) +} + var InvalidDSL = func() { var _ = API("test", func() { Server("test", func() { @@ -264,20 +329,26 @@ var IntValidationDSL = func() { var ArrayValidationDSL = func() { var Bar = Type("bar", func() { + Example(Val{"string": "item"}) Attribute("string", String, func() { MinLength(0) MaxLength(42) - Example("") + Example("item") }) }) var FooBar = Type("foobar", func() { - Attribute("foo", ArrayOf(String), func() { + Example(Val{"foo": []any{"item"}, "bar": []any{Val{"string": "item"}}}) + Attribute("foo", ArrayOf(String, func() { + Example("item") + }), func() { MinLength(0) MaxLength(42) + Example([]any{"item"}) }) Attribute("bar", ArrayOf(Bar), func() { MinLength(0) MaxLength(42) + Example([]any{Val{"string": "item"}}) }) }) var _ = API("test", func() { @@ -289,7 +360,9 @@ var ArrayValidationDSL = func() { }) Service("testService", func() { Method("testEndpoint", func() { - Payload(ArrayOf(FooBar)) + Payload(ArrayOf(FooBar), func() { + Example([]any{Val{"foo": []any{"item"}, "bar": []any{Val{"string": "item"}}}}) + }) Result(String, func() { MinLength(0) MaxLength(42) @@ -504,6 +577,7 @@ var ServerHostWithVariablesDSL = func() { var WithSpacesDSL = func() { var Bar = Type("bar", func() { + Example(Val{"string": "item"}) Attribute("string", String, func() { Example("") }) @@ -513,16 +587,22 @@ var WithSpacesDSL = func() { Attribute("foo", String, func() { Example("") }) - Attribute("bar", ArrayOf(Bar)) + Attribute("bar", ArrayOf(Bar), func() { + Example([]any{Val{"string": "item"}}) + }) }) Service("test service", func() { Method("test endpoint", func() { Payload(Bar) - Result(FooBar) + Result(FooBar, func() { + Example(Val{"foo": "", "bar": []any{Val{"string": "item"}}}) + }) HTTP(func() { POST("/") Response(StatusOK) - Response(StatusNotFound) + Response(StatusNotFound, func() { + Tag("foo", "not_found") + }) }) }) }) @@ -580,25 +660,33 @@ var WithAnyDSL = func() { Service("testService", func() { Method("testEndpoint", func() { Payload(func() { + Example(Val{"any": "", "any_array": []any{""}, "any_map": Val{"key": ""}}) Attribute("any", Any, func() { Example("") }) Attribute("any_array", ArrayOf(Any, func() { Example("") - })) + }), func() { + Example([]any{""}) + }) Attribute("any_map", MapOf(String, Any), func() { + Example(Val{"key": ""}) Key(func() { Example("") }) Elem(func() { Example("") }) }) }) Result(func() { + Example(Val{"any": "", "any_array": []any{""}, "any_map": Val{"key": ""}}) Attribute("any", Any, func() { Example("") }) Attribute("any_array", ArrayOf(Any, func() { Example("") - })) + }), func() { + Example([]any{""}) + }) Attribute("any_map", MapOf(String, Any), func() { + Example(Val{"key": ""}) Key(func() { Example("") }) Elem(func() { Example("") }) }) @@ -614,7 +702,9 @@ var PathWithWildcardDSL = func() { Service("test service", func() { Method("test endpoint", func() { Payload(func() { - Attribute("int_map", Int) + Attribute("int_map", Int, func() { + Example(1) + }) }) HTTP(func() { POST("/{*int_map}") @@ -627,8 +717,12 @@ var PathWithMultipleWildcardDSL = func() { Service("test service", func() { Method("test endpoint", func() { Payload(func() { - Attribute("foo", Int) - Attribute("bar", Int) + Attribute("foo", Int, func() { + Example(1) + }) + Attribute("bar", Int, func() { + Example(2) + }) }) HTTP(func() { POST("/{bar}") @@ -644,8 +738,12 @@ var PathWithMultipleExplicitWildcardDSL = func() { Service("test service", func() { Method("test endpoint", func() { Payload(func() { - Attribute("foo", Int) - Attribute("bar", Int) + Attribute("foo", Int, func() { + Example(1) + }) + Attribute("bar", Int, func() { + Example(2) + }) }) HTTP(func() { POST("/{bar}") @@ -663,8 +761,12 @@ var HeadersDSL = func() { Service("test service", func() { Method("test endpoint", func() { Payload(func() { - Attribute("foo", Int) - Attribute("bar", Int) + Attribute("foo", Int, func() { + Example(1) + }) + Attribute("bar", Int, func() { + Example(2) + }) }) HTTP(func() { POST("/") @@ -687,7 +789,9 @@ var WithTagsDSL = func() { }) Method("test endpoint", func() { Payload(func() { - Attribute("int_map", Int) + Attribute("int_map", Int, func() { + Example(1) + }) }) HTTP(func() { Meta("openapi:tag:SomeTag") @@ -732,7 +836,9 @@ var WithTagsSwaggerDSL = func() { }) Method("test endpoint", func() { Payload(func() { - Attribute("int_map", Int) + Attribute("int_map", Int, func() { + Example(1) + }) }) HTTP(func() { Meta("swagger:tag:SomeTag") @@ -872,7 +978,9 @@ var NotGenerateServerDSL = func() { }) Service("testService", func() { Method("testEndpoint", func() { - Result(String) + Result(String, func() { + Example("ok") + }) HTTP(func() { GET("/") }) @@ -891,7 +999,9 @@ var NotGenerateHostDSL = func() { }) Service("testService", func() { Method("testEndpoint", func() { - Result(String) + Result(String, func() { + Example("ok") + }) HTTP(func() { GET("/") }) @@ -1167,7 +1277,9 @@ var OpenAPIInvalidVersionDSL = func() { var TypeExtensionDSL = func() { var Notification = Type("Notification", func() { Meta("openapi:extension:x-test-include", "true") - Attribute("id", String) + Attribute("id", String, func() { + Example("notice") + }) }) Service("testService", func() { Method("testEndpoint", func() { @@ -1184,10 +1296,14 @@ var AliasTypeDSL = func() { var Stage = Type("Stage", String, func() { Description("Setup stage.") Enum("who", "when", "where", "what") + Example("who") }) var Setup = Type("Setup", func() { + Example(Val{"current": "who", "completed": []any{"when"}}) Attribute("current", Stage) - Attribute("completed", ArrayOf(Stage)) + Attribute("completed", ArrayOf(Stage), func() { + Example([]any{"when"}) + }) }) Service("testService", func() { Method("testEndpoint", func() { diff --git a/http/codegen/testdata/payload_dsls.go b/http/codegen/testdata/payload_dsls.go index ddd9359bf1..0fff4a84b7 100644 --- a/http/codegen/testdata/payload_dsls.go +++ b/http/codegen/testdata/payload_dsls.go @@ -2851,11 +2851,11 @@ var PayloadBodyUserInnerDSL = func() { var PayloadBodyUserInnerDefaultDSL = func() { var InnerType = Type("InnerType", func() { Attribute("a", String, func() { - Default("defaulta") + Default("patterna") Pattern("patterna") }) Attribute("b", String, func() { - Default("defaultb") + Default("patternb") Pattern("patternb") }) Required("a") @@ -3075,6 +3075,28 @@ var PayloadMultipartUserTypeDSL = func() { }) } +var PayloadMultipartValidationDSL = func() { + var Part = Type("MultipartPart", func() { + Attribute("code", String, func() { + Pattern("^[a-z]+$") + }) + Required("code") + }) + Service("ServiceMultipartValidation", func() { + Method("MethodMultipartValidation", func() { + Payload(func() { + Attribute("name", String) + Attribute("part", Part) + Required("name", "part") + }) + HTTP(func() { + POST("/") + MultipartRequest() + }) + }) + }) +} + var PayloadMultipartArrayTypeDSL = func() { var PayloadType = Type("PayloadType", func() { Attribute("a", String, func() { @@ -3142,7 +3164,8 @@ var PayloadMultipartWithParamsAndHeadersDSL = func() { Pattern("patternb") }) Attribute("c", MapOf(Int, ArrayOf(String))) - Required("a", "c") + Attribute("d", String) + Required("a", "c", "d") }) Service("ServiceMultipartWithParamsAndHeaders", func() { Method("MethodMultipartWithParamsAndHeaders", func() { diff --git a/http/codegen/testdata/required_array_dsls.go b/http/codegen/testdata/required_array_dsls.go new file mode 100644 index 0000000000..b2b32f080d --- /dev/null +++ b/http/codegen/testdata/required_array_dsls.go @@ -0,0 +1,32 @@ +// This file defines the HTTP service used to verify required primitive array +// elements in generated request and response types. +package testdata + +import ( + . "goa.design/goa/v3/dsl" +) + +// RequiredPrimitiveArrayDSL defines primitive and named primitive arrays whose +// JSON elements must not be null. +var RequiredPrimitiveArrayDSL = func() { + alias := Type("RequiredArrayAlias", String, func() { + Pattern("^[a-z]+$") + }) + Service("RequiredArrays", func() { + Method("Store", func() { + Payload(func() { + Attribute("names", ArrayOfRequired(String)) + Attribute("aliases", ArrayOfRequired(alias)) + Required("names", "aliases") + }) + Result(func() { + Attribute("names", ArrayOfRequired(String)) + Attribute("aliases", ArrayOfRequired(alias)) + Required("names", "aliases") + }) + HTTP(func() { + POST("/required-arrays") + }) + }) + }) +} diff --git a/http/codegen/testdata/result_decode_functions.go b/http/codegen/testdata/result_decode_functions.go deleted file mode 100644 index 6e08aae07d..0000000000 --- a/http/codegen/testdata/result_decode_functions.go +++ /dev/null @@ -1,936 +0,0 @@ -package testdata - -var EmptyServerResponseDecodeCode = `// DecodeMethodEmptyServerResponseResponse returns a decoder for responses -// returned by the ServiceEmptyServerResponse MethodEmptyServerResponse -// endpoint. restoreBody controls whether the response body should be restored -// after having been read. -func DecodeMethodEmptyServerResponseResponse(decoder func(*http.Response) goahttp.Decoder, restoreBody bool) func(*http.Response) (any, error) { - return func(resp *http.Response) (any, error) { - if restoreBody { - b, err := io.ReadAll(resp.Body) - if err != nil { - return nil, err - } - resp.Body = io.NopCloser(bytes.NewBuffer(b)) - defer func() { - resp.Body = io.NopCloser(bytes.NewBuffer(b)) - }() - } else { - defer resp.Body.Close() - } - switch resp.StatusCode { - case http.StatusOK: - res := NewMethodEmptyServerResponseResultOK() - return res, nil - default: - body, _ := io.ReadAll(resp.Body) - return nil, goahttp.ErrInvalidResponse("ServiceEmptyServerResponse", "MethodEmptyServerResponse", resp.StatusCode, string(body)) - } - } -} -` - -var ResultBodyMultipleViewsDecodeCode = `// DecodeMethodBodyMultipleViewResponse returns a decoder for responses -// returned by the ServiceBodyMultipleView MethodBodyMultipleView endpoint. -// restoreBody controls whether the response body should be restored after -// having been read. -func DecodeMethodBodyMultipleViewResponse(decoder func(*http.Response) goahttp.Decoder, restoreBody bool) func(*http.Response) (any, error) { - return func(resp *http.Response) (any, error) { - if restoreBody { - b, err := io.ReadAll(resp.Body) - if err != nil { - return nil, err - } - resp.Body = io.NopCloser(bytes.NewBuffer(b)) - defer func() { - resp.Body = io.NopCloser(bytes.NewBuffer(b)) - }() - } else { - defer resp.Body.Close() - } - switch resp.StatusCode { - case http.StatusOK: - var ( - body MethodBodyMultipleViewResponseBody - err error - ) - err = decoder(resp).Decode(&body) - if err != nil { - return nil, goahttp.ErrDecodingError("ServiceBodyMultipleView", "MethodBodyMultipleView", err) - } - var ( - c *string - ) - cRaw := resp.Header.Get("Location") - if cRaw != "" { - c = &cRaw - } - p := NewMethodBodyMultipleViewResulttypemultipleviewsOK(&body, c) - view := resp.Header.Get("goa-view") - vres := &servicebodymultipleviewviews.Resulttypemultipleviews{Projected: p, View: view} - if err = servicebodymultipleviewviews.ValidateResulttypemultipleviews(vres); err != nil { - return nil, goahttp.ErrValidationError("ServiceBodyMultipleView", "MethodBodyMultipleView", err) - } - res := servicebodymultipleview.NewResulttypemultipleviews(vres) - return res, nil - default: - body, _ := io.ReadAll(resp.Body) - return nil, goahttp.ErrInvalidResponse("ServiceBodyMultipleView", "MethodBodyMultipleView", resp.StatusCode, string(body)) - } - } -} -` - -var EmptyBodyResultMultipleViewsDecodeCode = `// DecodeMethodEmptyBodyResultMultipleViewResponse returns a decoder for -// responses returned by the ServiceEmptyBodyResultMultipleView -// MethodEmptyBodyResultMultipleView endpoint. restoreBody controls whether the -// response body should be restored after having been read. -func DecodeMethodEmptyBodyResultMultipleViewResponse(decoder func(*http.Response) goahttp.Decoder, restoreBody bool) func(*http.Response) (any, error) { - return func(resp *http.Response) (any, error) { - if restoreBody { - b, err := io.ReadAll(resp.Body) - if err != nil { - return nil, err - } - resp.Body = io.NopCloser(bytes.NewBuffer(b)) - defer func() { - resp.Body = io.NopCloser(bytes.NewBuffer(b)) - }() - } else { - defer resp.Body.Close() - } - switch resp.StatusCode { - case http.StatusOK: - var ( - c *string - ) - cRaw := resp.Header.Get("Location") - if cRaw != "" { - c = &cRaw - } - p := NewMethodEmptyBodyResultMultipleViewResulttypemultipleviewsOK(c) - view := resp.Header.Get("goa-view") - vres := &serviceemptybodyresultmultipleviewviews.Resulttypemultipleviews{Projected: p, View: view} - res := serviceemptybodyresultmultipleview.NewResulttypemultipleviews(vres) - return res, nil - default: - body, _ := io.ReadAll(resp.Body) - return nil, goahttp.ErrInvalidResponse("ServiceEmptyBodyResultMultipleView", "MethodEmptyBodyResultMultipleView", resp.StatusCode, string(body)) - } - } -} -` - -var ExplicitBodyPrimitiveResultDecodeCode = `// DecodeMethodExplicitBodyPrimitiveResultMultipleViewResponse returns a -// decoder for responses returned by the -// ServiceExplicitBodyPrimitiveResultMultipleView -// MethodExplicitBodyPrimitiveResultMultipleView endpoint. restoreBody controls -// whether the response body should be restored after having been read. -func DecodeMethodExplicitBodyPrimitiveResultMultipleViewResponse(decoder func(*http.Response) goahttp.Decoder, restoreBody bool) func(*http.Response) (any, error) { - return func(resp *http.Response) (any, error) { - if restoreBody { - b, err := io.ReadAll(resp.Body) - if err != nil { - return nil, err - } - resp.Body = io.NopCloser(bytes.NewBuffer(b)) - defer func() { - resp.Body = io.NopCloser(bytes.NewBuffer(b)) - }() - } else { - defer resp.Body.Close() - } - switch resp.StatusCode { - case http.StatusOK: - var ( - body string - err error - ) - err = decoder(resp).Decode(&body) - if err != nil { - return nil, goahttp.ErrDecodingError("ServiceExplicitBodyPrimitiveResultMultipleView", "MethodExplicitBodyPrimitiveResultMultipleView", err) - } - if utf8.RuneCountInString(body) < 5 { - err = goa.MergeErrors(err, goa.InvalidLengthError("body", body, utf8.RuneCountInString(body), 5, true)) - } - if err != nil { - return nil, goahttp.ErrValidationError("ServiceExplicitBodyPrimitiveResultMultipleView", "MethodExplicitBodyPrimitiveResultMultipleView", err) - } - var ( - c *string - ) - cRaw := resp.Header.Get("Location") - if cRaw != "" { - c = &cRaw - } - p := NewMethodExplicitBodyPrimitiveResultMultipleViewResulttypemultipleviewsOK(body, c) - view := resp.Header.Get("goa-view") - vres := &serviceexplicitbodyprimitiveresultmultipleviewviews.Resulttypemultipleviews{Projected: p, View: view} - if err = serviceexplicitbodyprimitiveresultmultipleviewviews.ValidateResulttypemultipleviews(vres); err != nil { - return nil, goahttp.ErrValidationError("ServiceExplicitBodyPrimitiveResultMultipleView", "MethodExplicitBodyPrimitiveResultMultipleView", err) - } - res := serviceexplicitbodyprimitiveresultmultipleview.NewResulttypemultipleviews(vres) - return res, nil - default: - body, _ := io.ReadAll(resp.Body) - return nil, goahttp.ErrInvalidResponse("ServiceExplicitBodyPrimitiveResultMultipleView", "MethodExplicitBodyPrimitiveResultMultipleView", resp.StatusCode, string(body)) - } - } -} -` - -var ExplicitBodyUserResultMultipleViewsDecodeCode = `// DecodeMethodExplicitBodyUserResultMultipleViewResponse returns a decoder for -// responses returned by the ServiceExplicitBodyUserResultMultipleView -// MethodExplicitBodyUserResultMultipleView endpoint. restoreBody controls -// whether the response body should be restored after having been read. -func DecodeMethodExplicitBodyUserResultMultipleViewResponse(decoder func(*http.Response) goahttp.Decoder, restoreBody bool) func(*http.Response) (any, error) { - return func(resp *http.Response) (any, error) { - if restoreBody { - b, err := io.ReadAll(resp.Body) - if err != nil { - return nil, err - } - resp.Body = io.NopCloser(bytes.NewBuffer(b)) - defer func() { - resp.Body = io.NopCloser(bytes.NewBuffer(b)) - }() - } else { - defer resp.Body.Close() - } - switch resp.StatusCode { - case http.StatusOK: - var ( - body MethodExplicitBodyUserResultMultipleViewResponseBody - err error - ) - err = decoder(resp).Decode(&body) - if err != nil { - return nil, goahttp.ErrDecodingError("ServiceExplicitBodyUserResultMultipleView", "MethodExplicitBodyUserResultMultipleView", err) - } - var ( - c *string - ) - cRaw := resp.Header.Get("Location") - if cRaw != "" { - c = &cRaw - } - p := NewMethodExplicitBodyUserResultMultipleViewResulttypemultipleviewsOK(&body, c) - view := resp.Header.Get("goa-view") - vres := &serviceexplicitbodyuserresultmultipleviewviews.Resulttypemultipleviews{Projected: p, View: view} - if err = serviceexplicitbodyuserresultmultipleviewviews.ValidateResulttypemultipleviews(vres); err != nil { - return nil, goahttp.ErrValidationError("ServiceExplicitBodyUserResultMultipleView", "MethodExplicitBodyUserResultMultipleView", err) - } - res := serviceexplicitbodyuserresultmultipleview.NewResulttypemultipleviews(vres) - return res, nil - default: - body, _ := io.ReadAll(resp.Body) - return nil, goahttp.ErrInvalidResponse("ServiceExplicitBodyUserResultMultipleView", "MethodExplicitBodyUserResultMultipleView", resp.StatusCode, string(body)) - } - } -} -` - -var ExplicitBodyResultCollectionDecodeCode = `// DecodeMethodExplicitBodyResultCollectionResponse returns a decoder for -// responses returned by the ServiceExplicitBodyResultCollection -// MethodExplicitBodyResultCollection endpoint. restoreBody controls whether -// the response body should be restored after having been read. -func DecodeMethodExplicitBodyResultCollectionResponse(decoder func(*http.Response) goahttp.Decoder, restoreBody bool) func(*http.Response) (any, error) { - return func(resp *http.Response) (any, error) { - if restoreBody { - b, err := io.ReadAll(resp.Body) - if err != nil { - return nil, err - } - resp.Body = io.NopCloser(bytes.NewBuffer(b)) - defer func() { - resp.Body = io.NopCloser(bytes.NewBuffer(b)) - }() - } else { - defer resp.Body.Close() - } - switch resp.StatusCode { - case http.StatusOK: - var ( - body ResulttypeCollection - err error - ) - err = decoder(resp).Decode(&body) - if err != nil { - return nil, goahttp.ErrDecodingError("ServiceExplicitBodyResultCollection", "MethodExplicitBodyResultCollection", err) - } - err = ValidateResulttypeCollection(body) - if err != nil { - return nil, goahttp.ErrValidationError("ServiceExplicitBodyResultCollection", "MethodExplicitBodyResultCollection", err) - } - res := NewMethodExplicitBodyResultCollectionResultOK(body) - return res, nil - default: - body, _ := io.ReadAll(resp.Body) - return nil, goahttp.ErrInvalidResponse("ServiceExplicitBodyResultCollection", "MethodExplicitBodyResultCollection", resp.StatusCode, string(body)) - } - } -} -` - -var ResultMultipleViewsTagDecodeCode = `// DecodeMethodTagMultipleViewsResponse returns a decoder for responses -// returned by the ServiceTagMultipleViews MethodTagMultipleViews endpoint. -// restoreBody controls whether the response body should be restored after -// having been read. -func DecodeMethodTagMultipleViewsResponse(decoder func(*http.Response) goahttp.Decoder, restoreBody bool) func(*http.Response) (any, error) { - return func(resp *http.Response) (any, error) { - if restoreBody { - b, err := io.ReadAll(resp.Body) - if err != nil { - return nil, err - } - resp.Body = io.NopCloser(bytes.NewBuffer(b)) - defer func() { - resp.Body = io.NopCloser(bytes.NewBuffer(b)) - }() - } else { - defer resp.Body.Close() - } - switch resp.StatusCode { - case http.StatusAccepted: - var ( - body MethodTagMultipleViewsAcceptedResponseBody - err error - ) - err = decoder(resp).Decode(&body) - if err != nil { - return nil, goahttp.ErrDecodingError("ServiceTagMultipleViews", "MethodTagMultipleViews", err) - } - var ( - c *string - ) - cRaw := resp.Header.Get("C") - if cRaw != "" { - c = &cRaw - } - p := NewMethodTagMultipleViewsResulttypemultipleviewsAccepted(&body, c) - tmp := "value" - p.B = &tmp - view := resp.Header.Get("goa-view") - vres := &servicetagmultipleviewsviews.Resulttypemultipleviews{Projected: p, View: view} - if err = servicetagmultipleviewsviews.ValidateResulttypemultipleviews(vres); err != nil { - return nil, goahttp.ErrValidationError("ServiceTagMultipleViews", "MethodTagMultipleViews", err) - } - res := servicetagmultipleviews.NewResulttypemultipleviews(vres) - return res, nil - case http.StatusOK: - var ( - body MethodTagMultipleViewsOKResponseBody - err error - ) - err = decoder(resp).Decode(&body) - if err != nil { - return nil, goahttp.ErrDecodingError("ServiceTagMultipleViews", "MethodTagMultipleViews", err) - } - p := NewMethodTagMultipleViewsResulttypemultipleviewsOK(&body) - view := resp.Header.Get("goa-view") - vres := &servicetagmultipleviewsviews.Resulttypemultipleviews{Projected: p, View: view} - if err = servicetagmultipleviewsviews.ValidateResulttypemultipleviews(vres); err != nil { - return nil, goahttp.ErrValidationError("ServiceTagMultipleViews", "MethodTagMultipleViews", err) - } - res := servicetagmultipleviews.NewResulttypemultipleviews(vres) - return res, nil - default: - body, _ := io.ReadAll(resp.Body) - return nil, goahttp.ErrInvalidResponse("ServiceTagMultipleViews", "MethodTagMultipleViews", resp.StatusCode, string(body)) - } - } -} -` - -var EmptyServerResponseWithTagsDecodeCode = `// DecodeMethodEmptyServerResponseWithTagsResponse returns a decoder for -// responses returned by the ServiceEmptyServerResponseWithTags -// MethodEmptyServerResponseWithTags endpoint. restoreBody controls whether the -// response body should be restored after having been read. -func DecodeMethodEmptyServerResponseWithTagsResponse(decoder func(*http.Response) goahttp.Decoder, restoreBody bool) func(*http.Response) (any, error) { - return func(resp *http.Response) (any, error) { - if restoreBody { - b, err := io.ReadAll(resp.Body) - if err != nil { - return nil, err - } - resp.Body = io.NopCloser(bytes.NewBuffer(b)) - defer func() { - resp.Body = io.NopCloser(bytes.NewBuffer(b)) - }() - } else { - defer resp.Body.Close() - } - switch resp.StatusCode { - case http.StatusNotModified: - res := NewMethodEmptyServerResponseWithTagsResultNotModified() - res.H = "true" - return res, nil - case http.StatusNoContent: - res := NewMethodEmptyServerResponseWithTagsResultNoContent() - return res, nil - default: - body, _ := io.ReadAll(resp.Body) - return nil, goahttp.ErrInvalidResponse("ServiceEmptyServerResponseWithTags", "MethodEmptyServerResponseWithTags", resp.StatusCode, string(body)) - } - } -} -` - -var ResultHeaderStringImplicitResponseDecodeCode = `// DecodeMethodHeaderStringImplicitResponse returns a decoder for responses -// returned by the ServiceHeaderStringImplicit MethodHeaderStringImplicit -// endpoint. restoreBody controls whether the response body should be restored -// after having been read. -func DecodeMethodHeaderStringImplicitResponse(decoder func(*http.Response) goahttp.Decoder, restoreBody bool) func(*http.Response) (any, error) { - return func(resp *http.Response) (any, error) { - if restoreBody { - b, err := io.ReadAll(resp.Body) - if err != nil { - return nil, err - } - resp.Body = io.NopCloser(bytes.NewBuffer(b)) - defer func() { - resp.Body = io.NopCloser(bytes.NewBuffer(b)) - }() - } else { - defer resp.Body.Close() - } - switch resp.StatusCode { - case http.StatusOK: - var ( - h string - err error - ) - hRaw := resp.Header.Get("H") - if hRaw == "" { - err = goa.MergeErrors(err, goa.MissingFieldError("h", "header")) - } - h = hRaw - if err != nil { - return nil, goahttp.ErrValidationError("ServiceHeaderStringImplicit", "MethodHeaderStringImplicit", err) - } - return h, nil - default: - body, _ := io.ReadAll(resp.Body) - return nil, goahttp.ErrInvalidResponse("ServiceHeaderStringImplicit", "MethodHeaderStringImplicit", resp.StatusCode, string(body)) - } - } -} -` - -var ResultHeaderStringArrayResponseDecodeCode = `// DecodeMethodAResponse returns a decoder for responses returned by the -// ServiceHeaderStringArrayResponse MethodA endpoint. restoreBody controls -// whether the response body should be restored after having been read. -func DecodeMethodAResponse(decoder func(*http.Response) goahttp.Decoder, restoreBody bool) func(*http.Response) (any, error) { - return func(resp *http.Response) (any, error) { - if restoreBody { - b, err := io.ReadAll(resp.Body) - if err != nil { - return nil, err - } - resp.Body = io.NopCloser(bytes.NewBuffer(b)) - defer func() { - resp.Body = io.NopCloser(bytes.NewBuffer(b)) - }() - } else { - defer resp.Body.Close() - } - switch resp.StatusCode { - case http.StatusOK: - var ( - array []string - ) - array = resp.Header["Array"] - - res := NewMethodAResultOK(array) - return res, nil - default: - body, _ := io.ReadAll(resp.Body) - return nil, goahttp.ErrInvalidResponse("ServiceHeaderStringArrayResponse", "MethodA", resp.StatusCode, string(body)) - } - } -} -` - -var ResultHeaderStringArrayValidateResponseDecodeCode = `// DecodeMethodAResponse returns a decoder for responses returned by the -// ServiceHeaderStringArrayValidateResponse MethodA endpoint. restoreBody -// controls whether the response body should be restored after having been read. -func DecodeMethodAResponse(decoder func(*http.Response) goahttp.Decoder, restoreBody bool) func(*http.Response) (any, error) { - return func(resp *http.Response) (any, error) { - if restoreBody { - b, err := io.ReadAll(resp.Body) - if err != nil { - return nil, err - } - resp.Body = io.NopCloser(bytes.NewBuffer(b)) - defer func() { - resp.Body = io.NopCloser(bytes.NewBuffer(b)) - }() - } else { - defer resp.Body.Close() - } - switch resp.StatusCode { - case http.StatusOK: - var ( - array []string - err error - ) - array = resp.Header["Array"] - - if len(array) < 5 { - err = goa.MergeErrors(err, goa.InvalidLengthError("array", array, len(array), 5, true)) - } - if err != nil { - return nil, goahttp.ErrValidationError("ServiceHeaderStringArrayValidateResponse", "MethodA", err) - } - res := NewMethodAResultOK(array) - return res, nil - default: - body, _ := io.ReadAll(resp.Body) - return nil, goahttp.ErrInvalidResponse("ServiceHeaderStringArrayValidateResponse", "MethodA", resp.StatusCode, string(body)) - } - } -} -` - -var ResultHeaderArrayResponseDecodeCode = `// DecodeMethodAResponse returns a decoder for responses returned by the -// ServiceHeaderArrayResponse MethodA endpoint. restoreBody controls whether -// the response body should be restored after having been read. -func DecodeMethodAResponse(decoder func(*http.Response) goahttp.Decoder, restoreBody bool) func(*http.Response) (any, error) { - return func(resp *http.Response) (any, error) { - if restoreBody { - b, err := io.ReadAll(resp.Body) - if err != nil { - return nil, err - } - resp.Body = io.NopCloser(bytes.NewBuffer(b)) - defer func() { - resp.Body = io.NopCloser(bytes.NewBuffer(b)) - }() - } else { - defer resp.Body.Close() - } - switch resp.StatusCode { - case http.StatusOK: - var ( - array []uint - err error - ) - { - arrayRaw := resp.Header["Array"] - - if arrayRaw != nil { - array = make([]uint, len(arrayRaw)) - for i, rv := range arrayRaw { - v, err2 := strconv.ParseUint(rv, 10, strconv.IntSize) - if err2 != nil { - err = goa.MergeErrors(err, goa.InvalidFieldTypeError("array", arrayRaw, "array of unsigned integers")) - } - array[i] = uint(v) - } - } - } - if err != nil { - return nil, goahttp.ErrValidationError("ServiceHeaderArrayResponse", "MethodA", err) - } - res := NewMethodAResultOK(array) - return res, nil - default: - body, _ := io.ReadAll(resp.Body) - return nil, goahttp.ErrInvalidResponse("ServiceHeaderArrayResponse", "MethodA", resp.StatusCode, string(body)) - } - } -} -` - -var ResultHeaderArrayValidateResponseDecodeCode = `// DecodeMethodAResponse returns a decoder for responses returned by the -// ServiceHeaderArrayValidateResponse MethodA endpoint. restoreBody controls -// whether the response body should be restored after having been read. -func DecodeMethodAResponse(decoder func(*http.Response) goahttp.Decoder, restoreBody bool) func(*http.Response) (any, error) { - return func(resp *http.Response) (any, error) { - if restoreBody { - b, err := io.ReadAll(resp.Body) - if err != nil { - return nil, err - } - resp.Body = io.NopCloser(bytes.NewBuffer(b)) - defer func() { - resp.Body = io.NopCloser(bytes.NewBuffer(b)) - }() - } else { - defer resp.Body.Close() - } - switch resp.StatusCode { - case http.StatusOK: - var ( - array []int - err error - ) - { - arrayRaw := resp.Header["Array"] - - if arrayRaw != nil { - array = make([]int, len(arrayRaw)) - for i, rv := range arrayRaw { - v, err2 := strconv.ParseInt(rv, 10, strconv.IntSize) - if err2 != nil { - err = goa.MergeErrors(err, goa.InvalidFieldTypeError("array", arrayRaw, "array of integers")) - } - array[i] = int(v) - } - } - } - for _, e := range array { - if e < 5 { - err = goa.MergeErrors(err, goa.InvalidRangeError("array[*]", e, 5, true)) - } - } - if err != nil { - return nil, goahttp.ErrValidationError("ServiceHeaderArrayValidateResponse", "MethodA", err) - } - res := NewMethodAResultOK(array) - return res, nil - default: - body, _ := io.ReadAll(resp.Body) - return nil, goahttp.ErrInvalidResponse("ServiceHeaderArrayValidateResponse", "MethodA", resp.StatusCode, string(body)) - } - } -} -` - -var WithHeadersBlockResponseDecodeCode = `// DecodeMethodAResponse returns a decoder for responses returned by the -// ServiceWithHeadersBlock MethodA endpoint. restoreBody controls whether the -// response body should be restored after having been read. -func DecodeMethodAResponse(decoder func(*http.Response) goahttp.Decoder, restoreBody bool) func(*http.Response) (any, error) { - return func(resp *http.Response) (any, error) { - if restoreBody { - b, err := io.ReadAll(resp.Body) - if err != nil { - return nil, err - } - resp.Body = io.NopCloser(bytes.NewBuffer(b)) - defer func() { - resp.Body = io.NopCloser(bytes.NewBuffer(b)) - }() - } else { - defer resp.Body.Close() - } - switch resp.StatusCode { - case http.StatusOK: - var ( - required int - optional *float32 - optionalButRequired uint - err error - ) - { - requiredRaw := resp.Header.Get("X-Request-Id") - if requiredRaw == "" { - return nil, goahttp.ErrValidationError("ServiceWithHeadersBlock", "MethodA", goa.MissingFieldError("required", "header")) - } - v, err2 := strconv.ParseInt(requiredRaw, 10, strconv.IntSize) - if err2 != nil { - err = goa.MergeErrors(err, goa.InvalidFieldTypeError("required", requiredRaw, "integer")) - } - required = int(v) - } - { - optionalRaw := resp.Header.Get("Authorization") - if optionalRaw != "" { - v, err2 := strconv.ParseFloat(optionalRaw, 32) - if err2 != nil { - err = goa.MergeErrors(err, goa.InvalidFieldTypeError("optional", optionalRaw, "float")) - } - pv := float32(v) - optional = &pv - } - } - { - optionalButRequiredRaw := resp.Header.Get("Location") - if optionalButRequiredRaw == "" { - return nil, goahttp.ErrValidationError("ServiceWithHeadersBlock", "MethodA", goa.MissingFieldError("optional_but_required", "header")) - } - v, err2 := strconv.ParseUint(optionalButRequiredRaw, 10, strconv.IntSize) - if err2 != nil { - err = goa.MergeErrors(err, goa.InvalidFieldTypeError("optional_but_required", optionalButRequiredRaw, "unsigned integer")) - } - optionalButRequired = uint(v) - } - if err != nil { - return nil, goahttp.ErrValidationError("ServiceWithHeadersBlock", "MethodA", err) - } - res := NewMethodAResultOK(required, optional, optionalButRequired) - return res, nil - default: - body, _ := io.ReadAll(resp.Body) - return nil, goahttp.ErrInvalidResponse("ServiceWithHeadersBlock", "MethodA", resp.StatusCode, string(body)) - } - } -} -` - -var WithHeadersBlockViewedResultResponseDecodeCode = `// DecodeMethodAResponse returns a decoder for responses returned by the -// ServiceWithHeadersBlockViewedResult MethodA endpoint. restoreBody controls -// whether the response body should be restored after having been read. -func DecodeMethodAResponse(decoder func(*http.Response) goahttp.Decoder, restoreBody bool) func(*http.Response) (any, error) { - return func(resp *http.Response) (any, error) { - if restoreBody { - b, err := io.ReadAll(resp.Body) - if err != nil { - return nil, err - } - resp.Body = io.NopCloser(bytes.NewBuffer(b)) - defer func() { - resp.Body = io.NopCloser(bytes.NewBuffer(b)) - }() - } else { - defer resp.Body.Close() - } - switch resp.StatusCode { - case http.StatusOK: - var ( - required int - optional *float32 - optionalButRequired uint - err error - ) - { - requiredRaw := resp.Header.Get("X-Request-Id") - if requiredRaw == "" { - return nil, goahttp.ErrValidationError("ServiceWithHeadersBlockViewedResult", "MethodA", goa.MissingFieldError("required", "header")) - } - v, err2 := strconv.ParseInt(requiredRaw, 10, strconv.IntSize) - if err2 != nil { - err = goa.MergeErrors(err, goa.InvalidFieldTypeError("required", requiredRaw, "integer")) - } - required = int(v) - } - { - optionalRaw := resp.Header.Get("Authorization") - if optionalRaw != "" { - v, err2 := strconv.ParseFloat(optionalRaw, 32) - if err2 != nil { - err = goa.MergeErrors(err, goa.InvalidFieldTypeError("optional", optionalRaw, "float")) - } - pv := float32(v) - optional = &pv - } - } - { - optionalButRequiredRaw := resp.Header.Get("Location") - if optionalButRequiredRaw == "" { - return nil, goahttp.ErrValidationError("ServiceWithHeadersBlockViewedResult", "MethodA", goa.MissingFieldError("optional_but_required", "header")) - } - v, err2 := strconv.ParseUint(optionalButRequiredRaw, 10, strconv.IntSize) - if err2 != nil { - err = goa.MergeErrors(err, goa.InvalidFieldTypeError("optional_but_required", optionalButRequiredRaw, "unsigned integer")) - } - optionalButRequired = uint(v) - } - if err != nil { - return nil, goahttp.ErrValidationError("ServiceWithHeadersBlockViewedResult", "MethodA", err) - } - p := NewMethodAAResultOK(required, optional, optionalButRequired) - view := resp.Header.Get("goa-view") - vres := &servicewithheadersblockviewedresultviews.AResult{Projected: p, View: view} - res := servicewithheadersblockviewedresult.NewAResult(vres) - return res, nil - default: - body, _ := io.ReadAll(resp.Body) - return nil, goahttp.ErrInvalidResponse("ServiceWithHeadersBlockViewedResult", "MethodA", resp.StatusCode, string(body)) - } - } -} -` - -var ValidateErrorResponseTypeDecodeCode = `// DecodeMethodAResponse returns a decoder for responses returned by the -// ValidateErrorResponseType MethodA endpoint. restoreBody controls whether the -// response body should be restored after having been read. -// DecodeMethodAResponse may return the following errors: -// - "some_error" (type *validateerrorresponsetype.AError): http.StatusBadRequest -// - error: internal error -func DecodeMethodAResponse(decoder func(*http.Response) goahttp.Decoder, restoreBody bool) func(*http.Response) (any, error) { - return func(resp *http.Response) (any, error) { - if restoreBody { - b, err := io.ReadAll(resp.Body) - if err != nil { - return nil, err - } - resp.Body = io.NopCloser(bytes.NewBuffer(b)) - defer func() { - resp.Body = io.NopCloser(bytes.NewBuffer(b)) - }() - } else { - defer resp.Body.Close() - } - switch resp.StatusCode { - case http.StatusOK: - var ( - required int - err error - ) - { - requiredRaw := resp.Header.Get("X-Request-Id") - if requiredRaw == "" { - return nil, goahttp.ErrValidationError("ValidateErrorResponseType", "MethodA", goa.MissingFieldError("required", "header")) - } - v, err2 := strconv.ParseInt(requiredRaw, 10, strconv.IntSize) - if err2 != nil { - err = goa.MergeErrors(err, goa.InvalidFieldTypeError("required", requiredRaw, "integer")) - } - required = int(v) - } - if err != nil { - return nil, goahttp.ErrValidationError("ValidateErrorResponseType", "MethodA", err) - } - p := NewMethodAAResultOK(required) - view := "default" - vres := &validateerrorresponsetypeviews.AResult{Projected: p, View: view} - res := validateerrorresponsetype.NewAResult(vres) - return res, nil - case http.StatusBadRequest: - var ( - error_ string - numOccur *int - err error - ) - error_Raw := resp.Header.Get("X-Application-Error") - if error_Raw == "" { - err = goa.MergeErrors(err, goa.MissingFieldError("error", "header")) - } - error_ = error_Raw - { - numOccurRaw := resp.Header.Get("X-Occur") - if numOccurRaw != "" { - v, err2 := strconv.ParseInt(numOccurRaw, 10, strconv.IntSize) - if err2 != nil { - err = goa.MergeErrors(err, goa.InvalidFieldTypeError("num_occur", numOccurRaw, "integer")) - } - pv := int(v) - numOccur = &pv - } - } - if numOccur != nil { - if *numOccur < 1 { - err = goa.MergeErrors(err, goa.InvalidRangeError("num_occur", *numOccur, 1, true)) - } - } - if err != nil { - return nil, goahttp.ErrValidationError("ValidateErrorResponseType", "MethodA", err) - } - return nil, NewMethodASomeError(error_, numOccur) - default: - body, _ := io.ReadAll(resp.Body) - return nil, goahttp.ErrInvalidResponse("ValidateErrorResponseType", "MethodA", resp.StatusCode, string(body)) - } - } -} -` - -var EmptyErrorResponseBodyDecodeCode = `// DecodeMethodEmptyErrorResponseBodyResponse returns a decoder for responses -// returned by the ServiceEmptyErrorResponseBody MethodEmptyErrorResponseBody -// endpoint. restoreBody controls whether the response body should be restored -// after having been read. -// DecodeMethodEmptyErrorResponseBodyResponse may return the following errors: -// - "internal_error" (type *goa.ServiceError): http.StatusInternalServerError -// - "not_found" (type serviceemptyerrorresponsebody.NotFound): http.StatusNotFound -// - error: internal error -func DecodeMethodEmptyErrorResponseBodyResponse(decoder func(*http.Response) goahttp.Decoder, restoreBody bool) func(*http.Response) (any, error) { - return func(resp *http.Response) (any, error) { - if restoreBody { - b, err := io.ReadAll(resp.Body) - if err != nil { - return nil, err - } - resp.Body = io.NopCloser(bytes.NewBuffer(b)) - defer func() { - resp.Body = io.NopCloser(bytes.NewBuffer(b)) - }() - } else { - defer resp.Body.Close() - } - switch resp.StatusCode { - case http.StatusOK: - return nil, nil - case http.StatusInternalServerError: - var ( - name string - id string - message string - temporary bool - timeout bool - fault bool - err error - ) - nameRaw := resp.Header.Get("Error-Name") - if nameRaw == "" { - err = goa.MergeErrors(err, goa.MissingFieldError("name", "header")) - } - name = nameRaw - idRaw := resp.Header.Get("Goa-Attribute-Id") - if idRaw == "" { - err = goa.MergeErrors(err, goa.MissingFieldError("id", "header")) - } - id = idRaw - messageRaw := resp.Header.Get("Goa-Attribute-Message") - if messageRaw == "" { - err = goa.MergeErrors(err, goa.MissingFieldError("message", "header")) - } - message = messageRaw - { - temporaryRaw := resp.Header.Get("Goa-Attribute-Temporary") - if temporaryRaw == "" { - return nil, goahttp.ErrValidationError("ServiceEmptyErrorResponseBody", "MethodEmptyErrorResponseBody", goa.MissingFieldError("temporary", "header")) - } - v, err2 := strconv.ParseBool(temporaryRaw) - if err2 != nil { - err = goa.MergeErrors(err, goa.InvalidFieldTypeError("temporary", temporaryRaw, "boolean")) - } - temporary = v - } - { - timeoutRaw := resp.Header.Get("Goa-Attribute-Timeout") - if timeoutRaw == "" { - return nil, goahttp.ErrValidationError("ServiceEmptyErrorResponseBody", "MethodEmptyErrorResponseBody", goa.MissingFieldError("timeout", "header")) - } - v, err2 := strconv.ParseBool(timeoutRaw) - if err2 != nil { - err = goa.MergeErrors(err, goa.InvalidFieldTypeError("timeout", timeoutRaw, "boolean")) - } - timeout = v - } - { - faultRaw := resp.Header.Get("Goa-Attribute-Fault") - if faultRaw == "" { - return nil, goahttp.ErrValidationError("ServiceEmptyErrorResponseBody", "MethodEmptyErrorResponseBody", goa.MissingFieldError("fault", "header")) - } - v, err2 := strconv.ParseBool(faultRaw) - if err2 != nil { - err = goa.MergeErrors(err, goa.InvalidFieldTypeError("fault", faultRaw, "boolean")) - } - fault = v - } - if err != nil { - return nil, goahttp.ErrValidationError("ServiceEmptyErrorResponseBody", "MethodEmptyErrorResponseBody", err) - } - return nil, NewMethodEmptyErrorResponseBodyInternalError(name, id, message, temporary, timeout, fault) - case http.StatusNotFound: - var ( - inHeader string - err error - ) - inHeaderRaw := resp.Header.Get("In-Header") - if inHeaderRaw == "" { - err = goa.MergeErrors(err, goa.MissingFieldError("in-header", "header")) - } - inHeader = inHeaderRaw - if err != nil { - return nil, goahttp.ErrValidationError("ServiceEmptyErrorResponseBody", "MethodEmptyErrorResponseBody", err) - } - return nil, NewMethodEmptyErrorResponseBodyNotFound(inHeader) - default: - body, _ := io.ReadAll(resp.Body) - return nil, goahttp.ErrInvalidResponse("ServiceEmptyErrorResponseBody", "MethodEmptyErrorResponseBody", resp.StatusCode, string(body)) - } - } -} -` diff --git a/http/codegen/testdata/result_dsls.go b/http/codegen/testdata/result_dsls.go index 9d0668c4f8..f3294636a9 100644 --- a/http/codegen/testdata/result_dsls.go +++ b/http/codegen/testdata/result_dsls.go @@ -850,6 +850,115 @@ var ResultTypeNestedUserTypeFieldsDSL = func() { }) } +// ResultDefaultResponseBeforeTagsDSL defines a default response before two +// tagged responses. Generated encoders must test the tags in authored order +// before using the default. +var ResultDefaultResponseBeforeTagsDSL = func() { + Service("ServiceResponseOrder", func() { + Method("MethodResponseOrder", func() { + Result(func() { + Attribute("first", String) + Attribute("second", String) + Attribute("value", String) + }) + HTTP(func() { + POST("/") + Response(StatusOK) + Response(StatusAccepted, func() { + Tag("first", "match") + }) + Response(StatusCreated, func() { + Tag("second", "match") + }) + }) + }) + }) +} + +// ResultTypeRequiredUserTypeArrayDSL defines a result type with a required +// array of a user type. Generating the response body constructor must spell the +// element type with the field context: the result type selects a view that does +// not apply to its nested types. +var ResultTypeRequiredUserTypeArrayDSL = func() { + var Elem = Type("Elem", func() { + Attribute("a", String) + }) + + var RT = ResultType("application/vnd.goa.required.array", func() { + Attributes(func() { + Attribute("items", ArrayOf(Elem)) + Required("items") + }) + }) + + Service("ServiceResultRequiredUserTypeArray", func() { + Method("MethodResultRequiredUserTypeArray", func() { + Result(RT) + HTTP(func() { + GET("/") + }) + }) + }) +} + +// ResultTypeRequiredNestedArrayDSL defines a result type with a required array +// of arrays of a user type. The element of the outer array is itself an array, +// so the element type is spelled through one more level of nesting. +var ResultTypeRequiredNestedArrayDSL = func() { + var Elem = Type("NestedElem", func() { + Attribute("a", String) + }) + + var RT = ResultType("application/vnd.goa.required.nested.array", func() { + Attributes(func() { + Attribute("items", ArrayOf(ArrayOf(Elem))) + Required("items") + }) + }) + + Service("ServiceResultRequiredNestedArray", func() { + Method("MethodResultRequiredNestedArray", func() { + Result(RT) + HTTP(func() { + GET("/") + }) + }) + }) +} + +// ResultTypeMultipleViewsRequiredArrayDSL defines a result type with explicit +// views and a required array of a user type. The response body is built for a +// named view, so the view must not leak into the element type. +var ResultTypeMultipleViewsRequiredArrayDSL = func() { + var Elem = Type("ViewedElem", func() { + Attribute("a", String) + }) + + var RT = ResultType("application/vnd.goa.viewed.required.array", func() { + Attributes(func() { + Attribute("items", ArrayOf(Elem)) + Attribute("name", String) + Required("items") + }) + View("default", func() { + Attribute("items") + Attribute("name") + }) + View("tiny", func() { + Attribute("items") + }) + }) + + Service("ServiceResultViewedRequiredArray", func() { + Method("MethodResultViewedRequiredArray", func() { + Result(RT) + HTTP(func() { + GET("/") + }) + }) + }) +} + var ResultWithCustomPkgTypeDSL = func() { var Foo = Type("Foo", func() { Meta("struct:pkg:path", "foo") @@ -998,6 +1107,25 @@ var EmptyCustomErrorResponseBodyDSL = func() { }) } +var ErrorBodyAttributeResponseDSL = func() { + var ErrorType = Type("SelectedError", func() { + Attribute("detail", String) + Required("detail") + }) + Service("ServiceErrorBodyAttributeResponse", func() { + Method("MethodErrorBodyAttributeResponse", func() { + Error("selected", ErrorType) + HTTP(func() { + GET("/") + Response(StatusOK) + Response("selected", StatusBadRequest, func() { + Body("detail") + }) + }) + }) + }) +} + var ResultWithResultViewDSL = func() { var RT = ResultType("RT", func() { Attributes(func() { @@ -1057,6 +1185,30 @@ var EmptyBodyResultMultipleViewsDSL = func() { }) } +// ExplicitBodyPrimitiveResultDefaultViewDSL sets the response body to one +// attribute of a result type that declares no view. The body is an attribute of +// the result, so no view projection applies to it. +var ExplicitBodyPrimitiveResultDefaultViewDSL = func() { + var ResultType = ResultType("ResultTypeDefaultView", func() { + Attribute("a", String, func() { + MinLength(5) + }) + Attribute("b", String) + }) + Service("ServiceExplicitBodyPrimitiveResultDefaultView", func() { + Method("MethodExplicitBodyPrimitiveResultDefaultView", func() { + Result(ResultType) + HTTP(func() { + POST("/") + Response(StatusOK, func() { + Header("b:Location") + Body("a") + }) + }) + }) + }) +} + var ExplicitBodyPrimitiveResultMultipleViewsDSL = func() { var ResultType = ResultType("ResultTypeMultipleViews", func() { Attribute("a", String, func() { @@ -1444,6 +1596,27 @@ var ResultTagStringRequiredDSL = func() { }) } +var ResultStatusTagRequiredDSL = func() { + result := ResultType("application/vnd.status-tag", func() { + TypeName("StatusTagResult") + Attribute("message", String) + Attribute("outcome", String) + Required("message", "outcome") + }) + Service("ServiceStatusTagRequired", func() { + Method("MethodStatusTagRequired", func() { + Result(result) + HTTP(func() { + GET("/") + Response(StatusCreated, func() { + Tag("outcome", "created") + }) + Response(StatusOK) + }) + }) + }) +} + var ResultMultipleViewsTagDSL = func() { var ResultType = ResultType("ResultTypeMultipleViews", func() { Attribute("a", String) diff --git a/http/codegen/testdata/security_alias_dsls.go b/http/codegen/testdata/security_alias_dsls.go new file mode 100644 index 0000000000..62e0c8ab2c --- /dev/null +++ b/http/codegen/testdata/security_alias_dsls.go @@ -0,0 +1,93 @@ +// This file defines HTTP services whose security credentials use named string +// types. The generated server must keep those types while HTTP carries strings. +package testdata + +import . "goa.design/goa/v3/dsl" + +// NamedSecurityTypesDSL exercises every security scheme with named string +// credentials and both required and optional payload fields. +var NamedSecurityTypesDSL = func() { + credential := Type("Credential", String) + basic := BasicAuthSecurity("basic") + apiKey := APIKeySecurity("api_key") + bearer := BearerSecurity("bearer") + jwt := JWTSecurity("jwt") + oauth := OAuth2Security("oauth") + + Service("NamedSecurityTypes", func() { + Method("Basic", func() { + Security(basic) + Payload(func() { + Username("username", credential) + Password("password", credential) + Required("username") + }) + HTTP(func() { + GET("/basic") + }) + }) + Method("BasicOptional", func() { + Security(basic) + Payload(func() { + Username("username", credential) + Password("password", credential) + }) + HTTP(func() { + GET("/basic-optional") + }) + }) + Method("BasicPasswordRequired", func() { + Security(basic) + Payload(func() { + Username("username", credential) + Password("password", credential) + Required("password") + }) + HTTP(func() { + GET("/basic-password-required") + }) + }) + Method("APIKey", func() { + Security(apiKey) + Payload(func() { + APIKey("api_key", "key", credential) + Required("key") + }) + HTTP(func() { + GET("/api-key") + Header("key:X-API-Key") + }) + }) + Method("Bearer", func() { + Security(bearer) + Payload(func() { + BearerToken("token", credential) + }) + HTTP(func() { + GET("/bearer") + Header("token:X-Bearer") + }) + }) + Method("JWT", func() { + Security(jwt) + Payload(func() { + Token("token", credential) + Required("token") + }) + HTTP(func() { + GET("/jwt") + Header("token:X-JWT") + }) + }) + Method("OAuth2", func() { + Security(oauth) + Payload(func() { + AccessToken("token", credential) + }) + HTTP(func() { + GET("/oauth") + Header("token:X-OAuth") + }) + }) + }) +} diff --git a/http/codegen/testdata/selected_body_default_dsl.go b/http/codegen/testdata/selected_body_default_dsl.go new file mode 100644 index 0000000000..573bd064f1 --- /dev/null +++ b/http/codegen/testdata/selected_body_default_dsl.go @@ -0,0 +1,123 @@ +// This file defines selected HTTP body defaults used by constructor goldens. +package testdata + +import . "goa.design/goa/v3/dsl" + +// PayloadSelectedBodyDefaultsDSL defines composite, byte, and OneOf defaults +// whose request body may be omitted. +var PayloadSelectedBodyDefaultsDSL = func() { + empty := Type("SelectedDefaultEmpty", func() {}) + details := Type("SelectedDefaultDetails", func() { + Attribute("name", String) + Required("name") + }) + labels := Type("SelectedDefaultLabels", MapOf(String, String)) + Service("SelectedBodyDefaults", func() { + Method("Object", func() { + Payload(func() { + Attribute("value", details, func() { + Default(map[string]any{"name": "safe"}) + }) + }) + HTTP(func() { + POST("/object") + Body("value") + }) + }) + Method("Map", func() { + Payload(func() { + Attribute("value", labels, func() { + Default(map[string]string{"mode": "safe"}) + }) + }) + HTTP(func() { + POST("/map") + Body("value") + }) + }) + Method("Bytes", func() { + Payload(func() { + Attribute("value", Bytes, func() { + Default("safe") + }) + }) + HTTP(func() { + POST("/bytes") + Body("value") + }) + }) + Method("Union", func() { + Payload(func() { + OneOf("value", func() { + TypeName("SelectedDefaultValue") + Field(1, "name", String) + Field(2, "inactive", empty) + Default(map[string]any{"type": "inactive", "value": map[string]any{}}) + }) + }) + HTTP(func() { + POST("/union") + Body("value") + }) + }) + }) +} + +// PayloadSelectedBodyObjectDefaultDSL defines an optional selected object body +// whose omitted value initializes a required child field. +var PayloadSelectedBodyObjectDefaultDSL = func() { + details := Type("SelectedObjectDefault", func() { + Attribute("name", String) + Required("name") + }) + Service("SelectedObjectBodyDefault", func() { + Method("Object", func() { + Payload(func() { + Attribute("value", details, func() { + Default(map[string]any{"name": "safe"}) + }) + }) + HTTP(func() { + POST("/object") + Body("value") + }) + }) + }) +} + +// PayloadSelectedBodyMapDefaultDSL defines an optional selected map body whose +// omitted value initializes the authored entries. +var PayloadSelectedBodyMapDefaultDSL = func() { + labels := Type("SelectedMapDefault", MapOf(String, String)) + Service("SelectedMapBodyDefault", func() { + Method("Map", func() { + Payload(func() { + Attribute("value", labels, func() { + Default(map[string]string{"mode": "safe"}) + }) + }) + HTTP(func() { + POST("/map") + Body("value") + }) + }) + }) +} + +// PayloadSelectedBodyBytesDefaultDSL defines an optional selected byte body +// whose omitted value initializes the authored text as bytes. +var PayloadSelectedBodyBytesDefaultDSL = func() { + Service("SelectedBytesBodyDefault", func() { + Method("Bytes", func() { + Payload(func() { + Attribute("value", Bytes, func() { + Default("safe") + }) + }) + HTTP(func() { + POST("/bytes") + Body("value") + }) + }) + }) +} diff --git a/http/codegen/testdata/shared_error_description_dsl.go b/http/codegen/testdata/shared_error_description_dsl.go new file mode 100644 index 0000000000..42e62255bc --- /dev/null +++ b/http/codegen/testdata/shared_error_description_dsl.go @@ -0,0 +1,64 @@ +// This file defines HTTP designs that reuse one named error type from several +// methods so OpenAPI tests can distinguish type text from response text. +package testdata + +import . "goa.design/goa/v3/dsl" + +var ( + // SharedErrorDescriptionDSL describes the shared type and declares the first + // method before the second method. + SharedErrorDescriptionDSL = sharedErrorDescriptionDSL(false, "Shared error value") + + // ReversedSharedErrorDescriptionDSL declares the same methods in reverse + // order to prove that method order does not change the shared schema. + ReversedSharedErrorDescriptionDSL = sharedErrorDescriptionDSL(true, "Shared error value") + + // UndescribedSharedErrorDSL leaves the shared type without a description so + // a method description cannot become the shared schema description. + UndescribedSharedErrorDSL = sharedErrorDescriptionDSL(false, "") +) + +// sharedErrorDescriptionDSL returns a design with two method errors that share +// one type but explain different failures to callers. +func sharedErrorDescriptionDSL(reverse bool, typeDescription string) func() { + return func() { + sharedError := Type("SharedError", func() { + if typeDescription != "" { + Description(typeDescription) + } + ErrorName("name", String, "Selected error name") + Attribute("message", String, "Error message", func() { + Example("shared failure") + }) + Required("name", "message") + }) + + Service("errors", func() { + first := func() { + Method("first", func() { + Error("first_error", sharedError, "First failure") + HTTP(func() { + GET("/first") + Response("first_error", StatusBadRequest) + }) + }) + } + second := func() { + Method("second", func() { + Error("second_error", sharedError, "Second failure") + HTTP(func() { + GET("/second") + Response("second_error", StatusBadRequest) + }) + }) + } + if reverse { + second() + first() + return + } + first() + second() + }) + } +} diff --git a/http/codegen/testdata/sse_dsls.go b/http/codegen/testdata/sse_dsls.go index d7de8bbf2b..ff84f9bf21 100644 --- a/http/codegen/testdata/sse_dsls.go +++ b/http/codegen/testdata/sse_dsls.go @@ -7,7 +7,9 @@ import ( var SSEStringDSL = func() { Service("SSEStringService", func() { Method("SSEStringMethod", func() { - StreamingResult(String) + StreamingResult(String, func() { + Example("event") + }) HTTP(func() { GET("/string") ServerSentEvents() @@ -44,9 +46,15 @@ var SSEObjectDSL = func() { Service("SSEObjectService", func() { Method("SSEObjectMethod", func() { StreamingResult(func() { - Attribute("id", String) - Attribute("value", Int) - Attribute("flag", Boolean) + Attribute("id", String, func() { + Example("event") + }) + Attribute("value", Int, func() { + Example(1) + }) + Attribute("flag", Boolean, func() { + Example(true) + }) }) HTTP(func() { GET("/object") @@ -60,8 +68,12 @@ var SSEDataFieldDSL = func() { Service("SSEDataFieldService", func() { Method("SSEDataFieldMethod", func() { StreamingResult(func() { - Attribute("data", String) - Attribute("flag", Boolean) + Attribute("data", String, func() { + Example("event") + }) + Attribute("flag", Boolean, func() { + Example(true) + }) }) HTTP(func() { GET("/data-field") @@ -92,9 +104,13 @@ var SSERequestIDDSL = func() { Service("SSERequestIDService", func() { Method("SSERequestIDMethod", func() { Payload(func() { - Attribute("id", String) + Attribute("id", String, func() { + Example("request") + }) + }) + StreamingResult(String, func() { + Example("event") }) - StreamingResult(String) HTTP(func() { GET("/request-id") ServerSentEvents(func() { @@ -109,7 +125,9 @@ var SSEAllFieldsDSL = func() { Service("SSEAllFieldsService", func() { Method("SSEAllFieldsMethod", func() { Payload(func() { - Attribute("id", String) + Attribute("id", String, func() { + Example("request") + }) }) StreamingResult(func() { Attribute("id", String, func() { diff --git a/http/codegen/testdata/streaming_code.go b/http/codegen/testdata/streaming_code.go index 4b9dc28626..9d631b5409 100644 --- a/http/codegen/testdata/streaming_code.go +++ b/http/codegen/testdata/streaming_code.go @@ -1,3 +1,5 @@ +// This file contains expected HTTP streaming sections used to verify that +// websocket client and server code names the exact catalog-owned wire types. package testdata var MixedEndpointsConnConfigurerStructCode = `// ConnConfigurer holds the websocket connection configurer functions for the @@ -119,7 +121,17 @@ func NewCreateHandler( Payload: payload, } _, err = endpoint(ctx, v) + stream := v.Stream.(*CreateServerStream) + if err == nil { + err = stream.finish() + } if err != nil { + if stream.attempted { + if errhandler != nil { + errhandler(ctx, w, err) + } + return + } if err := encodeError(ctx, w, err); err != nil && errhandler != nil { errhandler(ctx, w, err) } @@ -158,8 +170,8 @@ func NewCreateHandler( // discardCreateServerStream implements the mixedresultsservice.CreateServerStream // interface and drops all events. It is used for mixed results endpoints in -// unary (non-SSE) mode so service implementations can use the stream parameter -// without nil checks. +// regular HTTP requests so service implementations can use the stream +// parameter without nil checks. type discardCreateServerStream struct{} // Send discards the event. @@ -214,9 +226,33 @@ func (s *StreamingResultMethodServerStream) SendWithContext(ctx context.Context, var StreamingResultServerStreamCloseCode = `// Close closes the "StreamingResultMethod" endpoint websocket connection. func (s *StreamingResultMethodServerStream) Close() error { + s.closeOnce.Do(func() { + s.closeErr = s.close() + }) + return s.closeErr +} + +// close opens the websocket connection when needed, sends its normal close +// message, and closes it. +func (s *StreamingResultMethodServerStream) close() error { var err error - if s.conn == nil { - return nil + // Upgrade the HTTP connection to a websocket connection only once. Connection + // upgrade is done here so that authorization logic in the endpoint is executed + // before calling the actual service method which may call Close(). + s.once.Do(func() { + var conn *websocket.Conn + conn, err = s.upgrader.Upgrade(s.w, s.r, nil) + if err != nil { + s.upgradeErr = err + return + } + if s.configurer != nil { + conn = s.configurer(conn, s.cancel) + } + s.conn = conn + }) + if s.upgradeErr != nil { + return s.upgradeErr } if err = s.conn.WriteControl( websocket.CloseMessage, @@ -232,13 +268,27 @@ func (s *StreamingResultMethodServerStream) Close() error { var StreamingResultWithViewsServerStreamSendCode = `// Send streams instances of "streamingresultwithviewsservice.Usertype" to the // "StreamingResultWithViewsMethod" endpoint websocket connection. func (s *StreamingResultWithViewsMethodServerStream) Send(v *streamingresultwithviewsservice.Usertype) error { + view := s.view + if view == "" { + view = "default" + } + if s.sentView != "" && view != s.sentView { + return goa.InvalidEnumValueError("view", view, []any{s.sentView}) + } + switch view { + case "tiny": + case "extended": + case "default": + default: + return goa.InvalidEnumValueError("view", view, []any{"tiny", "extended", "default"}) + } var err error // Upgrade the HTTP connection to a websocket connection only once. Connection // upgrade is done here so that authorization logic in the endpoint is executed // before calling the actual service method which may call Send(). s.once.Do(func() { respHdr := make(http.Header) - respHdr.Add("goa-view", s.view) + respHdr.Add("goa-view", view) var conn *websocket.Conn conn, err = s.upgrader.Upgrade(s.w, s.r, respHdr) if err != nil { @@ -253,17 +303,22 @@ func (s *StreamingResultWithViewsMethodServerStream) Send(v *streamingresultwith if s.upgradeErr != nil { return s.upgradeErr } - res := streamingresultwithviewsservice.NewViewedUsertype(v, s.view) - var body any - switch s.view { + if s.sentView == "" { + s.sentView = view + } + switch view { case "tiny": - body = NewStreamingResultWithViewsMethodResponseBodyTiny(res.Projected) + res := streamingresultwithviewsservice.NewViewedUsertype(v, "tiny") + return s.conn.WriteJSON(NewStreamingResultWithViewsMethodResponseBodyTiny(res.Projected)) case "extended": - body = NewStreamingResultWithViewsMethodResponseBodyExtended(res.Projected) + res := streamingresultwithviewsservice.NewViewedUsertype(v, "extended") + return s.conn.WriteJSON(NewStreamingResultWithViewsMethodResponseBodyExtended(res.Projected)) case "default", "": - body = NewStreamingResultWithViewsMethodResponseBody(res.Projected) + res := streamingresultwithviewsservice.NewViewedUsertype(v, "default") + return s.conn.WriteJSON(NewStreamingResultWithViewsMethodResponseBody(res.Projected)) + default: + return goa.InvalidEnumValueError("view", view, []any{"tiny", "extended", "default"}) } - return s.conn.WriteJSON(body) } // SendWithContext streams instances of @@ -379,9 +434,46 @@ func (c *Client) StreamingResultMethod() goa.Endpoint { var StreamingResultWithViewsServerStreamCloseCode = `// Close closes the "StreamingResultWithViewsMethod" endpoint websocket // connection. func (s *StreamingResultWithViewsMethodServerStream) Close() error { + s.closeOnce.Do(func() { + s.closeErr = s.close() + }) + return s.closeErr +} + +// close opens the websocket connection when needed, sends its normal close +// message, and closes it. +func (s *StreamingResultWithViewsMethodServerStream) close() error { var err error - if s.conn == nil { - return nil + view := s.view + if view == "" { + view = "default" + } + switch view { + case "tiny": + case "extended": + case "default": + default: + return goa.InvalidEnumValueError("view", view, []any{"tiny", "extended", "default"}) + } + // Upgrade the HTTP connection to a websocket connection only once. Connection + // upgrade is done here so that authorization logic in the endpoint is executed + // before calling the actual service method which may call Close(). + s.once.Do(func() { + respHdr := make(http.Header) + respHdr.Add("goa-view", view) + var conn *websocket.Conn + conn, err = s.upgrader.Upgrade(s.w, s.r, respHdr) + if err != nil { + s.upgradeErr = err + return + } + if s.configurer != nil { + conn = s.configurer(conn, s.cancel) + } + s.conn = conn + }) + if s.upgradeErr != nil { + return s.upgradeErr } if err = s.conn.WriteControl( websocket.CloseMessage, @@ -462,45 +554,6 @@ func (c *Client) StreamingResultWithViewsMethod() goa.Endpoint { } ` -var StreamingResultWithViewsClientStreamRecvCode = `// Recv reads instances of "streamingresultwithviewsservice.Usertype" from the -// "StreamingResultWithViewsMethod" endpoint websocket connection. -func (s *StreamingResultWithViewsMethodClientStream) Recv() (*streamingresultwithviewsservice.Usertype, error) { - var ( - rv *streamingresultwithviewsservice.Usertype - body StreamingResultWithViewsMethodResponseBody - err error - ) - err = s.conn.ReadJSON(&body) - if websocket.IsCloseError(err, websocket.CloseNormalClosure) { - s.conn.Close() - return rv, io.EOF - } - if err != nil { - return rv, err - } - res := NewStreamingResultWithViewsMethodUsertypeOK(&body) - vres := &streamingresultwithviewsserviceviews.Usertype{Projected: res, View: s.view} - if err := streamingresultwithviewsserviceviews.ValidateUsertype(vres); err != nil { - return rv, goahttp.ErrValidationError("StreamingResultWithViewsService", "StreamingResultWithViewsMethod", err) - } - return streamingresultwithviewsservice.NewUsertype(vres), nil -} - -// RecvWithContext reads instances of -// "streamingresultwithviewsservice.Usertype" from the -// "StreamingResultWithViewsMethod" endpoint websocket connection with context. -func (s *StreamingResultWithViewsMethodClientStream) RecvWithContext(ctx context.Context) (*streamingresultwithviewsservice.Usertype, error) { - return s.Recv() -} -` - -var StreamingResultWithViewsClientStreamSetViewCode = `// SetView sets the view to render the type before sending to the -// "StreamingResultWithViewsMethod" endpoint websocket connection. -func (s *StreamingResultWithViewsMethodClientStream) SetView(view string) { - s.view = view -} -` - var StreamingResultWithExplicitViewClientEndpointCode = `// StreamingResultWithExplicitViewMethod returns an endpoint that makes HTTP // requests to the StreamingResultWithExplicitViewService service // StreamingResultWithExplicitViewMethod server. @@ -614,13 +667,27 @@ var StreamingResultCollectionWithViewsServerStreamSendCode = `// Send streams in // "streamingresultcollectionwithviewsservice.UsertypeCollection" to the // "StreamingResultCollectionWithViewsMethod" endpoint websocket connection. func (s *StreamingResultCollectionWithViewsMethodServerStream) Send(v streamingresultcollectionwithviewsservice.UsertypeCollection) error { + view := s.view + if view == "" { + view = "default" + } + if s.sentView != "" && view != s.sentView { + return goa.InvalidEnumValueError("view", view, []any{s.sentView}) + } + switch view { + case "tiny": + case "extended": + case "default": + default: + return goa.InvalidEnumValueError("view", view, []any{"tiny", "extended", "default"}) + } var err error // Upgrade the HTTP connection to a websocket connection only once. Connection // upgrade is done here so that authorization logic in the endpoint is executed // before calling the actual service method which may call Send(). s.once.Do(func() { respHdr := make(http.Header) - respHdr.Add("goa-view", s.view) + respHdr.Add("goa-view", view) var conn *websocket.Conn conn, err = s.upgrader.Upgrade(s.w, s.r, respHdr) if err != nil { @@ -635,17 +702,22 @@ func (s *StreamingResultCollectionWithViewsMethodServerStream) Send(v streamingr if s.upgradeErr != nil { return s.upgradeErr } - res := streamingresultcollectionwithviewsservice.NewViewedUsertypeCollection(v, s.view) - var body any - switch s.view { + if s.sentView == "" { + s.sentView = view + } + switch view { case "tiny": - body = NewUsertypeResponseTinyCollection(res.Projected) + res := streamingresultcollectionwithviewsservice.NewViewedUsertypeCollection(v, "tiny") + return s.conn.WriteJSON(NewUsertypeResponseTinyCollection(res.Projected)) case "extended": - body = NewUsertypeResponseExtendedCollection(res.Projected) + res := streamingresultcollectionwithviewsservice.NewViewedUsertypeCollection(v, "extended") + return s.conn.WriteJSON(NewUsertypeResponseExtendedCollection(res.Projected)) case "default", "": - body = NewUsertypeResponseCollection(res.Projected) + res := streamingresultcollectionwithviewsservice.NewViewedUsertypeCollection(v, "default") + return s.conn.WriteJSON(NewUsertypeResponseCollection(res.Projected)) + default: + return goa.InvalidEnumValueError("view", view, []any{"tiny", "extended", "default"}) } - return s.conn.WriteJSON(body) } // SendWithContext streams instances of @@ -666,47 +738,6 @@ func (s *StreamingResultCollectionWithViewsMethodServerStream) SetView(view stri } ` -var StreamingResultCollectionWithViewsClientStreamRecvCode = `// Recv reads instances of -// "streamingresultcollectionwithviewsservice.UsertypeCollection" from the -// "StreamingResultCollectionWithViewsMethod" endpoint websocket connection. -func (s *StreamingResultCollectionWithViewsMethodClientStream) Recv() (streamingresultcollectionwithviewsservice.UsertypeCollection, error) { - var ( - rv streamingresultcollectionwithviewsservice.UsertypeCollection - body StreamingResultCollectionWithViewsMethodResponseBody - err error - ) - err = s.conn.ReadJSON(&body) - if websocket.IsCloseError(err, websocket.CloseNormalClosure) { - s.conn.Close() - return rv, io.EOF - } - if err != nil { - return rv, err - } - res := NewStreamingResultCollectionWithViewsMethodUsertypeCollectionOK(body) - vres := streamingresultcollectionwithviewsserviceviews.UsertypeCollection{Projected: res, View: s.view} - if err := streamingresultcollectionwithviewsserviceviews.ValidateUsertypeCollection(vres); err != nil { - return rv, goahttp.ErrValidationError("StreamingResultCollectionWithViewsService", "StreamingResultCollectionWithViewsMethod", err) - } - return streamingresultcollectionwithviewsservice.NewUsertypeCollection(vres), nil -} - -// RecvWithContext reads instances of -// "streamingresultcollectionwithviewsservice.UsertypeCollection" from the -// "StreamingResultCollectionWithViewsMethod" endpoint websocket connection -// with context. -func (s *StreamingResultCollectionWithViewsMethodClientStream) RecvWithContext(ctx context.Context) (streamingresultcollectionwithviewsservice.UsertypeCollection, error) { - return s.Recv() -} -` - -var StreamingResultCollectionWithViewsClientStreamSetViewCode = `// SetView sets the view to render the type before sending to the -// "StreamingResultCollectionWithViewsMethod" endpoint websocket connection. -func (s *StreamingResultCollectionWithViewsMethodClientStream) SetView(view string) { - s.view = view -} -` - var StreamingResultCollectionWithExplicitViewServerStreamSendCode = `// Send streams instances of // "streamingresultcollectionwithexplicitviewservice.UsertypeCollection" to the // "StreamingResultCollectionWithExplicitViewMethod" endpoint websocket @@ -1553,9 +1584,33 @@ func (s *StreamingPayloadNoResultMethodServerStream) RecvWithContext(ctx context var StreamingPayloadNoResultServerStreamCloseCode = `// Close closes the "StreamingPayloadNoResultMethod" endpoint websocket // connection. func (s *StreamingPayloadNoResultMethodServerStream) Close() error { + s.closeOnce.Do(func() { + s.closeErr = s.close() + }) + return s.closeErr +} + +// close opens the websocket connection when needed, sends its normal close +// message, and closes it. +func (s *StreamingPayloadNoResultMethodServerStream) close() error { var err error - if s.conn == nil { - return nil + // Upgrade the HTTP connection to a websocket connection only once. Connection + // upgrade is done here so that authorization logic in the endpoint is executed + // before calling the actual service method which may call Close(). + s.once.Do(func() { + var conn *websocket.Conn + conn, err = s.upgrader.Upgrade(s.w, s.r, nil) + if err != nil { + s.upgradeErr = err + return + } + if s.configurer != nil { + conn = s.configurer(conn, s.cancel) + } + s.conn = conn + }) + if s.upgradeErr != nil { + return s.upgradeErr } if err = s.conn.WriteControl( websocket.CloseMessage, @@ -1593,146 +1648,6 @@ func (s *StreamingPayloadNoResultMethodClientStream) Close() error { } ` -var StreamingPayloadResultWithViewsServerStreamSendCode = `// SendAndClose streams instances of -// "streamingpayloadresultwithviewsservice.Usertype" to the -// "StreamingPayloadResultWithViewsMethod" endpoint websocket connection and -// closes the connection. -func (s *StreamingPayloadResultWithViewsMethodServerStream) SendAndClose(v *streamingpayloadresultwithviewsservice.Usertype) error { - defer s.conn.Close() - res := streamingpayloadresultwithviewsservice.NewViewedUsertype(v, s.view) - var body any - switch s.view { - case "tiny": - body = NewStreamingPayloadResultWithViewsMethodResponseBodyTiny(res.Projected) - case "extended": - body = NewStreamingPayloadResultWithViewsMethodResponseBodyExtended(res.Projected) - case "default", "": - body = NewStreamingPayloadResultWithViewsMethodResponseBody(res.Projected) - } - return s.conn.WriteJSON(body) -} - -// SendAndCloseWithContext streams instances of -// "streamingpayloadresultwithviewsservice.Usertype" to the -// "StreamingPayloadResultWithViewsMethod" endpoint websocket connection with -// context and closes the connection. -func (s *StreamingPayloadResultWithViewsMethodServerStream) SendAndCloseWithContext(ctx context.Context, v *streamingpayloadresultwithviewsservice.Usertype) error { - return s.SendAndClose(v) -} -` - -var StreamingPayloadResultWithViewsServerStreamRecvCode = `// Recv reads instances of "float32" from the -// "StreamingPayloadResultWithViewsMethod" endpoint websocket connection. -func (s *StreamingPayloadResultWithViewsMethodServerStream) Recv() (float32, error) { - var ( - rv float32 - msg *float32 - err error - ) - // Upgrade the HTTP connection to a websocket connection only once. Connection - // upgrade is done here so that authorization logic in the endpoint is executed - // before calling the actual service method which may call Recv(). - s.once.Do(func() { - var conn *websocket.Conn - conn, err = s.upgrader.Upgrade(s.w, s.r, nil) - if err != nil { - s.upgradeErr = err - return - } - if s.configurer != nil { - conn = s.configurer(conn, s.cancel) - } - s.conn = conn - }) - if s.upgradeErr != nil { - return rv, s.upgradeErr - } - if err = s.conn.ReadJSON(&msg); err != nil { - return rv, err - } - if msg == nil { - return rv, io.EOF - } - return *msg, nil -} - -// RecvWithContext reads instances of "float32" from the -// "StreamingPayloadResultWithViewsMethod" endpoint websocket connection with -// context. -func (s *StreamingPayloadResultWithViewsMethodServerStream) RecvWithContext(ctx context.Context) (float32, error) { - return s.Recv() -} -` - -var StreamingPayloadResultWithViewsServerStreamSetViewCode = `// SetView sets the view to render the -// streamingpayloadresultwithviewsservice.Usertype type before sending to the -// "StreamingPayloadResultWithViewsMethod" endpoint websocket connection. -func (s *StreamingPayloadResultWithViewsMethodServerStream) SetView(view string) { - s.view = view -} -` - -var StreamingPayloadResultWithViewsClientStreamSendCode = `// Send streams instances of "float32" to the -// "StreamingPayloadResultWithViewsMethod" endpoint websocket connection. -func (s *StreamingPayloadResultWithViewsMethodClientStream) Send(v float32) error { - return s.conn.WriteJSON(v) -} - -// SendWithContext streams instances of "float32" to the -// "StreamingPayloadResultWithViewsMethod" endpoint websocket connection with -// context. -func (s *StreamingPayloadResultWithViewsMethodClientStream) SendWithContext(ctx context.Context, v float32) error { - return s.Send(v) -} -` - -var StreamingPayloadResultWithViewsClientStreamRecvCode = `// CloseAndRecv stops sending messages to the -// "StreamingPayloadResultWithViewsMethod" endpoint websocket connection and -// reads instances of "streamingpayloadresultwithviewsservice.Usertype" from -// the connection. -func (s *StreamingPayloadResultWithViewsMethodClientStream) CloseAndRecv() (*streamingpayloadresultwithviewsservice.Usertype, error) { - var ( - rv *streamingpayloadresultwithviewsservice.Usertype - body StreamingPayloadResultWithViewsMethodResponseBody - err error - ) - defer s.conn.Close() - // Send a nil payload to the server implying end of message - if err = s.conn.WriteJSON(nil); err != nil { - return rv, err - } - err = s.conn.ReadJSON(&body) - if websocket.IsCloseError(err, websocket.CloseNormalClosure) { - s.conn.Close() - return rv, io.EOF - } - if err != nil { - return rv, err - } - res := NewStreamingPayloadResultWithViewsMethodUsertypeOK(&body) - vres := &streamingpayloadresultwithviewsserviceviews.Usertype{Projected: res, View: s.view} - if err := streamingpayloadresultwithviewsserviceviews.ValidateUsertype(vres); err != nil { - return rv, goahttp.ErrValidationError("StreamingPayloadResultWithViewsService", "StreamingPayloadResultWithViewsMethod", err) - } - return streamingpayloadresultwithviewsservice.NewUsertype(vres), nil -} - -// CloseAndRecvWithContext stops sending messages to the -// "StreamingPayloadResultWithViewsMethod" endpoint websocket connection and -// reads instances of "streamingpayloadresultwithviewsservice.Usertype" from -// the connection with context. -func (s *StreamingPayloadResultWithViewsMethodClientStream) CloseAndRecvWithContext(ctx context.Context) (*streamingpayloadresultwithviewsservice.Usertype, error) { - return s.CloseAndRecv() -} -` - -var StreamingPayloadResultWithViewsClientStreamSetViewCode = `// SetView sets the view to render the float32 type before sending to the -// "StreamingPayloadResultWithViewsMethod" endpoint websocket connection. -func (s *StreamingPayloadResultWithViewsMethodClientStream) SetView(view string) { - s.view = view -} -` - var StreamingPayloadResultWithExplicitViewServerStreamSendCode = `// SendAndClose streams instances of // "streamingpayloadresultwithexplicitviewservice.Usertype" to the // "StreamingPayloadResultWithExplicitViewMethod" endpoint websocket connection @@ -1851,38 +1766,30 @@ func (s *StreamingPayloadResultWithExplicitViewMethodClientStream) CloseAndRecvW } ` -var StreamingPayloadResultCollectionWithViewsServerStreamSendCode = `// SendAndClose streams instances of -// "streamingpayloadresultcollectionwithviewsservice.UsertypeCollection" to the -// "StreamingPayloadResultCollectionWithViewsMethod" endpoint websocket -// connection and closes the connection. -func (s *StreamingPayloadResultCollectionWithViewsMethodServerStream) SendAndClose(v streamingpayloadresultcollectionwithviewsservice.UsertypeCollection) error { +var StreamingPayloadResultCollectionWithExplicitViewServerStreamSendCode = `// SendAndClose streams instances of +// "streamingpayloadresultcollectionwithexplicitviewservice.UsertypeCollection" +// to the "StreamingPayloadResultCollectionWithExplicitViewMethod" endpoint +// websocket connection and closes the connection. +func (s *StreamingPayloadResultCollectionWithExplicitViewMethodServerStream) SendAndClose(v streamingpayloadresultcollectionwithexplicitviewservice.UsertypeCollection) error { defer s.conn.Close() - res := streamingpayloadresultcollectionwithviewsservice.NewViewedUsertypeCollection(v, s.view) - var body any - switch s.view { - case "tiny": - body = NewUsertypeResponseTinyCollection(res.Projected) - case "extended": - body = NewUsertypeResponseExtendedCollection(res.Projected) - case "default", "": - body = NewUsertypeResponseCollection(res.Projected) - } + res := streamingpayloadresultcollectionwithexplicitviewservice.NewViewedUsertypeCollection(v, "tiny") + body := NewUsertypeResponseTinyCollection(res.Projected) return s.conn.WriteJSON(body) } // SendAndCloseWithContext streams instances of -// "streamingpayloadresultcollectionwithviewsservice.UsertypeCollection" to the -// "StreamingPayloadResultCollectionWithViewsMethod" endpoint websocket -// connection with context and closes the connection. -func (s *StreamingPayloadResultCollectionWithViewsMethodServerStream) SendAndCloseWithContext(ctx context.Context, v streamingpayloadresultcollectionwithviewsservice.UsertypeCollection) error { +// "streamingpayloadresultcollectionwithexplicitviewservice.UsertypeCollection" +// to the "StreamingPayloadResultCollectionWithExplicitViewMethod" endpoint +// websocket connection with context and closes the connection. +func (s *StreamingPayloadResultCollectionWithExplicitViewMethodServerStream) SendAndCloseWithContext(ctx context.Context, v streamingpayloadresultcollectionwithexplicitviewservice.UsertypeCollection) error { return s.SendAndClose(v) } ` -var StreamingPayloadResultCollectionWithViewsServerStreamRecvCode = `// Recv reads instances of "any" from the -// "StreamingPayloadResultCollectionWithViewsMethod" endpoint websocket +var StreamingPayloadResultCollectionWithExplicitViewServerStreamRecvCode = `// Recv reads instances of "any" from the +// "StreamingPayloadResultCollectionWithExplicitViewMethod" endpoint websocket // connection. -func (s *StreamingPayloadResultCollectionWithViewsMethodServerStream) Recv() (any, error) { +func (s *StreamingPayloadResultCollectionWithExplicitViewMethodServerStream) Recv() (any, error) { var ( rv any msg *any @@ -1916,162 +1823,24 @@ func (s *StreamingPayloadResultCollectionWithViewsMethodServerStream) Recv() (an } // RecvWithContext reads instances of "any" from the -// "StreamingPayloadResultCollectionWithViewsMethod" endpoint websocket +// "StreamingPayloadResultCollectionWithExplicitViewMethod" endpoint websocket // connection with context. -func (s *StreamingPayloadResultCollectionWithViewsMethodServerStream) RecvWithContext(ctx context.Context) (any, error) { +func (s *StreamingPayloadResultCollectionWithExplicitViewMethodServerStream) RecvWithContext(ctx context.Context) (any, error) { return s.Recv() } ` -var StreamingPayloadResultCollectionWithViewsServerStreamSetViewCode = `// SetView sets the view to render the -// streamingpayloadresultcollectionwithviewsservice.UsertypeCollection type -// before sending to the "StreamingPayloadResultCollectionWithViewsMethod" -// endpoint websocket connection. -func (s *StreamingPayloadResultCollectionWithViewsMethodServerStream) SetView(view string) { - s.view = view -} -` - -var StreamingPayloadResultCollectionWithViewsClientStreamSendCode = `// Send streams instances of "any" to the -// "StreamingPayloadResultCollectionWithViewsMethod" endpoint websocket +var StreamingPayloadResultCollectionWithExplicitViewClientStreamSendCode = `// Send streams instances of "any" to the +// "StreamingPayloadResultCollectionWithExplicitViewMethod" endpoint websocket // connection. -func (s *StreamingPayloadResultCollectionWithViewsMethodClientStream) Send(v any) error { +func (s *StreamingPayloadResultCollectionWithExplicitViewMethodClientStream) Send(v any) error { return s.conn.WriteJSON(v) } // SendWithContext streams instances of "any" to the -// "StreamingPayloadResultCollectionWithViewsMethod" endpoint websocket +// "StreamingPayloadResultCollectionWithExplicitViewMethod" endpoint websocket // connection with context. -func (s *StreamingPayloadResultCollectionWithViewsMethodClientStream) SendWithContext(ctx context.Context, v any) error { - return s.Send(v) -} -` - -var StreamingPayloadResultCollectionWithViewsClientStreamRecvCode = `// CloseAndRecv stops sending messages to the -// "StreamingPayloadResultCollectionWithViewsMethod" endpoint websocket -// connection and reads instances of -// "streamingpayloadresultcollectionwithviewsservice.UsertypeCollection" from -// the connection. -func (s *StreamingPayloadResultCollectionWithViewsMethodClientStream) CloseAndRecv() (streamingpayloadresultcollectionwithviewsservice.UsertypeCollection, error) { - var ( - rv streamingpayloadresultcollectionwithviewsservice.UsertypeCollection - body StreamingPayloadResultCollectionWithViewsMethodResponseBody - err error - ) - defer s.conn.Close() - // Send a nil payload to the server implying end of message - if err = s.conn.WriteJSON(nil); err != nil { - return rv, err - } - err = s.conn.ReadJSON(&body) - if websocket.IsCloseError(err, websocket.CloseNormalClosure) { - s.conn.Close() - return rv, io.EOF - } - if err != nil { - return rv, err - } - res := NewStreamingPayloadResultCollectionWithViewsMethodUsertypeCollectionOK(body) - vres := streamingpayloadresultcollectionwithviewsserviceviews.UsertypeCollection{Projected: res, View: s.view} - if err := streamingpayloadresultcollectionwithviewsserviceviews.ValidateUsertypeCollection(vres); err != nil { - return rv, goahttp.ErrValidationError("StreamingPayloadResultCollectionWithViewsService", "StreamingPayloadResultCollectionWithViewsMethod", err) - } - return streamingpayloadresultcollectionwithviewsservice.NewUsertypeCollection(vres), nil -} - -// CloseAndRecvWithContext stops sending messages to the -// "StreamingPayloadResultCollectionWithViewsMethod" endpoint websocket -// connection and reads instances of -// "streamingpayloadresultcollectionwithviewsservice.UsertypeCollection" from -// the connection with context. -func (s *StreamingPayloadResultCollectionWithViewsMethodClientStream) CloseAndRecvWithContext(ctx context.Context) (streamingpayloadresultcollectionwithviewsservice.UsertypeCollection, error) { - return s.CloseAndRecv() -} -` - -var StreamingPayloadResultCollectionWithViewsClientStreamSetViewCode = `// SetView sets the view to render the any type before sending to the -// "StreamingPayloadResultCollectionWithViewsMethod" endpoint websocket -// connection. -func (s *StreamingPayloadResultCollectionWithViewsMethodClientStream) SetView(view string) { - s.view = view -} -` - -var StreamingPayloadResultCollectionWithExplicitViewServerStreamSendCode = `// SendAndClose streams instances of -// "streamingpayloadresultcollectionwithexplicitviewservice.UsertypeCollection" -// to the "StreamingPayloadResultCollectionWithExplicitViewMethod" endpoint -// websocket connection and closes the connection. -func (s *StreamingPayloadResultCollectionWithExplicitViewMethodServerStream) SendAndClose(v streamingpayloadresultcollectionwithexplicitviewservice.UsertypeCollection) error { - defer s.conn.Close() - res := streamingpayloadresultcollectionwithexplicitviewservice.NewViewedUsertypeCollection(v, "tiny") - body := NewUsertypeResponseTinyCollection(res.Projected) - return s.conn.WriteJSON(body) -} - -// SendAndCloseWithContext streams instances of -// "streamingpayloadresultcollectionwithexplicitviewservice.UsertypeCollection" -// to the "StreamingPayloadResultCollectionWithExplicitViewMethod" endpoint -// websocket connection with context and closes the connection. -func (s *StreamingPayloadResultCollectionWithExplicitViewMethodServerStream) SendAndCloseWithContext(ctx context.Context, v streamingpayloadresultcollectionwithexplicitviewservice.UsertypeCollection) error { - return s.SendAndClose(v) -} -` - -var StreamingPayloadResultCollectionWithExplicitViewServerStreamRecvCode = `// Recv reads instances of "any" from the -// "StreamingPayloadResultCollectionWithExplicitViewMethod" endpoint websocket -// connection. -func (s *StreamingPayloadResultCollectionWithExplicitViewMethodServerStream) Recv() (any, error) { - var ( - rv any - msg *any - err error - ) - // Upgrade the HTTP connection to a websocket connection only once. Connection - // upgrade is done here so that authorization logic in the endpoint is executed - // before calling the actual service method which may call Recv(). - s.once.Do(func() { - var conn *websocket.Conn - conn, err = s.upgrader.Upgrade(s.w, s.r, nil) - if err != nil { - s.upgradeErr = err - return - } - if s.configurer != nil { - conn = s.configurer(conn, s.cancel) - } - s.conn = conn - }) - if s.upgradeErr != nil { - return rv, s.upgradeErr - } - if err = s.conn.ReadJSON(&msg); err != nil { - return rv, err - } - if msg == nil { - return rv, io.EOF - } - return *msg, nil -} - -// RecvWithContext reads instances of "any" from the -// "StreamingPayloadResultCollectionWithExplicitViewMethod" endpoint websocket -// connection with context. -func (s *StreamingPayloadResultCollectionWithExplicitViewMethodServerStream) RecvWithContext(ctx context.Context) (any, error) { - return s.Recv() -} -` - -var StreamingPayloadResultCollectionWithExplicitViewClientStreamSendCode = `// Send streams instances of "any" to the -// "StreamingPayloadResultCollectionWithExplicitViewMethod" endpoint websocket -// connection. -func (s *StreamingPayloadResultCollectionWithExplicitViewMethodClientStream) Send(v any) error { - return s.conn.WriteJSON(v) -} - -// SendWithContext streams instances of "any" to the -// "StreamingPayloadResultCollectionWithExplicitViewMethod" endpoint websocket -// connection with context. -func (s *StreamingPayloadResultCollectionWithExplicitViewMethodClientStream) SendWithContext(ctx context.Context, v any) error { +func (s *StreamingPayloadResultCollectionWithExplicitViewMethodClientStream) SendWithContext(ctx context.Context, v any) error { return s.Send(v) } ` @@ -2806,9 +2575,33 @@ func (s *BidirectionalStreamingMethodServerStream) RecvWithContext(ctx context.C var BidirectionalStreamingServerStreamCloseCode = `// Close closes the "BidirectionalStreamingMethod" endpoint websocket // connection. func (s *BidirectionalStreamingMethodServerStream) Close() error { + s.closeOnce.Do(func() { + s.closeErr = s.close() + }) + return s.closeErr +} + +// close opens the websocket connection when needed, sends its normal close +// message, and closes it. +func (s *BidirectionalStreamingMethodServerStream) close() error { var err error - if s.conn == nil { - return nil + // Upgrade the HTTP connection to a websocket connection only once. Connection + // upgrade is done here so that authorization logic in the endpoint is executed + // before calling the actual service method which may call Close(). + s.once.Do(func() { + var conn *websocket.Conn + conn, err = s.upgrader.Upgrade(s.w, s.r, nil) + if err != nil { + s.upgradeErr = err + return + } + if s.configurer != nil { + conn = s.configurer(conn, s.cancel) + } + s.conn = conn + }) + if s.upgradeErr != nil { + return s.upgradeErr } if err = s.conn.WriteControl( websocket.CloseMessage, @@ -2968,9 +2761,33 @@ func NewBidirectionalStreamingNoPayloadMethodHandler( var BidirectionalStreamingNoPayloadServerStreamCloseCode = `// Close closes the "BidirectionalStreamingNoPayloadMethod" endpoint websocket // connection. func (s *BidirectionalStreamingNoPayloadMethodServerStream) Close() error { + s.closeOnce.Do(func() { + s.closeErr = s.close() + }) + return s.closeErr +} + +// close opens the websocket connection when needed, sends its normal close +// message, and closes it. +func (s *BidirectionalStreamingNoPayloadMethodServerStream) close() error { var err error - if s.conn == nil { - return nil + // Upgrade the HTTP connection to a websocket connection only once. Connection + // upgrade is done here so that authorization logic in the endpoint is executed + // before calling the actual service method which may call Close(). + s.once.Do(func() { + var conn *websocket.Conn + conn, err = s.upgrader.Upgrade(s.w, s.r, nil) + if err != nil { + s.upgradeErr = err + return + } + if s.configurer != nil { + conn = s.configurer(conn, s.cancel) + } + s.conn = conn + }) + if s.upgradeErr != nil { + return s.upgradeErr } if err = s.conn.WriteControl( websocket.CloseMessage, @@ -3068,189 +2885,6 @@ func (s *BidirectionalStreamingNoPayloadMethodClientStream) Close() error { } ` -var BidirectionalStreamingResultWithViewsServerStreamSendCode = `// Send streams instances of -// "bidirectionalstreamingresultwithviewsservice.Usertype" to the -// "BidirectionalStreamingResultWithViewsMethod" endpoint websocket connection. -func (s *BidirectionalStreamingResultWithViewsMethodServerStream) Send(v *bidirectionalstreamingresultwithviewsservice.Usertype) error { - var err error - // Upgrade the HTTP connection to a websocket connection only once. Connection - // upgrade is done here so that authorization logic in the endpoint is executed - // before calling the actual service method which may call Send(). - s.once.Do(func() { - respHdr := make(http.Header) - respHdr.Add("goa-view", s.view) - var conn *websocket.Conn - conn, err = s.upgrader.Upgrade(s.w, s.r, respHdr) - if err != nil { - s.upgradeErr = err - return - } - if s.configurer != nil { - conn = s.configurer(conn, s.cancel) - } - s.conn = conn - }) - if s.upgradeErr != nil { - return s.upgradeErr - } - res := bidirectionalstreamingresultwithviewsservice.NewViewedUsertype(v, s.view) - var body any - switch s.view { - case "tiny": - body = NewBidirectionalStreamingResultWithViewsMethodResponseBodyTiny(res.Projected) - case "extended": - body = NewBidirectionalStreamingResultWithViewsMethodResponseBodyExtended(res.Projected) - case "default", "": - body = NewBidirectionalStreamingResultWithViewsMethodResponseBody(res.Projected) - } - return s.conn.WriteJSON(body) -} - -// SendWithContext streams instances of -// "bidirectionalstreamingresultwithviewsservice.Usertype" to the -// "BidirectionalStreamingResultWithViewsMethod" endpoint websocket connection -// with context. -func (s *BidirectionalStreamingResultWithViewsMethodServerStream) SendWithContext(ctx context.Context, v *bidirectionalstreamingresultwithviewsservice.Usertype) error { - return s.Send(v) -} -` - -var BidirectionalStreamingResultWithViewsServerStreamRecvCode = `// Recv reads instances of "float32" from the -// "BidirectionalStreamingResultWithViewsMethod" endpoint websocket connection. -func (s *BidirectionalStreamingResultWithViewsMethodServerStream) Recv() (float32, error) { - var ( - rv float32 - msg *float32 - err error - ) - // Upgrade the HTTP connection to a websocket connection only once. Connection - // upgrade is done here so that authorization logic in the endpoint is executed - // before calling the actual service method which may call Recv(). - s.once.Do(func() { - var conn *websocket.Conn - conn, err = s.upgrader.Upgrade(s.w, s.r, nil) - if err != nil { - s.upgradeErr = err - return - } - if s.configurer != nil { - conn = s.configurer(conn, s.cancel) - } - s.conn = conn - }) - if s.upgradeErr != nil { - return rv, s.upgradeErr - } - if err = s.conn.ReadJSON(&msg); err != nil { - return rv, err - } - if msg == nil { - return rv, io.EOF - } - return *msg, nil -} - -// RecvWithContext reads instances of "float32" from the -// "BidirectionalStreamingResultWithViewsMethod" endpoint websocket connection -// with context. -func (s *BidirectionalStreamingResultWithViewsMethodServerStream) RecvWithContext(ctx context.Context) (float32, error) { - return s.Recv() -} -` - -var BidirectionalStreamingResultWithViewsServerStreamCloseCode = `// Close closes the "BidirectionalStreamingResultWithViewsMethod" endpoint -// websocket connection. -func (s *BidirectionalStreamingResultWithViewsMethodServerStream) Close() error { - var err error - if s.conn == nil { - return nil - } - if err = s.conn.WriteControl( - websocket.CloseMessage, - websocket.FormatCloseMessage(websocket.CloseNormalClosure, "server closing connection"), - time.Now().Add(time.Second), - ); err != nil { - return err - } - return s.conn.Close() -} -` - -var BidirectionalStreamingResultWithViewsServerStreamSetViewCode = `// SetView sets the view to render the -// bidirectionalstreamingresultwithviewsservice.Usertype type before sending to -// the "BidirectionalStreamingResultWithViewsMethod" endpoint websocket -// connection. -func (s *BidirectionalStreamingResultWithViewsMethodServerStream) SetView(view string) { - s.view = view -} -` - -var BidirectionalStreamingResultWithViewsClientStreamSendCode = `// Send streams instances of "float32" to the -// "BidirectionalStreamingResultWithViewsMethod" endpoint websocket connection. -func (s *BidirectionalStreamingResultWithViewsMethodClientStream) Send(v float32) error { - return s.conn.WriteJSON(v) -} - -// SendWithContext streams instances of "float32" to the -// "BidirectionalStreamingResultWithViewsMethod" endpoint websocket connection -// with context. -func (s *BidirectionalStreamingResultWithViewsMethodClientStream) SendWithContext(ctx context.Context, v float32) error { - return s.Send(v) -} -` - -var BidirectionalStreamingResultWithViewsClientStreamRecvCode = `// Recv reads instances of -// "bidirectionalstreamingresultwithviewsservice.Usertype" from the -// "BidirectionalStreamingResultWithViewsMethod" endpoint websocket connection. -func (s *BidirectionalStreamingResultWithViewsMethodClientStream) Recv() (*bidirectionalstreamingresultwithviewsservice.Usertype, error) { - var ( - rv *bidirectionalstreamingresultwithviewsservice.Usertype - body BidirectionalStreamingResultWithViewsMethodResponseBody - err error - ) - err = s.conn.ReadJSON(&body) - if websocket.IsCloseError(err, websocket.CloseNormalClosure) { - return rv, io.EOF - } - if err != nil { - return rv, err - } - res := NewBidirectionalStreamingResultWithViewsMethodUsertypeOK(&body) - vres := &bidirectionalstreamingresultwithviewsserviceviews.Usertype{Projected: res, View: s.view} - if err := bidirectionalstreamingresultwithviewsserviceviews.ValidateUsertype(vres); err != nil { - return rv, goahttp.ErrValidationError("BidirectionalStreamingResultWithViewsService", "BidirectionalStreamingResultWithViewsMethod", err) - } - return bidirectionalstreamingresultwithviewsservice.NewUsertype(vres), nil -} - -// RecvWithContext reads instances of -// "bidirectionalstreamingresultwithviewsservice.Usertype" from the -// "BidirectionalStreamingResultWithViewsMethod" endpoint websocket connection -// with context. -func (s *BidirectionalStreamingResultWithViewsMethodClientStream) RecvWithContext(ctx context.Context) (*bidirectionalstreamingresultwithviewsservice.Usertype, error) { - return s.Recv() -} -` - -var BidirectionalStreamingResultWithViewsClientStreamCloseCode = `// Close closes the "BidirectionalStreamingResultWithViewsMethod" endpoint -// websocket connection. -func (s *BidirectionalStreamingResultWithViewsMethodClientStream) Close() error { - var err error - // Send a nil payload to the server implying client closing connection. - if err = s.conn.WriteJSON(nil); err != nil { - return err - } - return s.conn.Close() -} -` - -var BidirectionalStreamingResultWithViewsClientStreamSetViewCode = `// SetView sets the view to render the float32 type before sending to the -// "BidirectionalStreamingResultWithViewsMethod" endpoint websocket connection. -func (s *BidirectionalStreamingResultWithViewsMethodClientStream) SetView(view string) { - s.view = view -} -` - var BidirectionalStreamingResultWithExplicitViewServerStreamSendCode = `// Send streams instances of // "bidirectionalstreamingresultwithexplicitviewservice.Usertype" to the // "BidirectionalStreamingResultWithExplicitViewMethod" endpoint websocket @@ -3382,165 +3016,6 @@ func (s *BidirectionalStreamingResultWithExplicitViewMethodClientStream) RecvWit } ` -var BidirectionalStreamingResultCollectionWithViewsServerStreamSendCode = `// Send streams instances of -// "bidirectionalstreamingresultcollectionwithviewsservice.UsertypeCollection" -// to the "BidirectionalStreamingResultCollectionWithViewsMethod" endpoint -// websocket connection. -func (s *BidirectionalStreamingResultCollectionWithViewsMethodServerStream) Send(v bidirectionalstreamingresultcollectionwithviewsservice.UsertypeCollection) error { - var err error - // Upgrade the HTTP connection to a websocket connection only once. Connection - // upgrade is done here so that authorization logic in the endpoint is executed - // before calling the actual service method which may call Send(). - s.once.Do(func() { - respHdr := make(http.Header) - respHdr.Add("goa-view", s.view) - var conn *websocket.Conn - conn, err = s.upgrader.Upgrade(s.w, s.r, respHdr) - if err != nil { - s.upgradeErr = err - return - } - if s.configurer != nil { - conn = s.configurer(conn, s.cancel) - } - s.conn = conn - }) - if s.upgradeErr != nil { - return s.upgradeErr - } - res := bidirectionalstreamingresultcollectionwithviewsservice.NewViewedUsertypeCollection(v, s.view) - var body any - switch s.view { - case "tiny": - body = NewUsertypeResponseTinyCollection(res.Projected) - case "extended": - body = NewUsertypeResponseExtendedCollection(res.Projected) - case "default", "": - body = NewUsertypeResponseCollection(res.Projected) - } - return s.conn.WriteJSON(body) -} - -// SendWithContext streams instances of -// "bidirectionalstreamingresultcollectionwithviewsservice.UsertypeCollection" -// to the "BidirectionalStreamingResultCollectionWithViewsMethod" endpoint -// websocket connection with context. -func (s *BidirectionalStreamingResultCollectionWithViewsMethodServerStream) SendWithContext(ctx context.Context, v bidirectionalstreamingresultcollectionwithviewsservice.UsertypeCollection) error { - return s.Send(v) -} -` - -var BidirectionalStreamingResultCollectionWithViewsServerStreamRecvCode = `// Recv reads instances of "any" from the -// "BidirectionalStreamingResultCollectionWithViewsMethod" endpoint websocket -// connection. -func (s *BidirectionalStreamingResultCollectionWithViewsMethodServerStream) Recv() (any, error) { - var ( - rv any - msg *any - err error - ) - // Upgrade the HTTP connection to a websocket connection only once. Connection - // upgrade is done here so that authorization logic in the endpoint is executed - // before calling the actual service method which may call Recv(). - s.once.Do(func() { - var conn *websocket.Conn - conn, err = s.upgrader.Upgrade(s.w, s.r, nil) - if err != nil { - s.upgradeErr = err - return - } - if s.configurer != nil { - conn = s.configurer(conn, s.cancel) - } - s.conn = conn - }) - if s.upgradeErr != nil { - return rv, s.upgradeErr - } - if err = s.conn.ReadJSON(&msg); err != nil { - return rv, err - } - if msg == nil { - return rv, io.EOF - } - return *msg, nil -} - -// RecvWithContext reads instances of "any" from the -// "BidirectionalStreamingResultCollectionWithViewsMethod" endpoint websocket -// connection with context. -func (s *BidirectionalStreamingResultCollectionWithViewsMethodServerStream) RecvWithContext(ctx context.Context) (any, error) { - return s.Recv() -} -` - -var BidirectionalStreamingResultCollectionWithViewsServerStreamSetViewCode = `// SetView sets the view to render the -// bidirectionalstreamingresultcollectionwithviewsservice.UsertypeCollection -// type before sending to the -// "BidirectionalStreamingResultCollectionWithViewsMethod" endpoint websocket -// connection. -func (s *BidirectionalStreamingResultCollectionWithViewsMethodServerStream) SetView(view string) { - s.view = view -} -` - -var BidirectionalStreamingResultCollectionWithViewsClientStreamSendCode = `// Send streams instances of "any" to the -// "BidirectionalStreamingResultCollectionWithViewsMethod" endpoint websocket -// connection. -func (s *BidirectionalStreamingResultCollectionWithViewsMethodClientStream) Send(v any) error { - return s.conn.WriteJSON(v) -} - -// SendWithContext streams instances of "any" to the -// "BidirectionalStreamingResultCollectionWithViewsMethod" endpoint websocket -// connection with context. -func (s *BidirectionalStreamingResultCollectionWithViewsMethodClientStream) SendWithContext(ctx context.Context, v any) error { - return s.Send(v) -} -` - -var BidirectionalStreamingResultCollectionWithViewsClientStreamRecvCode = `// Recv reads instances of -// "bidirectionalstreamingresultcollectionwithviewsservice.UsertypeCollection" -// from the "BidirectionalStreamingResultCollectionWithViewsMethod" endpoint -// websocket connection. -func (s *BidirectionalStreamingResultCollectionWithViewsMethodClientStream) Recv() (bidirectionalstreamingresultcollectionwithviewsservice.UsertypeCollection, error) { - var ( - rv bidirectionalstreamingresultcollectionwithviewsservice.UsertypeCollection - body BidirectionalStreamingResultCollectionWithViewsMethodResponseBody - err error - ) - err = s.conn.ReadJSON(&body) - if websocket.IsCloseError(err, websocket.CloseNormalClosure) { - return rv, io.EOF - } - if err != nil { - return rv, err - } - res := NewBidirectionalStreamingResultCollectionWithViewsMethodUsertypeCollectionOK(body) - vres := bidirectionalstreamingresultcollectionwithviewsserviceviews.UsertypeCollection{Projected: res, View: s.view} - if err := bidirectionalstreamingresultcollectionwithviewsserviceviews.ValidateUsertypeCollection(vres); err != nil { - return rv, goahttp.ErrValidationError("BidirectionalStreamingResultCollectionWithViewsService", "BidirectionalStreamingResultCollectionWithViewsMethod", err) - } - return bidirectionalstreamingresultcollectionwithviewsservice.NewUsertypeCollection(vres), nil -} - -// RecvWithContext reads instances of -// "bidirectionalstreamingresultcollectionwithviewsservice.UsertypeCollection" -// from the "BidirectionalStreamingResultCollectionWithViewsMethod" endpoint -// websocket connection with context. -func (s *BidirectionalStreamingResultCollectionWithViewsMethodClientStream) RecvWithContext(ctx context.Context) (bidirectionalstreamingresultcollectionwithviewsservice.UsertypeCollection, error) { - return s.Recv() -} -` - -var BidirectionalStreamingResultCollectionWithViewsClientStreamSetViewCode = `// SetView sets the view to render the any type before sending to the -// "BidirectionalStreamingResultCollectionWithViewsMethod" endpoint websocket -// connection. -func (s *BidirectionalStreamingResultCollectionWithViewsMethodClientStream) SetView(view string) { - s.view = view -} -` - var BidirectionalStreamingResultCollectionWithExplicitViewServerStreamSendCode = `// Send streams instances of // "bidirectionalstreamingresultcollectionwithexplicitviewservice.UsertypeCollection" // to the "BidirectionalStreamingResultCollectionWithExplicitViewMethod" diff --git a/http/codegen/testdata/streaming_dsls.go b/http/codegen/testdata/streaming_dsls.go index 4bd2bb815e..97a14b4a0d 100644 --- a/http/codegen/testdata/streaming_dsls.go +++ b/http/codegen/testdata/streaming_dsls.go @@ -34,6 +34,22 @@ var SkipRequestBodyEncodeDecodeDSL = func() { }) } +var SkipRequestBodyEncodeDecodeHeaderDSL = func() { + Service("SkipRequestBodyEncodeDecodeHeader", func() { + Method("Upload", func() { + Payload(func() { + Attribute("contentType", String) + }) + HTTP(func() { + POST("/") + Header("contentType:Content-Type") + SkipRequestBodyEncodeDecode() + Response(StatusNoContent) + }) + }) + }) +} + var StreamingMultipleServicesDSL = func() { Service("StreamingServiceA", func() { Method("Method", func() { @@ -57,10 +73,14 @@ var StreamingMultipleServicesDSL = func() { var StreamingResultDSL = func() { var Request = Type("Request", func() { - Attribute("x", String) + Attribute("x", String, func() { + Example("request") + }) }) var Result = Type("UserType", func() { - Attribute("a", String) + Attribute("a", String, func() { + Example("event") + }) }) Service("StreamingResultService", func() { Method("StreamingResultMethod", func() { @@ -76,15 +96,21 @@ var StreamingResultDSL = func() { var MixedResultsDSL = func() { var PayloadType = Type("Payload", func() { - Attribute("x", String) + Attribute("x", String, func() { + Example("request") + }) Required("x") }) var ResultType = Type("Result", func() { - Attribute("id", String) + Attribute("id", String, func() { + Example("result") + }) Required("id") }) var EventType = Type("Event", func() { - Attribute("message", String) + Attribute("message", String, func() { + Example("event") + }) Required("message") }) Service("MixedResultsService", func() { @@ -472,34 +498,6 @@ var StreamingPayloadNoResultDSL = func() { }) } -var StreamingPayloadResultWithViewsDSL = func() { - var ResultT = ResultType("UserType", func() { - Attributes(func() { - Attribute("a", String) - Attribute("b", Int) - Attribute("c", String) - }) - View("tiny", func() { - Attribute("a", String) - }) - View("extended", func() { - Attribute("a") - Attribute("b") - Attribute("c") - }) - }) - Service("StreamingPayloadResultWithViewsService", func() { - Method("StreamingPayloadResultWithViewsMethod", func() { - StreamingPayload(Float32) - Result(ResultT) - HTTP(func() { - GET("/") - Response(StatusOK) - }) - }) - }) -} - var StreamingPayloadResultWithExplicitViewDSL = func() { var ResultT = ResultType("UserType", func() { Attributes(func() { @@ -530,34 +528,6 @@ var StreamingPayloadResultWithExplicitViewDSL = func() { }) } -var StreamingPayloadResultCollectionWithViewsDSL = func() { - var ResultT = ResultType("UserType", func() { - Attributes(func() { - Attribute("a", String) - Attribute("b", Int) - Attribute("c", String) - }) - View("tiny", func() { - Attribute("a", String) - }) - View("extended", func() { - Attribute("a") - Attribute("b") - Attribute("c") - }) - }) - Service("StreamingPayloadResultCollectionWithViewsService", func() { - Method("StreamingPayloadResultCollectionWithViewsMethod", func() { - StreamingPayload(Any) - Result(CollectionOf(ResultT)) - HTTP(func() { - GET("/") - Response(StatusOK) - }) - }) - }) -} - var StreamingPayloadResultCollectionWithExplicitViewDSL = func() { var ResultT = ResultType("UserType", func() { Attributes(func() { @@ -711,34 +681,6 @@ var BidirectionalStreamingUserTypeMapDSL = func() { }) } -var BidirectionalStreamingResultWithViewsDSL = func() { - var ResultT = ResultType("UserType", func() { - Attributes(func() { - Attribute("a", String) - Attribute("b", Int) - Attribute("c", String) - }) - View("tiny", func() { - Attribute("a", String) - }) - View("extended", func() { - Attribute("a") - Attribute("b") - Attribute("c") - }) - }) - Service("BidirectionalStreamingResultWithViewsService", func() { - Method("BidirectionalStreamingResultWithViewsMethod", func() { - StreamingPayload(Float32) - StreamingResult(ResultT) - HTTP(func() { - GET("/") - Response(StatusOK) - }) - }) - }) -} - var BidirectionalStreamingResultWithExplicitViewDSL = func() { var ResultT = ResultType("UserType", func() { Attributes(func() { @@ -769,34 +711,6 @@ var BidirectionalStreamingResultWithExplicitViewDSL = func() { }) } -var BidirectionalStreamingResultCollectionWithViewsDSL = func() { - var ResultT = ResultType("UserType", func() { - Attributes(func() { - Attribute("a", String) - Attribute("b", Int) - Attribute("c", String) - }) - View("tiny", func() { - Attribute("a", String) - }) - View("extended", func() { - Attribute("a") - Attribute("b") - Attribute("c") - }) - }) - Service("BidirectionalStreamingResultCollectionWithViewsService", func() { - Method("BidirectionalStreamingResultCollectionWithViewsMethod", func() { - StreamingPayload(Any) - StreamingResult(CollectionOf(ResultT)) - HTTP(func() { - GET("/") - Response(StatusOK) - }) - }) - }) -} - var BidirectionalStreamingResultCollectionWithExplicitViewDSL = func() { var ResultT = ResultType("UserType", func() { Attributes(func() { diff --git a/http/codegen/testdata/transform_helper_functions.go b/http/codegen/testdata/transform_helper_functions.go index 0d4d709a55..9bdaf27c69 100644 --- a/http/codegen/testdata/transform_helper_functions.go +++ b/http/codegen/testdata/transform_helper_functions.go @@ -14,7 +14,7 @@ func unmarshalInnerTypeRequestBodyToServicebodyuserinnerdefaultInnerType(v *Inne res.B = *v.B } if v.B == nil { - res.B = "defaultb" + res.B = "patternb" } return res @@ -49,7 +49,7 @@ func marshalInnerTypeRequestBodyToServicebodyuserinnerdefaultInnerType(v *InnerT { var zero string if res.B == zero { - res.B = "defaultb" + res.B = "patternb" } } @@ -71,7 +71,7 @@ func marshalServicebodyuserinnerdefaultInnerTypeToInnerTypeRequestBody(v *servic { var zero string if res.B == zero { - res.B = "defaultb" + res.B = "patternb" } } diff --git a/http/codegen/testing.go b/http/codegen/testing.go deleted file mode 100644 index 94e1b0023c..0000000000 --- a/http/codegen/testing.go +++ /dev/null @@ -1,15 +0,0 @@ -package codegen - -import ( - "goa.design/goa/v3/codegen" - "goa.design/goa/v3/codegen/service" - "goa.design/goa/v3/expr" -) - -// CreateHTTPServices creates a new ServicesData instance for testing. The -// root is normalized first like the production Generate flow does before the -// generators read the design. -func CreateHTTPServices(root *expr.RootExpr) *ServicesData { - codegen.NormalizeRoot(root) - return NewServicesData(service.NewServicesData(root), root.API.HTTP) -} diff --git a/http/codegen/transform_helper_test.go b/http/codegen/transform_helper_test.go index 3744d2f81b..90d4b3bbf6 100644 --- a/http/codegen/transform_helper_test.go +++ b/http/codegen/transform_helper_test.go @@ -1,59 +1,578 @@ +// This file verifies HTTP and JSON-RPC conversion functions use the exact +// declarations selected while their generated packages are planned. package codegen import ( + "bytes" + "fmt" "testing" - "goa.design/goa/v3/codegen/testutil" - "goa.design/goa/v3/expr" - "github.com/stretchr/testify/require" "goa.design/goa/v3/codegen" - // "goa.design/goa/v3/http/codegen/testdata" + "goa.design/goa/v3/codegen/service" + "goa.design/goa/v3/codegen/testutil" + "goa.design/goa/v3/dsl" + "goa.design/goa/v3/eval" + "goa.design/goa/v3/expr" + "goa.design/goa/v3/http/codegen/testdata" ) -func TestTransformHelperServer(t *testing.T) { - cases := []struct { - Name string - DSL func() - Offset int - }{ - // {"body-user-inner-default-1", testdata.PayloadBodyUserInnerDefaultDSL1, 1}, - // {"body-user-recursive-default-1", testdata.PayloadBodyInlineRecursiveUserDSL1, 1}, - } - for _, c := range cases { - t.Run(c.Name, func(t *testing.T) { - root := expr.RunDSL(t, c.DSL) - services := CreateHTTPServices(root) - f := ServerEncodeDecodeFile("", root.API.HTTP.Services[0], services) - sections := f.SectionTemplates - require.Greater(t, len(sections), c.Offset) - code := codegen.SectionCode(t, sections[len(sections)-c.Offset]) - testutil.AssertGo(t, "testdata/golden/transform_helper_"+c.Name+".go.golden", code) +// TestTransformHelperOrderingSupportsMoreThan255Functions catches helper name +// ordering that narrows a plan position to one byte. +func TestTransformHelperOrderingSupportsMoreThan255Functions(t *testing.T) { + source, target := manyDistinctTransformChildren(257, false) + catalog, generation := testWireTypeCatalog(t) + policy := jsonBodyPolicy(true, false, false, "") + catalog.collect(target, wireRequestBody, policy) + catalog.collectTransform(source, target, "marshal", "many helpers", wireTransformLayout{ + wireSide: wireTransformTarget, + wirePolicy: policy, + servicePackage: testServicePackage(), + }) + + require.NoError(t, catalog.Declare()) + require.NoError(t, generation.Freeze()) +} + +// TestTransformHandleSelectsTheCollectedPlan catches structurally identical +// conversions being exchanged when rendering happens in a different order. +func TestTransformHandleSelectsTheCollectedPlan(t *testing.T) { + source, target := manyDistinctTransformChildren(1, false) + catalog, generation := testWireTypeCatalog(t) + policy := jsonBodyPolicy(true, false, false, "") + catalog.collect(target, wireRequestBody, policy) + first := catalog.collectTransform(source, target, "marshal", "first", wireTransformLayout{ + wireSide: wireTransformTarget, + wirePolicy: policy, + servicePackage: testServicePackage(), + }) + second := catalog.collectTransform(source, target, "marshal", "second", wireTransformLayout{ + wireSide: wireTransformTarget, + wirePolicy: policy, + servicePackage: testServicePackage(), + }) + linkTestWireTypeCatalog(t, generation, catalog) + + _, _, err := renderTestTransform(catalog, second, "inventory") + require.NoError(t, err) + require.False(t, first.record.used) + require.True(t, second.record.used) + _, _, err = renderTestTransform(catalog, first, "inventory") + require.NoError(t, err) +} + +// TestTransformHandleRejectsAnotherCatalog catches a planned conversion being +// rendered into a package that did not claim its declarations. +func TestTransformHandleRejectsAnotherCatalog(t *testing.T) { + source, target := manyDistinctTransformChildren(1, false) + first, firstGeneration := testWireTypeCatalog(t) + policy := jsonBodyPolicy(true, false, false, "") + first.collect(target, wireRequestBody, policy) + handle := first.collectTransform(source, target, "marshal", "first", wireTransformLayout{ + wireSide: wireTransformTarget, + wirePolicy: policy, + servicePackage: testServicePackage(), + }) + linkTestWireTypeCatalog(t, firstGeneration, first) + + second, secondGeneration := testWireTypeCatalog(t) + linkTestWireTypeCatalog(t, secondGeneration, second) + _, _, err := renderTestTransform(second, handle, "inventory") + require.ErrorContains(t, err, "different generated package") +} + +// TestTransformDefinitionsMatchAcrossPlans proves one package declaration may +// be shared by equivalent helper definitions produced by separate plans. +func TestTransformDefinitionsMatchAcrossPlans(t *testing.T) { + catalog, first, second := plannedMatchingTransforms(t) + _, firstHelpers, err := renderTestTransform(catalog, first, "inventory") + require.NoError(t, err) + _, secondHelpers, err := renderTestTransform(catalog, second, "inventory") + require.NoError(t, err) + require.Same(t, firstHelpers[0].Declaration, secondHelpers[0].Declaration) +} + +// TestTransformDefinitionsRejectMismatchAcrossPlans catches AppendHelpers +// hiding two different functions assigned to one package declaration. +func TestTransformDefinitionsRejectMismatchAcrossPlans(t *testing.T) { + catalog, first, second := plannedMatchingTransforms(t) + _, _, err := renderTestTransform(catalog, first, "inventory") + require.NoError(t, err) + _, _, err = renderTestTransform(catalog, second, "different") + require.ErrorContains(t, err, "has different definitions") + require.False(t, second.record.used) +} + +// TestPlanLinkRejectsUnusedTransform catches planned conversions that no +// generated constructor or stream method renders. +func TestPlanLinkRejectsUnusedTransform(t *testing.T) { + root := expr.RunDSL(t, func() { + dsl.Service("audit", func() { + dsl.Method("show", func() { + dsl.Result(dsl.String) + dsl.HTTP(func() { + dsl.GET("/show") + }) + }) }) + }) + generation, err := codegen.NewGeneration("generated.local/gen", []eval.Root{root}) + require.NoError(t, err) + servicePlan, err := service.NewPlan(root, generation, expr.NewExampleGenerator(root.API.RandomizerFactory)) + require.NoError(t, err) + plans, err := NewPlans(generation, PlanInput{Root: root, Service: servicePlan}) + require.NoError(t, err) + transportService := root.API.HTTP.Service("audit") + planned := plans[0].wireTypes[transportService] + record := &wireTransformRecord{owner: "unused audit transform", prefix: "marshal"} + planned.client.transforms = append(planned.client.transforms, record) + planned.transforms.streamingResults[transportService.HTTPEndpoints[0]] = &plannedResponseTransforms{ + clientDecode: wireTransformHandle{catalog: planned.client, record: record}, } + require.NoError(t, generation.Freeze()) + require.NoError(t, servicePlan.Link()) + + err = plans[0].Link() + require.ErrorContains(t, err, "unused audit transform") } -func TestTransformHelperCLI(t *testing.T) { - cases := []struct { - Name string - DSL func() - Offset int +// TestPlanLinkReturnsForeignTransformError catches render failures escaping as +// panics instead of the error returned by Plan.Link. +func TestPlanLinkReturnsForeignTransformError(t *testing.T) { + root, generation, servicePlan, plan := plannedTransformErrorService(t) + endpoint := root.API.HTTP.Services[0].HTTPEndpoints[0] + planned := plan.wireTypes[root.API.HTTP.Services[0]] + response := planned.transforms.responses[viewedConstructorKey{endpoint: endpoint, response: endpoint.Responses[0]}] + response.serverEncode = response.clientDecode + require.NoError(t, generation.Freeze()) + require.NoError(t, servicePlan.Link()) + + var linkErr error + require.NotPanics(t, func() { + linkErr = plan.Link() + }) + require.ErrorContains(t, linkErr, "different generated package") +} + +// TestPlanLinkReturnsReusedTransformError catches a second production render +// of one handle escaping as a panic. +func TestPlanLinkReturnsReusedTransformError(t *testing.T) { + root, generation, servicePlan, plan := plannedTransformErrorService(t) + serviceExpr := root.API.HTTP.Services[0] + endpoint := serviceExpr.HTTPEndpoints[0] + planned := plan.wireTypes[serviceExpr] + request := planned.transforms.requests[clientBodyConstructorKey{endpoint: endpoint, role: wireRequestBody}] + response := planned.transforms.responses[viewedConstructorKey{endpoint: endpoint, response: endpoint.Responses[0]}] + response.serverEncode = request.serverDecode + require.NoError(t, generation.Freeze()) + require.NoError(t, servicePlan.Link()) + + var linkErr error + require.NotPanics(t, func() { + linkErr = plan.Link() + }) + require.ErrorContains(t, linkErr, "already rendered") +} + +// TestTransformHelperOrderingDoesNotDependOnTraversalOrder catches suffixes +// changing when the same child conversions are collected in reverse order. +func TestTransformHelperOrderingDoesNotDependOnTraversalOrder(t *testing.T) { + require.Equal(t, plannedTransformHelperNames(t, false), plannedTransformHelperNames(t, true)) +} + +// TestTransformHelperUsesRetainedServicePackagePreference catches helper names +// derived from the HTTP output directory or a copied view type's spelling. +func TestTransformHelperUsesRetainedServicePackagePreference(t *testing.T) { + for _, test := range []struct { + name string + preference codegen.ImportSpec + want string }{ - // {"cli-body-user-inner-default-1", testdata.PayloadBodyUserInnerDefaultDSLCLI1, 1}, - // {"cli-body-user-inner-default-2", testdata.PayloadBodyUserInnerDefaultDSLCLI2, 2}, - // {"cli-body-user-recursive-default-1", testdata.PayloadBodyInlineRecursiveUserDSLCLI1, 1}, - // {"cli-body-user-recursive-default-2", testdata.PayloadBodyInlineRecursiveUserDSLCLI2, 2}, - } - for _, c := range cases { - t.Run(c.Name, func(t *testing.T) { - root := expr.RunDSL(t, c.DSL) - services := CreateHTTPServices(root) - f := ClientEncodeDecodeFile("", root.API.HTTP.Services[0], services) - sections := f.SectionTemplates - require.Greater(t, len(sections), c.Offset) - code := codegen.SectionCode(t, sections[len(sections)-c.Offset]) - testutil.AssertGo(t, "testdata/golden/transform_helper_"+c.Name+".go.golden", code) + {"service", codegen.ImportSpec{Name: "inventory", Path: "generated.local/gen/inventory"}, "InventoryChild"}, + {"views", codegen.ImportSpec{Name: "inventoryviews", Path: "generated.local/gen/inventory/views"}, "InventoryviewsChild"}, + } { + t.Run(test.name, func(t *testing.T) { + source, target := manyDistinctTransformChildren(1, false) + catalog, generation := testWireTypeCatalog(t) + policy := jsonBodyPolicy(true, false, false, "") + catalog.collect(target, wireRequestBody, policy) + catalog.collectTransform(source, target, "marshal", test.name, wireTransformLayout{ + wireSide: wireTransformTarget, + wirePolicy: policy, + servicePackage: test.preference, + }) + linkTestWireTypeCatalog(t, generation, catalog) + + require.Len(t, catalog.transformHelpers, 1) + require.Contains(t, catalog.transformHelpers[0].declaration.Name(), test.want) + require.NotContains(t, catalog.transformHelpers[0].declaration.Name(), "TestChild") + }) + } +} + +func TestClientTransformHelpersNameExactSourceAndTargetTypes(t *testing.T) { + root := expr.RunDSL(t, testdata.PayloadBodyUserInnerDefaultDSL) + plan := linkedHTTPPlanForRoot(t, root) + var ( + code bytes.Buffer + count int + ) + for _, file := range plan.ClientFiles() { + for _, section := range file.SectionTemplates { + if section.Name != "client-transform-helper" { + continue + } + count++ + require.NoError(t, section.Write(&code)) + } + } + require.Equal(t, 2, count) + testutil.AssertGo( + t, + "testdata/golden/transform_helper_bidirectional-client.go.golden", + codegen.FormatTestCode(t, "package client\n"+code.String()), + ) +} + +func TestViewedTransformHelpersNameViewsPackage(t *testing.T) { + root := expr.RunDSL(t, testdata.ExplicitBodyUserResultObjectDSL) + plan := linkedHTTPPlanForRoot(t, root) + service := plan.services.Get("ServiceExplicitBodyUserResultObject") + names := make([]string, 0, len(service.ClientTransformHelpers)) + for _, helper := range service.ClientTransformHelpers { + names = append(names, helper.Name) + } + require.Contains(t, names, "unmarshalUserTypeResponseBodyToServiceexplicitbodyuserresultobjectviewsUserTypeView") +} + +func TestTransformHelpersUseConciseServiceAndWireTypeNames(t *testing.T) { + root := expr.RunDSL(t, conciseTransformHelperDSL) + plan := linkedHTTPPlanForRoot(t, root) + service := plan.services.Get("Storage") + names := make([]string, 0, len(service.ClientTransformHelpers)) + for _, helper := range service.ClientTransformHelpers { + names = append(names, helper.Name) + } + require.Contains(t, names, "marshalStorageWineryToWineryRequestBody") + require.Contains(t, names, "marshalWineryRequestBodyToStorageWinery") +} + +func TestSiblingTransformHelpersShareOneDefinition(t *testing.T) { + root := expr.RunDSL(t, testdata.ResultTypeSiblingUserTypeFieldsDSL) + plan := linkedHTTPPlanForRoot(t, root) + service := plan.services.Get("ServiceResultUserTypeSibling") + require.Len(t, service.ServerTransformHelpers, 1) + name := "marshalServiceresultusertypesiblingviewsUserTypeViewToUserTypeResponseBody" + require.Equal(t, name, service.ServerTransformHelpers[0].Name) + result := service.Endpoint("MethodResultUserTypeSibling").Result + require.NotEmpty(t, result.Responses) + require.NotEmpty(t, result.Responses[0].ServerBody) + require.NotNil(t, result.Responses[0].ServerBody[0].Init) + constructor := result.Responses[0].ServerBody[0].Init.ServerCode + require.Contains(t, constructor, name+"(res.A)") + require.Contains(t, constructor, name+"(res.B)") + + var code bytes.Buffer + for _, file := range plan.ServerFiles() { + for _, section := range file.SectionTemplates { + if section.Name == "server-transform-helper" { + require.NoError(t, section.Write(&code)) + } + } + } + testutil.AssertGo( + t, + "testdata/golden/transform_helper_sibling-declarations.go.golden", + codegen.FormatTestCode(t, "package server\n"+code.String()), + ) +} + +func TestTransformHelpersShareExactPackageDeclaration(t *testing.T) { + root := expr.RunDSL(t, sharedTransformHelperDSL) + plan := linkedHTTPPlanForRoot(t, root) + service := plan.services.Get("SharedHelpers") + name := sharedTransformHelperName(t, service.serverWireTypes) + require.Contains(t, service.Endpoint("First").Payload.Request.PayloadInit.ServerCode, name+"(body.Child)") + require.Contains(t, service.Endpoint("Second").Payload.Request.PayloadInit.ServerCode, name+"(body.Child)") + optional := service.Endpoint("Optional").Payload.Request.PayloadInit.ServerCode + require.Contains(t, optional, "if body.Child != nil {") + require.Contains(t, optional, name+"(body.Child)") + var ( + code bytes.Buffer + count int + ) + for _, file := range plan.ServerFiles() { + for _, section := range file.SectionTemplates { + if section.Name != "server-transform-helper" { + continue + } + count++ + require.NoError(t, section.Write(&code)) + } + } + require.Equal(t, 1, count) + testutil.AssertGo( + t, + "testdata/golden/transform_helper_shared-declarations.go.golden", + codegen.FormatTestCode(t, "package server\n"+code.String()), + ) +} + +func TestJSONRPCTransformHelpersUseHTTPPackageDeclarations(t *testing.T) { + root := expr.RunDSL(t, sharedJSONRPCTransformHelperDSL) + generation, err := codegen.NewGeneration("generated.local/gen", []eval.Root{root}) + require.NoError(t, err) + servicePlan, err := service.NewPlan(root, generation, expr.NewExampleGenerator(root.API.RandomizerFactory)) + require.NoError(t, err) + plans, err := NewJSONRPCPlans(generation, PlanInput{Root: root, Service: servicePlan}) + require.NoError(t, err) + require.NoError(t, generation.Freeze()) + require.NoError(t, servicePlan.Link()) + require.NoError(t, plans[0].Link()) + + serviceData := plans[0].services.Get("SharedHelpers") + name := sharedTransformHelperName(t, serviceData.serverWireTypes) + snapshot, ok := plans[0].JSONRPCService("SharedHelpers") + require.True(t, ok) + require.Contains(t, snapshot.Endpoints[0].Payload.Request.PayloadInit.ServerCode, name+"(body.Child)") + require.Contains(t, snapshot.Endpoints[1].Payload.Request.PayloadInit.ServerCode, name+"(body.Child)") + require.Contains(t, snapshot.Endpoints[2].Payload.Request.PayloadInit.ServerCode, "if body.Child != nil {") + require.Contains(t, snapshot.Endpoints[2].Payload.Request.PayloadInit.ServerCode, name+"(body.Child)") + + first := jsonRPCTransformHelpers(snapshot.ServerCodecFile()) + second := jsonRPCTransformHelpers(snapshot.ServerCodecFile()) + require.Len(t, first, 1) + require.Len(t, second, 1) + require.Equal(t, name, first[0].Name) + first[0].Name = "changed" + fresh := jsonRPCTransformHelpers(snapshot.ServerCodecFile()) + require.Equal(t, second[0].Name, fresh[0].Name) + require.NotEqual(t, first[0].Name, fresh[0].Name) +} + +// jsonRPCTransformHelpers returns the copied conversion functions from one +// JSON-RPC codec file so the test can change one copy without changing another. +func jsonRPCTransformHelpers(file *codegen.File) []*jsonRPCTransformFunctionData { + var helpers []*jsonRPCTransformFunctionData + for _, section := range file.SectionTemplates { + if section.Name != "server-transform-helper" { + continue + } + helpers = append(helpers, section.Data.(*jsonRPCTransformFunctionData)) + } + return helpers +} + +// sharedTransformHelperName returns the one function used by the required and +// optional child fields in the test service. +func sharedTransformHelperName(t *testing.T, catalog *wireTypeCatalog) string { + t.Helper() + require.Len(t, catalog.transformHelpers, 1) + return catalog.transformHelpers[0].declaration.Name() +} + +// sharedTransformHelperDSL uses one named child in two required fields and one +// optional field so all three calls can share one strict function. +func sharedTransformHelperDSL() { + sharedTransformHelperDesign(false) +} + +// sharedJSONRPCTransformHelperDSL applies the same service types to JSON-RPC. +func sharedJSONRPCTransformHelperDSL() { + sharedTransformHelperDesign(true) +} + +// conciseTransformHelperDSL names the service and nested type like a normal +// application so generated functions should use those public names directly. +func conciseTransformHelperDSL() { + winery := dsl.ResultType("application/vnd.transform-helper.winery", func() { + dsl.TypeName("Winery") + dsl.Attribute("name", dsl.String) + dsl.Required("name") + }) + bottle := dsl.Type("Bottle", func() { + dsl.Attribute("winery", winery) + dsl.Required("winery") + }) + dsl.Service("Storage", func() { + dsl.Method("Create", func() { + dsl.Payload(bottle) + dsl.HTTP(func() { + dsl.POST("/") + }) }) + }) +} + +// sharedTransformHelperDesign creates the service used to test normal HTTP and +// JSON-RPC package generation. +func sharedTransformHelperDesign(jsonrpc bool) { + child := dsl.Type("SharedChild", func() { + dsl.Attribute("value", dsl.String) + dsl.Required("value") + }) + dsl.Service("SharedHelpers", func() { + for _, method := range []struct { + name string + path string + required bool + }{ + {name: "First", path: "/first", required: true}, + {name: "Second", path: "/second", required: true}, + {name: "Optional", path: "/optional"}, + } { + dsl.Method(method.name, func() { + dsl.Payload(func() { + dsl.Attribute("child", child) + if method.required { + dsl.Required("child") + } + }) + if jsonrpc { + dsl.JSONRPC(func() {}) + } else { + dsl.HTTP(func() { + dsl.POST(method.path) + }) + } + }) + } + }) +} + +// manyDistinctTransformChildren builds one object conversion with more helper +// functions than fit in one byte. Every child requests the same preferred type +// name but has a different field, so declaration ordering must use its complete +// source and target type identity. +func manyDistinctTransformChildren(count int, reverse bool) (*expr.AttributeExpr, *expr.AttributeExpr) { + sourceObject := make(expr.Object, 0, count) + targetObject := make(expr.Object, 0, count) + required := make([]string, count) + for position := range count { + index := position + if reverse { + index = count - position - 1 + } + field := fmt.Sprintf("field_%d", index) + value := fmt.Sprintf("value_%d", index) + sourceChild := &expr.UserTypeExpr{ + TypeName: "Child", + AttributeExpr: &expr.AttributeExpr{Type: &expr.Object{ + &expr.NamedAttributeExpr{Name: value, Attribute: &expr.AttributeExpr{Type: expr.String}}, + }}, + } + targetChild := &expr.UserTypeExpr{ + TypeName: "Child", + AttributeExpr: &expr.AttributeExpr{Type: &expr.Object{ + &expr.NamedAttributeExpr{Name: value, Attribute: &expr.AttributeExpr{Type: expr.String}}, + }}, + } + sourceObject = append(sourceObject, &expr.NamedAttributeExpr{Name: field, Attribute: &expr.AttributeExpr{Type: sourceChild}}) + targetObject = append(targetObject, &expr.NamedAttributeExpr{Name: field, Attribute: &expr.AttributeExpr{Type: targetChild}}) + required[index] = field + } + return &expr.AttributeExpr{ + Type: &sourceObject, + Validation: &expr.ValidationExpr{Required: required}, + }, &expr.AttributeExpr{ + Type: &targetObject, + Validation: &expr.ValidationExpr{Required: append([]string(nil), required...)}, + } +} + +// plannedTransformHelperNames returns each distinct child field and the helper +// name assigned to its conversion. +func plannedTransformHelperNames(t *testing.T, reverse bool) map[string]string { + t.Helper() + source, target := manyDistinctTransformChildren(3, reverse) + catalog, generation := testWireTypeCatalog(t) + policy := jsonBodyPolicy(true, false, false, "") + catalog.collect(target, wireRequestBody, policy) + catalog.collectTransform(source, target, "marshal", "ordered helpers", wireTransformLayout{ + wireSide: wireTransformTarget, + wirePolicy: policy, + servicePackage: testServicePackage(), + }) + linkTestWireTypeCatalog(t, generation, catalog) + + names := make(map[string]string, len(catalog.transformHelpers)) + for _, helper := range catalog.transformHelpers { + object := expr.AsObject(helper.identity.source.attribute.Type) + names[(*object)[0].Name] = helper.declaration.Name() + } + return names +} + +// plannedMatchingTransforms returns two independent plans whose child helpers +// share one declaration in the generated package. +func plannedMatchingTransforms( + t *testing.T, +) (*wireTypeCatalog, wireTransformHandle, wireTransformHandle) { + t.Helper() + source, target := manyDistinctTransformChildren(1, false) + catalog, generation := testWireTypeCatalog(t) + policy := jsonBodyPolicy(true, false, false, "") + catalog.collect(target, wireRequestBody, policy) + layout := wireTransformLayout{ + wireSide: wireTransformTarget, + wirePolicy: policy, + servicePackage: testServicePackage(), } + first := catalog.collectTransform(source, target, "marshal", "first", layout) + second := catalog.collectTransform(source, target, "marshal", "second", layout) + linkTestWireTypeCatalog(t, generation, catalog) + return catalog, first, second +} + +// renderTestTransform renders one service-to-wire conversion using the given +// service package qualifier. +func renderTestTransform( + catalog *wireTypeCatalog, + handle wireTransformHandle, + servicePackage string, +) (string, []*codegen.TransformFunctionData, error) { + serviceContext := codegen.NewAttributeContext(false, false, true, servicePackage, codegen.NewNameScope()) + wireContext := jsonBodyContext(catalog, catalog.scope, true, false) + return catalog.renderTransform(handle, handle.record.target, "source", "target", serviceContext, wireContext) +} + +// testServicePackage is the retained service package used by direct wire +// catalog tests. +func testServicePackage() codegen.ImportSpec { + return codegen.ImportSpec{Name: "inventory", Path: "generated.local/gen/inventory"} +} + +// plannedTransformErrorService returns an unlinked plan with request and +// response conversions that tests can replace with an invalid handle. +func plannedTransformErrorService( + t *testing.T, +) (*expr.RootExpr, *codegen.Generation, *service.Plan, *Plan) { + t.Helper() + root := expr.RunDSL(t, func() { + payload := dsl.Type("Payload", func() { + dsl.Attribute("value", dsl.String) + dsl.Required("value") + }) + result := dsl.Type("Result", func() { + dsl.Attribute("value", dsl.String) + dsl.Required("value") + }) + dsl.Service("transform errors", func() { + dsl.Method("show", func() { + dsl.Payload(payload) + dsl.Result(result) + dsl.HTTP(func() { + dsl.POST("/show") + }) + }) + }) + }) + generation, err := codegen.NewGeneration("generated.local/gen", []eval.Root{root}) + require.NoError(t, err) + servicePlan, err := service.NewPlan(root, generation, expr.NewExampleGenerator(root.API.RandomizerFactory)) + require.NoError(t, err) + plans, err := NewPlans(generation, PlanInput{Root: root, Service: servicePlan}) + require.NoError(t, err) + return root, generation, servicePlan, plans[0] } diff --git a/http/codegen/typedef.go b/http/codegen/typedef.go index 7afa972004..713c83433b 100644 --- a/http/codegen/typedef.go +++ b/http/codegen/typedef.go @@ -26,30 +26,27 @@ import ( func goTypeDef(scope *codegen.NameScope, att *expr.AttributeExpr, ptr, useDefault bool) string { ctx := codegen.NewAttributeContext(ptr, false, useDefault, "", scope) ctx.UnionPointer = true - return goTypeDefForContext(scope, att, ctx) + return goTypeDefForContext(att, ctx) } // goTypeDefForContext recursively renders an HTTP body type using the same // field representation consulted by transport conversion and validation. -func goTypeDefForContext(scope *codegen.NameScope, att *expr.AttributeExpr, ctx *codegen.AttributeContext) string { +func goTypeDefForContext(att *expr.AttributeExpr, ctx *codegen.AttributeContext) string { switch actual := att.Type.(type) { case expr.Primitive: - if t, _ := codegen.GetMetaType(att); t != "" { - return t - } - return codegen.GoNativeTypeName(actual) + return ctx.Scope.Name(att, ctx.Pkg(att), ctx.Pointer, ctx.UseDefault) case *expr.Array: - d := goTypeDefForContext(scope, actual.ElemType, ctx) - if expr.IsObject(actual.ElemType.Type) { + d := goTypeDefForContext(actual.ElemType, ctx) + if expr.IsObject(actual.ElemType.Type) || ctx.IsArrayElementPointer(actual) { d = "*" + d } return "[]" + d case *expr.Map: - keyDef := goTypeDefForContext(scope, actual.KeyType, ctx) + keyDef := goTypeDefForContext(actual.KeyType, ctx) if expr.IsObject(actual.KeyType.Type) { keyDef = "*" + keyDef } - elemDef := goTypeDefForContext(scope, actual.ElemType, ctx) + elemDef := goTypeDefForContext(actual.ElemType, ctx) if expr.IsObject(actual.ElemType.Type) { elemDef = "*" + elemDef } @@ -66,9 +63,9 @@ func goTypeDefForContext(scope *codegen.NameScope, att *expr.AttributeExpr, ctx tags string ) { - fn = codegen.GoifyAtt(at, name, true) - tdef = goTypeDefForContext(scope, at, ctx) - if ctx.IsFieldPointer(name, att) { + fn = codegen.GoifyAtt(at, elem, true) + tdef = goTypeDefForContext(at, ctx) + if ctx.IsFieldPointer(name, ma.AttributeExpr) { tdef = "*" + tdef } if at.Description != "" { @@ -93,7 +90,7 @@ func goTypeDefForContext(scope *codegen.NameScope, att *expr.AttributeExpr, ctx ss = append(ss, "}") return strings.Join(ss, "\n") case expr.UserType, *expr.Union: - return scope.GoTypeName(att) + return ctx.Scope.Name(att, ctx.Pkg(att), ctx.Pointer, ctx.UseDefault) default: panic(fmt.Sprintf("unknown data type %T", actual)) // bug } @@ -105,9 +102,7 @@ func attributeTags(att *expr.AttributeExpr, t string, optional bool) string { return tags } var o string - // Always use omitempty for JSON-RPC ID attributes, even when required - // since it is part of a different top-level field in the transport - if optional || isJSONRPCID(att) { + if optional { o = ",omitempty" } jsonName := t @@ -118,12 +113,3 @@ func attributeTags(att *expr.AttributeExpr, t string, optional bool) string { } return fmt.Sprintf(" `form:\"%s%s\" json:\"%s%s\" xml:\"%s%s\"`", t, o, jsonName, o, t, o) } - -// isJSONRPCID checks if the attribute is marked as a JSON-RPC ID attribute -func isJSONRPCID(att *expr.AttributeExpr) bool { - if att.Meta == nil { - return false - } - _, ok := att.Meta["jsonrpc:id"] - return ok -} diff --git a/http/codegen/types.go b/http/codegen/types.go index b6eb01db67..0bb2785707 100644 --- a/http/codegen/types.go +++ b/http/codegen/types.go @@ -1,3 +1,5 @@ +// This file renders HTTP request and response types per service and transport +// side, using imports attached to that exact generated type file. package codegen import ( @@ -7,20 +9,20 @@ import ( "goa.design/goa/v3/expr" ) -// ServerTypeFiles returns the HTTP transport type files. -func ServerTypeFiles(genpkg string, data *ServicesData) []*codegen.File { +// serverTypeFiles builds the server request and response types read by Plan.Link. +func serverTypeFiles(data *ServicesData) []*codegen.File { fw := make([]*codegen.File, len(data.Expressions.Services)) for i, svc := range data.Expressions.Services { - fw[i] = typesFile(genpkg, svc, true, data) + fw[i] = typesFile(svc, true, data) } return fw } -// ClientTypeFiles returns the HTTP transport client types files. -func ClientTypeFiles(genpkg string, data *ServicesData) []*codegen.File { +// clientTypeFiles builds the client request and response types read by Plan.Link. +func clientTypeFiles(data *ServicesData) []*codegen.File { fw := make([]*codegen.File, len(data.Expressions.Services)) for i, svc := range data.Expressions.Services { - fw[i] = typesFile(genpkg, svc, false, data) + fw[i] = typesFile(svc, false, data) } return fw } @@ -49,7 +51,7 @@ func ClientTypeFiles(genpkg string, data *ServicesData) []*codegen.File { // // - Response body fields (if the body is a struct) and header variables hold // pointers when not required and have no default value. -func typesFile(genpkg string, svc *expr.HTTPServiceExpr, svr bool, services *ServicesData) *codegen.File { +func typesFile(svc *expr.HTTPServiceExpr, svr bool, services *ServicesData) *codegen.File { var ( data = services.Get(svc.Name()) svcName = data.Service.PathName @@ -79,22 +81,10 @@ func typesFile(genpkg string, svc *expr.HTTPServiceExpr, svr bool, services *Ser bodyInitT = clientBodyInitT } path := filepath.Join(codegen.Gendir, services.dir(), svcName, side, "types.go") - imports := []*codegen.ImportSpec{ - {Path: "encoding/json"}, - {Path: "fmt"}, - {Path: "unicode/utf8"}, - {Path: genpkg + "/" + svcName, Name: data.Service.PkgName}, - } - if len(data.UnionTypes) > 0 { - imports = append(imports, &codegen.ImportSpec{Path: "bytes"}) - } - views := &codegen.ImportSpec{Path: genpkg + "/" + svcName + "/" + "views", Name: data.Service.ViewsPkg} - if svr { - imports = append(imports, codegen.GoaImport(""), views) - } else { - imports = append(imports, views, codegen.GoaImport("")) - } - header := codegen.Header(svc.Name()+" "+services.label()+" "+side+" types", side, imports) + outputPackage := generatedFileOutputPackage(services, path) + data = serviceDataForOutput(data, services, outputPackage) + unionTypes := data.wireTypes(svr).unionTypes() + header := plannedFileHeader(svc.Name()+" "+services.label()+" "+side+" types", side, path, services) var ( initData []*InitData @@ -102,46 +92,43 @@ func typesFile(genpkg string, svc *expr.HTTPServiceExpr, svr bool, services *Ser sections = []*codegen.SectionTemplate{header} - // seen tracks the body types already emitted in this file. Server - // types are deduplicated by type name because distinct - // endpoint-scoped composite wrappers may share a structural - // reference, client types by reference because structurally - // identical types are decoded interchangeably. - seen = make(map[string]struct{}) + // seen records each generated type declaration already written to this + // file. Two declarations may have similar Go type text, so the declaration + // itself decides whether another definition is needed. + seen = make(map[*wireTypeRecord]struct{}) seenInits = make(map[string]struct{}) - seenValidated = make(map[string]struct{}) + seenValidated = make(map[*wireTypeRecord]struct{}) ) - key := func(td *TypeData) string { - if svr { - return td.Name - } - return td.Ref - } // addDecl emits the type declaration section if the type has a // definition. addDecl := func(name string, td *TypeData) { - if td.Def != "" { + if td.declaration == nil || td.Def == "" { + return + } + if _, ok := seen[td.declaration]; ok { + return + } + seen[td.declaration] = struct{}{} + declaration := td.declaration.data + if declaration != nil { sections = append(sections, &codegen.SectionTemplate{ Name: name, Source: httpTemplates.Read(typeDeclT), - Data: td, + Data: declaration, }) } } - // addValidated records the type for validation method generation. Client - // types are deduplicated by name; server types rely on the body type - // dedup performed by the callers. + // addValidated records each validation helper declared in this generated + // package once. addValidated := func(td *TypeData) { - if td.ValidateDef == "" { + if td.declaration == nil || td.ValidateDef == "" { return } - if !svr { - if _, ok := seenValidated[td.Name]; ok { - return - } - seenValidated[td.Name] = struct{}{} + if _, ok := seenValidated[td.declaration]; ok { + return } - validatedTypes = append(validatedTypes, td) + seenValidated[td.declaration] = struct{}{} + validatedTypes = append(validatedTypes, td.declaration.data) } // request body types @@ -150,7 +137,7 @@ func typesFile(genpkg string, svc *expr.HTTPServiceExpr, svr bool, services *Ser var body, wsPayload *TypeData if svr { body = adata.Payload.Request.ServerBody - if adata.ServerWebSocket != nil && !adata.IsJSONRPC { + if adata.ServerWebSocket != nil { wsPayload = adata.ServerWebSocket.Payload } } else { @@ -163,12 +150,6 @@ func typesFile(genpkg string, svc *expr.HTTPServiceExpr, svr bool, services *Ser if td == nil { continue } - if !svr { - if _, ok := seen[td.Ref]; ok { - continue - } - seen[td.Ref] = struct{}{} - } name := requestBodySection if i == 1 { name = wsPayloadSection @@ -194,22 +175,50 @@ func typesFile(genpkg string, svc *expr.HTTPServiceExpr, svr bool, services *Ser bodies := resp.ServerBody if !svr { bodies = nil - if resp.ClientBody != nil { + if len(resp.ViewedRepresentations) > 0 { + for _, representation := range resp.ViewedRepresentations { + bodies = append(bodies, representation.ClientBody) + } + } else if resp.ClientBody != nil { bodies = []*TypeData{resp.ClientBody} } } for _, td := range bodies { - if _, ok := seen[key(td)]; ok { + if td == nil { continue } - seen[key(td)] = struct{}{} addDecl(responseBodySection, td) if td.Init != nil { - initData = append(initData, td.Init) + if _, ok := seenInits[td.Init.Name]; !ok { + seenInits[td.Init.Name] = struct{}{} + initData = append(initData, td.Init) + } } addValidated(td) } } + if !adata.HasMixedResults || adata.SSE == nil || adata.SSE.Response == nil { + continue + } + var bodies []*TypeData + if svr { + bodies = adata.SSE.Response.ServerBody + } else if adata.SSE.Response.ClientBody != nil { + bodies = []*TypeData{adata.SSE.Response.ClientBody} + } + for _, td := range bodies { + if td == nil { + continue + } + addDecl(responseBodySection, td) + if td.Init != nil { + if _, ok := seenInits[td.Init.Name]; !ok { + seenInits[td.Init.Name] = struct{}{} + initData = append(initData, td.Init) + } + } + addValidated(td) + } } // error body types @@ -225,18 +234,12 @@ func typesFile(genpkg string, svc *expr.HTTPServiceExpr, svr bool, services *Ser } } for _, td := range bodies { - if _, ok := seen[key(td)]; ok { - continue - } - // Server error body types without a definition are not - // marked as emitted: their endpoint-scoped constructors - // and validations are collected for every occurrence. - if !svr || td.Def != "" { - seen[key(td)] = struct{}{} - } addDecl(errorBodySection, td) if td.Init != nil { - initData = append(initData, td.Init) + if _, ok := seenInits[td.Init.Name]; !ok { + seenInits[td.Init.Name] = struct{}{} + initData = append(initData, td.Init) + } } addValidated(td) } @@ -250,18 +253,12 @@ func typesFile(genpkg string, svc *expr.HTTPServiceExpr, svr bool, services *Ser atts = data.ClientBodyAttributeTypes } for _, td := range atts { - if !svr { - if _, ok := seen[td.Ref]; ok { - continue - } - seen[td.Ref] = struct{}{} - } addDecl(attributeSection, td) addValidated(td) } // union sum types - for _, u := range data.UnionTypes { + for _, u := range unionTypes { sections = append(sections, &codegen.SectionTemplate{ Name: unionSection, Source: httpTemplates.Read(unionTypeT), @@ -295,10 +292,16 @@ func typesFile(genpkg string, svc *expr.HTTPServiceExpr, svr bool, services *Ser seenResultInits := make(map[string]struct{}) for _, adata := range data.Endpoints { for _, resp := range adata.Result.Responses { - if init := resp.ResultInit; init != nil { - if _, ok := seenResultInits[init.Name]; !ok { - seenResultInits[init.Name] = struct{}{} - sections = append(sections, resultInitSection("client-result-init", init)) + inits := []*InitData{resp.ResultInit} + for _, representation := range resp.ViewedRepresentations { + inits = append(inits, representation.ResultInit) + } + for _, init := range inits { + if init != nil { + if _, ok := seenResultInits[init.Name]; !ok { + seenResultInits[init.Name] = struct{}{} + sections = append(sections, resultInitSection("client-result-init", init)) + } } } } @@ -369,6 +372,7 @@ func fieldCode(init *InitData, typ string) string { FieldName: arg.FieldName, FieldPointer: arg.FieldPointer, FieldType: arg.FieldType, + FieldTypeRef: arg.ServiceTypeRef, } } // We can ignore the transform helpers as there won't be any generated diff --git a/http/codegen/validation_path_test.go b/http/codegen/validation_path_test.go new file mode 100644 index 0000000000..1b8c84555d --- /dev/null +++ b/http/codegen/validation_path_test.go @@ -0,0 +1,198 @@ +// This file verifies that generated HTTP validators keep complete error paths +// while reusing named validators for nested and recursive values. +package codegen + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/require" + + gencodegen "goa.design/goa/v3/codegen" + "goa.design/goa/v3/codegen/service" + . "goa.design/goa/v3/dsl" + "goa.design/goa/v3/eval" + "goa.design/goa/v3/expr" + "goa.design/goa/v3/http/codegen/testdata" +) + +func TestHTTPValidationPathsUseGeneratedCalls(t *testing.T) { + root := expr.RunDSL(t, recursiveValidationPathDSL) + code := renderedFiles(t, linkedHTTPPlanForRoot(t, root).ServerTypeFiles()) + + require.Contains(t, code, `validateNodeRequestBody(body.First, "body.first")`) + require.Contains(t, code, `validateNodeRequestBody(body.Second, "body.second")`) + require.Contains(t, code, `validateNodeRequestBody(body.Next, "body.next")`) + require.Contains(t, code, `validateNodeRequestBody(body.Next, path+".next")`) + require.Contains(t, code, `validateNodeRequestBody(e, path+".children[*]")`) + require.Contains(t, code, `validateNodeRequestBody(v, path+".children_by_name[key]")`) + require.Contains(t, code, `goa.InvalidLengthError("body.value"`) + require.Contains(t, code, `goa.InvalidLengthError(path+".value"`) + require.Equal(t, 1, strings.Count(code, "func validateNodeRequestBody(")) + require.NotContains(t, code, "fmt.Sprintf") +} + +func TestHTTPValidationPathsKeepMutualRecursion(t *testing.T) { + root := expr.RunDSL(t, mutualValidationPathDSL) + code := renderedFiles(t, linkedHTTPPlanForRoot(t, root).ServerTypeFiles()) + + require.Contains(t, code, `validateLeftRequestBody(body.Left, "body.left")`) + require.Contains(t, code, `validateRightRequestBody(body.Right, path+".right")`) + require.Contains(t, code, `validateLeftRequestBody(body.Left, path+".left")`) + require.Equal(t, 1, strings.Count(code, "func validateLeftRequestBody(")) + require.Equal(t, 1, strings.Count(code, "func validateRightRequestBody(")) +} + +func TestHTTPValidationPathsOmitUnusedNestedHelper(t *testing.T) { + root := expr.RunDSL(t, unusedClientNestedValidationDSL) + code := renderedFiles(t, linkedHTTPPlanForRoot(t, root).ClientTypeFiles()) + + require.Contains(t, code, "func ValidateChildRequestBody(") + require.NotContains(t, code, "func validateChildRequestBody(") +} + +func TestHTTPValidationPathsOmitUnusedViewedSSENestedHelper(t *testing.T) { + root := expr.RunDSL(t, viewedSSENestedValidationDSL) + var plan *Plan + require.NotPanics(t, func() { + plan = linkedHTTPPlanForRoot(t, root) + }) + types := renderedFiles(t, plan.ClientTypeFiles()) + serviceFiles, err := service.Files(plan.servicePlan) + require.NoError(t, err) + views := renderedFiles(t, serviceFiles) + + require.NotContains(t, types, "func validateProfile(") + require.Contains(t, views, `goa.ValidatePattern("result.code"`) +} + +func TestHTTPValidationPathsOmitUnusedClientArrayElementValidator(t *testing.T) { + root := expr.RunDSL(t, testdata.PayloadBodyPrimitiveArrayUserRequiredDSL) + generation, err := gencodegen.NewGeneration("generated.local/gen", []eval.Root{root}) + require.NoError(t, err) + servicePlan, err := service.NewPlan(root, generation, expr.NewExampleGenerator(root.API.RandomizerFactory)) + require.NoError(t, err) + plans, err := NewPlans(generation, PlanInput{Root: root, Service: servicePlan}) + require.NoError(t, err) + + planned := plans[0].wireTypes[root.API.HTTP.Services[0]] + var clientElement, serverElement *wireTypeRecord + for _, record := range planned.client.records { + if record.identity.preferred == "PayloadType" { + clientElement = record + break + } + } + for _, record := range planned.server.records { + if record.identity.preferred == "PayloadType" { + serverElement = record + break + } + } + require.NotNil(t, clientElement) + require.False(t, clientElement.needsNestedCall) + require.Nil(t, clientElement.nestedValidator) + require.NotNil(t, serverElement) + require.True(t, serverElement.needsNestedCall) + require.NotNil(t, serverElement.nestedValidator) +} + +// recursiveValidationPathDSL defines one named type used by two fields and by +// its own object, array, and map fields. +func recursiveValidationPathDSL() { + node := Type("Node", func() { + Attribute("value", String, func() { + MinLength(1) + }) + Attribute("next", "Node") + Attribute("children", ArrayOf("Node")) + Attribute("children_by_name", MapOf(String, "Node")) + }) + payload := Type("Payload", func() { + Attribute("first", node) + Attribute("second", node) + }) + Service("RecursiveValidation", func() { + Method("Check", func() { + Payload(payload) + HTTP(func() { + POST("/check") + }) + }) + }) +} + +// mutualValidationPathDSL defines two named types that refer to each other. +func mutualValidationPathDSL() { + left := Type("Left", func() { + Attribute("right", "Right") + }) + Type("Right", func() { + Attribute("code", String, func() { + Pattern("^[a-z]+$") + }) + Attribute("left", "Left") + }) + payload := Type("MutualPayload", func() { + Attribute("left", left) + }) + Service("MutualValidation", func() { + Method("Check", func() { + Payload(payload) + HTTP(func() { + POST("/check") + }) + }) + }) +} + +// unusedClientNestedValidationDSL defines a child validator whose client +// request body has no generated validation call to that child. +func unusedClientNestedValidationDSL() { + child := Type("Child", func() { + Attribute("code", String, func() { + Pattern("^[a-z]+$") + }) + }) + payload := Type("UnusedNestedPayload", func() { + Attribute("child", child) + }) + Service("UnusedNestedValidation", func() { + Method("Check", func() { + Payload(payload) + HTTP(func() { + POST("/check") + }) + }) + }) +} + +// viewedSSENestedValidationDSL defines a viewed event whose views package +// validates one nested named value. +func viewedSSENestedValidationDSL() { + profile := Type("Profile", func() { + Attribute("code", String, func() { + Pattern("^[a-z]+$") + }) + }) + event := ResultType("application/vnd.viewed-sse-nested-validation", func() { + TypeName("ViewedSSENestedValidation") + Attribute("profile", profile) + Required("profile") + View("summary", func() { + Attribute("profile") + }) + View("detailed", func() { + Attribute("profile") + }) + }) + Service("Viewed SSE Nested Validation", func() { + Method("Watch", func() { + StreamingResult(event) + HTTP(func() { + GET("/watch") + ServerSentEvents() + }) + }) + }) +} diff --git a/http/codegen/viewed_client_validation_runtime_test.go b/http/codegen/viewed_client_validation_runtime_test.go new file mode 100644 index 0000000000..f0dc894d24 --- /dev/null +++ b/http/codegen/viewed_client_validation_runtime_test.go @@ -0,0 +1,437 @@ +// This file renders HTTP clients with caller-selected, fixed, and ordinary +// response bodies. The generated tests prove each client validates the exact +// body shape selected by its design and response headers. +package codegen + +import ( + "context" + "os" + "os/exec" + "path/filepath" + "slices" + "testing" + "time" + + "github.com/stretchr/testify/require" + + "goa.design/goa/v3/codegen" + "goa.design/goa/v3/codegen/service" + "goa.design/goa/v3/dsl" + "goa.design/goa/v3/eval" + "goa.design/goa/v3/expr" +) + +// TestGeneratedViewedHTTPClientValidatesSelectedBody renders and runs clients +// whose response views select different required fields. +func TestGeneratedViewedHTTPClientValidatesSelectedBody(t *testing.T) { + root := expr.RunDSL(t, viewedHTTPClientValidationDSL) + generation, err := codegen.NewGeneration("generated.local/gen", []eval.Root{root}) + require.NoError(t, err) + servicePlan, err := service.NewPlan(root, generation, expr.NewExampleGenerator(root.API.RandomizerFactory)) + require.NoError(t, err) + httpPlans, err := NewPlans(generation, PlanInput{Root: root, Service: servicePlan}) + require.NoError(t, err) + require.NoError(t, generation.Freeze()) + require.NoError(t, servicePlan.Link()) + require.NoError(t, httpPlans[0].Link()) + mixed := httpPlans[0].services.Get("viewed_validation").Endpoint("mixed") + require.NotNil(t, mixed) + require.True(t, mixed.Result.Responses[0].SelectClientBodyByView) + require.NotEmpty(t, mixed.Result.Responses[0].ViewedRepresentations) + require.NotNil(t, mixed.SSE) + require.NotNil(t, mixed.SSE.Response) + require.NotNil(t, mixed.SSE.Response.ClientBody) + require.False(t, mixed.SSE.Response.SelectClientBodyByView) + require.Empty(t, mixed.SSE.Response.ViewedRepresentations) + + serviceFiles, err := service.Files(servicePlan) + require.NoError(t, err) + files := slices.Clone(serviceFiles) + files = append(files, httpPlans[0].ClientFiles()...) + files = append(files, httpPlans[0].ClientTypeFiles()...) + files = append(files, httpPlans[0].PathFiles()...) + runGeneratedViewedHTTPClientValidationTest(t, files) +} + +// viewedHTTPClientValidationDSL defines one result whose tiny view omits a +// required nested object, plus fixed-view, selected-field, and ordinary +// methods that keep their existing response rules. +func viewedHTTPClientValidationDSL() { + details := dsl.Type("Details", func() { + dsl.Attribute("name", dsl.String) + dsl.Required("name") + }) + viewed := dsl.ResultType("application/vnd.viewed-http-validation", func() { + dsl.TypeName("ViewedResult") + dsl.Attribute("id", dsl.String, func() { + dsl.MinLength(3) + }) + dsl.Attribute("details", details) + dsl.Required("id", "details") + dsl.View("default", func() { + dsl.Attribute("id") + dsl.Attribute("details") + }) + dsl.View("tiny", func() { + dsl.Attribute("id") + }) + }) + streamBody := dsl.Type("StreamBody", func() { + dsl.Attribute("id", dsl.String, func() { + dsl.MinLength(3) + }) + dsl.Required("id") + }) + explicitViewed := dsl.ResultType("application/vnd.explicit-viewed-http-validation", func() { + dsl.TypeName("ExplicitViewedResult") + dsl.Attribute("id", dsl.String, func() { + dsl.MinLength(3) + }) + dsl.Attribute("details", details) + dsl.Required("id") + dsl.View("default", func() { + dsl.Attribute("id") + dsl.Attribute("details") + }) + dsl.View("tiny", func() { + dsl.Attribute("id") + }) + }) + + dsl.Service("viewed_validation", func() { + dsl.Method("dynamic", func() { + dsl.Result(viewed) + dsl.HTTP(func() { + dsl.GET("/dynamic") + }) + }) + dsl.Method("dynamic_collection", func() { + dsl.Result(dsl.CollectionOf(viewed)) + dsl.HTTP(func() { + dsl.GET("/dynamic-collection") + }) + }) + dsl.Method("stream_dynamic", func() { + dsl.StreamingResult(viewed) + dsl.HTTP(func() { + dsl.GET("/stream-dynamic") + }) + }) + dsl.Method("stream_fixed", func() { + dsl.StreamingResult(viewed, func() { + dsl.View("tiny") + }) + dsl.HTTP(func() { + dsl.GET("/stream-fixed") + }) + }) + dsl.Method("stream_explicit", func() { + dsl.StreamingResult(explicitViewed) + dsl.HTTP(func() { + dsl.GET("/stream-explicit") + dsl.Response(func() { + dsl.Body(streamBody) + }) + }) + }) + dsl.Method("fixed", func() { + dsl.Result(viewed, func() { + dsl.View("tiny") + }) + dsl.HTTP(func() { + dsl.GET("/fixed") + }) + }) + dsl.Method("selected", func() { + dsl.Result(explicitViewed) + dsl.HTTP(func() { + dsl.GET("/selected") + dsl.Response(func() { + dsl.Body("id") + }) + }) + }) + dsl.Method("ordinary", func() { + dsl.Result(func() { + dsl.Attribute("id", dsl.String) + dsl.Attribute("details", details) + dsl.Required("id", "details") + }) + dsl.HTTP(func() { + dsl.GET("/ordinary") + }) + }) + dsl.Method("mixed", func() { + dsl.Result(viewed) + dsl.StreamingResult(func() { + dsl.Attribute("message", dsl.String) + dsl.Required("message") + }) + dsl.HTTP(func() { + dsl.GET("/mixed") + dsl.ServerSentEvents() + }) + }) + }) +} + +// runGeneratedViewedHTTPClientValidationTest writes the generated client into +// an isolated module and runs its response decoders against representative +// bodies and view headers. +func runGeneratedViewedHTTPClientValidationTest(t *testing.T, files []*codegen.File) { + t.Helper() + directory := t.TempDir() + repository, err := filepath.Abs(filepath.Join("..", "..")) + require.NoError(t, err) + module := "module generated.local\n\ngo 1.25\n\n" + + "require goa.design/goa/v3 v3.0.0\n\n" + + "replace goa.design/goa/v3 => " + filepath.ToSlash(repository) + "\n" + require.NoError(t, os.WriteFile(filepath.Join(directory, "go.mod"), []byte(module), 0o600)) + for _, file := range files { + _, err := file.Render(directory) + require.NoError(t, err) + } + + testPath := filepath.Join(directory, "gen", "http", "viewed_validation", "client", "viewed_validation_test.go") + require.NoError(t, os.WriteFile(testPath, []byte(generatedViewedHTTPClientValidationTest), 0o600)) + + ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) + defer cancel() + command := exec.CommandContext(ctx, "go", "test", "-mod=mod", "./gen/http/viewed_validation/client") + command.Dir = directory + command.Env = append(os.Environ(), "GOWORK=off") + output, err := command.CombinedOutput() + require.NoError(t, err, "run generated viewed HTTP client test:\n%s", output) +} + +const generatedViewedHTTPClientValidationTest = `package client + +import ( + "errors" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/gorilla/websocket" + "github.com/stretchr/testify/require" + + goahttp "goa.design/goa/v3/http" +) + +func TestDynamicResponseUsesSelectedView(t *testing.T) { + tests := []struct { + name string + view string + body string + wantError string + }{ + {name: "valid tiny", view: "tiny", body: ` + "`" + `{"id":"valid"}` + "`" + `}, + {name: "valid default", view: "default", body: ` + "`" + `{"id":"valid","details":{"name":"ready"}}` + "`" + `}, + { + name: "default requires nested object", + view: "default", + body: ` + "`" + `{"id":"valid"}` + "`" + `, + wantError: "details", + }, + {name: "tiny validates selected field", view: "tiny", body: ` + "`" + `{"id":"x"}` + "`" + `, wantError: "id"}, + {name: "missing view uses default", body: ` + "`" + `{"id":"valid","details":{"name":"ready"}}` + "`" + `}, + {name: "missing view validates default", body: ` + "`" + `{"id":"valid"}` + "`" + `, wantError: "details"}, + {name: "unknown view", view: "unknown", body: "{", wantError: "view"}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + result, err := DecodeDynamicResponse(goahttp.ResponseDecoder, false)(response(test.view, test.body)) + if test.wantError == "" { + require.NoError(t, err) + require.NotNil(t, result) + return + } + require.Nil(t, result) + require.ErrorContains(t, err, test.wantError) + }) + } +} + +func TestFixedResponseKeepsFixedView(t *testing.T) { + result, err := DecodeFixedResponse(goahttp.ResponseDecoder, false)(response("", ` + "`" + `{"id":"valid"}` + "`" + `)) + require.NoError(t, err) + require.NotNil(t, result) + + result, err = DecodeFixedResponse(goahttp.ResponseDecoder, false)(response("", ` + "`" + `{"id":"x"}` + "`" + `)) + require.Nil(t, result) + require.ErrorContains(t, err, "id") +} + +func TestDynamicCollectionUsesSelectedView(t *testing.T) { + result, err := DecodeDynamicCollectionResponse(goahttp.ResponseDecoder, false)( + response("tiny", ` + "`" + `[{"id":"valid"}]` + "`" + `), + ) + require.NoError(t, err) + require.NotNil(t, result) + + result, err = DecodeDynamicCollectionResponse(goahttp.ResponseDecoder, false)( + response("default", ` + "`" + `[{"id":"valid"}]` + "`" + `), + ) + require.Nil(t, result) + require.ErrorContains(t, err, "details") +} + +func TestMixedOrdinaryResponseUsesSelectedView(t *testing.T) { + tests := []struct { + name string + view string + body string + wantError string + }{ + {name: "valid tiny", view: "tiny", body: ` + "`" + `{"id":"valid"}` + "`" + `}, + {name: "valid default", body: ` + "`" + `{"id":"valid","details":{"name":"ready"}}` + "`" + `}, + {name: "default requires nested object", body: ` + "`" + `{"id":"valid"}` + "`" + `, wantError: "details"}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + result, err := DecodeMixedResponse(goahttp.ResponseDecoder, false)(response(test.view, test.body)) + if test.wantError == "" { + require.NoError(t, err) + require.NotNil(t, result) + return + } + require.Nil(t, result) + require.ErrorContains(t, err, test.wantError) + }) + } +} + +func TestDynamicWebSocketUsesSelectedView(t *testing.T) { + tests := []struct { + name string + view string + body string + wantError string + }{ + {name: "valid tiny", view: "tiny", body: ` + "`" + `{"id":"valid"}` + "`" + `}, + {name: "valid default", body: ` + "`" + `{"id":"valid","details":{"name":"ready"}}` + "`" + `}, + {name: "default requires nested object", body: ` + "`" + `{"id":"valid"}` + "`" + `, wantError: "details"}, + {name: "tiny validates selected field", view: "tiny", body: ` + "`" + `{"id":"x"}` + "`" + `, wantError: "id"}, + {name: "unknown view", view: "unknown", body: "{", wantError: "view"}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + stream := &StreamDynamicClientStream{ + conn: webSocketConnection(t, test.body), + view: test.view, + } + result, err := stream.Recv() + if test.wantError == "" { + require.NoError(t, err) + require.NotNil(t, result) + return + } + require.Nil(t, result) + require.ErrorContains(t, err, test.wantError) + }) + } +} + +func TestFixedWebSocketKeepsFixedView(t *testing.T) { + stream := &StreamFixedClientStream{conn: webSocketConnection(t, ` + "`" + `{"id":"valid"}` + "`" + `)} + result, err := stream.Recv() + require.NoError(t, err) + require.NotNil(t, result) + + stream = &StreamFixedClientStream{conn: webSocketConnection(t, ` + "`" + `{"id":"x"}` + "`" + `)} + result, err = stream.Recv() + require.Nil(t, result) + require.ErrorContains(t, err, "id") +} + +func TestExplicitBodyWebSocketKeepsOneBodyShape(t *testing.T) { + stream := &StreamExplicitClientStream{ + conn: webSocketConnection(t, ` + "`" + `{"id":"valid"}` + "`" + `), + } + result, err := stream.Recv() + require.NoError(t, err) + require.NotNil(t, result) + + stream = &StreamExplicitClientStream{ + conn: webSocketConnection(t, ` + "`" + `{"id":"x"}` + "`" + `), + view: "tiny", + } + result, err = stream.Recv() + require.Nil(t, result) + require.ErrorContains(t, err, "id") + + stream = &StreamExplicitClientStream{ + conn: webSocketConnection(t, ` + "`" + `{"id":"valid"}` + "`" + `), + view: "unknown", + } + result, err = stream.Recv() + require.Nil(t, result) + require.ErrorContains(t, err, "view") + require.ErrorContains(t, err, "unknown") +} + +func TestSelectedFieldResponseKeepsOneBodyShape(t *testing.T) { + decode := DecodeSelectedResponse(goahttp.ResponseDecoder, false) + result, err := decode(response("", ` + "`" + `"valid"` + "`" + `)) + require.NoError(t, err) + require.NotNil(t, result) + + result, err = decode(response("tiny", ` + "`" + `"valid"` + "`" + `)) + require.NoError(t, err) + require.NotNil(t, result) + + result, err = decode(response("tiny", ` + "`" + `"x"` + "`" + `)) + require.Nil(t, result) + require.ErrorContains(t, err, "id") + + result, err = decode(response("unknown", ` + "`" + `"valid"` + "`" + `)) + require.Nil(t, result) + require.ErrorContains(t, err, "view") + require.ErrorContains(t, err, "unknown") +} + +func TestOrdinaryResponseStillValidatesFullBody(t *testing.T) { + decode := DecodeOrdinaryResponse(goahttp.ResponseDecoder, false) + result, err := decode(response("", ` + "`" + `{"id":"valid"}` + "`" + `)) + require.Nil(t, result) + require.ErrorContains(t, err, "details") +} + +func response(view, body string) *http.Response { + response := &http.Response{ + StatusCode: http.StatusOK, + Header: make(http.Header), + Body: io.NopCloser(strings.NewReader(body)), + } + if view != "" { + response.Header.Set("goa-view", view) + } + return response +} + +func webSocketConnection(t *testing.T, body string) *websocket.Conn { + t.Helper() + serverErr := make(chan error, 1) + server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + connection, err := (&websocket.Upgrader{}).Upgrade(writer, request, nil) + if err != nil { + serverErr <- err + return + } + writeErr := connection.WriteMessage(websocket.TextMessage, []byte(body)) + serverErr <- errors.Join(writeErr, connection.Close()) + })) + t.Cleanup(server.Close) + + url := "ws" + strings.TrimPrefix(server.URL, "http") + connection, _, err := websocket.DefaultDialer.Dial(url, nil) + require.NoError(t, err) + require.NoError(t, <-serverErr) + t.Cleanup(func() { + require.NoError(t, connection.Close()) + }) + return connection +} +` diff --git a/http/codegen/viewed_result_constructor_compatibility_test.go b/http/codegen/viewed_result_constructor_compatibility_test.go new file mode 100644 index 0000000000..e1e24ecaee --- /dev/null +++ b/http/codegen/viewed_result_constructor_compatibility_test.go @@ -0,0 +1,79 @@ +// This file verifies that viewed HTTP response constructors keep their +// released names while sharing the package name plan with generated callers. +package codegen + +import ( + "testing" + + "github.com/stretchr/testify/require" + + "goa.design/goa/v3/codegen" + "goa.design/goa/v3/codegen/service" + "goa.design/goa/v3/codegen/testutil" + "goa.design/goa/v3/dsl" + "goa.design/goa/v3/eval" + "goa.design/goa/v3/expr" +) + +func TestViewedResultConstructorsKeepReleasedNames(t *testing.T) { + code := renderClientTypesCode(t, viewedResultConstructorCompatibilityDSL) + + testutil.AssertGo(t, "testdata/golden/viewed_result_constructor_names.go.golden", code) + require.Contains(t, code, "func NewArchiveMediaViewOK(") + require.Contains(t, code, "func NewReadArchiveMediaOK(") + require.NotContains(t, code, "NewArchiveArchiveMediaOK") +} + +func TestViewedResultConstructorCallsUsePlannedName(t *testing.T) { + root := expr.RunDSL(t, viewedResultConstructorCompatibilityDSL) + generation, err := codegen.NewGeneration("generated.local/gen", []eval.Root{root}) + require.NoError(t, err) + servicePlan, err := service.NewPlan(root, generation, expr.NewExampleGenerator(root.API.RandomizerFactory)) + require.NoError(t, err) + viewsPackage, err := generation.ClaimPackage("generated.local/gen/archiver/views") + require.NoError(t, err) + require.NoError(t, viewsPackage.DeclareName(codegen.NewExactName(codegen.NameType, "ArchiveMediaView"))) + + plans, err := NewPlans(generation, PlanInput{Root: root, Service: servicePlan}) + require.NoError(t, err) + require.NoError(t, generation.Freeze()) + require.NoError(t, servicePlan.Link()) + require.NoError(t, plans[0].Link()) + + archive := plans[0].services.Get("archiver").Endpoint("archive") + constructor := archive.Result.Responses[0].ResultInit.Declaration + require.Equal(t, "NewArchiveMediaView2OK", constructor.Name()) + require.Equal(t, constructor.Name(), archive.Result.Responses[0].ResultInit.Name) + require.Contains(t, renderedFiles(t, plans[0].ClientTypeFiles()), "func NewArchiveMediaView2OK(") + require.Contains(t, renderedFiles(t, plans[0].ClientFiles()), "NewArchiveMediaView2OK(") +} + +// viewedResultConstructorCompatibilityDSL defines one result used by a method +// whose name overlaps the result name and one whose name does not. +func viewedResultConstructorCompatibilityDSL() { + media := dsl.ResultType("application/vnd.archiver.media", func() { + dsl.TypeName("ArchiveMedia") + dsl.Attributes(func() { + dsl.Attribute("status", dsl.Int) + dsl.Required("status") + }) + dsl.View("default", func() { + dsl.Attribute("status") + }) + }) + + dsl.Service("archiver", func() { + dsl.Method("archive", func() { + dsl.Result(media) + dsl.HTTP(func() { + dsl.POST("/archive") + }) + }) + dsl.Method("read", func() { + dsl.Result(media) + dsl.HTTP(func() { + dsl.GET("/archive") + }) + }) + }) +} diff --git a/http/codegen/viewed_sse_test.go b/http/codegen/viewed_sse_test.go new file mode 100644 index 0000000000..7d2c75039c --- /dev/null +++ b/http/codegen/viewed_sse_test.go @@ -0,0 +1,418 @@ +// This file verifies that an HTTP server-sent event encodes and decodes the +// body selected for its result view. +package codegen + +import ( + "path" + "strings" + "testing" + + "github.com/stretchr/testify/require" + + "goa.design/goa/v3/codegen" + "goa.design/goa/v3/codegen/service" + "goa.design/goa/v3/dsl" + "goa.design/goa/v3/eval" + "goa.design/goa/v3/expr" +) + +// TestViewedSSEServerLocksFirstView verifies that a request-scoped stream +// rejects a later representation before encoding a body under the first view. +func TestViewedSSEServerLocksFirstView(t *testing.T) { + root := expr.RunDSL(t, viewedSSEDSL) + plan := linkedHTTPPlanForRoot(t, root) + code := renderedFile(t, plan.ServerFiles()) + + require.Contains(t, code, `if s.sentView != "" && view != s.sentView`) + require.Contains(t, code, `goa.InvalidEnumValueError("view", view, []any{s.sentView})`) + start := strings.LastIndex(code, `s.start(view)`) + require.NotEqual(t, -1, start) + require.Less(t, + strings.Index(code, `if s.sentView != "" && view != s.sentView`), + start, + ) + require.Less(t, strings.Index(code, "res := "), start) + require.Less(t, strings.Index(code, "body := "), start) + require.Less(t, strings.Index(code, `s.sentView = view`), start) +} + +// TestViewedSSEClientReconstructsCollections verifies collection events decode +// the selected body, run its constructor and validator, and return the service +// method's result type. +func TestViewedSSEClientReconstructsCollections(t *testing.T) { + root := expr.RunDSL(t, viewedSSECollectionDSL) + plan := linkedHTTPPlanForRoot(t, root) + code := renderedFile(t, plan.ClientFiles()) + + require.Contains(t, code, "switch view {") + require.Contains(t, code, "Decode(&body)") + require.Contains(t, code, "projected := New") + require.Contains(t, code, "views.Validate") + require.Contains(t, code, "result := viewedssecollection.New") + require.NotContains(t, code, `partial_sse_parse`) +} + +// TestViewedSSEUsesConfiguredDataField verifies the server encodes and the +// client decodes only the result field selected as the event data. +func TestViewedSSEUsesConfiguredDataField(t *testing.T) { + root := expr.RunDSL(t, viewedSSEDataFieldDSL) + plan := linkedHTTPPlanForRoot(t, root) + client := renderedFile(t, plan.ClientFiles()) + server := renderedFile(t, plan.ServerFiles()) + + require.Contains(t, client, "value := dataContent") + require.Contains(t, client, "body.Data = &value") + require.Contains(t, client, "projected := New") + require.Contains(t, client, "views.Validate") + require.Contains(t, server, "data = string(body.Data)") + require.NotContains(t, server, "var payload any") +} + +// TestViewedSSERebuildsRequiredResponseFields checks that the client reads the +// event id, event type, and data before it calls the generated result +// constructor and validator. +func TestViewedSSERebuildsRequiredResponseFields(t *testing.T) { + root := expr.RunDSL(t, viewedSSERequiredFieldsDSL) + plan := linkedHTTPPlanForRoot(t, root) + client := renderedFile(t, plan.ClientFiles()) + service := plan.services.Get("Viewed SSE Required") + require.Len(t, service.Endpoints, 1) + require.Len(t, service.Endpoints[0].Result.Responses, 1) + representations := service.Endpoints[0].Result.Responses[0].ViewedRepresentations + require.Len(t, representations, 3) + for _, representation := range representations { + require.NotNil(t, representation.ClientBody) + require.NotNil(t, representation.ClientBody.ValidatorDeclaration) + require.NotEmpty(t, representation.ClientBody.ValidationTarget) + } + + for _, assignment := range []string{ + "idContent = s.lastEventID", + "eventContent = string(value)", + "body.ID = &idContent", + "body.Kind = &eventContent", + "value := dataContent", + "body.Data = &value", + } { + require.Contains(t, client, assignment) + require.Less(t, strings.Index(client, assignment), strings.Index(client, "projected := New")) + } + require.NotContains(t, client, "result.ID =") + require.NotContains(t, client, "result.Kind =") + require.Less(t, strings.Index(client, "projected := New"), strings.Index(client, "views.Validate")) +} + +// TestViewedClientsUseAssignedValidator checks that each HTTP client calls the +// validator name chosen for the service views package when another declaration +// requests the validator's preferred spelling. +func TestViewedClientsUseAssignedValidator(t *testing.T) { + root := expr.RunDSL(t, viewedClientValidatorCollisionDSL) + generation, err := codegen.NewGeneration("generated.local/gen", []eval.Root{root}) + require.NoError(t, err) + servicePlan, err := service.NewPlan(root, generation, expr.NewExampleGenerator(root.API.RandomizerFactory)) + require.NoError(t, err) + viewsPackage, err := generation.ClaimPackage(path.Join(generation.GenPkg(), "viewed_validator", "views")) + require.NoError(t, err) + preferred := "ValidateViewedClientCollision" + require.NoError(t, viewsPackage.DeclareName(codegen.NewExactName(codegen.NameFunction, preferred))) + plans, err := NewPlans(generation, PlanInput{Root: root, Service: servicePlan}) + require.NoError(t, err) + require.NoError(t, generation.Freeze()) + require.NoError(t, servicePlan.Link()) + require.NoError(t, plans[0].Link()) + + var declaration *codegen.NameDeclaration + for _, endpoint := range plans[0].services.Get("Viewed Validator").Endpoints { + if endpoint.Method.ViewedResult != nil { + declaration = endpoint.Method.ViewedResult.Validate.Declaration + break + } + } + require.NotNil(t, declaration) + require.NotEqual(t, preferred, declaration.Name()) + client := renderedFiles(t, plans[0].ClientFiles()) + require.GreaterOrEqual(t, strings.Count(client, "."+declaration.Name()+"("), 3) + require.NotContains(t, client, "."+preferred+"(") +} + +// TestViewedSSESoleViewIsFixed checks that the generated service supplies its +// only legal view, so the HTTP response needs no view selector. +func TestViewedSSESoleViewIsFixed(t *testing.T) { + plan := linkedHTTPPlan(t, viewedSSESoleViewDSL) + viewed, ok := plan.ViewedResult("Viewed SSE Sole View", "Watch") + require.True(t, ok) + + require.False(t, viewed.Variable) + require.Equal(t, expr.DefaultView, viewed.FixedView) + require.Len(t, viewed.Representations, 1) +} + +// TestViewedResultConstructorsUsePackageDeclarations verifies Go-equivalent +// view names receive distinct stable functions and every definition and call +// uses the same package-owned declaration. +func TestViewedResultConstructorsUsePackageDeclarations(t *testing.T) { + root := expr.RunDSL(t, viewedSSEConstructorCollisionDSL) + generation, err := codegen.NewGeneration("generated.local/gen", []eval.Root{root}) + require.NoError(t, err) + servicePlan, err := service.NewPlan(root, generation, expr.NewExampleGenerator(root.API.RandomizerFactory)) + require.NoError(t, err) + plans, err := NewPlans(generation, PlanInput{Root: root, Service: servicePlan}) + require.NoError(t, err) + require.NoError(t, generation.Freeze()) + require.NoError(t, servicePlan.Link()) + require.NoError(t, plans[0].Link()) + + endpoint := root.API.HTTP.Services[0].HTTPEndpoints[0] + response := endpoint.Responses[0] + retained, ok := plans[0].ViewedResult("Viewed SSE Collision", "Watch") + require.True(t, ok) + require.GreaterOrEqual(t, len(retained.Representations), 2) + names := make(map[string]struct{}, len(retained.Representations)) + collidingNames := make(map[string]string, 2) + definitions := renderedFiles(t, plans[0].ClientTypeFiles()) + calls := renderedFile(t, plans[0].ClientFiles()) + for _, representation := range retained.Representations { + declaration := plans[0].constructors[viewedConstructorKey{ + endpoint: endpoint, + response: response, + view: representation.View, + }] + require.Same(t, declaration, representation.ResultInit.Declaration) + name := declaration.Name() + names[name] = struct{}{} + if representation.View == "foo-bar" || representation.View == "foo bar" { + collidingNames[representation.View] = name + } + require.Contains(t, definitions, "func "+name+"(") + require.Contains(t, calls, name+"(") + } + require.Len(t, names, len(retained.Representations)) + require.NotEqual(t, collidingNames["foo-bar"], collidingNames["foo bar"]) +} + +// renderedFile renders the generated sse.go file. +func renderedFile(t *testing.T, files []*codegen.File) string { + t.Helper() + for _, file := range files { + if strings.HasSuffix(file.Path, "sse.go") { + return renderedFiles(t, []*codegen.File{file}) + } + } + t.Error("generated sse.go file was not planned") + return "" +} + +// renderedFiles renders every planned section so tests compare generated Go +// definitions and calls rather than template text. +func renderedFiles(t *testing.T, files []*codegen.File) string { + t.Helper() + var rendered strings.Builder + for _, file := range files { + for _, section := range file.SectionTemplates[1:] { + rendered.WriteString(codegen.SectionCode(t, section)) + } + } + return rendered.String() +} + +// viewedSSEType defines two legal branches with different body shapes. +func viewedSSEType(name string) *expr.ResultTypeExpr { + return dsl.ResultType("application/vnd."+name, func() { + dsl.TypeName(name) + dsl.Attribute("id", dsl.String) + dsl.Attribute("detail", dsl.String) + dsl.Required("id") + dsl.View("summary", func() { dsl.Attribute("id") }) + dsl.View("detailed", func() { + dsl.Attribute("id") + dsl.Attribute("detail") + }) + }) +} + +// viewedSSEDSL defines a variable-view object stream. +func viewedSSEDSL() { + event := viewedSSEType("ViewedSSEEvent") + dsl.Service("Viewed SSE", func() { + dsl.Method("Watch", func() { + dsl.StreamingResult(event) + dsl.HTTP(func() { + dsl.GET("/watch") + dsl.ServerSentEvents() + }) + }) + }) +} + +// viewedSSECollectionDSL defines a variable-view collection stream. +func viewedSSECollectionDSL() { + event := viewedSSEType("ViewedSSECollectionEvent") + dsl.Service("Viewed SSE Collection", func() { + dsl.Method("Watch", func() { + dsl.StreamingResult(dsl.CollectionOf(event)) + dsl.HTTP(func() { + dsl.GET("/watch") + dsl.ServerSentEvents() + }) + }) + }) +} + +// viewedSSEDataFieldDSL defines a variable-view stream whose data line carries +// one configured result field. +func viewedSSEDataFieldDSL() { + event := dsl.ResultType("application/vnd.viewed-sse-data", func() { + dsl.TypeName("ViewedSSEData") + dsl.Attribute("data", dsl.String) + dsl.Attribute("detail", dsl.String) + dsl.Required("data") + dsl.View("summary", func() { dsl.Attribute("data") }) + dsl.View("detailed", func() { + dsl.Attribute("data") + dsl.Attribute("detail") + }) + }) + dsl.Service("Viewed SSE Data", func() { + dsl.Method("Watch", func() { + dsl.StreamingResult(event) + dsl.HTTP(func() { + dsl.GET("/watch") + dsl.ServerSentEvents("data") + }) + }) + }) +} + +// viewedSSEPrimitiveAliasDataFieldDSL defines a viewed stream whose data line +// carries a required field declared with a named string type. +func viewedSSEPrimitiveAliasDataFieldDSL() { + text := dsl.Type("ViewedEventText", dsl.String) + event := dsl.ResultType("application/vnd.viewed-sse-alias-data", func() { + dsl.TypeName("ViewedSSEAliasData") + dsl.Attribute("data", text) + dsl.Attribute("detail", dsl.String) + dsl.Required("data") + dsl.View("summary", func() { dsl.Attribute("data") }) + dsl.View("detailed", func() { + dsl.Attribute("data") + dsl.Attribute("detail") + }) + }) + dsl.Service("Viewed SSE Alias Data", func() { + dsl.Method("Watch", func() { + dsl.StreamingResult(event) + dsl.HTTP(func() { + dsl.GET("/watch") + dsl.ServerSentEvents("data") + }) + }) + }) +} + +// viewedSSERequiredFieldsDSL maps required result fields across every input a +// streamed HTTP response can carry. +func viewedSSERequiredFieldsDSL() { + event := dsl.ResultType("application/vnd.viewed-sse-required", func() { + dsl.TypeName("ViewedSSERequired") + dsl.Attribute("id", dsl.String) + dsl.Attribute("kind", dsl.String) + dsl.Attribute("data", dsl.String) + dsl.Attribute("hidden", dsl.String) + dsl.Required("id", "kind", "data", "hidden") + for _, name := range []string{"summary", "detailed"} { + dsl.View(name, func() { + dsl.Attribute("id") + dsl.Attribute("kind") + dsl.Attribute("data") + }) + } + }) + dsl.Service("Viewed SSE Required", func() { + dsl.Method("Watch", func() { + dsl.StreamingResult(event) + dsl.HTTP(func() { + dsl.GET("/watch") + dsl.ServerSentEvents("data", func() { + dsl.SSEEventID("id") + dsl.SSEEventType("kind") + }) + }) + }) + }) +} + +// viewedClientValidatorCollisionDSL exposes one viewed type through unary, +// server-sent event, and WebSocket responses. +func viewedClientValidatorCollisionDSL() { + result := dsl.ResultType("application/vnd.viewed-client-collision", func() { + dsl.TypeName("ViewedClientCollision") + dsl.Attribute("id", dsl.String) + dsl.Required("id") + dsl.View("summary", func() { dsl.Attribute("id") }) + dsl.View("detailed", func() { dsl.Attribute("id") }) + }) + dsl.Service("Viewed Validator", func() { + dsl.Method("Read", func() { + dsl.Result(result) + dsl.HTTP(func() { dsl.GET("/read") }) + }) + dsl.Method("Watch", func() { + dsl.StreamingResult(result) + dsl.HTTP(func() { + dsl.GET("/watch") + dsl.ServerSentEvents() + }) + }) + dsl.Method("Socket", func() { + dsl.StreamingResult(result) + dsl.HTTP(func() { dsl.GET("/socket") }) + }) + }) +} + +// viewedSSESoleViewDSL defines only Goa's default view. +func viewedSSESoleViewDSL() { + event := dsl.ResultType("application/vnd.viewed-sse-sole-view", func() { + dsl.TypeName("ViewedSSESoleView") + dsl.Attribute("id", dsl.String) + dsl.Required("id") + dsl.View(expr.DefaultView, func() { dsl.Attribute("id") }) + }) + dsl.Service("Viewed SSE Sole View", func() { + dsl.Method("Watch", func() { + dsl.StreamingResult(event) + dsl.HTTP(func() { + dsl.GET("/watch") + dsl.ServerSentEvents() + }) + }) + }) +} + +// linkedHTTPPlan runs the same planning steps as the generator and returns the +// HTTP plan after all service and package names are available. +func linkedHTTPPlan(t *testing.T, design func()) *Plan { + t.Helper() + root := expr.RunDSL(t, design) + return linkedHTTPPlanForRoot(t, root) +} + +// viewedSSEConstructorCollisionDSL defines two view names that Goify maps to +// the same preferred constructor spelling. +func viewedSSEConstructorCollisionDSL() { + event := dsl.ResultType("application/vnd.viewed-sse-collision", func() { + dsl.TypeName("ViewedSSECollisionEvent") + dsl.Attribute("id", dsl.String) + dsl.View("foo-bar", func() { dsl.Attribute("id") }) + dsl.View("foo bar", func() { dsl.Attribute("id") }) + }) + dsl.Service("Viewed SSE Collision", func() { + dsl.Method("Watch", func() { + dsl.StreamingResult(event) + dsl.HTTP(func() { + dsl.GET("/watch") + dsl.ServerSentEvents() + }) + }) + }) +} diff --git a/http/codegen/websocket.go b/http/codegen/websocket.go index 3ff14dd79c..83e472e0c5 100644 --- a/http/codegen/websocket.go +++ b/http/codegen/websocket.go @@ -1,21 +1,34 @@ +// This file builds the values used to write WebSocket client and server files +// for streaming HTTP methods. package codegen import ( "fmt" "path/filepath" "slices" - "strings" "goa.design/goa/v3/codegen" "goa.design/goa/v3/expr" ) type ( + // connConfigurerData gives WebSocket code the type and constructor names for + // either the client or server package. + connConfigurerData struct { + *ServiceData + Declaration *codegen.NameDeclaration + InitDeclaration *codegen.NameDeclaration + } + // WebSocketData contains the data needed to render struct type that // implements the server and client stream interfaces. WebSocketData struct { - // VarName is the name of the struct. + // VarName is the stream implementation type name kept for existing plugins. + // + // Deprecated: Use VarDeclaration.Name() after planning so name collisions are handled. VarName string + // VarDeclaration is the generated Go type name used by the stream implementation. + VarDeclaration *codegen.NameDeclaration // Type is type of the stream (server or client). Type string // Interface is the fully qualified name of the interface that @@ -65,6 +78,9 @@ type ( // MustClose indicates whether to generate the Close() function // for the stream. MustClose bool + // SelectClientBodyByView is true when the client stream must choose a + // response body, validator, and constructor from its selected view. + SelectClientBodyByView bool // PkgName is the service package name. PkgName string // Kind is the kind of the stream (payload, result or @@ -90,7 +106,7 @@ func (sds *ServicesData) initWebSocketData(ed *EndpointData, e *expr.HTTPEndpoin ) md := ed.Method svc := sd.Service - svcctx := serviceContext(sd.Service.PkgName, sd.Service.Scope) + svcctx := sds.serviceTypeContext(sd, "server").Enter(e.MethodExpr.StreamingPayload) svrSendTypeName := ed.Result.Name svrSendTypeRef := ed.Result.Ref svrSendDesc := fmt.Sprintf("%s streams instances of %q to the %q endpoint websocket connection.", md.ServerStream.SendName, svrSendTypeName, md.Name) @@ -99,9 +115,10 @@ func (sds *ServicesData) initWebSocketData(ed *EndpointData, e *expr.HTTPEndpoin cliRecvWithContextDesc := fmt.Sprintf("%s reads instances of %q from the %q endpoint websocket connection with context.", md.ClientStream.RecvWithContextName, svrSendTypeName, md.Name) if e.MethodExpr.Stream == expr.ClientStreamKind || e.MethodExpr.Stream == expr.BidirectionalStreamKind { streamBody := sd.bodies.streaming(e) - svrRecvTypeName = sd.Scope.GoFullTypeName(e.MethodExpr.StreamingPayload, svc.PkgName) - svrRecvTypeRef = sd.Scope.GoFullTypeRef(e.MethodExpr.StreamingPayload, svc.PkgName) - svrPayload = sds.buildRequestBodyType(streamBody, e.MethodExpr.StreamingPayload, e, true, sd) + streamOwner := expr.MethodStreamingPayloadExampleIdentity(e.MethodExpr) + svrRecvTypeName = svcctx.Scope.Name(e.MethodExpr.StreamingPayload, svcctx.Pkg(e.MethodExpr.StreamingPayload), false, true) + svrRecvTypeRef = svcctx.Scope.Ref(e.MethodExpr.StreamingPayload, svcctx.Pkg(e.MethodExpr.StreamingPayload)) + svrPayload = sds.buildRequestBodyType(streamBody, e.MethodExpr.StreamingPayload, e, wireStreamPayload, true, sd, streamOwner, streamOwner) if needInit(e.MethodExpr.StreamingPayload.Type) { body := streamBody.Type // generate constructor function to transform request body, @@ -113,15 +130,11 @@ func (sds *ServicesData) initWebSocketData(ed *EndpointData, e *expr.HTTPEndpoin serverCode string err error ) - n := codegen.Goify(e.MethodExpr.Name, true) - p := codegen.Goify(svrPayload.Name, true) - // Raw payload object has type name prefixed with endpoint name. No need to - // prefix the type name again. - if strings.HasPrefix(p, n) { - name = fmt.Sprintf("New%s", p) - } else { - name = fmt.Sprintf("New%s%s", n, p) + declaration := sds.streamConstructors[e] + if declaration == nil { + panic(fmt.Sprintf("streaming payload constructor for %s.%s was not submitted", svc.Name, e.Name())) } + name = declaration.Name() desc = fmt.Sprintf("%s builds a %s service %s endpoint payload.", name, svc.Name, e.MethodExpr.Name) if body != expr.Empty { ref := "body" @@ -131,7 +144,7 @@ func (sds *ServicesData) initWebSocketData(ed *EndpointData, e *expr.HTTPEndpoin var svcode string if ut, ok := body.(expr.UserType); ok { if val := ut.Attribute().Validation; val != nil { - httpctx := httpContext(sd.Scope, true, true) + httpctx := jsonBodyContext(sd.serverWireTypes, sd.serverWireTypes.scope, true, true) svcode = codegen.ValidationCode(ut.Attribute(), ut, httpctx, true, expr.IsAlias(ut), false, "body") } } @@ -140,21 +153,20 @@ func (sds *ServicesData) initWebSocketData(ed *EndpointData, e *expr.HTTPEndpoin AttributeData: &AttributeData{ Name: "payload", VarName: "body", - TypeName: sd.Scope.GoTypeName(streamBody), - TypeRef: sd.Scope.GoTypeRef(streamBody), + TypeName: svrPayload.VarName, + TypeRef: svrPayload.Ref, Type: streamBody.Type, Required: true, - // The example has always been computed from the - // request body, not the streaming body. - Example: sd.bodies.request(e).Example(sds.Root.API.ExampleGenerator), + Example: sds.Example(streamBody, streamOwner), Validate: svcode, }, }} } if body != expr.Empty { var helpers []*codegen.TransformFunctionData - httpctx := httpContext(sd.Scope, true, true) - serverCode, helpers, err = marshal(streamBody, e.MethodExpr.StreamingPayload, "body", "v", httpctx, svcctx) + httpctx := jsonBodyContext(sd.serverWireTypes, sd.serverWireTypes.scope, true, true) + transforms := sd.transforms.requests[clientBodyConstructorKey{endpoint: e, role: wireStreamPayload}] + serverCode, helpers, err = sd.serverWireTypes.renderTransform(transforms.serverDecode, streamBody, "body", "v", httpctx, svcctx) if err == nil { sd.ServerTransformHelpers = codegen.AppendHelpers(sd.ServerTransformHelpers, helpers) } @@ -163,21 +175,18 @@ func (sds *ServicesData) initWebSocketData(ed *EndpointData, e *expr.HTTPEndpoin panic(err) // bug } svrPayload.Init = &InitData{ + Declaration: declaration, Name: name, Description: desc, ServerArgs: serverArgs, - ReturnTypeName: svc.Scope.GoFullTypeName(e.MethodExpr.StreamingPayload, svc.PkgName), - ReturnTypeRef: svc.Scope.GoFullTypeRef(e.MethodExpr.StreamingPayload, svc.PkgName), + ReturnTypeName: svcctx.Scope.Name(e.MethodExpr.StreamingPayload, svcctx.Pkg(e.MethodExpr.StreamingPayload), false, true), + ReturnTypeRef: svcctx.Scope.Ref(e.MethodExpr.StreamingPayload, svcctx.Pkg(e.MethodExpr.StreamingPayload)), ReturnIsStruct: expr.IsObject(e.MethodExpr.StreamingPayload.Type), - ReturnTypePkg: svc.PkgName, + ReturnTypePkg: svcctx.Pkg(e.MethodExpr.StreamingPayload), ServerCode: serverCode, } } - cliPayload = sds.buildRequestBodyType(streamBody, e.MethodExpr.StreamingPayload, e, false, sd) - if cliPayload != nil { - sd.ClientTypeNames[cliPayload.Name] = struct{}{} - sd.ServerTypeNames[cliPayload.Name] = struct{}{} - } + cliPayload = sds.buildRequestBodyType(streamBody, e.MethodExpr.StreamingPayload, e, wireStreamPayload, false, sd, streamOwner, streamOwner) if e.MethodExpr.Stream == expr.ClientStreamKind { svrSendDesc = fmt.Sprintf("%s streams instances of %q to the %q endpoint websocket connection and closes the connection.", md.ServerStream.SendName, svrSendTypeName, md.Name) svrSendWithContextDesc = fmt.Sprintf("%s streams instances of %q to the %q endpoint websocket connection with context and closes the connection.", md.ServerStream.SendWithContextName, svrSendTypeName, md.Name) @@ -214,93 +223,77 @@ func (sds *ServicesData) initWebSocketData(ed *EndpointData, e *expr.HTTPEndpoin MustClose: md.ServerStream.MustClose, } ed.ClientWebSocket = &WebSocketData{ - VarName: md.ClientStream.VarName, - Interface: fmt.Sprintf("%s.%s", svc.PkgName, md.ClientStream.Interface), - Endpoint: ed, - Payload: cliPayload, - Response: ed.Result.Responses[0], - PkgName: svc.PkgName, - Type: "client", - Kind: md.ClientStream.Kind, - SendName: md.ClientStream.SendName, - SendDesc: cliSendDesc, - SendWithContextName: md.ClientStream.SendWithContextName, - SendWithContextDesc: cliSendWithContextDesc, - SendTypeName: svrRecvTypeName, - SendTypeRef: svrRecvTypeRef, - RecvName: md.ClientStream.RecvName, - RecvDesc: cliRecvDesc, - RecvWithContextName: md.ClientStream.RecvWithContextName, - RecvWithContextDesc: cliRecvWithContextDesc, - RecvTypeName: svrSendTypeName, - RecvTypeRef: svrSendTypeRef, - MustClose: md.ClientStream.MustClose, + VarName: md.ClientStream.VarName, + Interface: fmt.Sprintf("%s.%s", svc.PkgName, md.ClientStream.Interface), + Endpoint: ed, + Payload: cliPayload, + Response: ed.Result.Responses[0], + PkgName: svc.PkgName, + Type: "client", + Kind: md.ClientStream.Kind, + SendName: md.ClientStream.SendName, + SendDesc: cliSendDesc, + SendWithContextName: md.ClientStream.SendWithContextName, + SendWithContextDesc: cliSendWithContextDesc, + SendTypeName: svrRecvTypeName, + SendTypeRef: svrRecvTypeRef, + RecvName: md.ClientStream.RecvName, + RecvDesc: cliRecvDesc, + RecvWithContextName: md.ClientStream.RecvWithContextName, + RecvWithContextDesc: cliRecvWithContextDesc, + RecvTypeName: svrSendTypeName, + RecvTypeRef: svrSendTypeRef, + MustClose: md.ClientStream.MustClose, + SelectClientBodyByView: !e.IsJSONRPC() && len(ed.Result.Responses[0].ViewedRepresentations) > 0, } } // websocketServerFile returns the file implementing the WebSocket server // streaming implementation if any. -func websocketServerFile(genpkg string, svc *expr.HTTPServiceExpr, services *ServicesData) *codegen.File { +func websocketServerFile(svc *expr.HTTPServiceExpr, services *ServicesData) *codegen.File { data := services.Get(svc.Name()) if !HasWebSocket(data) { return nil } svcName := data.Service.PathName + outputPath := filepath.Join(codegen.Gendir, "http", svcName, "server", "websocket.go") + outputPackage := generatedFileOutputPackage(services, outputPath) + data = serviceDataForOutput(data, services, outputPackage) title := fmt.Sprintf("%s WebSocket server streaming", svc.Name()) - imports := []*codegen.ImportSpec{ - {Path: "context"}, - {Path: "io"}, - {Path: "net/http"}, - {Path: "sync"}, - {Path: "time"}, - {Path: "github.com/gorilla/websocket"}, - codegen.GoaImport(""), - codegen.GoaNamedImport("http", "goahttp"), - {Path: genpkg + "/" + svcName, Name: data.Service.PkgName}, - } structSections := serverStructWSSections(data) wsSections := serverWSSections(data) sections := make([]*codegen.SectionTemplate, 0, 1+len(structSections)+len(wsSections)) - sections = append(sections, codegen.Header(title, "server", imports)) + sections = append(sections, plannedFileHeader(title, "server", outputPath, services)) sections = append(sections, structSections...) sections = append(sections, wsSections...) return &codegen.File{ - Path: filepath.Join(codegen.Gendir, "http", svcName, "server", "websocket.go"), + Path: outputPath, SectionTemplates: sections, } } -// WebsocketClientFile returns the file implementing the WebSocket client +// websocketClientFile returns the file implementing the WebSocket client // streaming implementation if any. -func WebsocketClientFile(genpkg string, svc *expr.HTTPServiceExpr, services *ServicesData) *codegen.File { +func websocketClientFile(svc *expr.HTTPServiceExpr, services *ServicesData) *codegen.File { data := services.Get(svc.Name()) if !HasWebSocket(data) { return nil } svcName := data.Service.PathName + outputPath := filepath.Join(codegen.Gendir, "http", svcName, "client", "websocket.go") + outputPackage := generatedFileOutputPackage(services, outputPath) + data = serviceDataForOutput(data, services, outputPackage) title := fmt.Sprintf("%s WebSocket client streaming", svc.Name()) - imports := []*codegen.ImportSpec{ - {Path: "context"}, - {Path: "io"}, - {Path: "net/http"}, - {Path: "sync"}, - {Path: "time"}, - {Path: "github.com/gorilla/websocket"}, - codegen.GoaImport(""), - codegen.GoaNamedImport("http", "goahttp"), - {Path: genpkg + "/" + svcName + "/" + "views", Name: data.Service.ViewsPkg}, - {Path: genpkg + "/" + svcName, Name: data.Service.PkgName}, - } structSections := clientStructWSSections(data) wsSections := clientWSSections(data) sections := make([]*codegen.SectionTemplate, 0, 1+len(structSections)+len(wsSections)) - sections = append(sections, codegen.Header(title, "client", imports)) + sections = append(sections, plannedFileHeader(title, "client", outputPath, services)) sections = append(sections, structSections...) sections = append(sections, wsSections...) return &codegen.File{ - Path: filepath.Join(codegen.Gendir, "http", svcName, "client", "websocket.go"), + Path: outputPath, SectionTemplates: sections, } } @@ -308,11 +301,12 @@ func WebsocketClientFile(genpkg string, svc *expr.HTTPServiceExpr, services *Ser // serverStructWSSections return section templates that generate WebSocket // related struct type definitions for the server. func serverStructWSSections(data *ServiceData) []*codegen.SectionTemplate { + configurer := &connConfigurerData{data, data.ServerConnConfigurerDeclaration, data.ServerConnConfigurerInitDeclaration} var sections []*codegen.SectionTemplate sections = append(sections, &codegen.SectionTemplate{ Name: "server-websocket-conn-configurer-struct", Source: httpTemplates.Read(websocketConnConfigurerStructT), - Data: data, + Data: configurer, FuncMap: map[string]any{"isWebSocketEndpoint": IsWebSocketEndpoint}, }) for _, e := range data.Endpoints { @@ -331,11 +325,12 @@ func serverStructWSSections(data *ServiceData) []*codegen.SectionTemplate { // serverWSSections returns section templates that contain server WebSocket // specific code for the given service. func serverWSSections(data *ServiceData) []*codegen.SectionTemplate { + configurer := &connConfigurerData{data, data.ServerConnConfigurerDeclaration, data.ServerConnConfigurerInitDeclaration} var sections []*codegen.SectionTemplate sections = append(sections, &codegen.SectionTemplate{ Name: "server-websocket-conn-configurer-struct-init", Source: httpTemplates.Read(websocketConnConfigurerStructInitT), - Data: data, + Data: configurer, FuncMap: map[string]any{"isWebSocketEndpoint": IsWebSocketEndpoint}, }) for _, e := range data.Endpoints { @@ -346,26 +341,33 @@ func serverWSSections(data *ServiceData) []*codegen.SectionTemplate { Source: httpTemplates.Read(websocketSendT, websocketUpgradeP), Data: e.ServerWebSocket, FuncMap: map[string]any{ - "upgradeParams": upgradeParams, - "viewedServerBody": viewedServerBody, + "upgradeParams": upgradeParams, + "viewedServerBody": viewedServerBody, + "isClientStreamKind": isClientStreamKind, }, }) } switch e.ServerWebSocket.Kind { case expr.ClientStreamKind, expr.BidirectionalStreamKind: sections = append(sections, &codegen.SectionTemplate{ - Name: "server-websocket-recv", - Source: httpTemplates.Read(websocketRecvT, websocketUpgradeP), - Data: e.ServerWebSocket, - FuncMap: map[string]any{"upgradeParams": upgradeParams}, + Name: "server-websocket-recv", + Source: httpTemplates.Read(websocketRecvT, websocketUpgradeP), + Data: e.ServerWebSocket, + FuncMap: map[string]any{ + "upgradeParams": upgradeParams, + "isClientStreamKind": isClientStreamKind, + }, }) } if e.ServerWebSocket.MustClose { sections = append(sections, &codegen.SectionTemplate{ - Name: "server-websocket-close", - Source: httpTemplates.Read(websocketCloseT), - Data: e.ServerWebSocket, - FuncMap: map[string]any{"upgradeParams": upgradeParams}, + Name: "server-websocket-close", + Source: httpTemplates.Read(websocketCloseT, websocketUpgradeP), + Data: e.ServerWebSocket, + FuncMap: map[string]any{ + "upgradeParams": upgradeParams, + "isClientStreamKind": isClientStreamKind, + }, }) } if e.Method.ViewedResult != nil && e.Method.ViewedResult.ViewName == "" { @@ -383,11 +385,12 @@ func serverWSSections(data *ServiceData) []*codegen.SectionTemplate { // clientStructWSSections return section templates that generate WebSocket // related struct type definitions for the client. func clientStructWSSections(data *ServiceData) []*codegen.SectionTemplate { + configurer := &connConfigurerData{data, data.ClientConnConfigurerDeclaration, data.ClientConnConfigurerInitDeclaration} var sections []*codegen.SectionTemplate sections = append(sections, &codegen.SectionTemplate{ Name: "client-websocket-conn-configurer-struct", Source: httpTemplates.Read(websocketConnConfigurerStructT), - Data: data, + Data: configurer, FuncMap: map[string]any{"isWebSocketEndpoint": IsWebSocketEndpoint}, }) for _, e := range data.Endpoints { @@ -405,21 +408,25 @@ func clientStructWSSections(data *ServiceData) []*codegen.SectionTemplate { // clientWSSections returns section templates that contain client WebSocket // specific code for the given service. func clientWSSections(data *ServiceData) []*codegen.SectionTemplate { + configurer := &connConfigurerData{data, data.ClientConnConfigurerDeclaration, data.ClientConnConfigurerInitDeclaration} var sections []*codegen.SectionTemplate sections = append(sections, &codegen.SectionTemplate{ Name: "client-websocket-conn-configurer-struct-init", Source: httpTemplates.Read(websocketConnConfigurerStructInitT), - Data: data, + Data: configurer, FuncMap: map[string]any{"isWebSocketEndpoint": IsWebSocketEndpoint}, }) for _, e := range data.Endpoints { if e.ClientWebSocket != nil { if e.ClientWebSocket.RecvTypeRef != "" { sections = append(sections, &codegen.SectionTemplate{ - Name: "client-websocket-recv", - Source: httpTemplates.Read(websocketRecvT, websocketUpgradeP), - Data: e.ClientWebSocket, - FuncMap: map[string]any{"upgradeParams": upgradeParams}, + Name: "client-websocket-recv", + Source: httpTemplates.Read(websocketRecvT, websocketUpgradeP), + Data: e.ClientWebSocket, + FuncMap: map[string]any{ + "upgradeParams": upgradeParams, + "isClientStreamKind": isClientStreamKind, + }, }) } switch e.ClientWebSocket.Kind { @@ -429,8 +436,9 @@ func clientWSSections(data *ServiceData) []*codegen.SectionTemplate { Source: httpTemplates.Read(websocketSendT, websocketUpgradeP), Data: e.ClientWebSocket, FuncMap: map[string]any{ - "upgradeParams": upgradeParams, - "viewedServerBody": viewedServerBody, + "upgradeParams": upgradeParams, + "viewedServerBody": viewedServerBody, + "isClientStreamKind": isClientStreamKind, }, }) } @@ -460,6 +468,17 @@ func HasWebSocket(sd *ServiceData) bool { return slices.ContainsFunc(sd.Endpoints, IsWebSocketEndpoint) } +// isClientStreamKind reports whether the client finishes sending before it +// receives the server's single result. +func isClientStreamKind(kind expr.StreamKind) bool { + return kind == expr.ClientStreamKind +} + +// isServerStreamKind reports whether the client only receives stream values. +func isServerStreamKind(kind expr.StreamKind) bool { + return kind == expr.ServerStreamKind +} + // IsWebSocketEndpoint returns true if the endpoint defines a streaming payload // or result. func IsWebSocketEndpoint(ed *EndpointData) bool { diff --git a/http/codegen/websocket_golden_test.go b/http/codegen/websocket_golden_test.go index e6ab235778..5ca3b57fb6 100644 --- a/http/codegen/websocket_golden_test.go +++ b/http/codegen/websocket_golden_test.go @@ -12,6 +12,7 @@ import ( "goa.design/goa/v3/codegen/testutil" . "goa.design/goa/v3/dsl" "goa.design/goa/v3/expr" + "goa.design/goa/v3/http/codegen/testdata" ) // renderFileToString renders all sections of a file to a string without writing to disk @@ -39,6 +40,9 @@ func TestWebSocketGoldenFiles(t *testing.T) { {"websocket-server-streaming-object", serverStreamingObjectDSL, "server"}, {"websocket-server-streaming-user-type", serverStreamingUserTypeDSL, "server"}, {"websocket-server-streaming-with-views", serverStreamingWithViewsDSL, "server"}, + {"websocket-server-streaming-with-views-client", serverStreamingWithViewsDSL, "client"}, + {"websocket-server-streaming-collection-with-views-client", testdata.StreamingResultCollectionWithViewsDSL, "client"}, + {"websocket-server-streaming-with-explicit-body-client", serverStreamingWithExplicitBodyDSL, "client"}, // Client Streaming (Payload only) {"websocket-client-streaming-primitive", clientStreamingPrimitiveDSL, "client"}, @@ -73,13 +77,13 @@ func TestWebSocketGoldenFiles(t *testing.T) { for _, c := range cases { t.Run(c.name, func(t *testing.T) { root := expr.RunDSL(t, c.dsl) - services := CreateHTTPServices(root) + plan := linkedHTTPPlanForRoot(t, root) var files []*codegen.File if c.fileType == "server" { - files = ServerFiles("", services) + files = plan.ServerFiles() } else { - files = ClientFiles("", services) + files = plan.ClientFiles() } // Find the websocket.go file @@ -120,11 +124,11 @@ func TestWebSocketGoldenFiles(t *testing.T) { func TestWebSocketTemplateExercise(t *testing.T) { // Run a comprehensive test that should exercise all templates root := expr.RunDSL(t, comprehensiveWebSocketDSL) - services := CreateHTTPServices(root) + plan := linkedHTTPPlanForRoot(t, root) // Generate both server and client files - serverFiles := ServerFiles("", services) - clientFiles := ClientFiles("", services) + serverFiles := plan.ServerFiles() + clientFiles := plan.ClientFiles() // Verify WebSocket files were generated var serverWSFile, clientWSFile *codegen.File @@ -239,6 +243,41 @@ func serverStreamingWithViewsDSL() { }) } +func serverStreamingWithExplicitBodyDSL() { + var UserType = ResultType("User", func() { + Attribute("id", String, func() { + MinLength(3) + }) + Attribute("name", String) + Required("id", "name") + View("default", func() { + Attribute("id") + Attribute("name") + }) + View("tiny", func() { + Attribute("id") + }) + }) + var StreamBody = Type("StreamBody", func() { + Attribute("id", String, func() { + MinLength(3) + }) + Required("id") + }) + + Service("TestService", func() { + Method("StreamUserID", func() { + StreamingResult(UserType) + HTTP(func() { + GET("/stream/user/id") + Response(func() { + Body(StreamBody) + }) + }) + }) + }) +} + func clientStreamingPrimitiveDSL() { Service("TestService", func() { Method("ClientStreamPrimitive", func() { @@ -388,7 +427,9 @@ func bidirectionalStreamingWithViewsDSL() { Service("TestService", func() { Method("BidirectionalWithViews", func() { StreamingPayload(Request) - StreamingResult(Response) + StreamingResult(Response, func() { + View("minimal") + }) HTTP(func() { GET("/bidirectional/views") }) @@ -511,7 +552,9 @@ func comprehensiveWebSocketDSL() { Method("BidirectionalStreaming", func() { StreamingPayload(UserType) - StreamingResult(UserType) + StreamingResult(UserType, func() { + View("tiny") + }) HTTP(func() { GET("/bidirectional") }) diff --git a/http/codegen/wire_catalog.go b/http/codegen/wire_catalog.go new file mode 100644 index 0000000000..c3752823c1 --- /dev/null +++ b/http/codegen/wire_catalog.go @@ -0,0 +1,2179 @@ +// This file assigns Go names to request and response types in one generated +// HTTP or JSON-RPC package. Each copied type is recorded before names are +// assigned. Its definition, references, and validation function then use the +// same record. +package codegen + +import ( + "cmp" + "fmt" + "reflect" + "slices" + "strings" + + "goa.design/goa/v3/codegen" + "goa.design/goa/v3/codegen/service" + "goa.design/goa/v3/expr" +) + +type ( + // wireTypeCatalog stores every request or response type written into one Go + // package and the Go name chosen for each type. + wireTypeCatalog struct { + pkg *codegen.GeneratedPackage + scope *codegen.NameScope + records []*wireTypeRecord + transforms []*wireTransformRecord + unionOccurrences []wireUnionOccurrence + unions []*wireUnionRecord + validationRoots []wireValidationRoot + transformHelpers []*wireTransformHelperRecord + transformBindings map[codegen.TransformHelperID]*wireTransformHelperRecord + transformDefinitions map[*codegen.NameDeclaration]*codegen.TransformFunctionData + releasedSuffixes map[*expr.AttributeExpr]string + declared bool + linked bool + bindings map[*expr.AttributeExpr]*wireTypeRecord + unionBindings map[*expr.Union]*wireUnionRecord + } + + // wireTransformHandle identifies one exact request or response conversion + // recorded before generated package names are assigned. + wireTransformHandle struct { + catalog *wireTypeCatalog + record *wireTransformRecord + } + + // wireTransformRecord stores one value conversion and any extra functions it + // needs. + wireTransformRecord struct { + source *expr.AttributeExpr + target *expr.AttributeExpr + prefix string + owner string + layout wireTransformLayout + plan *codegen.TransformPlan + used bool + } + + // wireTransformLayout records which value belongs to the transport package, + // which pointer rules it uses, and whether the other value belongs to the + // generated service package or its views package. + wireTransformLayout struct { + wireSide wireTransformSide + wirePolicy wireTypePolicy + wireUse wireUnionUse + servicePointer bool + servicePackage codegen.ImportSpec + } + + // wireTransformHelperRecord stores one function declaration shared by + // matching conversions in the same generated package. + wireTransformHelperRecord struct { + identity wireTransformHelperIdentity + declaration *codegen.NameDeclaration + prefix string + preferred string + order wireNameOrder + } + + // wireTransformHelperIdentity contains the generated source and target Go + // types used by one conversion function. + wireTransformHelperIdentity struct { + source wireTransformTypeIdentity + target wireTransformTypeIdentity + } + + // wireTransformTypeIdentity selects either one HTTP package declaration or + // one service type and records the field layout used by its generated code. + wireTransformTypeIdentity struct { + wire *wireTypeRecord + origin expr.UserType + attribute *expr.AttributeExpr + layout codegen.GoLayoutPolicy + servicePackage codegen.ImportSpec + } + + // wireUnionRecord stores one generated union and the Go names used for its + // type, branches, constants, and functions. + wireUnionRecord struct { + identity wireUnionIdentity + attribute *expr.AttributeExpr + declaration *codegen.NameDeclaration + kind *codegen.NameDeclaration + kindDecls []*codegen.NameDeclaration + ctorDecls []*codegen.NameDeclaration + name string + kindName string + kindConsts []string + constructors []string + storageNames []string + data *service.UnionTypeData + } + + // wireUnionOccurrence stores one copied union until Goa assigns names to its branches. + wireUnionOccurrence struct { + attribute *expr.AttributeExpr + use wireUnionUse + role wireTypeRole + policy wireTypePolicy + } + + // wireUnionUse identifies the HTTP body role that owns a union declaration. + wireUnionUse struct { + role wireTypeRole + view string + } + + // wireUnionOwner identifies one authored OneOf in one HTTP body role and + // response view. + wireUnionOwner struct { + authored *expr.AttributeExpr + use wireUnionUse + } + + // wireValidationRoot stores one HTTP value whose generated code runs + // validation directly instead of calling a named validator. + wireValidationRoot struct { + attribute *expr.AttributeExpr + policy wireTypePolicy + } + + // wireUnionIdentity records which OneOf was written, where HTTP uses it, and + // the Go type used by each branch. + wireUnionIdentity struct { + declaration codegen.UnionDeclarationID + owner wireUnionOwner + declarations []*wireTypeRecord + } + + // wireTypeRecord stores one generated type and its optional functions. + wireTypeRecord struct { + identity wireTypeIdentity + declaration *codegen.NameDeclaration + validator *codegen.NameDeclaration + nestedValidator *codegen.NameDeclaration + constructor *codegen.NameDeclaration + needsValidator bool + needsNestedCall bool + needsConstructor bool + name string + ref string + data *TypeData + errorUses []wireErrorUse + releasedNames []string + } + + // wireErrorUse records one service error whose HTTP body uses a generated + // type declaration. + wireErrorUse struct { + service string + method string + name string + } + + // wireTypeIdentity contains a designed type and the rules that change its Go definition. + wireTypeIdentity struct { + api string + sourceID string + resultID string + role wireTypeRole + preferred string + attribute *expr.AttributeExpr + policy wireTypePolicy + } + + // wireTypePolicy records how one copied type represents fields, pointers, + // default values, validation, and result views. + wireTypePolicy struct { + request bool + pointer bool + useDefault bool + validate bool + arrayElementPointer bool + view string + } + + // wireTypeRole says whether an unnamed designed type is used for a request, + // response, field, or stream value. + wireTypeRole uint8 + + // wireTransformSide identifies which value in a conversion is declared in + // the generated HTTP package. + wireTransformSide uint8 + + // wireNameKind identifies the declaration being ordered for a generated + // package name. + wireNameKind uint8 + + // wireAttributePair remembers two attributes already compared so values that + // refer back to themselves do not cause an endless loop. + wireAttributePair struct { + left *expr.AttributeExpr + right *expr.AttributeExpr + } + + // wireNameOrder contains designed values used to choose stable suffixes when + // several declarations ask for the same Go name. + wireNameOrder struct { + kind wireNameKind + api string + source string + target string + role uint8 + preferred string + shape string + view string + request bool + pointer bool + arrayElementPointer bool + defaults bool + } + + // wireAttributeScope chooses the Go type name for each copied HTTP field. + wireAttributeScope struct { + catalog *wireTypeCatalog + base codegen.Attributor + pkg string + policy wireTypePolicy + use wireUnionUse + viewRoot *wireTypeRecord + exactOccurrence bool + } +) + +const ( + wireRequestBody wireTypeRole = iota + 1 + wireResponseBody + wireAttribute + wireStreamPayload +) + +const ( + wireTransformSource wireTransformSide = iota + 1 + wireTransformTarget +) + +const ( + wireNameConstructor wireNameKind = iota + 1 + wireNameNestedValidator + wireNameTransformHelper + wireNameType + wireNameValidator +) + +// newWireTypeCatalog creates the type list for one generated Go package. Tests +// may omit the package when they only compare copied attributes. +func newWireTypeCatalog(pkg ...*codegen.GeneratedPackage) *wireTypeCatalog { + catalog := &wireTypeCatalog{ + bindings: make(map[*expr.AttributeExpr]*wireTypeRecord), + unionBindings: make(map[*expr.Union]*wireUnionRecord), + transformBindings: make(map[codegen.TransformHelperID]*wireTransformHelperRecord), + releasedSuffixes: make(map[*expr.AttributeExpr]string), + } + if len(pkg) > 0 { + catalog.pkg = pkg[0] + } + return catalog +} + +// collect records attribute and every named type it contains. +func (c *wireTypeCatalog) collect(attribute *expr.AttributeExpr, role wireTypeRole, policy wireTypePolicy, api ...string) *wireTypeRecord { + return c.collectWithReleasedNames(attribute, role, policy, "", nil, api...) +} + +// collectWithReleasedNames records a response while keeping the public names +// produced before view selection moved into this package. +func (c *wireTypeCatalog) collectWithReleasedNames(attribute *expr.AttributeExpr, role wireTypeRole, policy wireTypePolicy, preferred string, releasedNames map[expr.UserType]string, api ...string) *wireTypeRecord { + if c.declared { + panic("cannot collect an HTTP type after its package declarations are submitted") + } + suffix := releasedWireTypeSuffix(attribute, role) + c.releasedSuffixes[attribute] = suffix + root := "" + if len(api) > 0 { + root = api[0] + } + use := wireUnionUse{role: role, view: policy.view} + return c.collectRecursive(attribute, role, policy, use, preferred, suffix, root, true, releasedNames, make(map[expr.UserType]struct{})) +} + +// collectChildren records named types inside attribute without recording its +// top-level named type a second time. +func (c *wireTypeCatalog) collectChildren(attribute *expr.AttributeExpr, use wireUnionUse, policy wireTypePolicy, api ...string) { + c.collectChildrenWithReleasedNames(attribute, use, policy, nil, api...) +} + +// collectChildrenWithReleasedNames records response fields with their released +// names when selecting a view changed the order of name suffixes. +func (c *wireTypeCatalog) collectChildrenWithReleasedNames(attribute *expr.AttributeExpr, use wireUnionUse, policy wireTypePolicy, releasedNames map[expr.UserType]string, api ...string) { + suffix := c.releasedSuffixes[attribute] + root := "" + if len(api) > 0 { + root = api[0] + } + if userType, ok := attribute.Type.(expr.UserType); ok { + c.collectRecursive(userType.Attribute(), wireAttribute, policy, use, "", suffix, root, false, releasedNames, make(map[expr.UserType]struct{})) + return + } + c.collectRecursive(attribute, wireAttribute, policy, use, "", suffix, root, false, releasedNames, make(map[expr.UserType]struct{})) +} + +// Declare requests every type and function name this HTTP package can write. +// The caller invokes it before Goa chooses names so every file writing to the +// same package can resolve conflicts together. +func (c *wireTypeCatalog) Declare() error { + if c.declared { + return nil + } + if c.pkg == nil { + return fmt.Errorf("HTTP type declarations require a generated package") + } + c.planNestedValidators() + for _, record := range c.records { + record.declaration = codegen.NewPreferredName( + codegen.NameType, + record.preferredName(), + codegen.ExportedName, + record.identity.order(wireNameType), + ) + if err := c.pkg.DeclareName(record.declaration); err != nil { + return err + } + if record.needsValidator { + declaration, err := c.pkg.DeclareDependentName( + codegen.NameFunction, + record.declaration, + "Validate", + "", + record.identity.order(wireNameValidator), + ) + if err != nil { + return err + } + record.validator = declaration + if record.needsNestedCall { + declaration, err = c.pkg.DeclareDependentName( + codegen.NameFunction, + record.declaration, + "validate", + "", + record.identity.order(wireNameNestedValidator), + ) + if err != nil { + return err + } + record.nestedValidator = declaration + } + } + if record.needsConstructor { + declaration, err := c.pkg.DeclareDependentName( + codegen.NameFunction, + record.declaration, + "New", + "", + record.identity.order(wireNameConstructor), + ) + if err != nil { + return err + } + record.constructor = declaration + } + } + for _, occurrence := range c.unionOccurrences { + if err := validateHTTPUnionBranchGoNames(occurrence.attribute.Type.(*expr.Union)); err != nil { + return err + } + identity := c.unionIdentity(occurrence.attribute, occurrence.use, occurrence.role, occurrence.policy) + if record := c.findUnion(identity); record != nil { + continue + } + if record := c.findUnionOwner(identity.owner); record != nil { + return fmt.Errorf( + "HTTP OneOf %q produces different %s definitions; use separate OneOf declarations", + occurrence.attribute.Type.Name(), + occurrence.use.description(), + ) + } + c.unions = append(c.unions, &wireUnionRecord{identity: identity, attribute: occurrence.attribute}) + } + for _, union := range c.unions { + actual := union.attribute.Type.(*expr.Union) + name := union.identity.owner.use.nameFor(actual) + union.declaration = codegen.NewExactName(codegen.NameType, name) + if err := c.declareUnionName(actual, union.identity.owner.use, union.declaration); err != nil { + return err + } + kind := codegen.NewExactName(codegen.NameType, name+"Kind") + if err := c.declareUnionName(actual, union.identity.owner.use, kind); err != nil { + return err + } + union.kind = kind + union.kindDecls = make([]*codegen.NameDeclaration, len(actual.Values)) + union.ctorDecls = make([]*codegen.NameDeclaration, len(actual.Values)) + union.storageNames = make([]string, len(actual.Values)) + storageNames := codegen.NewNameScope() + // Reserve kind for the selector so a branch named kind uses another field. + storageNames.Unique("kind") + for index, branch := range actual.Values { + branchName := codegen.Goify(branch.Name, true) + kindDeclaration := codegen.NewExactName(codegen.NameConstant, name+"Kind"+branchName) + if err := c.declareUnionName(actual, union.identity.owner.use, kindDeclaration); err != nil { + return err + } + constructor := codegen.NewExactName(codegen.NameFunction, "New"+name+branchName) + if err := c.declareUnionName(actual, union.identity.owner.use, constructor); err != nil { + return err + } + union.kindDecls[index] = kindDeclaration + union.ctorDecls[index] = constructor + union.storageNames[index] = storageNames.Unique(codegen.Goify(branch.Name, false)) + } + } + for _, transform := range c.transforms { + for _, helper := range transform.plan.Helpers() { + identity, err := c.transformHelperIdentity(transform, helper) + if err != nil { + return err + } + preferred, err := c.transformHelperPreferredName(transform.prefix, identity) + if err != nil { + return err + } + order := wireNameOrder{ + kind: wireNameTransformHelper, + source: identity.source.orderKey(), + target: identity.target.orderKey(), + preferred: preferred, + } + record := c.findTransformHelper(identity) + if record == nil { + record = &wireTransformHelperRecord{ + identity: identity, + prefix: transform.prefix, + preferred: preferred, + order: order, + } + c.transformHelpers = append(c.transformHelpers, record) + } else if order.ComparePackageName(record.order) < 0 { + record.prefix = transform.prefix + record.preferred = preferred + record.order = order + } + c.transformBindings[helper.ID] = record + } + } + for _, helper := range c.transformHelpers { + declaration, err := c.declareTransformHelper(helper) + if err != nil { + return err + } + helper.declaration = declaration + } + for _, transform := range c.transforms { + for _, planned := range transform.plan.Helpers() { + helper := c.transformBindings[planned.ID] + if helper == nil { + return fmt.Errorf("HTTP conversion function declaration was not recorded") + } + if err := transform.plan.BindHelperDeclaration(planned.ID, helper.declaration); err != nil { + return err + } + } + } + c.declared = true + return nil +} + +// collectTransform records one request or response conversion before Goa +// chooses the names of any extra conversion functions. The returned handle +// selects this record when the caller later writes the conversion. +func (c *wireTypeCatalog) collectTransform(source, target *expr.AttributeExpr, prefix, owner string, layout wireTransformLayout) wireTransformHandle { + if c.declared { + panic("cannot collect an HTTP conversion after package declarations are submitted") + } + source = expr.DupAtt(source) + target = expr.DupAtt(target) + plan, err := codegen.NewTransformPlan(source, target, "", nil) + if err != nil { + panic(err) + } + record := &wireTransformRecord{ + source: source, + target: target, + prefix: prefix, + owner: owner, + layout: layout, + plan: plan, + } + c.transforms = append(c.transforms, record) + return wireTransformHandle{catalog: c, record: record} +} + +// transformHelperIdentity resolves the exact generated declarations and field +// layouts used by one planned conversion function. +func (c *wireTypeCatalog) transformHelperIdentity(transform *wireTransformRecord, helper codegen.TransformHelper) (wireTransformHelperIdentity, error) { + sourceWire := transform.layout.wireSide == wireTransformSource + targetWire := transform.layout.wireSide == wireTransformTarget + source, err := c.transformTypeIdentity(helper.Source, sourceWire, transform.layout.wirePolicy, transform.layout.servicePointer, transform.layout.servicePackage) + if err != nil { + return wireTransformHelperIdentity{}, err + } + target, err := c.transformTypeIdentity(helper.Target, targetWire, transform.layout.wirePolicy, transform.layout.servicePointer, transform.layout.servicePackage) + if err != nil { + return wireTransformHelperIdentity{}, err + } + return wireTransformHelperIdentity{ + source: source, + target: target, + }, nil +} + +// transformTypeIdentity returns the declaration and field rules that determine +// a generated conversion function's parameter or result type. +func (c *wireTypeCatalog) transformTypeIdentity(attribute *expr.AttributeExpr, wire bool, policy wireTypePolicy, servicePointer bool, servicePackage codegen.ImportSpec) (wireTransformTypeIdentity, error) { + userType, ok := attribute.Type.(expr.UserType) + if !ok { + return wireTransformTypeIdentity{}, fmt.Errorf("HTTP conversion function type %q is not named", attribute.Type.Name()) + } + if !wire { + if location := codegen.UserTypeLocation(userType); location != nil { + servicePackage = codegen.ImportSpec{Name: location.PackageName(), Path: location.RelImportPath} + } + if servicePackage.Name == "" || servicePackage.Path == "" { + return wireTransformTypeIdentity{}, fmt.Errorf("HTTP conversion function type %q has no service package", userType.Name()) + } + return wireTransformTypeIdentity{ + origin: userType.Origin(), + attribute: attribute, + servicePackage: servicePackage, + layout: codegen.GoLayoutPolicy{ + Pointer: servicePointer, + UseDefault: true, + SumType: true, + }, + }, nil + } + policy.view = "" + preferred := wireTypePreferredName(userType, policy) + record := c.find(newWireTypeIdentity(attribute, wireAttribute, policy, preferred)) + if record == nil { + return wireTransformTypeIdentity{}, fmt.Errorf("HTTP conversion function type %q was not recorded", preferred) + } + return wireTransformTypeIdentity{ + wire: record, + attribute: attribute, + layout: codegen.GoLayoutPolicy{ + Pointer: policy.pointer, + UseDefault: policy.useDefault, + UnionPointer: true, + ArrayElementPointer: policy.arrayElementPointer, + SumType: true, + }, + }, nil +} + +// transformHelperPreferredName describes both generated types converted by +// one helper before their package selects final declaration names. +func (c *wireTypeCatalog) transformHelperPreferredName(prefix string, identity wireTransformHelperIdentity) (string, error) { + source, err := c.transformTypeRoleName(identity.source) + if err != nil { + return "", err + } + target, err := c.transformTypeRoleName(identity.target) + if err != nil { + return "", err + } + return prefix + codegen.Goify(source, true) + "To" + codegen.Goify(target, true), nil +} + +// transformTypeRoleName returns the package and type role used in a helper +// name. Wire values use their planned request or response declaration. +func (c *wireTypeCatalog) transformTypeRoleName(identity wireTransformTypeIdentity) (string, error) { + if identity.wire != nil { + return identity.wire.preferredName(), nil + } + userType, ok := identity.attribute.Type.(expr.UserType) + if !ok { + return "", fmt.Errorf("HTTP conversion function type %q is not named", identity.attribute.Type.Name()) + } + typeName := codegen.Goify(wireTypeDeclaredName(userType), true) + return identity.servicePackage.Name + typeName, nil +} + +// declareTransformHelper makes the function name depend on the wire type it +// converts. If that type receives a suffix, the helper receives it too. +func (c *wireTypeCatalog) declareTransformHelper(helper *wireTransformHelperRecord) (*codegen.NameDeclaration, error) { + sourceWire := helper.identity.source.wire + targetWire := helper.identity.target.wire + if (sourceWire == nil) == (targetWire == nil) { + return nil, fmt.Errorf("HTTP conversion function must convert between one wire type and one service type") + } + if sourceWire != nil { + target, err := c.transformTypeRoleName(helper.identity.target) + if err != nil { + return nil, err + } + return c.pkg.DeclareDependentName( + codegen.NameFunction, + sourceWire.declaration, + helper.prefix, + "To"+codegen.Goify(target, true), + helper.order, + ) + } + source, err := c.transformTypeRoleName(helper.identity.source) + if err != nil { + return nil, err + } + return c.pkg.DeclareDependentName( + codegen.NameFunction, + targetWire.declaration, + helper.prefix+codegen.Goify(source, true)+"To", + "", + helper.order, + ) +} + +// findTransformHelper returns the declaration for an equivalent conversion. +func (c *wireTypeCatalog) findTransformHelper(identity wireTransformHelperIdentity) *wireTransformHelperRecord { + for _, record := range c.transformHelpers { + if wireTransformHelperIdentitiesEqual(record.identity, identity) { + return record + } + } + return nil +} + +// wireTransformHelperIdentitiesEqual reports whether two functions have the +// same generated parameter, result, and field layout. +func wireTransformHelperIdentitiesEqual(left, right wireTransformHelperIdentity) bool { + return wireTransformTypeIdentitiesEqual(left.source, right.source) && + wireTransformTypeIdentitiesEqual(left.target, right.target) +} + +// wireTransformTypeIdentitiesEqual compares exact generated declarations and +// the concrete fields written inside service types. +func wireTransformTypeIdentitiesEqual(left, right wireTransformTypeIdentity) bool { + if left.wire != right.wire || left.origin != right.origin || left.layout != right.layout || left.servicePackage != right.servicePackage { + return false + } + if left.wire != nil { + return true + } + leftType := left.attribute.Type.(expr.UserType) + rightType := right.attribute.Type.(expr.UserType) + return wireAttributesEqual(leftType.Attribute(), rightType.Attribute(), make(map[wireAttributePair]struct{})) +} + +// orderKey describes every generated fact that changes a conversion +// function's parameter or result type. +func (i wireTransformTypeIdentity) orderKey() string { + if i.wire != nil { + order := i.wire.identity.order(wireNameType) + return fmt.Sprintf( + "wire:%q:%d:%q:%q:%q:%t:%t:%t:%t", + order.source, + order.role, + order.preferred, + order.shape, + order.view, + order.request, + order.pointer, + order.arrayElementPointer, + order.defaults, + ) + } + return fmt.Sprintf( + "service:%q:%q:%q:%q:%t:%t:%t:%t:%t", + i.servicePackage.Path, + i.servicePackage.Name, + wireTypeDeclaredName(i.origin), + expr.Hash(i.attribute.Type, false, false, false), + i.layout.Pointer, + i.layout.IgnoreRequired, + i.layout.UseDefault, + i.layout.UnionPointer, + i.layout.ArrayElementPointer, + ) +} + +// renderTransform writes the conversion selected when wire types were +// collected. The handle prevents two structurally identical conversions from +// being exchanged when callers render them in a different order. +func (c *wireTypeCatalog) renderTransform( + handle wireTransformHandle, + wireAttribute *expr.AttributeExpr, + sourceVar, targetVar string, + sourceContext, targetContext *codegen.AttributeContext, +) (string, []*codegen.TransformFunctionData, error) { + if handle.catalog != c || handle.record == nil { + return "", nil, fmt.Errorf("HTTP conversion handle belongs to a different generated package") + } + transform := handle.record + if transform.used { + return "", nil, fmt.Errorf("HTTP %s conversion for %s was already rendered", transform.prefix, transform.owner) + } + if err := transform.plan.BindContexts(sourceContext, targetContext); err != nil { + return "", nil, err + } + if transform.layout.wireSide == wireTransformSource { + if err := c.bindTransformOccurrence(transform.source, wireAttribute, sourceContext, transform.layout.wireUse); err != nil { + return "", nil, err + } + } else { + if err := c.bindTransformOccurrence(transform.target, wireAttribute, targetContext, transform.layout.wireUse); err != nil { + return "", nil, err + } + } + for _, helper := range transform.plan.Helpers() { + c.bindTransformHelper(helper.Source, sourceContext, transform.layout.wireUse) + c.bindTransformHelper(helper.Target, targetContext, transform.layout.wireUse) + } + code, helpers, err := transform.plan.Render(sourceVar, targetVar, true) + if err != nil { + return "", nil, err + } + if err := c.retainTransformDefinitions(helpers); err != nil { + return "", nil, err + } + transform.used = true + return code, helpers, nil +} + +// checkTransformUsed rejects a planned conversion that was never written. A +// generated package may contain records from several transport plans, so the +// caller checks only handles owned by the plan currently linking. +func (c *wireTypeCatalog) checkTransformUsed(handle wireTransformHandle) error { + if handle.record == nil { + return nil + } + if handle.catalog != c { + return fmt.Errorf("HTTP conversion handle belongs to a different generated package") + } + if !handle.record.used { + return fmt.Errorf("HTTP %s conversion for %s was planned but not rendered", handle.record.prefix, handle.record.owner) + } + return nil +} + +// retainTransformDefinitions verifies that every independently planned use of +// one package function has the same parameter type, result type, and body. +func (c *wireTypeCatalog) retainTransformDefinitions(helpers []*codegen.TransformFunctionData) error { + if c.transformDefinitions == nil { + c.transformDefinitions = make(map[*codegen.NameDeclaration]*codegen.TransformFunctionData) + } + pending := make(map[*codegen.NameDeclaration]*codegen.TransformFunctionData) + for _, helper := range helpers { + previous := c.transformDefinitions[helper.Declaration] + if previous == nil { + previous = pending[helper.Declaration] + } + if previous != nil && !wireTransformDefinitionsEqual(previous, helper) { + return fmt.Errorf("HTTP transform helper declaration %q has different definitions", helper.Declaration.Name()) + } + pending[helper.Declaration] = helper + } + for declaration, helper := range pending { + c.transformDefinitions[declaration] = helper + } + return nil +} + +// wireTransformDefinitionsEqual reports whether two planned conversions emit +// the same package-level function. +func wireTransformDefinitionsEqual(left, right *codegen.TransformFunctionData) bool { + return left.ParamTypeRef == right.ParamTypeRef && + left.ResultTypeRef == right.ResultTypeRef && + left.Code == right.Code +} + +// bindTransformHelper gives a nested copied field the same Go type name used by +// its generated conversion function. Service values already receive names from +// the service generator. +func (c *wireTypeCatalog) bindTransformHelper(attribute *expr.AttributeExpr, context *codegen.AttributeContext, use wireUnionUse) { + scope, ok := context.Scope.(*wireAttributeScope) + if !ok || scope.catalog != c { + return + } + policy := scope.policy + policy.view = "" + c.applyNamesRecursive(attribute, wireAttribute, policy, use, make(map[expr.UserType]struct{})) +} + +// bindTransformOccurrence records the Go type used by one copied conversion +// value and each named field inside it. +func (c *wireTypeCatalog) bindTransformOccurrence(planned, rendered *expr.AttributeExpr, context *codegen.AttributeContext, use wireUnionUse) error { + scope, ok := context.Scope.(*wireAttributeScope) + if !ok || scope.catalog != c { + return nil + } + // The transform planner works on its own copy. Carry each union's planned + // declaration from the rendered HTTP body to that copy before rendering. + if err := c.bindCopiedUnionOccurrences(planned, rendered, "body", make(map[wireAttributePair]struct{})); err != nil { + return err + } + record := c.bindings[rendered] + if record == nil { + c.applyNamesRecursive(planned, wireAttribute, scope.policy, use, make(map[expr.UserType]struct{})) + return nil + } + c.bindOccurrence(planned, record) + c.applyNamesRecursive(planned, record.identity.role, record.identity.policy, use, make(map[expr.UserType]struct{})) + return nil +} + +// bindCopiedUnionOccurrences gives unions in a transform copy the declarations +// already selected for the matching fields in the rendered HTTP body. It +// rejects a mismatch because a declaration from another field would generate +// a conversion with the wrong public type. +func (c *wireTypeCatalog) bindCopiedUnionOccurrences(planned, rendered *expr.AttributeExpr, path string, seen map[wireAttributePair]struct{}) error { + if planned == nil || rendered == nil { + switch { + case planned == nil && rendered == nil: + return nil + case planned == nil: + return fmt.Errorf("planned attribute is missing at %s", path) + default: + return fmt.Errorf("rendered attribute is missing at %s", path) + } + } + if planned.Type == expr.Empty || rendered.Type == expr.Empty { + if planned.Type == rendered.Type { + return nil + } + return copiedUnionTypeMismatch(planned, rendered, path) + } + pair := wireAttributePair{left: planned, right: rendered} + if _, ok := seen[pair]; ok { + return nil + } + seen[pair] = struct{}{} + + if plannedType, ok := planned.Type.(expr.UserType); ok { + renderedType, ok := rendered.Type.(expr.UserType) + if !ok { + return copiedUnionTypeMismatch(planned, rendered, path) + } + if plannedType.Name() != renderedType.Name() { + return copiedUnionTypeMismatch(planned, rendered, path) + } + return c.bindCopiedUnionOccurrences(plannedType.Attribute(), renderedType.Attribute(), path, seen) + } + if _, ok := rendered.Type.(expr.UserType); ok { + return copiedUnionTypeMismatch(planned, rendered, path) + } + switch plannedType := planned.Type.(type) { + case *expr.Object: + renderedType, ok := rendered.Type.(*expr.Object) + if !ok { + return copiedUnionTypeMismatch(planned, rendered, path) + } + for _, field := range *plannedType { + renderedField := renderedType.Attribute(field.Name) + fieldPath := path + "." + field.Name + if renderedField == nil { + return fmt.Errorf("rendered object has no field %q at %s", field.Name, fieldPath) + } + if err := c.bindCopiedUnionOccurrences(field.Attribute, renderedField, fieldPath, seen); err != nil { + return err + } + } + for _, field := range *renderedType { + if plannedType.Attribute(field.Name) == nil { + return fmt.Errorf("planned object has no field %q at %s.%s", field.Name, path, field.Name) + } + } + case *expr.Array: + renderedType, ok := rendered.Type.(*expr.Array) + if !ok { + return copiedUnionTypeMismatch(planned, rendered, path) + } + return c.bindCopiedUnionOccurrences(plannedType.ElemType, renderedType.ElemType, path+"[]", seen) + case *expr.Map: + renderedType, ok := rendered.Type.(*expr.Map) + if !ok { + return copiedUnionTypeMismatch(planned, rendered, path) + } + if err := c.bindCopiedUnionOccurrences(plannedType.KeyType, renderedType.KeyType, path+" key", seen); err != nil { + return err + } + return c.bindCopiedUnionOccurrences(plannedType.ElemType, renderedType.ElemType, path+" value", seen) + case *expr.Union: + renderedType, ok := rendered.Type.(*expr.Union) + if !ok { + return copiedUnionTypeMismatch(planned, rendered, path) + } + if plannedType.Name() != renderedType.Name() || plannedType.TypeKey != renderedType.TypeKey || plannedType.ValueKey != renderedType.ValueKey { + return copiedUnionTypeMismatch(planned, rendered, path) + } + if record := c.unionBindings[renderedType]; record != nil { + c.unionBindings[plannedType] = record + } + for _, branch := range plannedType.Values { + renderedBranch := unionBranchAttribute(renderedType, branch.Name) + branchPath := path + "." + branch.Name + if renderedBranch == nil { + return fmt.Errorf("rendered OneOf %q has no branch %q at %s", renderedType.Name(), branch.Name, branchPath) + } + if err := c.bindCopiedUnionOccurrences(branch.Attribute, renderedBranch, branchPath, seen); err != nil { + return err + } + } + for _, branch := range renderedType.Values { + if unionBranchAttribute(plannedType, branch.Name) == nil { + return fmt.Errorf("planned OneOf %q has no branch %q at %s.%s", plannedType.Name(), branch.Name, path, branch.Name) + } + } + default: + if planned.Type != rendered.Type { + return copiedUnionTypeMismatch(planned, rendered, path) + } + } + return nil +} + +// copiedUnionTypeMismatch describes two expression nodes that cannot represent +// the same field in a planned and rendered HTTP conversion. +func copiedUnionTypeMismatch(planned, rendered *expr.AttributeExpr, path string) error { + return fmt.Errorf( + "planned %s does not match rendered %s at %s", + copiedUnionTypeName(planned.Type), + copiedUnionTypeName(rendered.Type), + path, + ) +} + +// copiedUnionTypeName returns a short concrete name for a mismatch diagnostic. +func copiedUnionTypeName(dataType expr.DataType) string { + if dataType == expr.Empty { + return "empty type" + } + if userType, ok := dataType.(expr.UserType); ok { + return fmt.Sprintf("user type %q", userType.Name()) + } + if union, ok := dataType.(*expr.Union); ok { + return fmt.Sprintf("OneOf %q", union.Name()) + } + return dataType.Name() +} + +// unionBranchAttribute returns the branch with name, or nil when two transform +// copies do not have the same fields. +func unionBranchAttribute(union *expr.Union, name string) *expr.AttributeExpr { + for _, branch := range union.Values { + if branch.Name == name { + return branch.Attribute + } + } + return nil +} + +// Link reads the assigned package names and builds the type definitions, +// references, unions, and validation functions written to files. +func (c *wireTypeCatalog) Link() { + if c.linked { + return + } + if !c.declared { + panic("cannot link HTTP types before declaring their package names") + } + c.scope = c.pkg.Scope() + for _, union := range c.unions { + union.name = union.declaration.Name() + union.kindName = union.kind.Name() + union.kindConsts = make([]string, len(union.kindDecls)) + union.constructors = make([]string, len(union.ctorDecls)) + for index := range union.kindDecls { + union.kindConsts[index] = union.kindDecls[index].Name() + union.constructors[index] = union.ctorDecls[index].Name() + } + c.applyUnionRecord(union.attribute, union) + } + for _, record := range c.records { + record.name = record.declaration.Name() + use := wireUnionUse{ + role: record.identity.role, + view: record.identity.policy.view, + } + resolver := c.rootResolver(c.scope, record.identity.policy, use, record) + layout, err := resolver.(*wireAttributeScope).goTypeLayout( + record.identity.attribute, + wireGoLayoutPolicy(record.identity.policy), + true, + ) + if err != nil { + panic(fmt.Sprintf("link HTTP type %q: %v", record.name, err)) + } + record.ref = layout.Ref() + } + for _, union := range c.unions { + actual := union.attribute.Type.(*expr.Union) + union.data = buildHTTPUnionTypeData(actual, c.occurrenceResolver(c.scope, union.identity.owner.use), union) + } + c.linked = true +} + +// optionalWireTypeRef returns the exact transport type used to preserve an +// optional top-level value. The booleans report whether command-line decoding +// must preserve absence and whether conversion must dereference an added +// pointer. +func optionalWireTypeRef(layout codegen.LinkedGoType, optional bool) (typeRef string, preserveAbsence, dereference bool) { + if optional && !layout.ReferenceCanBeNil() { + return layout.RefWithPointer(true), true, layout.Kind() != codegen.GoStruct + } + return layout.Ref(), optional && layout.ReferenceIsPointer(), false +} + +// wireGoLayoutPolicy converts the HTTP package choices into the shared Go type +// plan used by definitions, references, and default values. +func wireGoLayoutPolicy(policy wireTypePolicy) codegen.GoLayoutPolicy { + return codegen.GoLayoutPolicy{ + Pointer: policy.pointer, + UseDefault: policy.useDefault, + UnionPointer: true, + ArrayElementPointer: policy.arrayElementPointer, + SumType: true, + } +} + +// lookup returns the chosen Go names for an equivalent copied type. It panics +// when the type was not recorded before names were assigned. +func (c *wireTypeCatalog) lookup(attribute *expr.AttributeExpr, role wireTypeRole, policy wireTypePolicy, preferred string) *wireTypeRecord { + if !c.linked { + panic("cannot resolve an HTTP type before its generated package freezes") + } + identity := newWireTypeIdentity(attribute, role, policy, preferred) + record := c.find(identity) + if record != nil { + c.bindOccurrence(attribute, record) + return record + } + panic(fmt.Sprintf("HTTP type %q was not submitted before package names were assigned", preferred)) +} + +// lookupUser returns the generated name information for a named design type. +// It returns nil for inline and primitive values because they define no named +// type at the top level. +func (c *wireTypeCatalog) lookupUser(attribute *expr.AttributeExpr, role wireTypeRole, policy wireTypePolicy) *wireTypeRecord { + if attribute.Type == expr.Empty { + return nil + } + userType, ok := attribute.Type.(expr.UserType) + if !ok { + return nil + } + return c.lookup(attribute, role, policy, wireTypePreferredName(userType, policy)) +} + +// applyNames associates every copied nested attribute with the Go type name +// used by its definition and conversions. +func (c *wireTypeCatalog) applyNames(attribute *expr.AttributeExpr, role wireTypeRole, policy wireTypePolicy) { + use := wireUnionUse{role: role, view: policy.view} + c.applyNamesRecursive(attribute, role, policy, use, make(map[expr.UserType]struct{})) +} + +// applyNamesRecursive follows each named field once, including fields that +// refer back to an outer type. +func (c *wireTypeCatalog) applyNamesRecursive(attribute *expr.AttributeExpr, role wireTypeRole, policy wireTypePolicy, use wireUnionUse, seen map[expr.UserType]struct{}) { + if attribute.Type == expr.Empty { + return + } + if userType, ok := attribute.Type.(expr.UserType); ok { + c.lookupUser(attribute, role, policy) + origin := userType.Origin() + if _, ok := seen[origin]; ok { + return + } + seen[origin] = struct{}{} + nestedPolicy := policy + nestedPolicy.view = "" + c.applyNamesRecursive(userType.Attribute(), wireAttribute, nestedPolicy, use, seen) + delete(seen, origin) + return + } + nestedPolicy := policy + nestedPolicy.view = "" + switch actual := attribute.Type.(type) { + case *expr.Object: + for _, named := range *actual { + c.applyNamesRecursive(named.Attribute, wireAttribute, nestedPolicy, use, seen) + } + case *expr.Array: + c.applyNamesRecursive(actual.ElemType, wireAttribute, nestedPolicy, use, seen) + case *expr.Map: + c.applyNamesRecursive(actual.KeyType, wireAttribute, nestedPolicy, use, seen) + c.applyNamesRecursive(actual.ElemType, wireAttribute, nestedPolicy, use, seen) + case *expr.Union: + if record := c.unionBindings[actual]; record != nil { + c.applyUnionRecord(attribute, record) + return + } + identity := c.unionIdentity(attribute, use, role, policy) + record := c.findUnion(identity) + if record == nil { + panic(fmt.Sprintf("HTTP union %q was not submitted before package names were assigned", actual.Name())) + } + c.applyUnionRecord(attribute, record) + } +} + +// unionTypes returns the generated union definitions in Go name order. +func (c *wireTypeCatalog) unionTypes() []*service.UnionTypeData { + unions := make([]*service.UnionTypeData, len(c.unions)) + for index, record := range c.unions { + unions[index] = record.data + } + slices.SortFunc(unions, func(left, right *service.UnionTypeData) int { return strings.Compare(left.Name, right.Name) }) + return unions +} + +// bind associates the data used to write a type with its chosen Go name. When +// several equivalent copies need validation, it stores their shared validator. +func (c *wireTypeCatalog) bind(record *wireTypeRecord, data *TypeData) *TypeData { + data.declaration = record + data.Declaration = record.declaration + data.VarName = record.declaration.Name() + data.ValidatorDeclaration = record.validator + data.NestedValidatorDeclaration = record.nestedValidator + if record.validator != nil { + data.ValidatorName = record.validator.Name() + } + if record.nestedValidator != nil { + data.NestedValidatorName = record.nestedValidator.Name() + } + if data.Init != nil { + data.Init.Declaration = record.constructor + data.Init.Name = record.constructor.Name() + } + if description := record.errorDescription(); description != "" { + data.Description = description + } + if record.data == nil { + if data.Def == "" && data.ValidateDef == "" { + return data + } + declaration := *data + declaration.Init = nil + record.data = &declaration + return data + } + if data.Def != "" { + if record.data.Def == "" { + record.data.Def = data.Def + } else if record.data.Def != data.Def { + panic(fmt.Sprintf("HTTP type %q produced conflicting declarations", record.name)) + } + } + if data.ValidateDef != "" { + if record.data.ValidateDef == "" { + record.data.ValidateDef = data.ValidateDef + record.data.ValidateRef = data.ValidateRef + record.data.ValidatorName = data.ValidatorName + } else if record.data.ValidateDef != data.ValidateDef || record.data.ValidateRef != data.ValidateRef { + panic(fmt.Sprintf("HTTP type %q produced conflicting validators", record.name)) + } + } + if data.NestedValidateDef != "" { + if record.data.NestedValidateDef == "" { + record.data.NestedValidateDef = data.NestedValidateDef + record.data.NestedValidatorName = data.NestedValidatorName + } else if record.data.NestedValidateDef != data.NestedValidateDef { + panic(fmt.Sprintf("HTTP type %q produced conflicting nested validators", record.name)) + } + } + return data +} + +// addErrorUse records one error body role and keeps all roles in a stable +// order before generated source is written. +func (r *wireTypeRecord) addErrorUse(use wireErrorUse) { + for _, existing := range r.errorUses { + if existing == use { + return + } + } + r.errorUses = append(r.errorUses, use) + slices.SortFunc(r.errorUses, func(left, right wireErrorUse) int { + for _, compared := range []int{ + cmp.Compare(left.service, right.service), + cmp.Compare(left.method, right.method), + cmp.Compare(left.name, right.name), + } { + if compared != 0 { + return compared + } + } + return 0 + }) +} + +// errorDescription describes every designed error that uses the generated +// type. Two errors on one endpoint remain short enough for one sentence. +func (r *wireTypeRecord) errorDescription() string { + if len(r.errorUses) == 0 { + return "" + } + first := r.errorUses[0] + if len(r.errorUses) == 1 { + return fmt.Sprintf( + "%s is the type of the %q service %q endpoint HTTP response body for the %q error.", + r.name, + first.service, + first.method, + first.name, + ) + } + if len(r.errorUses) == 2 && first.service == r.errorUses[1].service && first.method == r.errorUses[1].method { + return fmt.Sprintf( + "%s is the type of the %q service %q endpoint HTTP response body for the %q and %q errors.", + r.name, + first.service, + first.method, + first.name, + r.errorUses[1].name, + ) + } + var description strings.Builder + fmt.Fprintf(&description, "%s is the HTTP response body type for these service errors:", r.name) + for _, use := range r.errorUses { + fmt.Fprintf( + &description, + "\n- %q service %q endpoint: %q error", + use.service, + use.method, + use.name, + ) + } + return description.String() +} + +// collectRecursive records named types and stops when a type refers back to one +// it is already reading. Released names are kept separately from current type +// identity so several old declarations can share one current declaration. +func (c *wireTypeCatalog) collectRecursive(attribute *expr.AttributeExpr, role wireTypeRole, policy wireTypePolicy, use wireUnionUse, preferred, releasedSuffix, api string, root bool, releasedNames map[expr.UserType]string, seen map[expr.UserType]struct{}) *wireTypeRecord { + if attribute.Type == expr.Empty { + return nil + } + var record *wireTypeRecord + if userType, ok := attribute.Type.(expr.UserType); ok { + preferred = wireTypePreferredName(userType, policy) + identity := newWireTypeIdentity(attribute, role, policy, preferred) + identity.api = api + record = c.findOrAppend(identity) + released := preferred + if name := releasedNames[userType]; name != "" { + released = name + } else if !root { + released += releasedSuffix + } + record.addReleasedName(released) + origin := userType.Origin() + if _, ok := seen[origin]; ok { + return record + } + seen[origin] = struct{}{} + nestedPolicy := policy + nestedPolicy.view = "" + c.collectRecursive(userType.Attribute(), wireAttribute, nestedPolicy, use, "", releasedSuffix, api, false, releasedNames, seen) + delete(seen, origin) + return record + } + if preferred != "" { + identity := newWireTypeIdentity(attribute, role, policy, preferred) + identity.api = api + record = c.findOrAppend(identity) + record.addReleasedName(preferred) + } + switch actual := attribute.Type.(type) { + case *expr.Object: + nestedPolicy := policy + nestedPolicy.view = "" + for _, named := range sortedWireAttributes(*actual) { + c.collectRecursive(named.Attribute, wireAttribute, nestedPolicy, use, "", releasedSuffix, api, false, releasedNames, seen) + } + case *expr.Array: + nestedPolicy := policy + nestedPolicy.view = "" + c.collectRecursive(actual.ElemType, wireAttribute, nestedPolicy, use, "", releasedSuffix, api, false, releasedNames, seen) + case *expr.Map: + nestedPolicy := policy + nestedPolicy.view = "" + c.collectRecursive(actual.KeyType, wireAttribute, nestedPolicy, use, "", releasedSuffix, api, false, releasedNames, seen) + c.collectRecursive(actual.ElemType, wireAttribute, nestedPolicy, use, "", releasedSuffix, api, false, releasedNames, seen) + case *expr.Union: + nestedPolicy := policy + nestedPolicy.view = "" + unionAttribute := expr.DupAtt(attribute) + c.unionOccurrences = append(c.unionOccurrences, wireUnionOccurrence{ + attribute: unionAttribute, + use: use, + role: role, + policy: policy, + }) + for _, named := range actual.Values { + c.collectRecursive(named.Attribute, wireAttribute, nestedPolicy, use, "", releasedSuffix, api, false, releasedNames, seen) + } + } + return record +} + +// addReleasedName records one spelling used before HTTP types were retained. +func (r *wireTypeRecord) addReleasedName(name string) { + if name == "" || slices.Contains(r.releasedNames, name) { + return + } + r.releasedNames = append(r.releasedNames, name) + slices.Sort(r.releasedNames) +} + +// preferredName keeps a released spelling only when it still names exactly one +// retained declaration. Shared declarations use their current designed name. +func (r *wireTypeRecord) preferredName() string { + if len(r.releasedNames) == 1 { + return r.releasedNames[0] + } + return r.identity.preferred +} + +// findOrAppend reuses a record with the same generated type definition or adds +// a new record. +func (c *wireTypeCatalog) findOrAppend(identity wireTypeIdentity) *wireTypeRecord { + if record := c.find(identity); record != nil { + record.needsValidator = record.needsValidator || identity.policy.validate + return record + } + record := &wireTypeRecord{ + identity: identity, + needsValidator: identity.policy.validate, + } + c.records = append(c.records, record) + return record +} + +// addValidationRoot records an inline HTTP value whose generated decoder or +// constructor runs validation. +func (c *wireTypeCatalog) addValidationRoot(attribute *expr.AttributeExpr, policy wireTypePolicy) { + c.validationRoots = append(c.validationRoots, wireValidationRoot{ + attribute: expr.DupAtt(attribute), + policy: policy, + }) +} + +// planNestedValidators marks the named validators called by generated public +// validators and inline validation code. +func (c *wireTypeCatalog) planNestedValidators() { + for _, record := range c.records { + record.needsNestedCall = false + } + for _, record := range c.records { + if record.needsValidator { + c.markNestedValidatorCalls(record.identity.attribute, record.identity.policy) + } + } + for _, root := range c.validationRoots { + c.markNestedValidatorCalls(root.attribute, root.policy) + } +} + +// markNestedValidatorCalls follows inline validation until it reaches a named +// type. A named type gets one private helper because the caller supplies its +// complete error path. +func (c *wireTypeCatalog) markNestedValidatorCalls(attribute *expr.AttributeExpr, policy wireTypePolicy) { + if userType, ok := attribute.Type.(expr.UserType); ok && !expr.IsAlias(userType) { + attribute = userType.Attribute() + } + policy.view = "" + c.markInlineValidationCalls(attribute, policy, policy.pointer) +} + +// markInlineValidationCalls records named calls inside one generated +// validation body. Anonymous values and aliases remain inside their caller. +func (c *wireTypeCatalog) markInlineValidationCalls(attribute *expr.AttributeExpr, policy wireTypePolicy, pointer bool) { + if userType, ok := attribute.Type.(expr.UserType); ok { + if expr.IsAlias(userType) { + c.markInlineValidationCalls(userType.Attribute(), policy, pointer) + return + } + layout := codegen.GoLayoutPolicy{ + Pointer: pointer, + UseDefault: policy.useDefault, + UnionPointer: true, + ArrayElementPointer: policy.arrayElementPointer, + SumType: true, + } + if !codegen.NeedsValidation(userType.Attribute(), layout) { + return + } + preferred := wireTypePreferredName(userType, policy) + record := c.find(newWireTypeIdentity(attribute, wireAttribute, policy, preferred)) + if record == nil { + panic(fmt.Sprintf("HTTP nested validator for %q was not collected", preferred)) + } + if !record.needsValidator { + panic(fmt.Sprintf("HTTP nested validator for %q has no public validator", record.name)) + } + record.needsNestedCall = true + return + } + + switch actual := attribute.Type.(type) { + case *expr.Object: + for _, field := range *actual { + c.markInlineValidationCalls(field.Attribute, policy, pointer) + } + case *expr.Array: + c.markInlineValidationCalls(actual.ElemType, policy, pointer) + case *expr.Map: + c.markInlineValidationCalls(actual.KeyType, policy, false) + c.markInlineValidationCalls(actual.ElemType, policy, false) + case *expr.Union: + for _, branch := range actual.Values { + branchPointer := pointer && expr.IsObject(branch.Attribute.Type) + c.markInlineValidationCalls(branch.Attribute, policy, branchPointer) + } + } +} + +// find returns the record for the same generated type definition. +func (c *wireTypeCatalog) find(identity wireTypeIdentity) *wireTypeRecord { + for _, record := range c.records { + if wireTypeIdentitiesEqual(record.identity, identity) { + return record + } + } + return nil +} + +// unionIdentity returns the authored declaration, HTTP use, and Go type used +// by every named branch without changing attribute. +func (c *wireTypeCatalog) unionIdentity(attribute *expr.AttributeExpr, use wireUnionUse, role wireTypeRole, policy wireTypePolicy) wireUnionIdentity { + identity := wireUnionIdentity{ + declaration: codegen.NewUnionDeclarationID(attribute), + owner: wireUnionOwner{ + authored: attribute.AuthoredAttribute(), + use: use, + }, + } + c.collectUnionDeclarations(attribute, role, policy, &identity.declarations, make(map[expr.UserType]struct{})) + return identity +} + +// collectUnionDeclarations records generated branch types in branch order. +func (c *wireTypeCatalog) collectUnionDeclarations(attribute *expr.AttributeExpr, role wireTypeRole, policy wireTypePolicy, declarations *[]*wireTypeRecord, seen map[expr.UserType]struct{}) { + if attribute.Type == expr.Empty { + return + } + if userType, ok := attribute.Type.(expr.UserType); ok { + preferred := wireTypePreferredName(userType, policy) + record := c.find(newWireTypeIdentity(attribute, role, policy, preferred)) + if record == nil { + panic(fmt.Sprintf("HTTP union branch type %q was not submitted before package names were assigned", preferred)) + } + *declarations = append(*declarations, record) + origin := userType.Origin() + if _, ok := seen[origin]; ok { + return + } + seen[origin] = struct{}{} + nestedPolicy := policy + nestedPolicy.view = "" + c.collectUnionDeclarations(userType.Attribute(), wireAttribute, nestedPolicy, declarations, seen) + delete(seen, origin) + return + } + nestedPolicy := policy + nestedPolicy.view = "" + switch actual := attribute.Type.(type) { + case *expr.Object: + for _, named := range *actual { + c.collectUnionDeclarations(named.Attribute, wireAttribute, nestedPolicy, declarations, seen) + } + case *expr.Array: + c.collectUnionDeclarations(actual.ElemType, wireAttribute, nestedPolicy, declarations, seen) + case *expr.Map: + c.collectUnionDeclarations(actual.KeyType, wireAttribute, nestedPolicy, declarations, seen) + c.collectUnionDeclarations(actual.ElemType, wireAttribute, nestedPolicy, declarations, seen) + case *expr.Union: + for _, named := range actual.Values { + c.collectUnionDeclarations(named.Attribute, wireAttribute, nestedPolicy, declarations, seen) + } + } +} + +// applyUnionRecord gives one copied union the exact branch type names stored in +// record. +func (c *wireTypeCatalog) applyUnionRecord(attribute *expr.AttributeExpr, record *wireUnionRecord) { + union := attribute.Type.(*expr.Union) + c.unionBindings[union] = record + index := 0 + seen := make(map[expr.UserType]struct{}) + for _, branch := range union.Values { + c.applyResolvedDeclarations(branch.Attribute, record.identity.owner.use, record.identity.declarations, &index, seen) + } + if index != len(record.identity.declarations) { + panic(fmt.Sprintf("HTTP union %q did not use every submitted branch name", record.name)) + } +} + +// applyResolvedDeclarations gives each named branch its previously chosen Go +// type in the same order those types were recorded. +func (c *wireTypeCatalog) applyResolvedDeclarations(attribute *expr.AttributeExpr, use wireUnionUse, declarations []*wireTypeRecord, index *int, seen map[expr.UserType]struct{}) { + if attribute.Type == expr.Empty { + return + } + if userType, ok := attribute.Type.(expr.UserType); ok { + if *index >= len(declarations) { + panic(fmt.Sprintf("HTTP union branch %q has no submitted Go type name", wireTypeDeclaredName(userType))) + } + record := declarations[*index] + (*index)++ + c.bindOccurrence(attribute, record) + origin := userType.Origin() + if _, ok := seen[origin]; ok { + return + } + seen[origin] = struct{}{} + c.applyResolvedDeclarations(userType.Attribute(), use, declarations, index, seen) + delete(seen, origin) + return + } + switch actual := attribute.Type.(type) { + case *expr.Object: + for _, named := range *actual { + c.applyResolvedDeclarations(named.Attribute, use, declarations, index, seen) + } + case *expr.Array: + c.applyResolvedDeclarations(actual.ElemType, use, declarations, index, seen) + case *expr.Map: + c.applyResolvedDeclarations(actual.KeyType, use, declarations, index, seen) + c.applyResolvedDeclarations(actual.ElemType, use, declarations, index, seen) + case *expr.Union: + start := *index + for _, branch := range actual.Values { + c.applyResolvedDeclarations(branch.Attribute, use, declarations, index, seen) + } + identity := wireUnionIdentity{ + declaration: codegen.NewUnionDeclarationID(attribute), + owner: wireUnionOwner{ + authored: attribute.AuthoredAttribute(), + use: use, + }, + declarations: declarations[start:*index], + } + record := c.findUnion(identity) + if record == nil { + panic(fmt.Sprintf("HTTP nested union %q has no submitted Go type name", actual.Name())) + } + c.unionBindings[actual] = record + } +} + +// resolver returns the chosen HTTP type names. The supplied name list is used +// only to keep local variable names unique. +func (c *wireTypeCatalog) resolver(scope *codegen.NameScope, policy wireTypePolicy) codegen.Attributor { + return &wireAttributeScope{catalog: c, base: codegen.NewAttributeScope(scope), policy: policy} +} + +// resolverForUse names unions from copies made while rendering one exact HTTP +// body role and response view. +func (c *wireTypeCatalog) resolverForUse(scope *codegen.NameScope, policy wireTypePolicy, use wireUnionUse) codegen.Attributor { + return &wireAttributeScope{catalog: c, base: codegen.NewAttributeScope(scope), policy: policy, use: use} +} + +// occurrenceResolver uses only the exact type records attached to one copied +// expression. Union declarations use it after their branch records are fixed. +func (c *wireTypeCatalog) occurrenceResolver(scope *codegen.NameScope, use wireUnionUse) codegen.Attributor { + return &wireAttributeScope{ + catalog: c, + base: codegen.NewAttributeScope(scope), + use: use, + exactOccurrence: true, + } +} + +// rootResolver applies a selected result view only to root. Nested fields use +// their normal type definitions without that view. +func (c *wireTypeCatalog) rootResolver(scope *codegen.NameScope, policy wireTypePolicy, use wireUnionUse, root *wireTypeRecord) codegen.Attributor { + return &wireAttributeScope{catalog: c, base: codegen.NewAttributeScope(scope), policy: policy, use: use, viewRoot: root} +} + +// bindOccurrence records the Go type used by one copied named value and its fields. +func (c *wireTypeCatalog) bindOccurrence(attribute *expr.AttributeExpr, record *wireTypeRecord) { + c.bindings[attribute] = record + if userType, ok := attribute.Type.(expr.UserType); ok { + c.bindings[userType.Attribute()] = record + } +} + +// applyReleasedNames gives a copied composite body the public nested type +// names used to build its released constructor name. +func (c *wireTypeCatalog) applyReleasedNames(attribute *expr.AttributeExpr, policy wireTypePolicy, seen map[expr.UserType]struct{}) { + if attribute.Type == expr.Empty { + return + } + if userType, ok := attribute.Type.(expr.UserType); ok { + preferred := wireTypePreferredName(userType, policy) + record := c.find(newWireTypeIdentity(attribute, wireAttribute, policy, preferred)) + if record == nil { + panic(fmt.Sprintf("HTTP type %q was not submitted before its constructor name was built", preferred)) + } + userType.Attribute().AddMeta("struct:type:name", record.preferredName()) + origin := userType.Origin() + if _, ok := seen[origin]; ok { + return + } + seen[origin] = struct{}{} + nestedPolicy := policy + nestedPolicy.view = "" + c.applyReleasedNames(userType.Attribute(), nestedPolicy, seen) + delete(seen, origin) + return + } + nestedPolicy := policy + nestedPolicy.view = "" + switch actual := attribute.Type.(type) { + case *expr.Object: + for _, named := range *actual { + c.applyReleasedNames(named.Attribute, nestedPolicy, seen) + } + case *expr.Array: + c.applyReleasedNames(actual.ElemType, nestedPolicy, seen) + case *expr.Map: + c.applyReleasedNames(actual.KeyType, nestedPolicy, seen) + c.applyReleasedNames(actual.ElemType, nestedPolicy, seen) + case *expr.Union: + for _, named := range actual.Values { + c.applyReleasedNames(named.Attribute, nestedPolicy, seen) + } + } +} + +// releasedCompositeName returns the old public name for an array or map after +// applying its nested public type names. +func (c *wireTypeCatalog) releasedCompositeName(body *expr.AttributeExpr, policy wireTypePolicy) string { + body = expr.DupAtt(body) + c.applyReleasedNames(body, policy, make(map[expr.UserType]struct{})) + name := codegen.NewAttributeScope(codegen.NewNameScope()).Name(body, "", policy.pointer, policy.useDefault) + return codegen.Goify(name, true) +} + +// releasedCompositeConstructorName returns the old public constructor name for +// an array or map body. +func (c *wireTypeCatalog) releasedCompositeConstructorName(body *expr.AttributeExpr, policy wireTypePolicy) string { + return "New" + c.releasedCompositeName(body, policy) +} + +// Name returns the type name selected for this HTTP attribute copy. +func (s *wireAttributeScope) Name(attribute *expr.AttributeExpr, pkg string, pointer, useDefault bool) string { + if record := s.record(attribute); record != nil { + if pkg == "" { + return record.name + } + return pkg + "." + record.name + } + if _, ok := attribute.Type.(*expr.Union); ok { + if record := s.unionRecord(attribute); record != nil { + if pkg == "" { + return record.name + } + return pkg + "." + record.name + } + } + switch actual := attribute.Type.(type) { + case expr.Primitive: + return s.base.Name(attribute, pkg, pointer, useDefault) + case *expr.Array, *expr.Map, *expr.Object: + context := &codegen.AttributeContext{ + Pointer: pointer, + UseDefault: useDefault, + Scope: s, + UnionPointer: true, + ArrayElementPointer: s.policy.arrayElementPointer, + } + return goTypeDefForContext(attribute, context) + case expr.UserType: + panic(fmt.Sprintf("HTTP type %q has no package declaration", wireTypeDeclaredName(actual))) + default: + return s.base.Name(attribute, pkg, pointer, useDefault) + } +} + +// Ref returns the pointer or value spelling for this HTTP attribute copy. +func (s *wireAttributeScope) Ref(attribute *expr.AttributeExpr, pkg string) string { + layout, err := s.goTypeLayout(attribute, wireGoLayoutPolicy(s.policy), true) + if err != nil { + panic(fmt.Sprintf("resolve HTTP type %q: %v", attribute.Type.Name(), err)) + } + return layout.Ref() +} + +// GoTypeLayout returns the exact fields and references selected by this HTTP +// package for attribute. +func (s *wireAttributeScope) GoTypeLayout(attribute *expr.AttributeExpr, policy codegen.GoLayoutPolicy) (codegen.LinkedGoType, error) { + return s.goTypeLayout(attribute, policy, false) +} + +// goTypeLayout may stop at a named root when a caller only needs its reference. +func (s *wireAttributeScope) goTypeLayout(attribute *expr.AttributeExpr, policy codegen.GoLayoutPolicy, referenceOnly bool) (codegen.LinkedGoType, error) { + owner := s.catalog.pkg.ImportPath() + layout, err := codegen.PlanGoType(attribute, codegen.GoTypePlanOptions{ + Owner: owner, + Policy: policy, + RetainNamedValue: !referenceOnly, + Bind: func(request codegen.GoTypeBindingRequest) (codegen.GoTypeBinding, error) { + switch request.Kind { + case codegen.GoNamed: + record := s.record(request.Attribute) + if record == nil || record.declaration == nil { + return codegen.GoTypeBinding{}, fmt.Errorf("HTTP type %q has no planned declaration", request.Attribute.Type.Name()) + } + return codegen.GoTypeBinding{Owner: owner, Declaration: record.declaration}, nil + case codegen.GoUnion: + record := s.unionRecord(request.Attribute) + if record == nil || record.declaration == nil { + return codegen.GoTypeBinding{}, fmt.Errorf("HTTP OneOf %q has no planned declaration", request.Attribute.Type.Name()) + } + return codegen.GoTypeBinding{Owner: owner, Declaration: record.declaration}, nil + default: + return codegen.GoTypeBinding{}, fmt.Errorf("resolve unsupported HTTP Go type kind %s", request.Kind) + } + }, + }) + if err != nil { + return codegen.LinkedGoType{}, err + } + return layout.Link(owner, s.catalog.pkg.ImportName), nil +} + +// Field returns the generated Go field for an HTTP attribute. +func (*wireAttributeScope) Field(attribute *expr.AttributeExpr, name string, firstUpper bool) string { + return codegen.GoifyAtt(attribute, name, firstUpper) +} + +// Package returns the Go package name written before the type for attribute. +func (s *wireAttributeScope) Package(attribute *expr.AttributeExpr) string { + if location := codegen.UserTypeLocation(attribute.Type); location != nil { + return s.base.Package(attribute) + } + return s.pkg +} + +// Enter returns type names with the Go package name needed by nested fields. +func (s *wireAttributeScope) Enter(attribute *expr.AttributeExpr) codegen.Attributor { + pkg := s.pkg + if location := codegen.UserTypeLocation(attribute.Type); location != nil { + pkg = s.base.Package(attribute) + } + policy := s.policy + viewRoot := s.viewRoot + if policy.view != "" && (s.viewRoot == nil || !wireTypeIdentitiesEqual( + s.viewRoot.identity, + newWireTypeIdentity(attribute, s.viewRoot.identity.role, policy, s.viewRoot.identity.preferred), + )) { + policy.view = "" + viewRoot = nil + } + return &wireAttributeScope{ + catalog: s.catalog, + base: s.base.Enter(attribute), + pkg: pkg, + policy: policy, + use: s.use, + viewRoot: viewRoot, + exactOccurrence: s.exactOccurrence, + } +} + +// IsSumType reports that HTTP unions use generated values that hold one branch. +func (*wireAttributeScope) IsSumType() bool { + return true +} + +// ValidatorCall returns the exact private call used for a named value inside +// another HTTP body value. +func (s *wireAttributeScope) ValidatorCall(attribute *expr.AttributeExpr, _, target, path string) string { + if record := s.record(attribute); record != nil { + if record.nestedValidator == nil { + panic(fmt.Sprintf("HTTP type %q has no nested validator", record.name)) + } + return fmt.Sprintf("%s(%s, %s)", record.nestedValidator.Name(), target, path) + } + if userType, ok := attribute.Type.(expr.UserType); ok { + panic(fmt.Sprintf("HTTP validator for %q has no package declaration", wireTypeDeclaredName(userType))) + } + return s.base.ValidatorCall(attribute, "", target, path) +} + +// record returns the chosen type for attribute. A copied nested value may reuse +// a type when its pointer and default-value rules are the same. +func (s *wireAttributeScope) record(attribute *expr.AttributeExpr) *wireTypeRecord { + userType, ok := attribute.Type.(expr.UserType) + if !ok { + return nil + } + if s.exactOccurrence { + return s.catalog.bindings[attribute] + } + preferred := wireTypePreferredName(userType, s.policy) + if s.viewRoot != nil { + identity := newWireTypeIdentity(attribute, s.viewRoot.identity.role, s.policy, s.viewRoot.identity.preferred) + if wireTypeIdentitiesEqual(s.viewRoot.identity, identity) { + return s.viewRoot + } + } + identity := newWireTypeIdentity(attribute, wireAttribute, s.policy, preferred) + if record := s.catalog.bindings[attribute]; record != nil && wireTypeIdentitiesEqual(record.identity, identity) { + return record + } + return s.catalog.find(identity) +} + +// unionRecord returns the declaration assigned to this exact copied union. +func (s *wireAttributeScope) unionRecord(attribute *expr.AttributeExpr) *wireUnionRecord { + union := attribute.Type.(*expr.Union) + if record := s.catalog.unionBindings[union]; record != nil { + return record + } + identity := s.catalog.unionIdentity(attribute, s.use, wireAttribute, s.policy) + if record := s.catalog.findUnion(identity); record != nil { + s.catalog.applyUnionRecord(attribute, record) + return record + } + if s.catalog.linked { + panic(fmt.Sprintf("HTTP union %q was not bound to its planned declaration", union.Name())) + } + return nil +} + +// Scope returns the list used to keep local variable names unique. +func (s *wireAttributeScope) Scope() *codegen.NameScope { + return s.base.Scope() +} + +// findUnion returns the package record for the same generated union. +func (c *wireTypeCatalog) findUnion(identity wireUnionIdentity) *wireUnionRecord { + for _, record := range c.unions { + if wireUnionIdentitiesEqual(record.identity, identity) { + return record + } + } + return nil +} + +// findUnionOwner returns the declaration for one authored OneOf in one HTTP +// body role and response view. +func (c *wireTypeCatalog) findUnionOwner(owner wireUnionOwner) *wireUnionRecord { + for _, record := range c.unions { + if record.identity.owner == owner { + return record + } + } + return nil +} + +// wireUnionIdentitiesEqual reports whether two copies come from the same OneOf, +// serve the same HTTP use, and emit the same branch declarations. +func wireUnionIdentitiesEqual(left, right wireUnionIdentity) bool { + return left.declaration == right.declaration && left.owner.use == right.owner.use && slices.Equal(left.declarations, right.declarations) +} + +// nameFor returns the exact public name for one union in this HTTP body role. +func (u wireUnionUse) nameFor(union *expr.Union) string { + return codegen.Goify(union.Name(), true) + u.suffix() +} + +// suffix returns the fixed public suffix for one HTTP body role and response +// view. +func (u wireUnionUse) suffix() string { + switch u.role { + case wireRequestBody: + return "RequestBody" + case wireStreamPayload: + return "StreamingBody" + case wireResponseBody: + return codegen.Goify(u.view, true) + "ResponseBody" + default: + return "" + } +} + +// description names one HTTP use in an error shown to a design author. +func (u wireUnionUse) description() string { + if suffix := u.suffix(); suffix != "" { + return suffix + } + return "direct attribute" +} + +// declareUnionName reserves one exact public name and explains how the design +// author can resolve a collision. +func (c *wireTypeCatalog) declareUnionName(union *expr.Union, use wireUnionUse, declaration *codegen.NameDeclaration) error { + if err := c.pkg.DeclareName(declaration); err != nil { + return fmt.Errorf( + "declare HTTP OneOf %q for %s: %w; set TypeName on the OneOf to a unique name", + union.Name(), + use.description(), + err, + ) + } + return nil +} + +// validateHTTPUnionBranchGoNames rejects branch spellings that would create +// the same exported Go identifier and therefore the same public union names. +func validateHTTPUnionBranchGoNames(union *expr.Union) error { + branches := make(map[string]string, len(union.Values)) + for _, branch := range union.Values { + goName := codegen.Goify(branch.Name, true) + if existing, ok := branches[goName]; ok { + return fmt.Errorf( + "OneOf %q branches %q and %q both generate Go name %q; rename one of the branches", + union.Name(), + existing, + branch.Name, + goName, + ) + } + branches[goName] = branch.Name + } + return nil +} + +// newWireTypeIdentity records the designed value and the rules that determine +// its generated Go type. +func newWireTypeIdentity(attribute *expr.AttributeExpr, role wireTypeRole, policy wireTypePolicy, preferred string) wireTypeIdentity { + identity := wireTypeIdentity{role: role, preferred: preferred, attribute: expr.DupAtt(attribute), policy: policy} + if userType, ok := attribute.Type.(expr.UserType); ok { + if resultType, ok := userType.(*expr.ResultTypeExpr); ok { + identity.resultID = resultType.Identifier + identity.role = 0 + identity.policy.view = "" + } else if policy.view == "" { + identity.sourceID = userType.Origin().ID() + identity.role = 0 + } + } + return identity +} + +// wireTypePreferredName returns the Go name requested for one designed type. +// A result type created for one view already has the view in its name. Other +// values use their authored type name unless selecting a view changes the +// fields in the generated transport type. +func wireTypePreferredName(userType expr.UserType, policy wireTypePolicy) string { + named := userType.Origin() + if _, projected := userType.(*expr.ResultTypeExpr); projected || policy.view != "" { + named = userType + } + return codegen.Goify(wireTypeDeclaredName(named), true) +} + +// releasedWireTypeSuffix returns the suffix that HTTP copies added to named +// values nested inside one body. The body declaration itself already keeps its +// endpoint name. +func releasedWireTypeSuffix(attribute *expr.AttributeExpr, role wireTypeRole) string { + switch role { + case wireRequestBody: + return "RequestBody" + case wireStreamPayload: + if expr.IsObject(attribute.Type) { + return "StreamingBody" + } + return "" + case wireResponseBody: + if expr.IsObject(attribute.Type) { + return "ResponseBody" + } + return "Response" + default: + return "" + } +} + +// wireTypeIdentitiesEqual reports whether two records produce the same Go type. +func wireTypeIdentitiesEqual(left, right wireTypeIdentity) bool { + if left.sourceID != right.sourceID || left.resultID != right.resultID || left.role != right.role || left.preferred != right.preferred || !wireTypePoliciesEqual(left.policy, right.policy) { + return false + } + if left.sourceID != "" { + leftType := left.attribute.Type.(expr.UserType) + rightType := right.attribute.Type.(expr.UserType) + return wireAttributesEqual(leftType.Attribute(), rightType.Attribute(), make(map[wireAttributePair]struct{})) + } + if leftType, ok := left.attribute.Type.(expr.UserType); ok { + rightType, ok := right.attribute.Type.(expr.UserType) + return ok && wireAttributesEqual(leftType.Attribute(), rightType.Attribute(), make(map[wireAttributePair]struct{})) + } + return wireAttributesEqual(left.attribute, right.attribute, make(map[wireAttributePair]struct{})) +} + +// order returns the designed values used to choose a stable suffix when several +// HTTP declarations ask for the same Go name. +func (i wireTypeIdentity) order(kind wireNameKind) wireNameOrder { + return wireNameOrder{ + kind: kind, + api: i.api, + source: i.sourceID + i.resultID, + role: uint8(i.role), + preferred: i.preferred, + shape: expr.Hash(i.attribute.Type, false, false, false), + view: i.policy.view, + request: i.policy.request, + pointer: i.policy.pointer, + arrayElementPointer: i.policy.arrayElementPointer, + defaults: i.policy.useDefault, + } +} + +// ComparePackageName orders HTTP declarations from designed values so memory +// addresses and design reading order cannot change generated names. +func (o wireNameOrder) ComparePackageName(other codegen.PackageNameOrder) int { + right := other.(wireNameOrder) + for _, compared := range []int{ + cmp.Compare(o.kind, right.kind), + cmp.Compare(o.api, right.api), + cmp.Compare(o.source, right.source), + cmp.Compare(o.target, right.target), + cmp.Compare(o.role, right.role), + cmp.Compare(o.preferred, right.preferred), + cmp.Compare(o.shape, right.shape), + cmp.Compare(o.view, right.view), + cmp.Compare(boolOrder(o.request), boolOrder(right.request)), + cmp.Compare(boolOrder(o.pointer), boolOrder(right.pointer)), + cmp.Compare(boolOrder(o.arrayElementPointer), boolOrder(right.arrayElementPointer)), + cmp.Compare(boolOrder(o.defaults), boolOrder(right.defaults)), + } { + if compared != 0 { + return compared + } + } + return 0 +} + +// boolOrder converts false to zero and true to one for name ordering. +func boolOrder(value bool) uint8 { + if value { + return 1 + } + return 0 +} + +// wireTypePoliciesEqual compares only rules that change a Go type definition. +// A validation function does not create a second type when every field is the +// same. +func wireTypePoliciesEqual(left, right wireTypePolicy) bool { + left.validate = false + right.validate = false + return left == right +} + +// wireAttributesEqual compares the designed facts that change a generated type +// or its validation function. It handles types that refer back to themselves. +func wireAttributesEqual(left, right *expr.AttributeExpr, seen map[wireAttributePair]struct{}) bool { + if left == right { + return true + } + pair := wireAttributePair{left: left, right: right} + if _, ok := seen[pair]; ok { + return true + } + seen[pair] = struct{}{} + if !reflect.DeepEqual(left.DefaultValue, right.DefaultValue) || !reflect.DeepEqual(left.Validation, right.Validation) || !wireMetadataEqual(left.Meta, right.Meta) { + return false + } + switch ltype := left.Type.(type) { + case expr.UserType: + rtype, ok := right.Type.(expr.UserType) + if !ok { + return false + } + if lresult, ok := ltype.(*expr.ResultTypeExpr); ok { + rresult, ok := rtype.(*expr.ResultTypeExpr) + return ok && lresult.Identifier == rresult.Identifier && lresult.Name() == rresult.Name() && wireAttributesEqual(lresult.Attribute(), rresult.Attribute(), seen) + } + return ltype.Origin() == rtype.Origin() && wireAttributesEqual(ltype.Attribute(), rtype.Attribute(), seen) + case *expr.Object: + rtype, ok := right.Type.(*expr.Object) + if !ok || len(*ltype) != len(*rtype) { + return false + } + for index, field := range *ltype { + other := (*rtype)[index] + if field.Name != other.Name || !wireAttributesEqual(field.Attribute, other.Attribute, seen) { + return false + } + } + return true + case *expr.Array: + rtype, ok := right.Type.(*expr.Array) + return ok && ltype.NonNullableElems == rtype.NonNullableElems && wireAttributesEqual(ltype.ElemType, rtype.ElemType, seen) + case *expr.Map: + rtype, ok := right.Type.(*expr.Map) + return ok && wireAttributesEqual(ltype.KeyType, rtype.KeyType, seen) && wireAttributesEqual(ltype.ElemType, rtype.ElemType, seen) + case *expr.Union: + rtype, ok := right.Type.(*expr.Union) + if !ok || ltype.Name() != rtype.Name() || ltype.GetTypeKey() != rtype.GetTypeKey() || ltype.GetValueKey() != rtype.GetValueKey() || len(ltype.Values) != len(rtype.Values) { + return false + } + for index, branch := range ltype.Values { + other := rtype.Values[index] + if branch.Name != other.Name || !wireAttributesEqual(branch.Attribute, other.Attribute, seen) { + return false + } + } + return true + default: + return left.Type.Kind() == right.Type.Kind() && left.Type.Name() == right.Type.Name() + } +} + +// wireMetadataEqual compares design metadata while ignoring the Go name added later. +func wireMetadataEqual(left, right expr.MetaExpr) bool { + keys := make([]string, 0, len(left)) + for key := range left { + if key != "struct:type:name" { + keys = append(keys, key) + } + } + slices.Sort(keys) + otherKeys := make([]string, 0, len(right)) + for key := range right { + if key != "struct:type:name" { + otherKeys = append(otherKeys, key) + } + } + slices.Sort(otherKeys) + if !slices.Equal(keys, otherKeys) { + return false + } + for _, key := range keys { + if !slices.Equal(left[key], right[key]) { + return false + } + } + return true +} + +// sortedWireAttributes keeps generated Go names stable when object fields are +// listed in a different order. +func sortedWireAttributes(attributes []*expr.NamedAttributeExpr) []*expr.NamedAttributeExpr { + sorted := slices.Clone(attributes) + slices.SortFunc(sorted, func(left, right *expr.NamedAttributeExpr) int { + return strings.Compare(left.Name, right.Name) + }) + return sorted +} + +// wireTypeDeclaredName returns the type name written in the design instead of a +// generated Go name saved in metadata. +func wireTypeDeclaredName(userType expr.UserType) string { + switch actual := userType.(type) { + case *expr.UserTypeExpr: + return actual.TypeName + case *expr.ResultTypeExpr: + return actual.TypeName + default: + panic(fmt.Sprintf("unsupported HTTP wire user type %T", userType)) + } +} diff --git a/http/codegen/wire_catalog_test.go b/http/codegen/wire_catalog_test.go new file mode 100644 index 0000000000..42ba7f19a1 --- /dev/null +++ b/http/codegen/wire_catalog_test.go @@ -0,0 +1,541 @@ +// This file verifies copied HTTP request and response types receive the right +// Go names when their source types, field rules, or generated functions differ. +package codegen + +import ( + "testing" + + "github.com/stretchr/testify/require" + + "goa.design/goa/v3/codegen" + "goa.design/goa/v3/expr" +) + +func TestWireTypeCatalogIdentity(t *testing.T) { + request := wireTypePolicy{request: true, pointer: true} + response := wireTypePolicy{useDefault: true} + first := wireCatalogType("Shared", "same", "first", true) + second := wireCatalogType("Shared", "same", "second", false) + + catalog, generation := testWireTypeCatalog(t) + firstBody := makeHTTPType(&expr.AttributeExpr{Type: first}) + catalog.collect(firstBody, wireRequestBody, request) + catalog.collect(makeHTTPType(&expr.AttributeExpr{Type: first}), wireRequestBody, request) + catalog.collect(makeHTTPType(&expr.AttributeExpr{Type: second}), wireRequestBody, request) + catalog.collect(makeHTTPType(&expr.AttributeExpr{Type: first}), wireResponseBody, response) + linkTestWireTypeCatalog(t, generation, catalog) + firstRecord := catalog.lookupUser(firstBody, wireRequestBody, request) + reusedRecord := catalog.lookupUser(makeHTTPType(&expr.AttributeExpr{Type: first}), wireRequestBody, request) + secondRecord := catalog.lookupUser(makeHTTPType(&expr.AttributeExpr{Type: second}), wireRequestBody, request) + responseRecord := catalog.lookupUser(makeHTTPType(&expr.AttributeExpr{Type: first}), wireResponseBody, response) + + require.Same(t, firstRecord, reusedRecord) + require.Len(t, map[string]struct{}{ + firstRecord.name: {}, secondRecord.name: {}, responseRecord.name: {}, + }, 3) +} + +func TestWireTypeCatalogRecursiveIdentityTerminates(t *testing.T) { + recursive := &expr.UserTypeExpr{TypeName: "Node", UID: "node"} + object := &expr.Object{} + recursive.AttributeExpr = &expr.AttributeExpr{Type: object} + object.Set("next", &expr.AttributeExpr{Type: recursive}) + + catalog, generation := testWireTypeCatalog(t) + body := makeHTTPType(&expr.AttributeExpr{Type: recursive}) + policy := wireTypePolicy{request: true, pointer: true} + catalog.collect(body, wireRequestBody, policy) + linkTestWireTypeCatalog(t, generation, catalog) + record := catalog.lookupUser(body, wireRequestBody, policy) + + require.Equal(t, "Node", record.name) + require.Len(t, catalog.records, 1) +} + +func TestOptionalWireTypeRefUsesLinkedLayout(t *testing.T) { + catalog, generation := testWireTypeCatalog(t) + linkTestWireTypeCatalog(t, generation, catalog) + resolver := catalog.resolver(catalog.scope, wireTypePolicy{}).(*wireAttributeScope) + + tests := []struct { + name string + attribute *expr.AttributeExpr + want string + preserve bool + dereference bool + }{ + { + name: "anonymous object", + attribute: &expr.AttributeExpr{Type: &expr.Object{}}, + want: "*struct {\n}", + preserve: true, + }, + { + name: "primitive", + attribute: &expr.AttributeExpr{Type: expr.String}, + want: "*string", + preserve: true, + dereference: true, + }, + { + name: "array already preserves absence", + attribute: &expr.AttributeExpr{Type: &expr.Array{ElemType: &expr.AttributeExpr{Type: expr.String}}}, + want: "[]string", + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + layout, err := resolver.GoTypeLayout(test.attribute, wireGoLayoutPolicy(wireTypePolicy{})) + require.NoError(t, err) + ref, preserve, dereference := optionalWireTypeRef(layout, true) + require.Equal(t, test.want, ref) + require.Equal(t, test.preserve, preserve) + require.Equal(t, test.dereference, dereference) + }) + } +} + +// TestWireAttributeScopeUsesFinalImportAliases verifies that HTTP type names +// use the package names selected before the generated files are written. +func TestWireAttributeScopeUsesFinalImportAliases(t *testing.T) { + catalog, generation := testWireTypeCatalog(t) + const ( + customPath = "example.com/custom/wire" + generatedPath = "generated.local/gen/types" + ) + require.NoError(t, catalog.pkg.DeclareImport(codegen.NewImport("wire", customPath))) + require.NoError(t, catalog.pkg.RequireImport(codegen.NewImport("wire", "example.com/fixed/wire"))) + require.NoError(t, catalog.pkg.ReserveGeneratedImport(codegen.NewImport("types", generatedPath))) + require.NoError(t, catalog.pkg.RequireImport(codegen.NewImport("types", "example.com/fixed/types"))) + linkTestWireTypeCatalog(t, generation, catalog) + resolver := catalog.resolver(catalog.scope, wireTypePolicy{}).(*wireAttributeScope) + + custom := &expr.AttributeExpr{ + Type: expr.String, + Meta: expr.MetaExpr{ + "struct:field:type": {"map[wire.Key]wire.Value", customPath, "wire"}, + }, + } + relocated := &expr.UserTypeExpr{ + TypeName: "Record", + AttributeExpr: &expr.AttributeExpr{ + Type: expr.String, + Meta: expr.MetaExpr{"struct:pkg:path": {"types"}}, + }, + } + relocatedAttribute := &expr.AttributeExpr{Type: relocated} + + require.Equal(t, "map[wire2.Key]wire2.Value", resolver.Name(custom, "", false, true)) + require.Equal(t, "types2", resolver.Package(relocatedAttribute)) + require.Equal(t, "types2", resolver.Enter(relocatedAttribute).(*wireAttributeScope).pkg) +} + +func TestWireTypeCatalogPreservesReleasedNestedNames(t *testing.T) { + cases := []struct { + name string + role wireTypeRole + body *expr.AttributeExpr + want string + }{ + { + name: "request body", + role: wireRequestBody, + body: wireCatalogContainer(wireCatalogType("Child", "request-child", "value", true)), + want: "ChildRequestBody", + }, + { + name: "streaming body", + role: wireStreamPayload, + body: wireCatalogContainer(wireCatalogType("Child", "stream-child", "value", true)), + want: "ChildStreamingBody", + }, + { + name: "object response body", + role: wireResponseBody, + body: wireCatalogContainer(wireCatalogType("Child", "response-child", "value", true)), + want: "ChildResponseBody", + }, + { + name: "collection response body", + role: wireResponseBody, + body: &expr.AttributeExpr{Type: &expr.Array{ElemType: &expr.AttributeExpr{ + Type: wireCatalogType("Child", "response-element", "value", true), + }}}, + want: "ChildResponse", + }, + } + for _, test := range cases { + t.Run(test.name, func(t *testing.T) { + catalog, generation := testWireTypeCatalog(t) + catalog.collect(test.body, test.role, wireTypePolicy{}) + linkTestWireTypeCatalog(t, generation, catalog) + + child := firstWireUserType(test.body) + record := catalog.lookupUser(child, wireAttribute, wireTypePolicy{}) + require.Equal(t, test.want, record.name) + }) + } +} + +func TestWireTypeCatalogKeepsCurrentNameForSharedReleasedDeclarations(t *testing.T) { + child := wireCatalogType("Shared", "shared", "value", true) + request := wireCatalogContainer(child) + stream := wireCatalogContainer(child) + catalog, generation := testWireTypeCatalog(t) + + catalog.collect(request, wireRequestBody, wireTypePolicy{}) + catalog.collect(stream, wireStreamPayload, wireTypePolicy{}) + linkTestWireTypeCatalog(t, generation, catalog) + + record := catalog.lookupUser(firstWireUserType(request), wireAttribute, wireTypePolicy{}) + require.Equal(t, "Shared", record.name) +} + +func TestWireTypeCatalogSuffixesReleasedNameAfterPackageCollision(t *testing.T) { + body := wireCatalogContainer(wireCatalogType("Child", "child", "value", true)) + catalog, generation := testWireTypeCatalog(t, "ChildRequestBody") + catalog.collect(body, wireRequestBody, wireTypePolicy{}) + linkTestWireTypeCatalog(t, generation, catalog) + + record := catalog.lookupUser(firstWireUserType(body), wireAttribute, wireTypePolicy{}) + require.Equal(t, "ChildRequestBody2", record.name) +} + +func TestWireTypeCatalogSeparatesDeclarationIdentityFromValidatorPlacement(t *testing.T) { + typeAttribute := &expr.AttributeExpr{Type: wireCatalogType("Shared", "shared", "value", true)} + withoutValidator := wireTypePolicy{pointer: true} + withValidator := wireTypePolicy{pointer: true, validate: true} + catalog, generation := testWireTypeCatalog(t) + + first := catalog.collect(typeAttribute, wireResponseBody, withoutValidator) + second := catalog.collect(expr.DupAtt(typeAttribute), wireAttribute, withValidator) + catalog.addValidationRoot(&expr.AttributeExpr{Type: &expr.Object{ + {Name: "shared", Attribute: expr.DupAtt(typeAttribute)}, + }}, withValidator) + + require.Same(t, first, second) + linkTestWireTypeCatalog(t, generation, catalog) + catalog.bind(first, &TypeData{Def: "struct { Value string }"}) + catalog.bind(second, &TypeData{ + Def: "struct { Value string }", + ValidateDef: "validate shared from body", + NestedValidateDef: "validate shared from parent path", + }) + require.Equal(t, "Shared", first.name) + require.Equal(t, "validate shared from body", first.data.ValidateDef) + require.Equal(t, "validate shared from parent path", first.data.NestedValidateDef) + require.Equal(t, "ValidateShared", first.data.ValidatorName) + require.Equal(t, "validateShared", first.data.NestedValidatorName) +} + +func TestWireTypeCatalogErrorDescriptionUsesAllPlannedErrors(t *testing.T) { + cases := []struct { + name string + uses []wireErrorUse + want string + }{ + { + name: "same endpoint", + uses: []wireErrorUse{ + {service: "Calc", method: "Add", name: "underflow"}, + {service: "Calc", method: "Add", name: "overflow"}, + }, + want: "Shared is the type of the \"Calc\" service \"Add\" endpoint HTTP response body for the \"overflow\" and \"underflow\" errors.", + }, + { + name: "several endpoints", + uses: []wireErrorUse{ + {service: "Beta", method: "Write", name: "conflict"}, + {service: "Alpha", method: "Read", name: "missing"}, + }, + want: "Shared is the HTTP response body type for these service errors:\n" + + "- \"Alpha\" service \"Read\" endpoint: \"missing\" error\n" + + "- \"Beta\" service \"Write\" endpoint: \"conflict\" error", + }, + } + for _, test := range cases { + t.Run(test.name, func(t *testing.T) { + attribute := &expr.AttributeExpr{Type: wireCatalogType("Shared", "shared", "value", true)} + policy := wireTypePolicy{pointer: true} + catalog, generation := testWireTypeCatalog(t) + record := catalog.collect(attribute, wireResponseBody, policy) + for _, use := range test.uses { + record.addErrorUse(use) + } + record.addErrorUse(test.uses[0]) + + linkTestWireTypeCatalog(t, generation, catalog) + catalog.bind(record, &TypeData{ + Description: "Shared is an HTTP response body.", + Def: "struct { Value string }", + }) + + require.Equal(t, test.want, record.data.Description) + }) + } +} + +func TestWireTypeCatalogPlansNestedValidatorNameWithPackageNames(t *testing.T) { + attribute := &expr.AttributeExpr{Type: wireCatalogType("Shared", "shared", "value", true)} + policy := wireTypePolicy{pointer: true, validate: true} + catalog, generation := testWireTypeCatalog(t, "validateShared") + record := catalog.collect(attribute, wireAttribute, policy) + catalog.addValidationRoot(&expr.AttributeExpr{Type: &expr.Object{ + {Name: "shared", Attribute: expr.DupAtt(attribute)}, + }}, policy) + + linkTestWireTypeCatalog(t, generation, catalog) + catalog.bind(record, &TypeData{ + ValidateDef: "validate shared from body", + NestedValidateDef: "validate shared from parent path", + }) + + require.Equal(t, "validateShared2", record.data.NestedValidatorName) +} + +func TestWireTypeCatalogDoesNotRewriteValidationCalls(t *testing.T) { + attribute := &expr.AttributeExpr{Type: wireCatalogType("Shared", "shared", "value", true)} + policy := wireTypePolicy{pointer: true, validate: true} + catalog, generation := testWireTypeCatalog(t, "ValidateShared") + record := catalog.collect(attribute, wireAttribute, policy) + linkTestWireTypeCatalog(t, generation, catalog) + + catalog.bind(record, &TypeData{ + ValidateDef: "validate shared from body", + ValidateRef: "err = ValidateSharedCopy(v)", + }) + + require.Equal(t, "ValidateShared2", record.data.ValidatorName) + require.Equal(t, "err = ValidateSharedCopy(v)", record.data.ValidateRef) +} + +func TestWireTypeCatalogCollectsUnionsBeforeFreeze(t *testing.T) { + union := &expr.Union{ + TypeName: "Choice", + Values: []*expr.NamedAttributeExpr{ + {Name: "text", Attribute: &expr.AttributeExpr{Type: expr.String}}, + {Name: "count", Attribute: &expr.AttributeExpr{Type: expr.Int}}, + }, + } + attribute := &expr.AttributeExpr{Type: union} + catalog, generation := testWireTypeCatalog(t) + + catalog.collect(attribute, wireAttribute, wireTypePolicy{}) + require.Len(t, catalog.unionOccurrences, 1) + linkTestWireTypeCatalog(t, generation, catalog) + catalog.applyNames(attribute, wireAttribute, wireTypePolicy{}) + require.Len(t, catalog.unions, 1) + require.NotNil(t, catalog.unions[0].data) +} + +func TestWireTypeCatalogLookupDoesNotDeriveIdentityFromAssignedName(t *testing.T) { + attribute := &expr.AttributeExpr{Type: wireCatalogType("Shared", "shared", "value", true)} + policy := wireTypePolicy{pointer: true} + catalog, generation := testWireTypeCatalog(t, "Shared") + catalog.collect(attribute, wireAttribute, policy) + linkTestWireTypeCatalog(t, generation, catalog) + + first := catalog.lookupUser(attribute, wireAttribute, policy) + second := catalog.lookupUser(attribute, wireAttribute, policy) + + require.Same(t, first, second) + require.Equal(t, "Shared2", first.name) +} + +func TestWireTypeCatalogBindingUsesCurrentLayoutPolicy(t *testing.T) { + attribute := &expr.AttributeExpr{Type: wireCatalogType("Shared", "shared", "value", true)} + valuePolicy := wireTypePolicy{} + pointerPolicy := wireTypePolicy{pointer: true} + catalog, generation := testWireTypeCatalog(t) + catalog.collect(attribute, wireAttribute, valuePolicy) + catalog.collect(attribute, wireAttribute, pointerPolicy) + linkTestWireTypeCatalog(t, generation, catalog) + + valueRecord := catalog.lookupUser(attribute, wireAttribute, valuePolicy) + pointerRecord := catalog.lookupUser(attribute, wireAttribute, pointerPolicy) + require.NotSame(t, valueRecord, pointerRecord) + + valueScope := catalog.resolver(catalog.scope, valuePolicy) + pointerScope := catalog.resolver(catalog.scope, pointerPolicy) + require.Equal(t, valueRecord.name, valueScope.Name(attribute, "", false, false)) + require.Equal(t, pointerRecord.name, pointerScope.Name(attribute, "", true, false)) +} + +func TestWireTypeCatalogDoesNotNameTheSharedEmptySentinel(t *testing.T) { + originalNil := expr.Empty.Attribute().Meta == nil + original := expr.Empty.Attribute().Meta.Dup() + attribute := &expr.AttributeExpr{Type: &expr.Object{ + {Name: "empty", Attribute: &expr.AttributeExpr{Type: expr.Empty}}, + }} + catalog, generation := testWireTypeCatalog(t) + + catalog.collect(attribute, wireAttribute, wireTypePolicy{}) + linkTestWireTypeCatalog(t, generation, catalog) + catalog.applyNames(attribute, wireAttribute, wireTypePolicy{}) + + if originalNil { + require.Nil(t, expr.Empty.Attribute().Meta) + } else { + require.Equal(t, original, expr.Empty.Attribute().Meta) + } +} + +func TestWireTypeCatalogRejectsLateAndUnknownDeclarations(t *testing.T) { + typeAttribute := &expr.AttributeExpr{Type: wireCatalogType("Known", "known", "value", true)} + policy := wireTypePolicy{request: true, pointer: true} + catalog, generation := testWireTypeCatalog(t) + catalog.collect(typeAttribute, wireRequestBody, policy) + linkTestWireTypeCatalog(t, generation, catalog) + + require.Panics(t, func() { + catalog.collect(&expr.AttributeExpr{Type: wireCatalogType("Late", "late", "value", true)}, wireRequestBody, policy) + }) + require.Panics(t, func() { + catalog.lookupUser(&expr.AttributeExpr{Type: wireCatalogType("Unknown", "unknown", "value", true)}, wireRequestBody, policy) + }) + require.Panics(t, func() { + catalog.scope.Unique("late") + }) +} + +func TestBindCopiedUnionOccurrencesRejectsDifferentGraphs(t *testing.T) { + tests := []struct { + name string + planned *expr.AttributeExpr + rendered *expr.AttributeExpr + message string + }{ + { + name: "different type", + planned: &expr.AttributeExpr{Type: &expr.Array{ElemType: &expr.AttributeExpr{Type: expr.String}}}, + rendered: &expr.AttributeExpr{Type: &expr.Map{KeyType: &expr.AttributeExpr{Type: expr.String}, ElemType: &expr.AttributeExpr{Type: expr.String}}}, + message: "planned array does not match rendered map at body", + }, + { + name: "missing field", + planned: &expr.AttributeExpr{Type: &expr.Object{{ + Name: "value", + Attribute: &expr.AttributeExpr{Type: expr.String}, + }}}, + rendered: &expr.AttributeExpr{Type: &expr.Object{}}, + message: `rendered object has no field "value" at body.value`, + }, + { + name: "extra field", + planned: &expr.AttributeExpr{Type: &expr.Object{}}, + rendered: &expr.AttributeExpr{Type: &expr.Object{{Name: "value", Attribute: &expr.AttributeExpr{Type: expr.String}}}}, + message: `planned object has no field "value" at body.value`, + }, + { + name: "missing union branch", + planned: &expr.AttributeExpr{Type: &expr.Union{TypeName: "Choice", Values: []*expr.NamedAttributeExpr{{ + Name: "value", + Attribute: &expr.AttributeExpr{Type: expr.String}, + }}}}, + rendered: &expr.AttributeExpr{Type: &expr.Union{TypeName: "Choice"}}, + message: `rendered OneOf "Choice" has no branch "value" at body.value`, + }, + { + name: "extra union branch", + planned: &expr.AttributeExpr{Type: &expr.Union{TypeName: "Choice"}}, + rendered: &expr.AttributeExpr{Type: &expr.Union{TypeName: "Choice", Values: []*expr.NamedAttributeExpr{{Name: "value", Attribute: &expr.AttributeExpr{Type: expr.String}}}}}, + message: `planned OneOf "Choice" has no branch "value" at body.value`, + }, + { + name: "different union envelope", + planned: &expr.AttributeExpr{Type: &expr.Union{TypeName: "Choice", TypeKey: "type", ValueKey: "value"}}, + rendered: &expr.AttributeExpr{Type: &expr.Union{TypeName: "Choice", TypeKey: "kind", ValueKey: "body"}}, + message: `planned OneOf "Choice" does not match rendered OneOf "Choice" at body`, + }, + { + name: "different primitive", + planned: &expr.AttributeExpr{Type: expr.String}, + rendered: &expr.AttributeExpr{Type: expr.Int}, + message: "planned string does not match rendered int at body", + }, + { + name: "different user type", + planned: &expr.AttributeExpr{Type: &expr.UserTypeExpr{ + TypeName: "First", + AttributeExpr: &expr.AttributeExpr{Type: expr.String}, + }}, + rendered: &expr.AttributeExpr{Type: &expr.UserTypeExpr{ + TypeName: "Second", + AttributeExpr: &expr.AttributeExpr{Type: expr.String}, + }}, + message: `planned user type "First" does not match rendered user type "Second" at body`, + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + catalog := newWireTypeCatalog() + + err := catalog.bindCopiedUnionOccurrences( + test.planned, + test.rendered, + "body", + make(map[wireAttributePair]struct{}), + ) + + require.EqualError(t, err, test.message) + }) + } +} + +// testWireTypeCatalog creates the generated package that assigns names for a test. +// Reserved names simulate declarations contributed by another generator. +func testWireTypeCatalog(t *testing.T, reserved ...string) (*wireTypeCatalog, *codegen.Generation) { + t.Helper() + generation, err := codegen.NewGeneration("generated.local/gen", nil) + require.NoError(t, err) + pkg, err := generation.ClaimPackage("generated.local/gen/http/test/client") + require.NoError(t, err) + for _, name := range reserved { + require.NoError(t, pkg.DeclareName(codegen.NewExactName(codegen.NameVariable, name))) + } + return newWireTypeCatalog(pkg), generation +} + +// linkTestWireTypeCatalog submits the collected declarations, asks the +// generation to assign all package names, and makes those names available to +// the test. +func linkTestWireTypeCatalog(t *testing.T, generation *codegen.Generation, catalog *wireTypeCatalog) { + t.Helper() + require.NoError(t, catalog.Declare()) + require.NoError(t, generation.Freeze()) + catalog.Link() +} + +// wireCatalogType builds an independent declared type. Equal UIDs are +// intentional because the original declared type, not the example ID, selects +// the copied HTTP type. +func wireCatalogType(name, uid, field string, required bool) *expr.UserTypeExpr { + attribute := &expr.AttributeExpr{Type: expr.String} + attribute.Validation = &expr.ValidationExpr{Pattern: field} + object := &expr.Object{{Name: field, Attribute: attribute}} + if required { + objectAttribute := &expr.AttributeExpr{Type: object, Validation: &expr.ValidationExpr{Required: []string{field}}} + return &expr.UserTypeExpr{AttributeExpr: objectAttribute, TypeName: name, UID: uid} + } + return &expr.UserTypeExpr{AttributeExpr: &expr.AttributeExpr{Type: object}, TypeName: name, UID: uid} +} + +// wireCatalogContainer places a named type inside an object body. +func wireCatalogContainer(child expr.UserType) *expr.AttributeExpr { + return &expr.AttributeExpr{Type: &expr.Object{{ + Name: "child", + Attribute: &expr.AttributeExpr{Type: child}, + }}} +} + +// firstWireUserType returns the first named value inside an object or array body. +func firstWireUserType(body *expr.AttributeExpr) *expr.AttributeExpr { + switch actual := body.Type.(type) { + case *expr.Object: + return (*actual)[0].Attribute + case *expr.Array: + return actual.ElemType + default: + panic("test body does not contain a named type") + } +} diff --git a/internal/codegenname/name.go b/internal/codegenname/name.go new file mode 100644 index 0000000000..db8cdf5b43 --- /dev/null +++ b/internal/codegenname/name.go @@ -0,0 +1,200 @@ +// This package defines the Go names shared by design validation and code +// generation. Keeping the conversion here ensures authored Go values are +// checked against the same field names that generators use. +package codegenname + +import ( + "go/doc" + "go/token" + "strings" + "unicode" + + "golang.org/x/text/cases" + "golang.org/x/text/language" +) + +var ( + commonInitialisms = map[string]bool{ + "API": true, + "ASCII": true, + "CPU": true, + "CSS": true, + "DNS": true, + "EOF": true, + "GUID": true, + "HTML": true, + "HTTP": true, + "HTTPS": true, + "ID": true, + "IP": true, + "JMES": true, + "JSON": true, + "JWT": true, + "LHS": true, + "OK": true, + "QPS": true, + "RAM": true, + "RHS": true, + "RPC": true, + "SDK": true, + "SLA": true, + "SMTP": true, + "SQL": true, + "SSH": true, + "TCP": true, + "TLS": true, + "TTL": true, + "UDP": true, + "UI": true, + "UID": true, + "UUID": true, + "URI": true, + "URL": true, + "UTF8": true, + "VM": true, + "XML": true, + "XSRF": true, + "XSS": true, + } + reservedPackages = map[string]bool{ + "errors": true, + "fmt": true, + "http": true, + "json": true, + "os": true, + "url": true, + "time": true, + } +) + +// AttributeName returns the name selected by struct:field:name metadata, or +// the design name when the metadata does not provide an override. +func AttributeName(name string, fieldNames []string) string { + if len(fieldNames) > 0 { + return fieldNames[0] + } + return name +} + +// CamelCase removes characters that cannot appear in identifiers and joins +// the remaining words while preserving common abbreviations such as ID and +// HTTP. +func CamelCase(name string, firstUpper, acronym bool) string { + if name == "" { + return "" + } + runes := removeTrailingInvalid([]rune(name)) + if len(runes) == 0 { + return "" + } + + wordStart, index := 0, 0 + for index+1 <= len(runes) { + wordEnd := false + runes = removeInvalidAtIndex(index, runes) + switch { + case index+1 == len(runes): + wordEnd = true + case !validIdentifier(runes[index]): + runes = append(runes[:index], runes[index+1:]...) + case runes[index+1] == '_': + wordEnd = true + count := 1 + for index+count+1 < len(runes) && runes[index+count+1] == '_' { + count++ + } + copy(runes[index+1:], runes[index+count+1:]) + runes = runes[:len(runes)-count] + case isLower(runes[index]) && !isLower(runes[index+1]): + wordEnd = true + } + index++ + if !wordEnd { + continue + } + + word := string(runes[wordStart:index]) + if upper := strings.ToUpper(word); commonInitialisms[upper] { + switch { + case firstUpper && acronym: + case firstUpper && !acronym: + upper = title(strings.ToLower(upper)) + case wordStart > 0 && !acronym: + upper = title(strings.ToLower(upper)) + case wordStart == 0: + upper = strings.ToLower(upper) + } + copy(runes[wordStart:], []rune(upper)) + } else if wordStart > 0 && strings.ToLower(word) == word { + runes[wordStart] = unicode.ToUpper(runes[wordStart]) + } else if wordStart == 0 && strings.ToLower(word) == word && firstUpper { + runes[wordStart] = unicode.ToUpper(runes[wordStart]) + } + if wordStart == 0 && !firstUpper { + runes[wordStart] = unicode.ToLower(runes[wordStart]) + } + wordStart = index + } + return string(runes) +} + +// Goify returns a valid Go identifier for name. A suffix after a colon names +// a transport field and does not take part in the Go identifier. +func Goify(name string, firstUpper bool) string { + if name == "" { + return "" + } + if index := strings.Index(name, ":"); index > 0 { + name = name[:index] + } + name = CamelCase(name, firstUpper, true) + if name == "" { + if firstUpper { + return "Val" + } + return "val" + } + return FixReserved(name) +} + +// FixReserved appends an underscore when name is reserved by Go or commonly +// used by generated imports. +func FixReserved(name string) string { + if doc.IsPredeclared(name) || token.IsKeyword(name) || reservedPackages[name] { + return name + "_" + } + return name +} + +// isLower reports whether a rune continues a lower-case word. +func isLower(value rune) bool { + return unicode.IsDigit(value) || unicode.IsLower(value) +} + +// validIdentifier reports whether a rune may appear after the first character +// of a Go identifier. +func validIdentifier(value rune) bool { + return unicode.IsLetter(value) || unicode.IsDigit(value) +} + +// removeTrailingInvalid removes characters that cannot end an identifier. +func removeTrailingInvalid(runes []rune) []rune { + valid := len(runes) - 1 + for ; valid >= 0 && !validIdentifier(runes[valid]); valid-- { + } + return runes[:valid+1] +} + +// removeInvalidAtIndex removes consecutive invalid characters at index. +func removeInvalidAtIndex(index int, runes []rune) []rune { + valid := index + for ; valid < len(runes) && !validIdentifier(runes[valid]); valid++ { + } + return append(runes[:index], runes[valid:]...) +} + +// title capitalizes the first character of each word without changing the +// remaining characters. +func title(value string) string { + return cases.Title(language.Und, cases.NoLower).String(value) +} diff --git a/jsonrpc/ARCHITECTURE.md b/jsonrpc/ARCHITECTURE.md index f2930a7ae2..4712d1513a 100644 --- a/jsonrpc/ARCHITECTURE.md +++ b/jsonrpc/ARCHITECTURE.md @@ -1,201 +1,163 @@ # Goa JSON-RPC Architecture -This document explains the architecture of Goa's JSON-RPC support, covering both basic HTTP and advanced WebSocket-based streaming communication. It details the code generation process, runtime behavior, and recommended usage patterns. +Goa implements JSON-RPC 2.0 over one HTTP POST route per service. A method +either returns one result in the HTTP response or streams results as JSON-RPC +messages carried by Server-Sent Events (SSE). -## Core Principle: Composition Over Modification +JSON-RPC does not support Goa client streams or bidirectional streams. A method +with `StreamingResult` must select `ServerSentEvents`. Ordinary HTTP methods may +still use WebSockets; that transport has its own generated code and does not +change the JSON-RPC service contract. -The fundamental principle behind Goa's JSON-RPC implementation is **composition over modification**. Instead of altering shared HTTP templates to accommodate JSON-RPC, the JSON-RPC code generation layer builds upon the existing HTTP transport infrastructure. This approach ensures a clean separation of concerns, preventing the HTTP layer from becoming coupled to JSON-RPC specifics and allowing both to evolve independently. +## Generation layers -## Code Generation - -The generation of JSON-RPC enabled services follows a layered process that starts with the standard HTTP transport code. - -### HTTP Codegen Foundation - -The process begins by generating the transport-agnostic service code, which includes: - -* Service interfaces and endpoints -* Basic HTTP handlers and middleware -* Encoding and decoding utilities -* Error handling infrastructure - -### JSON-RPC Composition Layer - -The JSON-RPC `codegen` package then composes on top of the generated HTTP code by programmatically manipulating the `codegen.File` data structure before it is rendered. This involves a three-step process: - -1. **Generate Base HTTP Code**: The standard `httpcodegen.ServerEncodeDecodeFile` function is called to produce the initial set of files. -2. **Modify Sections**: The generated sections are iterated upon to introduce JSON-RPC specific behavior. This includes adding necessary imports and replacing HTTP handler signatures with their JSON-RPC counterparts. -3. **Add JSON-RPC Sections**: Finally, new sections containing JSON-RPC specific logic, such as server handler initializers, are appended. - -This process is exemplified by the following snippet: +The generated service package owns transport-neutral method and stream +interfaces. JSON-RPC uses the same exact typed per-method server stream as HTTP +SSE and gRPC: ```go -// Step 1: Generate base HTTP code -f := httpcodegen.ServerEncodeDecodeFile(genpkg, svc, data) - -// Step 2: Modify sections before final code generation -for _, s := range f.SectionTemplates { - // Add JSON-RPC imports - if s.Name == "source-header" { - codegen.AddImport(s, codegen.GoaImport("jsonrpc")) - } - - // Modify signatures for JSON-RPC context - if s.Name == "request-decoder" { - s.Source = strings.Replace(s.Source, - httpRequestDecoderTemplate, - jsonrpcRequestDecoderTemplate, 1) - } - - // Namespace sections to avoid conflicts - s.Name = "jsonrpc-" + s.Name +type WatchServerStream interface { + Send(Event) error + SendWithContext(context.Context, Event) error + Close() error } - -// Step 3: Add JSON-RPC specific sections -sections = append(sections, - &codegen.SectionTemplate{ - Name: "jsonrpc-server-handler-init", - Source: jsonrpcTemplates.Read(serverHandlerInitT), - Data: e - }) ``` -### Key Codegen Patterns - -Three key patterns enable this compositional approach: - -1. **Template Namespacing**: JSON-RPC sections are prefixed with `jsonrpc-` to prevent name collisions with HTTP sections. -2. **In-Memory Modification**: Instead of altering the source templates on disk, modifications are made to the `Source` field of the `codegen.SectionTemplate` struct in memory. -3. **Conditional Template Selection**: The code generation logic dynamically selects the appropriate templates based on the endpoint configuration, for example, adding WebSocket-specific templates only when a WebSocket transport is defined for the service. - -### Template Responsibilities - -This layered approach results in a clean separation of responsibilities between HTTP and JSON-RPC templates: - -* **HTTP Templates (Shared)**: These are responsible for transport-agnostic service logic. They must not contain any JSON-RPC or WebSocket specific logic. -* **JSON-RPC Templates (Specialized)**: These handle JSON-RPC protocol specifics and WebSocket streaming. They can specialize HTTP behavior but should do so through composition, not modification of the HTTP templates. - -## Runtime Architecture and Usage - -The generated code provides two primary mechanisms for JSON-RPC communication: a simple HTTP transport for traditional request-response interactions, and a WebSocket transport for real-time, bidirectional streaming. - -### Standard JSON-RPC over HTTP - -For services that do not require streaming, JSON-RPC messages are exchanged over standard HTTP. The generated server code includes an HTTP handler that decodes the JSON-RPC request from the HTTP body, invokes the corresponding service method, and writes the JSON-RPC response back to the HTTP response writer. - -The handler signatures clearly illustrate the differences between the transport layers: - -* **Regular HTTP**: `func(context.Context, *http.Request, http.ResponseWriter)` -* **JSON-RPC HTTP**: `func(context.Context, *http.Request, *jsonrpc.RawRequest, http.ResponseWriter)` - -### JSON-RPC over WebSocket - -Goa provides a powerful abstraction for building streaming services with WebSockets. This allows for full-duplex communication channels that can support a variety of interaction patterns. - -#### Architectural Principles - -The WebSocket architecture is guided by three principles: - -1. **Single WebSocket Connection**: A single WebSocket connection is used to handle all JSON-RPC communication for a given service, including multiplexing different method calls and streaming patterns. -2. **User Code Owns Streaming Logic**: The core streaming logic is implemented by the developer in the `HandleStream` method. Goa provides the infrastructure and the `Stream` interface, but the implementation of the streaming strategy is left to the user. -3. **Clean Separation of Concerns**: The architecture separates the business logic (in service methods), the transport layer (JSON-RPC protocol and WebSocket management), and the streaming logic (in `HandleStream`). - -#### Core Components - -The WebSocket support is built around three core components: - -* **`HandleStream` Method**: This method is the entry point for all WebSocket communication. It is where the developer implements the application-specific streaming logic. - - ```go - func (s *serviceImpl) HandleStream(ctx context.Context, stream ServiceName.Stream) error { - // User implements their streaming strategy here. - // Can listen to channels, timers, events, etc. - // Can call stream.Recv() to process incoming JSON-RPC requests. - // Can call stream.SendMethodName() to send responses or notifications. - } - ``` - -* **`Stream` Interface**: This generated interface provides the methods for interacting with the WebSocket connection, including receiving requests (`Recv`), sending responses (`SendMethodName`), sending errors (`SendError`), and closing the connection (`Close`). - -* **Service Methods**: These are the regular service methods with standard Go signatures. They are called automatically when `Recv()` processes a matching JSON-RPC request and can also be called directly from `HandleStream` for server-initiated communication. - -The handler signature for WebSocket streaming endpoints reflects the asynchronous nature of the communication: - -```go -func(context.Context, *http.Request, *jsonrpc.RawRequest) (any, error) +The HTTP generation plan owns JSON request and response body types, +conversions, validation, and file imports. The JSON-RPC generator copies the +finished HTTP plan and adds JSON-RPC request dispatch and message framing. It +does not change the service interface or inspect generated types at runtime. + +Generation follows this order: + +1. The design is evaluated and rejects stream shapes JSON-RPC cannot carry. +2. The service plan assigns exact method and stream types. +3. The HTTP JSON-RPC plan assigns request and response body types and + conversions. +4. The JSON-RPC plan writes only the unary or SSE code selected by the design. + +## Unary requests + +The server reads one JSON-RPC request, decodes its `params` into the designed +payload, calls the service endpoint once, and writes one JSON-RPC response when +the request contains an `id`. A request without an `id` is a notification and +receives no response. + +An optional field selected by `Body` keeps absence through the generated +transport constructor. For an object, map, array, or union, an absent service +field omits `params`; an authored empty value still writes its direct JSON +object or array. Optional primitive fields remain positional, so `[null]` +decodes as absent and an explicit zero value remains present. The server rejects +direct `params: null` instead of treating it as an absent value. +When the selected field has a default, omitted or null input applies that +default. A defaulted scalar is a service value with no absent state, so a +direct generated client always sends its value. A nil defaulted collection +omits `params`; a nonnil empty collection writes an empty array or object. + +The generated request encoder returns the exact string ID written into the +request. The endpoint passes that ID to the unary response decoder or SSE +stream so the client can reject a response for another call. Notification +encoders return only an error because notifications do not create an ID. + +The shared service route dispatches requests by their JSON-RPC `method`. Batch +requests use the same per-method handlers and collect only responses for calls +that contain an `id`. + +Result and error fields stay inside each JSON-RPC message. Design validation +rejects `Header` and `Cookie` inside a JSON-RPC `Response` because one HTTP +response may contain several batch messages or carry a server-sent-event +stream. Request headers and cookies remain available because they describe the +one incoming HTTP request. + +## Server-sent-event streams + +The client sends one JSON-RPC request over HTTP. When that request has an ID, +each service call to `Send` writes an SSE event whose `data:` value is a +complete JSON-RPC notification: + +```json +{"jsonrpc":"2.0","method":"watch","params":{"value":"ready"}} ``` -The handler returns a result and an error because responses are sent asynchronously via the `Stream` interface rather than being written directly to an `http.ResponseWriter`. - -#### Streaming Patterns - -The flexibility of the `HandleStream` method allows for a variety of streaming patterns: - -* **Request-Response**: The traditional JSON-RPC pattern can be implemented by simply calling `stream.Recv()` in a loop. When `Recv()` is called, it reads a JSON-RPC request from the WebSocket, dispatches it to the appropriate service method, and automatically sends the response back. - - ```go - func (s *serviceImpl) HandleStream(ctx context.Context, stream ServiceName.Stream) error { - defer stream.Close() - - for { - select { - case <-ctx.Done(): - return ctx.Err() - default: - if err := stream.Recv(ctx); err != nil { - return err - } - } - } - } - ``` - -* **Server Streaming**: To push data from the server to the client, the `HandleStream` method can initiate a goroutine that sends data at regular intervals or in response to events. - -* **Client Streaming**: To receive a stream of data from a client, the `HandleStream` method can repeatedly call `stream.Recv()` and accumulate the results. - -* **Bidirectional Streaming**: For interactive communication, `HandleStream` can combine both server and client streaming patterns, for example by launching a goroutine to handle outgoing messages while the main loop processes incoming messages. - -#### Advanced Patterns - -The `HandleStream` method can also be used to implement more advanced patterns, such as: - -* **Mixed Request-Response and Streaming**: A service can handle both traditional request-response interactions and asynchronous, server-initiated notifications within the same WebSocket connection. -* **Conditional Streaming**: The streaming strategy can be determined dynamically based on the properties of the connection or the initial messages exchanged. - -#### Method Dispatch and Results - -When `stream.Recv()` is called, it automatically handles the parsing of the incoming JSON-RPC request, validation, routing to the appropriate service method, and marshalling of the response. Service methods can also be invoked manually from within `HandleStream` for server-initiated communication. - -#### Error Handling - -The architecture provides mechanisms for handling various types of errors: - -* **Connection Errors**: Errors at the WebSocket connection level will cause `HandleStream` to terminate. -* **JSON-RPC Protocol Errors**: Invalid requests will result in the automatic sending of a JSON-RPC error response. -* **Streaming Errors**: Errors that occur while sending or receiving data can be handled within the `HandleStream` implementation. - -#### Testing Strategies - -The separation of concerns in the architecture simplifies testing: - -* **Integration Tests**: The `HandleStream` implementation can be overridden in tests to simulate specific streaming behaviors. -* **Unit Tests**: Service methods can be tested independently as standard Go functions. - -## Maintenance Guidelines - -To maintain the clean separation of concerns and the long-term health of the codebase, it is important to adhere to the following guidelines: +The service method return completes the stream. For a request with an `id`, the +transport writes exactly one terminal JSON-RPC message in an SSE `data:` line: + +- `result: null` when the method succeeds; or +- the mapped JSON-RPC error when the method returns an error. + +Goa does not add private outer event names for notifications, success, or +failure. `SSEEventType` may map a streaming-result field to the outer `event:` +line. `SSEEventID` and `SSEEventRetry` do the same for `id:` and `retry:`. If a +mapping is absent, the generated server omits that line. The client inspects +the complete JSON-RPC message in `data:` to distinguish a streamed value from +terminal success or failure. + +`SSEEventData` may select one streaming-result field for notification params. +Primitive values use one positional parameter; structured values keep their +JSON shape. An optional primitive writes `[null]` when its service pointer is +nil and keeps an explicit zero value. The client maps omitted params and +`[null]` to absence, then applies an authored default when one exists. This +mapping is rejected for viewed results because one selected field would omit +the view name required to rebuild the result. The other outer SSE mappings +remain available for viewed results. + +`SSERequestID` maps one initial payload field to the HTTP `Last-Event-ID` +request header. The client writes the header and the server reads it before +payload validation. It is not part of JSON-RPC params. + +Generated clients accept only the `text/event-stream` media type, with optional +standard parameters. They join multiple `data:` lines with a newline, accept +CR, LF, and CRLF line endings, remove one byte-order marker at the start of the +stream, and discard a final event that has no blank-line terminator. The last +valid `id:` value applies to later events until another `id:` changes it. An +empty `id:` resets that value, and an `id:` containing NUL is ignored. + +Generated servers accept encoded JSON that uses CR, LF, or CRLF and prefix +every resulting physical line with `data:`. They reject event IDs containing +CR, LF, or NUL, event names containing a line break, and negative retry values. + +JSON-RPC rejects a method that defines both `Result` and `StreamingResult`, +even when both declarations use the same Goa type. Its client stream has no +separate operation that could return the final `Result`. Use one method for the +stream and another method for the final resource. gRPC has the same +restriction. Ordinary HTTP keeps mixed-result support. + +A request without an `id` receives no HTTP output. The generated server still +runs the service and lets every `Send` perform its normal encoding work, but a +private response writer discards headers and bytes. Decode, encode, and service +failures still reach the configured error handler for logging. Request IDs and +JSON-RPC messages remain transport details and never appear in the generated +service stream. + +The generated client returns each notification value from `Recv`. A successful +terminal response makes the next `Recv` return `io.EOF`. A terminal JSON-RPC +error is returned as an error. A designed error is rebuilt as its generated +service error. An unknown error code returns `*jsonrpc.RawErrorResponse`, which +preserves the received code, message, and data. + +## Result conversion and views + +All JSON names, required fields, transport pointers, result conversions, and +view branches are decided while generating code. A variable-view result uses +this JSON-RPC value: + +```json +{"view":"summary","body":{"value":"ready"}} +``` -### DO: +The same representation is used for unary results and streamed notification +parameters. A fixed-view or non-viewed result uses only its designed body. +Malformed JSON is returned as a generated client `decoding_error`. A decoded +body with a missing field, an unknown view, or another failed design rule is +returned as a generated client `validation_error`. -* ✅ Modify JSON-RPC templates for JSON-RPC specific behavior. -* ✅ Use `codegen.File` section manipulation for signature changes. -* ✅ Add JSON-RPC specific sections for specialized functionality. -* ✅ Compose on top of HTTP-generated code. +## Errors -### DON'T: +Protocol parsing, request validation, method dispatch, designed service errors, +and unexpected service errors are mapped by the JSON-RPC transport. Service +implementations return ordinary errors; they do not write protocol error +messages themselves. -* ❌ Modify HTTP templates with JSON-RPC specific logic. -* ❌ Add WebSocket conditionals to shared HTTP templates. -* ❌ Break the transport independence of the HTTP layer. -* ❌ Couple the HTTP codegen to JSON-RPC requirements. +If the request has an `id`, an SSE decode or service error becomes one terminal +JSON-RPC error event. If the request has no `id`, the server writes no response +and passes the error to the configured server error handler. diff --git a/jsonrpc/README.md b/jsonrpc/README.md index 9489912e6b..a7874577ac 100644 --- a/jsonrpc/README.md +++ b/jsonrpc/README.md @@ -1,1006 +1,326 @@ # JSON-RPC 2.0 in Goa -Goa provides first-class, type-safe support for JSON-RPC 2.0, enabling you to build robust RPC services with the same powerful DSL used for REST and gRPC. This implementation handles all protocol complexities while preserving Goa's design-first philosophy. - -## Table of Contents - -- [Quick Start](#quick-start) -- [Core Concepts](#core-concepts) - - [Protocol Fundamentals](#protocol-fundamentals) - - [Single Endpoint Architecture](#single-endpoint-architecture) - - [Request vs Notification](#request-vs-notification) -- [Defining Services](#defining-services) - - [Service Configuration](#service-configuration) - - [Method Configuration](#method-configuration) - - [ID Field Mapping](#id-field-mapping) -- [Transport Options](#transport-options) - - [HTTP: Request-Response](#http-request-response) - - [Server-Sent Events: Server Streaming](#server-sent-events-server-streaming) - - [WebSocket: Bidirectional Streaming](#websocket-bidirectional-streaming) - - [Mixed Transports: Content Negotiation](#mixed-transports-content-negotiation) -- [Advanced Features](#advanced-features) - - [Batch Processing](#batch-processing) - - [Error Handling](#error-handling) - - [Streaming Patterns](#streaming-patterns) - - [Mixed Results](#mixed-results) -- [Best Practices](#best-practices) - -## Quick Start - -Define a simple JSON-RPC calculator service: +Goa generates typed JSON-RPC 2.0 clients and servers from the same service +designs used for HTTP and gRPC. Generated code owns request decoding, +validation, method dispatch, response encoding, errors, notifications, batch +requests, and server-sent-event streams. -```go -// design/design.go -package design - -import . "goa.design/goa/v3/dsl" +## Unary methods -var _ = API("calculator", func() { - Title("Calculator Service") - Description("A simple calculator exposed via JSON-RPC") -}) +Enable JSON-RPC on a service and expose each method that should be callable: +```go var _ = Service("calc", func() { - Description("The calc service performs basic arithmetic") - - // Enable JSON-RPC for this service at /rpc endpoint - JSONRPC(func() { - POST("/rpc") - }) - - // Define an add method - Method("add", func() { - Description("Add two numbers") - Payload(func() { - Attribute("a", Float64, "First operand") - Attribute("b", Float64, "Second operand") - Required("a", "b") - }) - Result(Float64) - - // Expose this method via JSON-RPC - JSONRPC(func() {}) - }) - - // Define a divide method with error handling - Method("divide", func() { - Description("Divide two numbers") - Payload(func() { - Field(1, "dividend", Float64, "The dividend") - Field(2, "divisor", Float64, "The divisor") - Required("dividend", "divisor") - }) - Result(Float64) - Error("division_by_zero") - - JSONRPC(func() { - Response("division_by_zero", func() { - Code(-32001) // Custom error code - }) - }) - }) + JSONRPC(func() { + POST("/rpc") + }) + + Method("add", func() { + Payload(func() { + Attribute("a", Int) + Attribute("b", Int) + Required("a", "b") + }) + Result(Int) + JSONRPC(func() {}) + }) }) ``` -Generate the code: - -```bash -goa gen calculator/design -``` - -Implement the service: +Every JSON-RPC method in the service shares the service route. The `method` +property inside the JSON-RPC request selects the Goa method: -```go -// calc.go -package calcapi - -import ( - "context" - calc "calculator/gen/calc" -) - -type calcService struct{} - -func NewCalc() calc.Service { - return &calcService{} -} - -func (s *calcService) Add(ctx context.Context, p *calc.AddPayload) (float64, error) { - return p.A + p.B, nil -} - -func (s *calcService) Divide(ctx context.Context, p *calc.DividePayload) (float64, error) { - if p.Divisor == 0 { - return 0, calc.MakeDivisionByZero("cannot divide by zero") - } - return p.Dividend / p.Divisor, nil -} -``` - -## Core Concepts - -### Protocol Fundamentals - -JSON-RPC 2.0 is a stateless, lightweight remote procedure call protocol that -uses JSON for encoding. Key characteristics: - -1. **Transport Agnostic**: While commonly used over HTTP, the protocol itself doesn't specify transport -2. **Simple Message Format**: All communication uses a consistent JSON structure -3. **Bidirectional**: Supports both client-to-server and server-to-client communication -4. **Batch Support**: Multiple calls can be sent in a single request - -Message structure: ```json -// Request -{ - "jsonrpc": "2.0", - "method": "add", - "params": {"a": 5, "b": 3}, - "id": 1 -} - -// Response -{ - "jsonrpc": "2.0", - "result": 8, - "id": 1 -} +{"jsonrpc":"2.0","id":"sum-1","method":"add","params":{"a":2,"b":3}} ``` -### Single Endpoint Architecture - -Unlike REST where each resource has its own URL, JSON-RPC services multiplex all -methods through a single endpoint: - -- **REST**: `/users` (GET), `/users/{id}` (GET/PUT/DELETE), `/products` (GET/POST) -- **JSON-RPC**: `/rpc` (all methods) - -This design provides several benefits: - -1. **Simplified Routing**: No complex URL patterns to manage -2. **Protocol Consistency**: All methods follow the same calling convention -3. **Connection Efficiency**: WebSocket/SSE connections can handle multiple methods -4. **Easy Versioning**: Version the entire API at once - -The `method` field in the JSON-RPC payload determines which service method to invoke: +The generated server validates `params`, calls the service method once, and +returns the designed result: ```json -{"jsonrpc": "2.0", "method": "add", "params": {"a": 5, "b": 3}, "id": 1} -{"jsonrpc": "2.0", "method": "divide", "params": {"dividend": 10, "divisor": 2}, "id": 2} +{"jsonrpc":"2.0","id":"sum-1","result":5} ``` -### Request vs Notification +A JSON-RPC `Response` cannot map result or error fields with `Header` or +`Cookie`. One HTTP response can carry several JSON-RPC messages in a batch or +server-sent-event stream, so an HTTP header or cookie cannot belong to one of +those messages. Put result fields in the JSON-RPC `result` value and error +fields in `error.data`. Request `Header` and `Cookie` mappings remain valid. -JSON-RPC distinguishes between two types of messages based on the presence of an ID: +Calling `JSONRPC` inside a method automatically enables JSON-RPC for its +service. A service-level `JSONRPC` block is still useful for declaring the +shared route and defaults. -**Requests** (with ID) expect a response: -```json -{"jsonrpc": "2.0", "method": "process", "params": {"data": "hello"}, "id": "req-123"} -// Server MUST send a response with matching ID -``` +Objects, maps, arrays, and unions keep their designed JSON shape in `params`. +A primitive value, named primitive, byte slice, or `Any` value uses one +positional value. For example, a string payload is sent as: -**Notifications** (without ID) are fire-and-forget: ```json -{"jsonrpc": "2.0", "method": "log", "params": {"message": "user logged in"}} -// Server MUST NOT send a response -``` - -This behavior is determined at **runtime** by the client, not design time. The -same method can be called as either a request or notification. - -## Defining Services - -### Service Configuration - -Enable JSON-RPC at the service level to define the shared endpoint: - -```go -Service("myservice", func() { - Description("A service exposed via JSON-RPC") - - // Define the JSON-RPC endpoint - JSONRPC(func() { - POST("/jsonrpc") // For HTTP and SSE - // OR - GET("/ws") // For WebSocket - }) - - // Define error mappings for all methods - Error("unauthorized", func() { - Description("Unauthorized access") - }) - - JSONRPC(func() { - Response("unauthorized", func() { - Code(-32000) // Map to JSON-RPC error code - }) - }) -}) +{"jsonrpc":"2.0","id":"echo-1","method":"echo","params":["hello"]} ``` -### Method Configuration +When `Body("value")` selects an optional object, map, array, or union field, +the generated client omits `params` when that service field is absent. A +present empty array, empty map, or selected empty-message union branch still +writes `params`. An incoming `params: null` is invalid because JSON-RPC requires +parameters to be an object or array. Optional primitives keep the positional +array shape: `[null]` means absent, while `[""]` means a present empty string. +When the selected field has a default, omitted or null input applies that +default. A direct client always sends a defaulted scalar because its service +field has no absent state. A nil defaulted collection omits `params`, while an +explicit empty collection remains empty. -Each method needs its own `JSONRPC()` block to be exposed: +The server rejects a scalar `params` value because JSON-RPC permits only an +object or array there. -```go -Method("process", func() { - Description("Process data") - - Payload(func() { - Attribute("data", String, "Data to process") - Attribute("priority", Int, "Processing priority") - Required("data") - }) - - Result(func() { - Attribute("output", String, "Processed output") - Attribute("duration", Int, "Processing time in ms") - Required("output", "duration") - }) - - // Enable JSON-RPC for this method - JSONRPC(func() { - // Method-specific error mappings (optional) - Response("invalid_data", func() { - Code(-32002) - }) - }) -}) -``` +## Requests, notifications, and IDs -### ID Field Mapping +A JSON-RPC request contains an `id` and receives one response. A notification +omits `id` and receives no response, including when decoding or service work +fails. -Control how JSON-RPC message IDs map to your payload and result types: +Declare a generated client call as a notification explicitly: ```go -Method("track", func() { - Payload(func() { - ID("request_id", String, "Tracking ID") // Maps to JSON-RPC request ID - Attribute("action", String) - Required("request_id", "action") - }) - - Result(func() { - ID("request_id", String, "Tracking ID") // Optional; if empty the - // response uses the request id - Attribute("status", String) - Required("request_id", "status") - }) - - JSONRPC(func() {}) +Method("record", func() { + Payload(func() { + Attribute("message", String) + Required("message") + }) + JSONRPC(func() { + Notification() + }) }) ``` +The generated client omits `id`, accepts the HTTP acknowledgement, and does +not decode a JSON-RPC response. The method cannot define a result, stream, or +ID field. -The `ID()` function marks which field receives the JSON-RPC message ID. Rules: - -1. ID fields must be String type -2. Result can only have an ID if Payload has one -3. For non-streaming methods, the response `id` defaults to the request `id`. - If the result ID is set, that value is used instead. -4. Missing ID at runtime means the message is a notification - -### ID Semantics - -How IDs behave across transports and shapes: - -- Design-time type - - `ID()` marks the field that carries the JSON-RPC ID; it must be `String` in - the design. - -- Runtime type - - JSON-RPC allows string or number IDs. Goa accepts either on the wire and - normalizes to string when assigning to your `ID()` fields. - -- HTTP (request/response) - - Client - - If the payload has an ID field and it is non-empty, the client sends it - as `id` (request). If empty (or nil pointer), the client omits `id` - (notification). - - If the payload has no ID field, the client generates a string `id` and - sends a request (never a notification). - - Server - - The response envelope `id` equals the result ID if set; otherwise it - equals the request `id`. The server does not inject the request `id` into - your result struct. - -- SSE (server streaming) - - `Send(ctx, event)`: emits a JSON-RPC notification (no `id`). - - `SendAndClose(ctx, result)`: sends a JSON-RPC response. The `id` equals the - result ID if set; otherwise the original request `id`. To avoid duplicate - fields, the framework clears the result ID field when it is used for the - envelope. - -- WebSocket (streaming) - - Server replies use the original request `id` automatically. Use - `SendNotification` for server-initiated messages (no `id`). - - Client generates a string `id` per request in bidirectional or recv-only - patterns. When receiving, if your result has an ID field and it is empty, - the client populates it from the envelope `id` for convenience. - -- When to use `ID()` in the DSL - - Non-streaming: put `ID()` in the payload to receive request IDs in your - handler; add it to the result only if you need to surface the ID in your - result type. - - Streaming (WebSocket bidirectional): include `ID()` in both streaming - payload and result to correlate messages at the type level. - - Notifications: omit `ID()` (no `id` is sent or expected). - -## Transport Options - -### HTTP: Request-Response - -Standard synchronous RPC over HTTP. Best for: -- Simple request-response patterns -- Stateless operations -- RESTful service migration +Goa can map the protocol ID to a designed payload field: ```go -Service("api", func() { - JSONRPC(func() { - POST("/rpc") - }) - - Method("query", func() { - Payload(func() { - Attribute("sql", String) - Required("sql") - }) - Result(ArrayOf(map[string]any)) - JSONRPC(func() {}) - }) +Payload(func() { + ID("request_id", String) + Attribute("value", String) + Required("value") }) ``` -**Client usage:** -```go -client := api.NewClient("http", "localhost:8080", http.DefaultClient, - goahttp.RequestEncoder, goahttp.ResponseDecoder, false) - -result, err := client.Query(ctx, &api.QueryPayload{SQL: "SELECT * FROM users"}) -``` - -**Wire format:** -```http -POST /rpc HTTP/1.1 -Content-Type: application/json - -{"jsonrpc":"2.0","method":"query","params":{"sql":"SELECT * FROM users"},"id":1} -``` +An ID mapping must be one direct string field. A required field with no default +is a value. An optional field with no default is a pointer: nil makes the +generated client create an ID, while a non-nil value is sent exactly, including +an empty string. A field with an authored default is a value, and the client +sends that value. Without a mapped field, the client generates an ID. The +generated server requires a non-null ID for every ordinary method and sets a +mapped field before calling the service. -**How it works internally:** - -- The generated server inspects the first byte of the body to route batch - (`[` starts a JSON array) vs single requests, then decodes a - `jsonrpc.RawRequest` and validates `jsonrpc:"2.0"`, `method`, and - `params`. -- Dispatch is by the `method` field to the corresponding generated handler - for your service method. The handler decodes the typed payload, invokes - your implementation, and encodes a typed JSON-RPC response via - `MakeSuccessResponse(id, result)`. -- If the incoming message has no `id` (a notification), the server does not - send a response, per the spec. -- Batch requests are decoded to `[]jsonrpc.RawRequest` and each entry is - processed independently; responses are streamed into a JSON array. - -### Server-Sent Events: Server Streaming - -Unidirectional streaming from server to client. Perfect for: -- Progress updates -- Live notifications -- Real-time feeds -- Long-running operations +Results cannot declare an `ID` field. The transport owns correlation and copies +the exact incoming ID, including its JSON type, into every response. -```go -Service("monitor", func() { - JSONRPC(func() { - POST("/events") // SSE uses POST for initial payload - }) - - Method("watch", func() { - Description("Watch system metrics") - - Payload(func() { - Attribute("metrics", ArrayOf(String), "Metrics to watch") - Required("metrics") - }) - - StreamingResult(func() { - Attribute("metric", String) - Attribute("value", Float64) - Attribute("timestamp", String, func() { - Format(FormatDateTime) - }) - Required("metric", "value", "timestamp") - }) - - JSONRPC(func() { - ServerSentEvents(func() { - SSEEventType("metric") // SSE event type field - }) - }) - }) -}) -``` +Generated clients check the complete response before decoding its result. The +response must use JSON-RPC 2.0, contain exactly one of `result` or `error`, and +repeat the string ID sent by that client call. Parse errors and invalid-request +errors may use a null ID because the server may be unable to recover the +request ID from malformed input. A response for a different call is rejected. -**Server implementation:** +The method declaration and incoming message must agree. An ordinary method +rejects a missing or null ID with `-32600` before it decodes parameters or calls +the service. A method declared with `Notification()` rejects every present ID, +including null, with `-32600`. A valid declared notification omits the ID, runs +the service, and never writes a JSON-RPC response. Empty string IDs remain valid +for ordinary methods. -```go -func (s *monitorSvc) Watch(ctx context.Context, p *monitor.WatchPayload, - stream monitor.WatchServerStream) error { - - ticker := time.NewTicker(1 * time.Second) - defer ticker.Stop() - - for { - select { - case <-ctx.Done(): - return nil - case <-ticker.C: - for _, metric := range p.Metrics { - err := stream.Send(ctx, &monitor.WatchResult{ - Metric: metric, - Value: getMetricValue(metric), - Timestamp: time.Now().Format(time.RFC3339), - }) - if err != nil { - return err - } - } - } - } -} -``` +## Server streaming with SSE -**Client usage:** +JSON-RPC supports server-to-client streams through Server-Sent Events (SSE). +Define a `StreamingResult` and select `ServerSentEvents` in the method-level +JSON-RPC block: ```go -httpClient := monitorjsonrpc.NewClient(/* ... */) -stream, err := httpClient.Watch(ctx, &monitor.WatchPayload{ - Metrics: []string{"cpu", "memory"}, +var Event = Type("Event", func() { + Attribute("message", String) + Required("message") }) -for { - result, err := stream.Recv() - if err == io.EOF { - break - } - log.Printf("%s: %f", result.Metric, result.Value) -} -``` - -**How it works internally:** - -- SSE uses a regular HTTP POST to deliver the initial JSON-RPC request. The - generated handler decodes a `jsonrpc.RawRequest`, validates it, and - dispatches to the method-specific SSE handler. -- The SSE response is a long-lived HTTP response with - `Content-Type: text/event-stream`. The generated stream type writes events - using standard SSE framing (`id:`, `event:`, `data:`, blank line). -- The server stream interface exposes: - - `Send(ctx, event)`: writes a JSON-RPC notification as an SSE event - (no response expected). Use this for progress or updates. - - `SendAndClose(ctx, result)`: sends the final JSON-RPC response (with `id`) - and closes the stream. The response `id` is taken from the original - request `id`, or from a result `ID()` field if defined in the design. - - `SendError(ctx, id, err)`: writes a JSON-RPC error response. -- Notifications vs responses: - - Notifications omit `id` per JSON-RPC and are represented as SSE events - with the `data:` being the result body. - - Final responses include a JSON-RPC envelope; the SSE `id:` field mirrors - the JSON-RPC response `id` when an ID is present. -- Example on-the-wire SSE frame (simplified): - - ```text - event: metric - id: 7 - data: {"jsonrpc":"2.0","result":{"metric":"cpu","value":0.9},"id":"7"} - - ``` - -### WebSocket: Bidirectional Streaming - -Full-duplex, persistent connections for real-time communication. Ideal for: -- Chat applications -- Collaborative editing -- Gaming -- Live bidirectional data exchange - -```go -Service("chat", func() { - JSONRPC(func() { - GET("/ws") // WebSocket upgrade - }) - - // Client-to-server notifications - Method("send", func() { - StreamingPayload(func() { - Attribute("message", String) - Required("message") - }) - JSONRPC(func() {}) - }) - - // Server-to-client notifications - Method("broadcast", func() { - StreamingResult(func() { - Attribute("from", String) - Attribute("message", String) - Required("from", "message") - }) - JSONRPC(func() {}) - }) - - // Bidirectional request-response - Method("echo", func() { - StreamingPayload(func() { - ID("msg_id", String) - Attribute("text", String) - Required("msg_id", "text") - }) - StreamingResult(func() { - ID("msg_id", String) - Attribute("echo", String) - Required("msg_id", "echo") - }) - JSONRPC(func() {}) - }) +var _ = Service("updates", func() { + JSONRPC(func() { + POST("/rpc") + }) + + Method("watch", func() { + Payload(func() { + Attribute("topic", String) + Required("topic") + }) + StreamingResult(Event) + JSONRPC(func() { + ServerSentEvents() + }) + }) }) ``` -**Server implementation:** +The generated service method receives the same exact typed stream interface as +other Goa transports: ```go -type chatSvc struct { - connections map[string]chat.BroadcastServerStream - mu sync.RWMutex -} - -func (s *chatSvc) HandleStream(ctx context.Context, stream chat.Stream) error { - // Register connection - connID := generateConnID() - s.mu.Lock() - s.connections[connID] = stream.(chat.BroadcastServerStream) - s.mu.Unlock() - - defer func() { - s.mu.Lock() - delete(s.connections, connID) - s.mu.Unlock() - stream.Close() - }() - - // Handle incoming messages - for { - _, err := stream.Recv(ctx) - if err != nil { - return err - } - // Messages are automatically dispatched to method handlers - } -} - -func (s *chatSvc) Send(ctx context.Context, p *chat.SendPayload) error { - // Broadcast to all connections - s.mu.RLock() - defer s.mu.RUnlock() - - for _, conn := range s.connections { - conn.SendNotification(ctx, &chat.BroadcastResult{ - From: "user", - Message: p.Message, - }) - } - return nil -} - -func (s *chatSvc) Echo(ctx context.Context, p *chat.EchoPayload, - stream chat.EchoServerStream) error { - - return stream.SendResponse(ctx, &chat.EchoResult{ - MsgID: p.MsgID, - Echo: "Echo: " + p.Text, - }) +func (s *updatesService) Watch(ctx context.Context, p *updates.WatchPayload, stream updates.WatchServerStream) error { + if err := stream.Send(&updates.Event{Message: "ready"}); err != nil { + return err + } + return nil } ``` -**How it works internally:** - -- Connection lifecycle: - - The generated server upgrades the HTTP request to a WebSocket and - constructs a `Stream` implementation, then calls your - `HandleStream(ctx, stream)`. - - Your `HandleStream` should defer `stream.Close()` and typically loop on - `stream.Recv(ctx)`, which reads a JSON-RPC message and dispatches it to - the appropriate generated handler based on its `method`. -- Dispatch and method invocation: - - For non-streaming methods, `Recv` decodes the payload, invokes your - method, and sends the typed JSON-RPC success response via the stream. - - For streaming methods, `Recv` creates a method-specific stream wrapper - that implements your generated `XServerStream` interface and calls your - method implementation with it. -- Sending from your methods: - - In server or bidirectional streaming, your method receives a stream - wrapper providing: - - `SendNotification(ctx, result)`: sends a JSON-RPC notification (no id). - - `SendResponse(ctx, result)`: sends a JSON-RPC success response using the - original request `id`. You do not need to pass the id; the wrapper holds - it for you. - - `SendError(ctx, err)`: sends a JSON-RPC error response correlated to the - original request `id` when present. -- Notifications and responses: - - Messages without `id` are notifications. Use `SendNotification` for - server-initiated messages that should not expect a response. - - When replying to a client request that had an `id`, use `SendResponse` to - correlate via that `id` automatically. -- Error handling: - - Invalid messages (parse errors, missing method) trigger JSON-RPC error - responses when an `id` is present; otherwise they are ignored to keep the - connection alive. - - Unexpected WebSocket close codes abort the loop and close the connection. - -### Mixed Transports: Content Negotiation - -Combine HTTP and SSE in a single service using automatic content negotiation: +The stream provides: + +- `Send(T) error` +- `SendWithContext(context.Context, T) error` +- `Close() error` +- `SetView(string)` when the streaming result has selectable views + +For a request with an ID, each `Send` writes one SSE event whose `data:` value +is a complete JSON-RPC notification. Goa does not invent private `event:` names +for streamed values, success, or failure. When the method returns, the +transport writes one terminal event: + +- success writes `result: null`; +- a returned error writes a JSON-RPC error. + +Server-streaming methods are ordinary methods and require a non-null ID. A +missing or null ID receives an Invalid Request event with code `-32600`, and +the service does not start. The DSL does not allow a method declared with +`Notification()` to stream. + +The generated client returns streamed notification values from `Recv`. After a +successful terminal response it returns `io.EOF`; after an error response it +returns that error. Designed error codes return the generated service error +type. An unknown error code returns `*jsonrpc.RawErrorResponse` with the exact +code, message, and data received from the server. + +`SSEEventData` selects one streaming-result field for notification `params`. +A primitive selected value uses one positional value; a structured value keeps +its JSON shape. For an optional primitive, a nil service pointer is `[null]` +and an explicit empty string is `[""]`. The client treats omitted `params` and +`[null]` as absent, while preserving explicit zero values. If the field has a +default, absence uses that default. The generated client rebuilds the service +streaming result from that value. A viewed streaming result cannot use +`SSEEventData` because one selected field would omit the view name required to +decode the result. + +`SSEEventID`, `SSEEventType`, and `SSEEventRetry` map streaming-result fields to +the outer `id:`, `event:`, and `retry:` lines. When a mapping is absent, Goa +omits that line. The JSON-RPC message in `data:` determines whether the event is +a streamed notification, terminal success, or terminal error; the outer event +name does not. + +Generated clients require the response media type to be `text/event-stream`; +standard parameters such as `charset=utf-8` are accepted. Multiple `data:` +lines are joined with a newline. The parser accepts CR, LF, and CRLF line +endings, removes one byte-order marker at the start of the stream, and discards +an incomplete final event. A valid `id:` applies to later events until another +one changes it. An empty `id:` resets the value, while an `id:` containing NUL +is ignored. Generated servers prefix every physical encoded JSON line with +`data:` for all three line-ending forms. Event IDs sent by a generated server +cannot contain CR, LF, or NUL, event names cannot contain a line break, and +retry values must be non-negative decimal integers. + +JSON-RPC does not accept `StreamingPayload` or bidirectional streaming. Use +gRPC or an ordinary HTTP WebSocket method when the client must send a stream of +values. + +### Last-Event-ID + +`SSERequestID` maps a string field in the initial method payload to the HTTP +`Last-Event-ID` request header. Generated clients write the header, and +generated servers read it before validating the payload: ```go -Service("hybrid", func() { - JSONRPC(func() { - POST("/api") - }) - - // Standard HTTP method - Method("status", func() { - Result(func() { - Attribute("healthy", Boolean) - Required("healthy") - }) - JSONRPC(func() {}) - }) - - // SSE streaming method - Method("monitor", func() { - StreamingResult(func() { - Attribute("event", String) - Attribute("data", Any) - }) - JSONRPC(func() { - ServerSentEvents(func() { - SSEEventType("update") - }) - }) - }) - - // Mixed results with content negotiation - Method("flexible", func() { - Payload(func() { - Attribute("resource", String) - Required("resource") - }) - - // Return simple result for HTTP - Result(func() { - Attribute("data", String) - Required("data") - }) - - // Return stream for SSE - StreamingResult(func() { - Attribute("chunk", String) - Attribute("progress", Int) - }) - - JSONRPC(func() { - ServerSentEvents(func() { - SSEEventType("progress") - }) - }) - }) +Payload(func() { + Attribute("last_event_id", String) }) -``` - -The server automatically routes based on the `Accept` header: -- `Accept: application/json` → HTTP handler → `Result` -- `Accept: text/event-stream` → SSE handler → `StreamingResult` - -Under the hood, the generated handler checks `Accept` at runtime and invokes -the SSE stream only when `text/event-stream` is requested and the method has -`StreamingResult` (including mixed-result shapes). Otherwise, the standard -HTTP request-response path is used. - -## Advanced Features - -### Batch Processing -JSON-RPC supports sending multiple requests in a single HTTP call: - -```json -[ - {"jsonrpc": "2.0", "method": "add", "params": {"a": 1, "b": 2}, "id": 1}, - {"jsonrpc": "2.0", "method": "multiply", "params": {"a": 3, "b": 4}, "id": 2}, - {"jsonrpc": "2.0", "method": "divide", "params": {"dividend": 10, "divisor": 2}, "id": 3} -] -``` - -The server processes each request independently and returns an array of responses: - -```json -[ - {"jsonrpc": "2.0", "result": 3, "id": 1}, - {"jsonrpc": "2.0", "result": 12, "id": 2}, - {"jsonrpc": "2.0", "result": 5, "id": 3} -] -``` - -Batch processing is automatic - no special configuration needed. - -### Error Handling - -Goa provides comprehensive error handling with standard JSON-RPC error codes: - -```go -Service("api", func() { - // Define service-level errors - Error("unauthorized", func() { - Description("User is not authorized") - }) - Error("rate_limited", func() { - Description("Too many requests") - }) - - JSONRPC(func() { - // Map errors to JSON-RPC codes - Response("unauthorized", func() { - Code(-32001) // Custom application code - }) - Response("rate_limited", func() { - Code(-32002) - }) - }) - - Method("secure", func() { - // ... method definition ... - Error("unauthorized") // Method can return this error - Error("invalid_token") // Method-specific error - - JSONRPC(func() { - Response("invalid_token", func() { - Code(-32003) - }) - }) - }) +JSONRPC(func() { + ServerSentEvents(func() { + SSERequestID("last_event_id") + }) }) ``` -Standard error codes: -- `-32700`: Parse error -- `-32600`: Invalid request -- `-32601`: Method not found -- `-32602`: Invalid params -- `-32603`: Internal error -- `-32000` to `-32099`: Reserved for implementation +The payload field stays optional unless the design marks it required. +An absent optional field stays absent unless the design gives it a default. An +explicitly empty header remains an explicitly empty string. A required field +can be satisfied by the header because the server reads it before validating +the payload. The field is not repeated in JSON-RPC `params`. -### Streaming Patterns +JSON-RPC rejects a method that defines both `Result` and `StreamingResult`, +even when both declarations use the same Goa type, because the generated client +cannot receive both from one call. Define one method for the stream and another +method for the final resource. The two methods may share the same service and +JSON-RPC path. -#### Client Streaming (WebSocket only) -```go -Method("upload", func() { - StreamingPayload(func() { - Attribute("chunk", Bytes) - Attribute("offset", Int64) - Required("chunk", "offset") - }) - Result(func() { - Attribute("size", Int64) - Attribute("checksum", String) - }) - JSONRPC(func() {}) -}) -``` - -#### Server Streaming (SSE or WebSocket) -```go -Method("download", func() { - Payload(func() { - Attribute("file", String) - Required("file") - }) - StreamingResult(func() { - Attribute("chunk", Bytes) - Attribute("offset", Int64) - Required("chunk", "offset") - }) - JSONRPC(func() { - ServerSentEvents(func() {}) // Or use WebSocket - }) -}) -``` - -#### Bidirectional Streaming (WebSocket only) -```go -Method("transform", func() { - StreamingPayload(func() { - ID("seq", String) - Attribute("input", String) - Required("seq", "input") - }) - StreamingResult(func() { - ID("seq", String) - Attribute("output", String) - Required("seq", "output") - }) - JSONRPC(func() {}) -}) -``` +## Errors -### Mixed Results - -Support different response types based on content negotiation: +Map designed errors to JSON-RPC codes in the method design: ```go -Method("report", func() { - Payload(func() { - Attribute("query", String) - Required("query") - }) - - // Simple result for synchronous HTTP - Result(func() { - Attribute("summary", String) - Attribute("count", Int) - Required("summary", "count") - }) - - // Streaming result for SSE - StreamingResult(func() { - Attribute("row", Map(String, Any)) - Attribute("progress", Float64) - }) - - JSONRPC(func() { - ServerSentEvents(func() { - SSEEventType("row") - }) - }) +Method("divide", func() { + Error("division_by_zero") + JSONRPC(func() { + Response("division_by_zero", func() { + Code(-32001) + }) + }) }) ``` -Implementation: - -```go -// Called for Accept: application/json -func (s *svc) Report(ctx context.Context, p *ReportPayload) (*ReportResult, error) { - summary, count := generateReport(p.Query) - return &ReportResult{Summary: summary, Count: count}, nil -} - -// Called for Accept: text/event-stream -func (s *svc) ReportStream(ctx context.Context, p *ReportPayload, - stream ReportServerStream) error { - - rows := queryRows(p.Query) - for i, row := range rows { - err := stream.Send(ctx, &ReportStreamingResult{ - Row: row, - Progress: float64(i) / float64(len(rows)), - }) - if err != nil { - return err - } - } - return nil -} -``` - -## Best Practices - -### 1. Service Design - -**DO:** -- Group related methods in the same service -- Use consistent naming conventions -- Define clear error codes and messages -- Document expected behavior - -**DON'T:** -- Mix WebSocket with HTTP endpoints in the same service -- Use deeply nested payload structures -- Rely on transport-specific features - -### 2. Error Handling +Goa also uses the standard JSON-RPC codes: -**DO:** -- Map application errors to appropriate JSON-RPC codes -- Provide meaningful error messages -- Use standard codes when applicable -- Include error data when helpful +- `-32700` for malformed JSON; +- `-32600` for an invalid request; +- `-32601` for an unknown method; +- `-32602` for invalid parameters; and +- `-32603` for an unexpected service error. -**DON'T:** -- Use reserved error code ranges -- Return stack traces in production -- Ignore validation errors +Service implementations return ordinary designed or unexpected errors. The +generated transport writes the JSON-RPC error and preserves the request ID. +Generated clients report malformed response JSON as `decoding_error` and +decoded values that fail a field or view rule as `validation_error`. -### 3. Streaming +## Batch requests -**DO:** -- Use SSE for server-push scenarios -- Use WebSocket for bidirectional needs -- Implement proper cleanup in stream handlers -- Handle connection failures gracefully +Unary JSON-RPC methods accept a JSON array of requests and declared +notifications. The generated server checks each item against its method +declaration and returns an array containing ordinary responses and contract +errors. Valid declared notifications produce no array item. A batch containing +only valid declared notifications receives no JSON-RPC response body. -**DON'T:** -- Keep streams open indefinitely -- Send large payloads in single messages -- Ignore backpressure +SSE streams are opened by one request and are not batch operations. -### 4. Performance +## Using JSON-RPC with other transports -**DO:** -- Use batch requests for multiple operations -- Implement connection pooling for clients -- Cache frequently accessed data -- Monitor message sizes +A Goa method may also have ordinary `HTTP` or `GRPC` transport mappings. Each +generated transport implements the same service method contract. JSON-RPC +methods share their JSON-RPC route; ordinary HTTP routes and gRPC procedures +remain independent. -**DON'T:** -- Create new connections per request -- Send unnecessary notifications -- Block stream handlers +## Generation -### Supporting Multiple Transports +Run Goa against the design package import path: -Expose the same service over multiple protocols: - -```go -Service("universal", func() { - // JSON-RPC configuration - JSONRPC(func() { - POST("/rpc") - }) - - Method("process", func() { - Payload(func() { - Attribute("data", String) - Required("data") - }) - Result(func() { - Attribute("output", String) - Required("output") - }) - - // Available via JSON-RPC - JSONRPC(func() {}) - - // Also available via HTTP REST - HTTP(func() { - POST("/process") - }) - - // And via gRPC - GRPC(func() {}) - }) -}) +```bash +goa gen example.com/project/design ``` -## Additional Resources - -- [JSON-RPC 2.0 Specification](https://www.jsonrpc.org/specification) -- [Goa Documentation](https://goa.design) -- [Example Services](https://github.com/goadesign/examples) -- [Integration Tests](../jsonrpc/integration_tests) - -## Summary - -Goa's JSON-RPC implementation provides: +Do not edit generated files. Change the design or the owning generator and run +generation again. -- **Type Safety**: Full compile-time type checking -- **Code Generation**: Automatic client/server code from DSL -- **Protocol Compliance**: Complete JSON-RPC 2.0 support -- **Transport Flexibility**: HTTP, SSE, and WebSocket options -- **Streaming Support**: Unidirectional and bidirectional patterns -- **Error Handling**: Comprehensive error mapping and codes -- **Content Negotiation**: Mixed results based on Accept headers -- **Batch Processing**: Automatic batch request handling +Code that calls generated transport helpers directly must pass the request ID +to JSON-RPC response decoders and SSE stream constructors. Request encoders for +ordinary calls now return `(string, error)`, where the string is the ID written +to the request. Generated service method and `Send`/`Recv` interfaces are +unchanged. -The implementation seamlessly integrates with Goa's existing features while -maintaining clean separation of concerns and enabling powerful real-time -communication patterns. \ No newline at end of file +See [ARCHITECTURE.md](ARCHITECTURE.md) for generator ownership and the exact SSE +message lifecycle. diff --git a/jsonrpc/codegen/client.go b/jsonrpc/codegen/client.go index 2c17647ae0..1d7fd9d1e5 100644 --- a/jsonrpc/codegen/client.go +++ b/jsonrpc/codegen/client.go @@ -1,3 +1,5 @@ +// This file writes JSON-RPC client calls, request encoders, and response +// decoders for each service. Each file imports only the types it uses. package codegen import ( @@ -5,121 +7,157 @@ import ( "path/filepath" "goa.design/goa/v3/codegen" - "goa.design/goa/v3/expr" httpcodegen "goa.design/goa/v3/http/codegen" ) -// ClientFiles returns the generated JSON-RPC client files. -func ClientFiles(genpkg string, data *httpcodegen.ServicesData) []*codegen.File { - jsvcs := data.Root.API.JSONRPC.Services - files := make([]*codegen.File, 0, len(jsvcs)*3) - for _, svc := range jsvcs { - files = append(files, clientFile(genpkg, svc, data)) - if f := websocketClientFile(genpkg, svc, data); f != nil { - files = append(files, f) - } - if f := sseClientFile(genpkg, svc, data); f != nil { - files = append(files, f) +type ( + // clientTemplateData stores the service values and Go names used to write + // one client package. + clientTemplateData struct { + httpcodegen.JSONRPCServiceSnapshot + // BufferPool is the byte buffer variable used while encoding requests. + BufferPool *codegen.NameDeclaration + } +) + +// clientFiles builds client, stream, and JSON conversion files from the +// services recorded before every generated Go name was assigned. +func clientFiles(services []*servicePlan) []*codegen.File { + files := make([]*codegen.File, 0, len(services)*3) + for _, planned := range services { + renderPlan := servicePlanForOutput(planned, true) + files = append(files, setFileImports(clientFile(renderPlan), renderPlan)) + if f := sseClientFile(renderPlan); f != nil { + files = append(files, setFileImports(f, renderPlan)) } } - for _, svc := range jsvcs { - f := httpcodegen.ClientEncodeDecodeFile(genpkg, svc, data) + for _, planned := range services { + f := planned.data.ClientCodecFile() if f == nil { continue } - var swapped int + sections := make([]*codegen.SectionTemplate, 0, len(f.SectionTemplates)) + var decoders int for _, s := range f.SectionTemplates { - switch s.Name { - case "source-header": - codegen.AddImport(s, &codegen.ImportSpec{Path: "bufio"}) - codegen.AddImport(s, &codegen.ImportSpec{Path: "bytes"}) - codegen.AddImport(s, &codegen.ImportSpec{Path: "sync"}) - codegen.AddImport(s, &codegen.ImportSpec{Path: "sync/atomic"}) - codegen.AddImport(s, codegen.GoaImport("jsonrpc")) - case "response-decoder": + if s.Name == "response-decoder" { + endpoint := s.Data.(*httpcodegen.JSONRPCEndpointSnapshot) + if endpoint.SSE != nil || endpoint.IsJSONRPCNotification { + continue + } s.Source = jsonrpcTemplates.Read(responseDecoderT, singleResponseP, queryTypeConversionP, elementSliceConversionP, sliceItemConversionP) - swapped++ + s.FuncMap["buildResponseData"] = buildJSONRPCResponseData + for name, function := range viewedResultFuncs(planned) { + s.FuncMap[name] = function + } + decoders++ } s.Name = "jsonrpc-" + s.Name + sections = append(sections, s) + } + f.SectionTemplates = sections + viewed := clientViewedResultSections(planned) + if len(viewed) > 0 { + f.SectionTemplates = append(f.SectionTemplates, &codegen.SectionTemplate{ + Name: "jsonrpc-viewed-result-body-decoder", + Source: jsonrpcTemplates.Read(viewedResultBodyDecodeT), + Data: planned.bodyDecoder, + }) + f.SectionTemplates = append(f.SectionTemplates, viewed...) } - // The HTTP client file emits exactly one response decoder per - // endpoint. Guard against the two generators drifting apart. - if n := len(data.Get(svc.Name()).Endpoints); swapped != n { - panic(fmt.Sprintf("jsonrpc: swapped %d response decoders for service %q, expected %d", swapped, svc.Name(), n)) + // Each ordinary unary method receives exactly one response. + var expected int + for _, endpoint := range planned.data.Endpoints { + if endpoint.SSE == nil && !endpoint.IsJSONRPCNotification { + expected++ + } } - files = append(files, f) + if decoders != expected { + panic(fmt.Sprintf("jsonrpc: wrote %d response decoders for service %q, expected %d", decoders, planned.name, expected)) + } + files = append(files, setFileImports(f, planned)) } return files } -// clientFile returns the client HTTP transport file -func clientFile(genpkg string, svc *expr.HTTPServiceExpr, services *httpcodegen.ServicesData) *codegen.File { - data := services.Get(svc.Name()) +// buildJSONRPCResponseData gives the shared response reader one copied JSON-RPC +// response together with the service and method names written in client errors. +func buildJSONRPCResponseData(data httpcodegen.JSONRPCResponseData, serviceName string, method httpcodegen.JSONRPCMethodData) map[string]any { + return map[string]any{ + "Data": data, + "ServiceName": serviceName, + "Method": method, + } +} + +// clientFile builds the JSON-RPC client methods for one service. +func clientFile(planned *servicePlan) *codegen.File { + data := planned.data + renderData := &clientTemplateData{ + JSONRPCServiceSnapshot: data, + BufferPool: planned.clientNames.bufferPool, + } svcName := data.Service.PathName path := filepath.Join(codegen.Gendir, "jsonrpc", svcName, "client", "client.go") - title := fmt.Sprintf("%s client JSON-RPC transport", svc.Name()) - sections := []*codegen.SectionTemplate{ - codegen.Header(title, "client", []*codegen.ImportSpec{ - {Path: "bufio"}, - {Path: "bytes"}, - {Path: "context"}, - {Path: "fmt"}, - {Path: "io"}, - {Path: "net/http"}, - {Path: "strconv"}, - {Path: "strings"}, - {Path: "sync"}, - {Path: "sync/atomic"}, - {Path: "time"}, - {Path: "github.com/gorilla/websocket"}, - codegen.GoaImport(""), - codegen.GoaImport("jsonrpc"), - codegen.GoaNamedImport("http", "goahttp"), - {Path: genpkg + "/" + svcName, Name: data.Service.PkgName}, - {Path: genpkg + "/" + svcName + "/" + "views", Name: data.Service.ViewsPkg}, - }), - } + title := fmt.Sprintf("%s client JSON-RPC transport", planned.name) + sections := make([]*codegen.SectionTemplate, 0, 3+len(planned.endpoints)) + sections = append(sections, codegen.Header(title, "client", nil)) sections = append(sections, &codegen.SectionTemplate{ Name: "jsonrpc-client-struct", Source: jsonrpcTemplates.Read(clientStructT), - Data: data, + Data: renderData, FuncMap: map[string]any{ - "hasWebSocket": httpcodegen.HasWebSocket, - "hasSSE": httpcodegen.HasSSE, - "isSSEEndpoint": httpcodegen.IsSSEEndpoint, + "hasSSE": hasJSONRPCSSE, + "isSSEEndpoint": isJSONRPCSSEEndpoint, }, }) sections = append(sections, &codegen.SectionTemplate{ Name: "jsonrpc-client-init", Source: jsonrpcTemplates.Read(clientInitT), - Data: data, + Data: renderData, FuncMap: map[string]any{ - "hasWebSocket": httpcodegen.HasWebSocket, - "hasSSE": httpcodegen.HasSSE, - "isSSEEndpoint": httpcodegen.IsSSEEndpoint, + "hasSSE": hasJSONRPCSSE, + "isSSEEndpoint": isJSONRPCSSEEndpoint, }, }) - for _, e := range data.Endpoints { + funcs := viewedResultFuncs(planned) + for _, e := range planned.endpoints { sections = append(sections, &codegen.SectionTemplate{ Name: "jsonrpc-client-endpoint-init", Source: jsonrpcTemplates.Read(clientEndpointInitT), - Data: e, + Data: &e.JSONRPCEndpointSnapshot, FuncMap: map[string]any{ - "isWebSocketEndpoint": httpcodegen.IsWebSocketEndpoint, - "isSSEEndpoint": httpcodegen.IsSSEEndpoint, + "isSSEEndpoint": isJSONRPCSSEEndpoint, + "viewedDecodeName": funcs["viewedDecodeName"], }, }) } - if httpcodegen.HasWebSocket(data) { - sections = append(sections, &codegen.SectionTemplate{ - Name: "jsonrpc-client-websocket-conn", - Source: jsonrpcTemplates.Read(websocketClientConnT), - Data: data, - }) + return &codegen.File{Path: path, SectionTemplates: sections} +} + +// hasJSONRPCSSE reports whether service has a method that sends server-sent +// events. Generated clients include stream fields only when one is needed. +func hasJSONRPCSSE(data any) bool { + service := jsonRPCClientService(data) + for _, endpoint := range service.Endpoints { + if endpoint.SSE != nil { + return true + } } + return false +} - return &codegen.File{Path: path, SectionTemplates: sections} +// jsonRPCClientService returns the copied service values used to write a +// generated client. +func jsonRPCClientService(data any) httpcodegen.JSONRPCServiceSnapshot { + switch value := data.(type) { + case httpcodegen.JSONRPCServiceSnapshot: + return value + case *clientTemplateData: + return value.JSONRPCServiceSnapshot + default: + panic(fmt.Sprintf("JSON-RPC client received data of type %T", data)) + } } diff --git a/jsonrpc/codegen/designed_error_runtime_test.go b/jsonrpc/codegen/designed_error_runtime_test.go new file mode 100644 index 0000000000..6af0a331ba --- /dev/null +++ b/jsonrpc/codegen/designed_error_runtime_test.go @@ -0,0 +1,542 @@ +// This file renders a JSON-RPC service into a temporary module and runs the +// generated unary and streaming error paths. It verifies that error names, +// codes, and designed response bodies survive the complete server/client flow. +package codegen + +import ( + "context" + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/require" + + goacodegen "goa.design/goa/v3/codegen" + "goa.design/goa/v3/codegen/service" + "goa.design/goa/v3/codegen/testutil" + "goa.design/goa/v3/dsl" + "goa.design/goa/v3/eval" + "goa.design/goa/v3/expr" + httpcodegen "goa.design/goa/v3/http/codegen" +) + +// TestGeneratedDesignedErrorEnvelope renders and runs both JSON-RPC transports +// with errors that share codes but have different names and response bodies. +func TestGeneratedDesignedErrorEnvelope(t *testing.T) { + dir, plan := renderDesignedErrorRuntimeModule(t) + testutil.AssertGo( + t, + "testdata/golden/designed_error_unary_client.go.golden", + codegenSection(t, plan.ClientFiles(), "encode_decode.go", "jsonrpc-response-decoder", "func DecodeRunResponse"), + ) + streamClient := codegenSection(t, plan.ClientFiles(), "stream.go", "jsonrpc-sse-client-stream", "decodeError(") + require.Contains(t, streamClient, "decodeError(response.Error, response.ID == nil)") + require.Contains(t, streamClient, "decodeError(response *jsonrpc.RawErrorResponse, nullID bool)") + testutil.AssertGo( + t, + "testdata/golden/designed_error_unary_server.go.golden", + codegenSection(t, plan.ServerFiles(), "server.go", "jsonrpc-server-handler-init", "func NewRunHandler"), + ) + clientDir := filepath.Join(dir, "jsonrpc", "error_service", "client") + serverDir := filepath.Join(dir, "jsonrpc", "error_service", "server") + require.NoError(t, os.WriteFile(filepath.Join(clientDir, "designed_error_runtime_test.go"), []byte(designedErrorClientRuntimeTest), 0o600)) + require.NoError(t, os.WriteFile(filepath.Join(serverDir, "designed_error_runtime_test.go"), []byte(designedErrorServerRuntimeTest), 0o600)) + + ctx, cancel := context.WithTimeout(context.Background(), time.Minute) + defer cancel() + command := exec.CommandContext(ctx, "go", "test", "-mod=mod", "./jsonrpc/error_service/client", "./jsonrpc/error_service/server") + command.Dir = dir + command.Env = append(os.Environ(), "GOWORK=off") + output, err := command.CombinedOutput() + require.NoError(t, err, string(output)) +} + +// renderDesignedErrorRuntimeModule renders one service whose errors exercise +// shared codes, protocol-code collisions, renamed fields, and empty bodies. +func renderDesignedErrorRuntimeModule(t *testing.T) (string, *Plan) { + t.Helper() + root := expr.RunDSL(t, designedErrorRuntimeDSL) + generation, err := goacodegen.NewGeneration("generated.local/gen", []eval.Root{root}) + require.NoError(t, err) + servicePlan, err := service.NewPlan(root, generation, expr.NewExampleGenerator(root.API.RandomizerFactory)) + require.NoError(t, err) + httpPlans, err := httpcodegen.NewJSONRPCPlans(generation, httpcodegen.PlanInput{Root: root, Service: servicePlan}) + require.NoError(t, err) + jsonPlans, err := NewPlans(generation, PlanInput{ + Root: root, + Service: servicePlan, + HTTP: httpPlans[0], + }) + require.NoError(t, err) + require.NoError(t, generation.Freeze()) + require.NoError(t, servicePlan.Link()) + require.NoError(t, httpPlans[0].Link()) + require.NoError(t, jsonPlans[0].Link()) + + files, err := service.Files(servicePlan) + require.NoError(t, err) + files = append(files, jsonPlans[0].ClientFiles()...) + files = append(files, jsonPlans[0].ServerFiles()...) + files = append(files, jsonPlans[0].ClientTypeFiles()...) + files = append(files, jsonPlans[0].ServerTypeFiles()...) + files = append(files, jsonPlans[0].PathFiles()...) + base := t.TempDir() + for _, file := range files { + _, err := file.Render(base) + require.NoError(t, err) + } + + moduleDir := filepath.Join(base, goacodegen.Gendir) + workingDir, err := os.Getwd() + require.NoError(t, err) + repository := filepath.Clean(filepath.Join(workingDir, "..", "..")) + module := fmt.Sprintf( + "module generated.local/gen\n\ngo 1.25\n\nrequire goa.design/goa/v3 v3.0.0\n\nreplace goa.design/goa/v3 => %s\n", + repository, + ) + require.NoError(t, os.WriteFile(filepath.Join(moduleDir, "go.mod"), []byte(module), 0o600)) + return moduleDir, jsonPlans[0] +} + +// codegenSection returns the generated method section identified by its +// declaration instead of depending on design order. +func codegenSection(t *testing.T, files []*goacodegen.File, fileName, sectionName, declaration string) string { + t.Helper() + for _, file := range files { + if filepath.Base(file.Path) != fileName { + continue + } + for _, section := range file.Section(sectionName) { + source := goacodegen.SectionCode(t, section) + if strings.Contains(source, declaration) { + return source + } + } + } + require.Fail(t, "generated section not found", "%s in %s", declaration, fileName) + return "" +} + +// designedErrorRuntimeDSL declares errors whose code alone is deliberately +// insufficient to identify the designed service error. +func designedErrorRuntimeDSL() { + firstProblem := dsl.Type("FirstProblem", func() { + dsl.Attribute("detail", dsl.String, func() { + dsl.MinLength(3) + }) + dsl.Attribute("private", dsl.String) + dsl.Required("detail") + }) + secondProblem := dsl.Type("SecondProblem", func() { + dsl.Attribute("detail", dsl.String) + dsl.Required("detail") + }) + internalProblem := dsl.Type("InternalProblem", func() { + dsl.Attribute("detail", dsl.String) + dsl.Required("detail") + }) + selectedProblem := dsl.Type("SelectedProblem", func() { + dsl.Attribute("detail", dsl.String) + dsl.Required("detail") + }) + parseProblem := dsl.Type("ParseProblem", func() { + dsl.Attribute("detail", dsl.String) + dsl.Required("detail") + }) + invalidProblem := dsl.Type("InvalidProblem", func() { + dsl.Attribute("detail", dsl.String) + dsl.Required("detail") + }) + emptyProblem := dsl.Type("EmptyProblem", func() { + dsl.Attribute("detail", dsl.String) + }) + + dsl.Service("Error Service", func() { + dsl.JSONRPC(func() { + dsl.POST("/rpc") + }) + dsl.Method("run", func() { + dsl.Error("first", firstProblem) + dsl.Error("second", secondProblem) + dsl.Error("internal", internalProblem) + dsl.Error("selected", selectedProblem) + dsl.Error("parse", parseProblem) + dsl.Error("invalid", invalidProblem) + dsl.Error("empty", emptyProblem) + dsl.JSONRPC(func() { + dsl.Response("first", 7001, func() { + dsl.Body(func() { + dsl.Attribute("detail:message") + }) + }) + dsl.Response("second", 7001) + dsl.Response("internal", func() {}) + dsl.Response("selected", 7003, func() { + dsl.Body("detail") + }) + dsl.Response("parse", -32700) + dsl.Response("invalid", -32600) + dsl.Response("empty", 7002, func() { + dsl.Body(dsl.Empty) + }) + }) + }) + dsl.Method("watch", func() { + dsl.StreamingResult(dsl.String) + dsl.Error("first", firstProblem) + dsl.Error("second", secondProblem) + dsl.Error("selected", selectedProblem) + dsl.Error("parse", parseProblem) + dsl.Error("invalid", invalidProblem) + dsl.JSONRPC(func() { + dsl.Response("first", 7001, func() { + dsl.Body(func() { + dsl.Attribute("detail:message") + }) + }) + dsl.Response("second", 7001) + dsl.Response("selected", 7003, func() { + dsl.Body("detail") + }) + dsl.Response("parse", -32700) + dsl.Response("invalid", -32600) + dsl.ServerSentEvents() + }) + }) + }) +} + +const designedErrorClientRuntimeTest = `package client + +import ( + "io" + "net/http" + "strings" + "testing" + + "github.com/stretchr/testify/require" + + service "generated.local/gen/error_service" + goahttp "goa.design/goa/v3/http" + "goa.design/goa/v3/jsonrpc" +) + +func TestUnaryDesignedErrorsUseCodeAndName(t *testing.T) { + tests := []struct { + name string + response string + check func(*testing.T, error) + }{ + { + name: "first shared code", + response: ` + "`" + `{"jsonrpc":"2.0","id":"request-1","error":{"code":7001,"message":"first","data":{"name":"first","body":{"message":"visible"}}}}` + "`" + `, + check: func(t *testing.T, err error) { + var problem *service.FirstProblem + require.ErrorAs(t, err, &problem) + require.Equal(t, "visible", problem.Detail) + require.Empty(t, problem.Private) + }, + }, + { + name: "second shared code", + response: ` + "`" + `{"jsonrpc":"2.0","id":"request-1","error":{"code":7001,"message":"second","data":{"name":"second","body":{"detail":"other"}}}}` + "`" + `, + check: func(t *testing.T, err error) { + var problem *service.SecondProblem + require.ErrorAs(t, err, &problem) + require.Equal(t, "other", problem.Detail) + }, + }, + { + name: "designed internal code", + response: ` + "`" + `{"jsonrpc":"2.0","id":"request-1","error":{"code":-32603,"message":"designed","data":{"name":"internal","body":{"detail":"known"}}}}` + "`" + `, + check: func(t *testing.T, err error) { + var problem *service.InternalProblem + require.ErrorAs(t, err, &problem) + require.Equal(t, "known", problem.Detail) + }, + }, + { + name: "empty body", + response: ` + "`" + `{"jsonrpc":"2.0","id":"request-1","error":{"code":7002,"message":"empty","data":{"name":"empty","body":null}}}` + "`" + `, + check: func(t *testing.T, err error) { + var problem *service.EmptyProblem + require.ErrorAs(t, err, &problem) + }, + }, + { + name: "selected primitive body", + response: ` + "`" + `{"jsonrpc":"2.0","id":"request-1","error":{"code":7003,"message":"selected","data":{"name":"selected","body":"field"}}}` + "`" + `, + check: func(t *testing.T, err error) { + var problem *service.SelectedProblem + require.ErrorAs(t, err, &problem) + require.Equal(t, "field", problem.Detail) + }, + }, + { + name: "designed parse code with exact id", + response: ` + "`" + `{"jsonrpc":"2.0","id":"request-1","error":{"code":-32700,"message":"parse","data":{"name":"parse","body":{"detail":"known"}}}}` + "`" + `, + check: func(t *testing.T, err error) { + var problem *service.ParseProblem + require.ErrorAs(t, err, &problem) + require.Equal(t, "known", problem.Detail) + }, + }, + { + name: "designed invalid request code with exact id", + response: ` + "`" + `{"jsonrpc":"2.0","id":"request-1","error":{"code":-32600,"message":"invalid","data":{"name":"invalid","body":{"detail":"known"}}}}` + "`" + `, + check: func(t *testing.T, err error) { + var problem *service.InvalidProblem + require.ErrorAs(t, err, &problem) + require.Equal(t, "known", problem.Detail) + }, + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + response := &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader(test.response))} + result, err := DecodeRunResponse(goahttp.ResponseDecoder, false)(response, "request-1") + require.Nil(t, result) + test.check(t, err) + }) + } +} + +func TestUnaryProtocolAndUnknownErrorsStayRaw(t *testing.T) { + for _, test := range []struct { + name string + response string + message string + }{ + {name: "protocol internal error", response: ` + "`" + `{"jsonrpc":"2.0","id":"request-1","error":{"code":-32603,"message":"Internal error"}}` + "`" + `, message: "Internal error"}, + {name: "protocol parse error with null id", response: ` + "`" + `{"jsonrpc":"2.0","id":null,"error":{"code":-32700,"message":"Parse error"}}` + "`" + `, message: "Parse error"}, + {name: "unknown parse envelope with null id", response: ` + "`" + `{"jsonrpc":"2.0","id":null,"error":{"code":-32700,"message":"unknown","data":{"name":"other","body":{"detail":"known"}}}}` + "`" + `, message: "unknown"}, + {name: "protocol invalid request with null id", response: ` + "`" + `{"jsonrpc":"2.0","id":null,"error":{"code":-32600,"message":"Invalid request"}}` + "`" + `, message: "Invalid request"}, + {name: "malformed invalid envelope with null id", response: ` + "`" + `{"jsonrpc":"2.0","id":null,"error":{"code":-32600,"message":"malformed","data":[]}}` + "`" + `, message: "malformed"}, + {name: "unknown name", response: ` + "`" + `{"jsonrpc":"2.0","id":"request-1","error":{"code":7001,"message":"unknown","data":{"name":"other","body":{}}}}` + "`" + `, message: "unknown"}, + {name: "name not allowed for code", response: ` + "`" + `{"jsonrpc":"2.0","id":"request-1","error":{"code":7002,"message":"wrong code","data":{"name":"first","body":{"message":"visible"}}}}` + "`" + `, message: "wrong code"}, + {name: "missing body", response: ` + "`" + `{"jsonrpc":"2.0","id":"request-1","error":{"code":7001,"message":"missing body","data":{"name":"first"}}}` + "`" + `, message: "missing body"}, + {name: "malformed data", response: ` + "`" + `{"jsonrpc":"2.0","id":"request-1","error":{"code":7001,"message":"malformed","data":[]}}` + "`" + `, message: "malformed"}, + {name: "non-null empty body", response: ` + "`" + `{"jsonrpc":"2.0","id":"request-1","error":{"code":7002,"message":"non-null empty","data":{"name":"empty","body":{}}}}` + "`" + `, message: "non-null empty"}, + } { + t.Run(test.name, func(t *testing.T) { + response := &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader(test.response))} + result, err := DecodeRunResponse(goahttp.ResponseDecoder, false)(response, "request-1") + require.Nil(t, result) + var rpcError *jsonrpc.RawErrorResponse + require.ErrorAs(t, err, &rpcError) + require.Equal(t, test.message, rpcError.Message) + }) + } +} + +func TestUnaryDesignedErrorsRejectNullIDs(t *testing.T) { + for _, responseBody := range []string{ + ` + "`" + `{"jsonrpc":"2.0","id":null,"error":{"code":-32700,"message":"parse","data":{"name":"parse","body":{"detail":"known"}}}}` + "`" + `, + ` + "`" + `{"jsonrpc":"2.0","id":null,"error":{"code":-32600,"message":"invalid","data":{"name":"invalid","body":{"detail":"known"}}}}` + "`" + `, + ` + "`" + `{"jsonrpc":"2.0","id":null,"error":{"code":-32603,"message":"internal","data":{"name":"internal","body":{"detail":"known"}}}}` + "`" + `, + ` + "`" + `{"jsonrpc":"2.0","id":null,"error":{"code":7001,"message":"first","data":{"name":"first","body":{"message":"known"}}}}` + "`" + `, + } { + response := &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader(responseBody))} + result, err := DecodeRunResponse(goahttp.ResponseDecoder, false)(response, "request-1") + require.Nil(t, result) + require.ErrorContains(t, err, "response id is null") + var rpcError *jsonrpc.RawErrorResponse + require.NotErrorAs(t, err, &rpcError) + } +} + +func TestUnaryDesignedErrorValidatesItsResponseBody(t *testing.T) { + response := &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(` + "`" + `{"jsonrpc":"2.0","id":"request-1","error":{"code":7001,"message":"second","data":{"name":"second","body":{}}}}` + "`" + `)), + } + result, err := DecodeRunResponse(goahttp.ResponseDecoder, false)(response, "request-1") + require.Nil(t, result) + require.ErrorContains(t, err, "\"detail\" is missing from body") + var rpcError *jsonrpc.RawErrorResponse + require.NotErrorAs(t, err, &rpcError) +} + +func TestSSEDesignedErrorUsesCodeAndName(t *testing.T) { + response := &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader("data: " + ` + "`" + `{"jsonrpc":"2.0","id":"request-1","error":{"code":7003,"message":"selected","data":{"name":"selected","body":"stream"}}}` + "`" + ` + "\n\n")), + } + _, err := NewWatchStream(response, goahttp.ResponseDecoder, "request-1").Recv() + var problem *service.SelectedProblem + require.ErrorAs(t, err, &problem) + require.Equal(t, "stream", problem.Detail) +} + +func TestSSEStandardCodesRequireTheExactRequestIDForDesignedErrors(t *testing.T) { + for _, test := range []struct { + name string + response string + check func(*testing.T, error) + }{ + { + name: "parse exact id", + response: ` + "`" + `{"jsonrpc":"2.0","id":"request-1","error":{"code":-32700,"message":"parse","data":{"name":"parse","body":{"detail":"known"}}}}` + "`" + `, + check: func(t *testing.T, err error) { + var problem *service.ParseProblem + require.ErrorAs(t, err, &problem) + }, + }, + { + name: "invalid request exact id", + response: ` + "`" + `{"jsonrpc":"2.0","id":"request-1","error":{"code":-32600,"message":"invalid","data":{"name":"invalid","body":{"detail":"known"}}}}` + "`" + `, + check: func(t *testing.T, err error) { + var problem *service.InvalidProblem + require.ErrorAs(t, err, &problem) + }, + }, + { + name: "parse protocol null id", + response: ` + "`" + `{"jsonrpc":"2.0","id":null,"error":{"code":-32700,"message":"Parse error"}}` + "`" + `, + check: requireRawError, + }, + { + name: "parse designed envelope null id", + response: ` + "`" + `{"jsonrpc":"2.0","id":null,"error":{"code":-32700,"message":"parse","data":{"name":"parse","body":{"detail":"known"}}}}` + "`" + `, + check: requireInvalidResponseID, + }, + { + name: "invalid request protocol null id", + response: ` + "`" + `{"jsonrpc":"2.0","id":null,"error":{"code":-32600,"message":"Invalid request"}}` + "`" + `, + check: requireRawError, + }, + { + name: "invalid request designed envelope null id", + response: ` + "`" + `{"jsonrpc":"2.0","id":null,"error":{"code":-32600,"message":"invalid","data":{"name":"invalid","body":{"detail":"known"}}}}` + "`" + `, + check: requireInvalidResponseID, + }, + { + name: "unknown parse envelope null id", + response: ` + "`" + `{"jsonrpc":"2.0","id":null,"error":{"code":-32700,"message":"unknown","data":{"name":"other","body":{"detail":"known"}}}}` + "`" + `, + check: requireRawError, + }, + { + name: "malformed invalid envelope null id", + response: ` + "`" + `{"jsonrpc":"2.0","id":null,"error":{"code":-32600,"message":"malformed","data":[]}}` + "`" + `, + check: requireRawError, + }, + } { + t.Run(test.name, func(t *testing.T) { + response := &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader("data: " + test.response + "\n\n")), + } + _, err := NewWatchStream(response, goahttp.ResponseDecoder, "request-1").Recv() + test.check(t, err) + }) + } +} + +// requireRawError checks that a protocol failure was not rebuilt as a designed error. +func requireRawError(t *testing.T, err error) { + t.Helper() + var response *jsonrpc.RawErrorResponse + require.ErrorAs(t, err, &response) +} + +// requireInvalidResponseID checks that a designed error did not accept a null ID. +func requireInvalidResponseID(t *testing.T, err error) { + t.Helper() + require.ErrorContains(t, err, "response id is null") + var response *jsonrpc.RawErrorResponse + require.NotErrorAs(t, err, &response) +} +` + +const designedErrorServerRuntimeTest = `package server + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/stretchr/testify/require" + + service "generated.local/gen/error_service" + goa "goa.design/goa/v3/pkg" + goahttp "goa.design/goa/v3/http" + "goa.design/goa/v3/jsonrpc" +) + +func TestUnaryServerEncodesWrappedDesignedBody(t *testing.T) { + private := "secret" + endpoint := goa.Endpoint(func(context.Context, any) (any, error) { + return nil, fmt.Errorf("wrapped: %w", &service.FirstProblem{Detail: "visible", Private: &private}) + }) + handler := NewRunHandler(endpoint, nil, goahttp.RequestDecoder, goahttp.ResponseEncoder, func(context.Context, http.ResponseWriter, error) {}) + recorder := httptest.NewRecorder() + request := httptest.NewRequest(http.MethodPost, "/rpc", nil) + raw := &jsonrpc.RawRequest{JSONRPC: "2.0", Method: "run", ID: "request-1", HasID: true, HasMethod: true} + + require.NoError(t, handler(context.Background(), request, raw, recorder)) + var response jsonrpc.RawResponse + require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &response)) + require.NotNil(t, response.Error) + require.Equal(t, 7001, response.Error.Code) + require.JSONEq(t, ` + "`" + `{"name":"first","body":{"message":"visible"}}` + "`" + `, string(response.Error.Data)) +} + +func TestUnaryServerEncodesEmptyDesignedBody(t *testing.T) { + detail := "not sent" + endpoint := goa.Endpoint(func(context.Context, any) (any, error) { + return nil, &service.EmptyProblem{Detail: &detail} + }) + handler := NewRunHandler(endpoint, nil, goahttp.RequestDecoder, goahttp.ResponseEncoder, func(context.Context, http.ResponseWriter, error) {}) + recorder := httptest.NewRecorder() + request := httptest.NewRequest(http.MethodPost, "/rpc", nil) + raw := &jsonrpc.RawRequest{JSONRPC: "2.0", Method: "run", ID: "request-1", HasID: true, HasMethod: true} + + require.NoError(t, handler(context.Background(), request, raw, recorder)) + var response jsonrpc.RawResponse + require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &response)) + require.NotNil(t, response.Error) + require.JSONEq(t, ` + "`" + `{"name":"empty","body":null}` + "`" + `, string(response.Error.Data)) +} + +func TestUnaryServerEncodesSelectedDesignedBody(t *testing.T) { + endpoint := goa.Endpoint(func(context.Context, any) (any, error) { + return nil, fmt.Errorf("wrapped: %w", &service.SelectedProblem{Detail: "chosen"}) + }) + handler := NewRunHandler(endpoint, nil, goahttp.RequestDecoder, goahttp.ResponseEncoder, func(context.Context, http.ResponseWriter, error) {}) + recorder := httptest.NewRecorder() + request := httptest.NewRequest(http.MethodPost, "/rpc", nil) + raw := &jsonrpc.RawRequest{JSONRPC: "2.0", Method: "run", ID: "request-1", HasID: true, HasMethod: true} + + require.NoError(t, handler(context.Background(), request, raw, recorder)) + var response jsonrpc.RawResponse + require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &response)) + require.NotNil(t, response.Error) + require.JSONEq(t, ` + "`" + `{"name":"selected","body":"chosen"}` + "`" + `, string(response.Error.Data)) +} + +func TestSSEServerEncodesWrappedDesignedBody(t *testing.T) { + endpoint := goa.Endpoint(func(context.Context, any) (any, error) { + return nil, fmt.Errorf("wrapped: %w", &service.SelectedProblem{Detail: "stream"}) + }) + handler := NewWatchHandler(endpoint, nil, goahttp.RequestDecoder, goahttp.ResponseEncoder, func(context.Context, http.ResponseWriter, error) {}) + recorder := httptest.NewRecorder() + request := httptest.NewRequest(http.MethodPost, "/rpc", nil) + raw := &jsonrpc.RawRequest{JSONRPC: "2.0", Method: "watch", ID: "request-1", HasID: true, HasMethod: true} + + require.NoError(t, handler(context.Background(), request, raw, recorder)) + data := strings.TrimSuffix(strings.TrimPrefix(recorder.Body.String(), "data: "), "\n\n") + var response jsonrpc.RawResponse + require.NoError(t, json.Unmarshal([]byte(data), &response)) + require.NotNil(t, response.Error) + require.Equal(t, 7003, response.Error.Code) + require.JSONEq(t, ` + "`" + `{"name":"selected","body":"stream"}` + "`" + `, string(response.Error.Data)) +} +` diff --git a/jsonrpc/codegen/example_server.go b/jsonrpc/codegen/example_server.go deleted file mode 100644 index c149a47c9b..0000000000 --- a/jsonrpc/codegen/example_server.go +++ /dev/null @@ -1,85 +0,0 @@ -package codegen - -import ( - "path" - "path/filepath" - - "goa.design/goa/v3/codegen" - "goa.design/goa/v3/codegen/example" - "goa.design/goa/v3/expr" - httpcodegen "goa.design/goa/v3/http/codegen" -) - -// ExampleServerFiles returns example JSON-RPC server implementation. -func ExampleServerFiles(genpkg string, data *httpcodegen.ServicesData, files []*codegen.File) []*codegen.File { - var fw []*codegen.File - for _, svr := range data.Root.API.Servers { - if m := exampleServer(genpkg, data, svr, files); m != nil { - fw = append(fw, m) - } - } - return fw -} - -func exampleServer(genpkg string, data *httpcodegen.ServicesData, svr *expr.ServerExpr, files []*codegen.File) *codegen.File { - svrdata := example.Servers.Get(svr, data.Root) - httppath := filepath.Join("cmd", svrdata.Dir, "http.go") - - // Retrieve existing HTTP server file or create a new one - var file *codegen.File - var hasHTTP bool - for _, f := range files { - if f.Path == httppath { - file = f - hasHTTP = true - break - } - } - if file == nil { - file = httpcodegen.ExampleServer(genpkg, data.Root, svr, data) - } - - // Add JSON-RPC imports to the HTTP server file - header := file.SectionTemplates[0] - scope := codegen.NewNameScope() - for _, svc := range data.Root.API.JSONRPC.Services { - sd := data.Get(svc.Name()) - svcName := sd.Service.PathName - codegen.AddImport(header, &codegen.ImportSpec{ - Path: path.Join(genpkg, svcName), - Name: scope.Unique(sd.Service.PkgName), - }) - codegen.AddImport(header, &codegen.ImportSpec{ - Path: path.Join(genpkg, "jsonrpc", svcName, "server"), - Name: scope.Unique(sd.Service.PkgName + "jssvr"), - }) - } - - // Add JSON-RPC to the HTTP server file - var svcdata []*httpcodegen.ServiceData - for _, svc := range svr.Services { - if d := data.Get(svc); d != nil { - svcdata = append(svcdata, d) - } - } - for _, s := range file.SectionTemplates { - switch s.Name { - case "server-http-start": - // Only set the JSON-RPC services if not already populated. - data := s.Data.(map[string]any) - if existing, _ := data["JSONRPCServices"].([]*httpcodegen.ServiceData); len(existing) == 0 { - data["JSONRPCServices"] = svcdata - } - case "server-http-init", "server-http-end": - updateData(s, svcdata, hasHTTP) - } - } - return file -} - -func updateData(s *codegen.SectionTemplate, svcdata []*httpcodegen.ServiceData, hasHTTP bool) { - s.Data.(map[string]any)["JSONRPCServices"] = svcdata - if !hasHTTP { - delete(s.Data.(map[string]any), "Services") - } -} diff --git a/jsonrpc/codegen/file_imports_test.go b/jsonrpc/codegen/file_imports_test.go new file mode 100644 index 0000000000..800e3576bc --- /dev/null +++ b/jsonrpc/codegen/file_imports_test.go @@ -0,0 +1,178 @@ +// This file verifies every JSON-RPC output file uses the exact imports saved +// for that file before generated package names become final. +package codegen + +import ( + "go/ast" + "go/parser" + "go/token" + "path" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/require" + + "goa.design/goa/v3/codegen" + "goa.design/goa/v3/dsl" +) + +// TestJSONRPCFilesUsePlannedImports verifies each generated header contains no +// package that its source does not use and keeps imports needed only by +// specific request or stream shapes. +func TestJSONRPCFilesUsePlannedImports(t *testing.T) { + tests := []struct { + name string + design func() + minimal bool + validation bool + }{ + {name: "ordinary and event stream", design: jsonRPCFileImportsDSL, minimal: true}, + {name: "required event resume header", design: requiredResumeJSONRPCFileImportsDSL, validation: true}, + {name: "optional event resume header", design: optionalResumeJSONRPCFileImportsDSL, validation: true}, + {name: "viewed results", design: viewedJSONRPCPlanDSL}, + {name: "mapped parameters and events", design: jsonRPCParamsPlanDSL}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + _, plan := linkedJSONRPCPlan(t, test.design) + files := append(plan.ClientFiles(), plan.ServerFiles()...) + for _, file := range files { + t.Run(file.Path, func(t *testing.T) { + requireEveryJSONRPCImportUsed(t, file) + }) + } + + if test.validation { + clientCodec := jsonRPCFileByPath(t, files, "gen/jsonrpc/calc/client/encode_decode.go") + require.Contains(t, jsonRPCImportPaths(clientCodec), codegen.GoaImport("").Path) + } + if !test.minimal { + return + } + + client := jsonRPCFileByPath(t, files, "gen/jsonrpc/calc/client/client.go") + require.NotContains(t, jsonRPCImportPaths(client), "strconv") + + clientStream := jsonRPCFileByPath(t, files, "gen/jsonrpc/calc/client/stream.go") + require.NotContains(t, jsonRPCImportPaths(clientStream), "generated.local/gen/calc") + + serverStream := jsonRPCFileByPath(t, files, "gen/jsonrpc/calc/server/sse.go") + require.NotContains(t, jsonRPCImportPaths(serverStream), "fmt") + }) + } +} + +// requireEveryJSONRPCImportUsed parses the unformatted generated source and +// fails when a file header names a package that no generated section uses. +func requireEveryJSONRPCImportUsed(t *testing.T, file *codegen.File) { + t.Helper() + var source strings.Builder + for _, section := range file.SectionTemplates { + require.NoError(t, section.Write(&source)) + } + parsed, err := parser.ParseFile(token.NewFileSet(), file.Path, source.String(), 0) + require.NoError(t, err) + used := make(map[string]struct{}) + ast.Inspect(parsed, func(node ast.Node) bool { + selector, ok := node.(*ast.SelectorExpr) + if !ok { + return true + } + identifier, ok := selector.X.(*ast.Ident) + if ok { + used[identifier.Name] = struct{}{} + } + return true + }) + for _, spec := range jsonRPCHeaderImports(file) { + name := spec.Name + if name == "" { + name = path.Base(spec.Path) + } + _, ok := used[name] + require.True(t, ok, "import %s is not used by %s", spec.Path, file.Path) + } +} + +// jsonRPCHeaderImports returns the imports written by file's source header. +func jsonRPCHeaderImports(file *codegen.File) []*codegen.ImportSpec { + return file.SectionTemplates[0].Data.(map[string]any)["Imports"].([]*codegen.ImportSpec) +} + +// jsonRPCImportPaths returns the package paths written by file's source header. +func jsonRPCImportPaths(file *codegen.File) []string { + imports := jsonRPCHeaderImports(file) + paths := make([]string, len(imports)) + for index, spec := range imports { + paths[index] = spec.Path + } + return paths +} + +// jsonRPCFileByPath returns one planned JSON-RPC file or fails the test when +// the design did not produce that file. +func jsonRPCFileByPath(t *testing.T, files []*codegen.File, filePath string) *codegen.File { + t.Helper() + expected := filepath.FromSlash(filePath) + for _, file := range files { + if file.Path == expected { + return file + } + } + t.Fatalf("JSON-RPC plan did not produce %s", filePath) + return nil +} + +// jsonRPCFileImportsDSL defines one ordinary method and one server-sent-event +// method without conversions or retry values that need strconv or fmt. +func jsonRPCFileImportsDSL() { + dsl.Service("calc", func() { + dsl.Method("read", func() { + dsl.JSONRPC(func() {}) + }) + dsl.Method("watch", func() { + dsl.StreamingResult(dsl.String) + dsl.JSONRPC(func() { + dsl.ServerSentEvents() + }) + }) + }) +} + +// requiredResumeJSONRPCFileImportsDSL defines a required event stream resume +// header whose generated validation calls Goa's pattern error helper. +func requiredResumeJSONRPCFileImportsDSL() { + dsl.Service("calc", func() { + dsl.Method("watch", func() { + dsl.Payload(func() { + dsl.Attribute("last_event_id", dsl.String) + dsl.Required("last_event_id") + }) + dsl.StreamingResult(dsl.String) + dsl.JSONRPC(func() { + dsl.ServerSentEvents(func() { + dsl.SSERequestID("last_event_id") + }) + }) + }) + }) +} + +// optionalResumeJSONRPCFileImportsDSL defines an optional event stream resume +// header. Its generated validation uses the same Goa error as a required one. +func optionalResumeJSONRPCFileImportsDSL() { + dsl.Service("calc", func() { + dsl.Method("watch", func() { + dsl.Payload(func() { + dsl.Attribute("last_event_id", dsl.String) + }) + dsl.StreamingResult(dsl.String) + dsl.JSONRPC(func() { + dsl.ServerSentEvents(func() { + dsl.SSERequestID("last_event_id") + }) + }) + }) + }) +} diff --git a/jsonrpc/codegen/idempotency_test.go b/jsonrpc/codegen/idempotency_test.go index 89444dab4e..2a987672f3 100644 --- a/jsonrpc/codegen/idempotency_test.go +++ b/jsonrpc/codegen/idempotency_test.go @@ -31,8 +31,8 @@ func TestIdempotentJSONRPCEndpointCodegen(t *testing.T) { }) }) }) - services := CreateJSONRPCServices(root) - clientFiles := ClientFiles("", services) + plan := CreateJSONRPCPlan(root) + clientFiles := plan.ClientFiles() require.NotEmpty(t, clientFiles) var clientCode string diff --git a/jsonrpc/codegen/kitchen_sink_test.go b/jsonrpc/codegen/kitchen_sink_test.go index b423926d31..46ebcc200d 100644 --- a/jsonrpc/codegen/kitchen_sink_test.go +++ b/jsonrpc/codegen/kitchen_sink_test.go @@ -1,40 +1,68 @@ +// This file compiles a representative JSON-RPC design and compares every +// generated package with its checked-in golden contract. package codegen_test import ( + "context" "io/fs" "os" + "os/exec" "path/filepath" "sort" "strings" "testing" + "time" "github.com/stretchr/testify/require" goacodegen "goa.design/goa/v3/codegen" - "goa.design/goa/v3/codegen/generator" + "goa.design/goa/v3/codegen/example" + "goa.design/goa/v3/codegen/service" "goa.design/goa/v3/codegen/testutil" "goa.design/goa/v3/eval" "goa.design/goa/v3/expr" + httpcodegen "goa.design/goa/v3/http/codegen" + jsonrpccodegen "goa.design/goa/v3/jsonrpc/codegen" "goa.design/goa/v3/jsonrpc/codegen/testdata" ) // TestJSONRPCKitchenSink pins every file the transport and example generators // produce for a design covering the full JSON-RPC surface (plain methods, -// required/optional IDs, custom errors, WebSocket, SSE, mixed HTTP+JSON-RPC +// required and optional IDs, custom errors, SSE, and mixed HTTP and JSON-RPC // transports). Each rendered file is compared against a golden copy under // testdata/golden/kitchen_sink and the set of generated paths is compared // against a manifest so files that appear or disappear fail the test. func TestJSONRPCKitchenSink(t *testing.T) { root := expr.RunDSL(t, testdata.JSONRPCKitchenSinkDSL) - // The test invokes the generator functions directly so it must apply the - // design normalization generator.Generate runs before them. - goacodegen.NormalizeRoot(root) roots := []eval.Root{root} - - tfiles, err := generator.Transport("kitchensink", roots) + generation, err := goacodegen.NewGeneration("generated.local/gen", roots) + require.NoError(t, err) + examples := expr.NewExampleGenerator(root.API.RandomizerFactory) + servicePlan, err := service.NewPlan(root, generation, examples) + require.NoError(t, err) + httpPlans, err := httpcodegen.NewPlans(generation, httpcodegen.PlanInput{Root: root, Service: servicePlan}) + require.NoError(t, err) + jsonHTTPPlans, err := httpcodegen.NewJSONRPCPlans(generation, httpcodegen.PlanInput{Root: root, Service: servicePlan}) + require.NoError(t, err) + jsonPlans, err := jsonrpccodegen.NewPlans(generation, jsonrpccodegen.PlanInput{ + Root: root, Service: servicePlan, HTTP: jsonHTTPPlans[0], ApplicationHTTP: httpPlans[0], + }) require.NoError(t, err) - efiles, err := generator.Example("kitchensink", roots) + examplePlan, err := example.NewPlan(generation, servicePlan) require.NoError(t, err) + httpExamples, err := httpcodegen.NewExamplePlan(httpPlans[0], examplePlan) + require.NoError(t, err) + jsonExamples, err := jsonrpccodegen.NewExamplePlan(jsonPlans[0], examplePlan) + require.NoError(t, err) + require.NoError(t, generation.Freeze()) + require.NoError(t, servicePlan.Link()) + require.NoError(t, httpPlans[0].Link()) + require.NoError(t, jsonHTTPPlans[0].Link()) + require.NoError(t, jsonPlans[0].Link()) + tfiles := kitchenSinkTransportFiles(httpPlans[0], jsonPlans[0]) + rootData, ok := examplePlan.Root(servicePlan) + require.True(t, ok) + efiles := kitchenSinkExampleFiles(rootData, servicePlan, httpExamples, jsonExamples) tmp := t.TempDir() for _, f := range append(tfiles, efiles...) { @@ -66,4 +94,80 @@ func TestJSONRPCKitchenSink(t *testing.T) { require.NoError(t, err) testutil.CompareOrUpdateGolden(t, string(content), filepath.Join(goldenDir, rel+".golden")) } + feedCodec, err := os.ReadFile(filepath.Join(tmp, "gen", "jsonrpc", "feed", "client", "encode_decode.go")) + require.NoError(t, err) + require.NotContains(t, string(feedCodec), "DecodeWatchResponse") + require.Contains(t, string(feedCodec), "DecodeSnapshotResponse") + compileKitchenSink(t, servicePlan, tfiles) +} + +// kitchenSinkTransportFiles assembles every transport file through the public +// subsystem APIs exercised by the golden fixture. +func kitchenSinkTransportFiles(httpPlan *httpcodegen.Plan, jsonPlan *jsonrpccodegen.Plan) []*goacodegen.File { + files := httpPlan.ServerFiles() + files = append(files, httpPlan.ClientFiles()...) + files = append(files, httpPlan.ServerTypeFiles()...) + files = append(files, httpPlan.ClientTypeFiles()...) + files = append(files, httpPlan.PathFiles()...) + files = append(files, httpPlan.ClientCLIFiles()...) + + files = append(files, jsonPlan.ServerFiles()...) + files = append(files, jsonPlan.ClientFiles()...) + files = append(files, jsonPlan.ServerTypeFiles()...) + files = append(files, jsonPlan.ClientTypeFiles()...) + files = append(files, jsonPlan.PathFiles()...) + return append(files, jsonPlan.ClientCLIFiles()...) +} + +// kitchenSinkExampleFiles assembles example service and transport files +// through their public subsystem APIs. +func kitchenSinkExampleFiles(root *example.Root, plan *service.Plan, httpPlan *httpcodegen.ExamplePlan, jsonPlan *jsonrpccodegen.ExamplePlan) []*goacodegen.File { + services := plan.Services() + files := service.ExampleServiceFiles(plan) + files = append(files, service.ExampleInterceptorsFiles(plan)...) + files = append(files, example.ServerFiles(root, services)...) + files = append(files, example.CLIFiles(root)...) + + files = append(files, httpPlan.CLIFiles()...) + files = append(files, jsonPlan.ServerFiles()...) + files = append(files, jsonPlan.CLIFiles()...) + return files +} + +// compileKitchenSink renders the service and transport packages together so +// every generated conversion must use a concrete value accepted by the +// service contract it returns. +func compileKitchenSink(t *testing.T, servicePlan *service.Plan, transportFiles []*goacodegen.File) { + t.Helper() + serviceFiles, err := service.Files(servicePlan) + require.NoError(t, err) + dir := t.TempDir() + for _, file := range append(serviceFiles, transportFiles...) { + _, err := file.Render(dir) + require.NoError(t, err) + } + + goaDir := goaModuleDirectory(t) + module := "module generated.local\n\ngo 1.25\n\nrequire goa.design/goa/v3 v3.0.0\n\n" + + "replace goa.design/goa/v3 => " + filepath.ToSlash(goaDir) + "\n" + require.NoError(t, os.WriteFile(filepath.Join(dir, "go.mod"), []byte(module), 0o600)) + + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) + defer cancel() + cmd := exec.CommandContext(ctx, "go", "test", "-mod=mod", "./gen/...") + cmd.Dir = dir + cmd.Env = append(os.Environ(), "GOWORK=off") + output, err := cmd.CombinedOutput() + require.NoError(t, err, string(output)) +} + +// goaModuleDirectory returns the local Goa checkout used to build this test. +func goaModuleDirectory(t *testing.T) string { + t.Helper() + cmd := exec.Command("go", "list", "-m", "-f", "{{.Dir}}", "goa.design/goa/v3") + output, err := cmd.CombinedOutput() + require.NoError(t, err, string(output)) + dir := strings.TrimSpace(string(output)) + require.NotEmpty(t, dir) + return dir } diff --git a/jsonrpc/codegen/notification_client_golden_test.go b/jsonrpc/codegen/notification_client_golden_test.go new file mode 100644 index 0000000000..0430beaaf8 --- /dev/null +++ b/jsonrpc/codegen/notification_client_golden_test.go @@ -0,0 +1,142 @@ +// This file checks that generated JSON-RPC clients preserve methods declared +// as notifications by sending no request ID. +package codegen + +import ( + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/require" + + goacodegen "goa.design/goa/v3/codegen" + "goa.design/goa/v3/codegen/testutil" + "goa.design/goa/v3/dsl" +) + +// TestNotificationClientGeneratedSource checks notifications with and without +// parameters beside an ordinary method that returns JSON null. +func TestNotificationClientGeneratedSource(t *testing.T) { + _, plan := linkedJSONRPCPlan(t, notificationClientDSL) + var encoderSource string + for _, file := range plan.ClientFiles() { + if filepath.Base(file.Path) == "encode_decode.go" { + encoderSource = "package client\n\n" + goacodegen.SectionsCode(t, file.Section("jsonrpc-request-encoder")) + break + } + } + require.NotEmpty(t, encoderSource) + require.Contains(t, encoderSource, `Method: "with_payload"`) + require.Contains(t, encoderSource, `Method: "without_payload"`) + require.Contains(t, encoderSource, `Method: "ordinary_void"`) + require.Equal(t, 2, strings.Count(encoderSource, "uuid.New")) + require.NotContains(t, encoderSource, "ID: p.ID") + testutil.AssertGo(t, "testdata/golden/notification_client.go.golden", encoderSource) + + var endpointSource string + for _, file := range plan.ClientFiles() { + if filepath.Base(file.Path) == "client.go" { + endpointSource = "package client\n\n" + goacodegen.SectionsCode(t, file.Section("jsonrpc-client-endpoint-init")) + break + } + } + require.NotEmpty(t, endpointSource) + require.Equal(t, 5, strings.Count(endpointSource, "decodeResponse =")) + require.Equal(t, 4, strings.Count(endpointSource, "resp.Body.Close()")) + testutil.AssertGo(t, "testdata/golden/notification_endpoint.go.golden", endpointSource) + + var serverSource string + for _, file := range plan.ServerFiles() { + if filepath.Base(file.Path) == "server.go" { + serverSource = "package server\n\n" + goacodegen.SectionsCode(t, file.Section("jsonrpc-server-handler-init")) + break + } + } + require.NotEmpty(t, serverSource) + require.NotContains(t, serverSource, "MakeSuccessResponse(id,") + require.Contains(t, serverSource, "MakeSuccessResponse(req.ID,") + testutil.AssertGo(t, "testdata/golden/notification_server.go.golden", serverSource) + + var dispatchSource string + for _, file := range plan.ServerFiles() { + if filepath.Base(file.Path) == "server.go" { + dispatchSource = "package server\n\n" + goacodegen.SectionsCode(t, file.Section("jsonrpc-server-handler")) + break + } + } + require.NotEmpty(t, dispatchSource) + testutil.AssertGo(t, "testdata/golden/notification_dispatch.go.golden", dispatchSource) + + var decoderSource string + for _, file := range plan.ServerFiles() { + if filepath.Base(file.Path) == "encode_decode.go" { + decoderSource = "package server\n\n" + goacodegen.SectionsCode(t, file.Section("jsonrpc-request-decoder")) + break + } + } + require.NotEmpty(t, decoderSource) + require.Contains(t, decoderSource, "if !req.HasID || req.ID == nil") + require.Contains(t, decoderSource, "jsonrpc.IDToString(req.ID)") + testutil.AssertGo(t, "testdata/golden/notification_decoder.go.golden", decoderSource) +} + +// notificationClientDSL declares two methods that receive no JSON-RPC +// response and therefore must never send a request ID. +func notificationClientDSL() { + requestID := dsl.Type("RequestID", dsl.String) + validatedRequestID := dsl.Type("ValidatedRequestID", dsl.String, func() { + dsl.Pattern(`^req-[0-9]+$`) + }) + dsl.Service("notifications", func() { + dsl.JSONRPC(func() { + dsl.POST("/rpc") + }) + dsl.Method("with_payload", func() { + dsl.Payload(func() { + dsl.Attribute("message", dsl.String) + dsl.Required("message") + }) + dsl.JSONRPC(func() { + dsl.Notification() + }) + }) + dsl.Method("without_payload", func() { + dsl.JSONRPC(func() { + dsl.Notification() + }) + }) + dsl.Method("ordinary_void", func() { + dsl.JSONRPC(func() {}) + }) + dsl.Method("required_id", func() { + dsl.Payload(func() { + dsl.ID("id", requestID) + dsl.Attribute("message", dsl.String) + dsl.Required("id", "message") + }) + dsl.JSONRPC(func() {}) + }) + dsl.Method("optional_id", func() { + dsl.Payload(func() { + dsl.ID("id", requestID) + }) + dsl.JSONRPC(func() {}) + }) + dsl.Method("defaulted_id", func() { + dsl.Payload(func() { + dsl.ID("id", requestID, func() { + dsl.Default("default-id") + }) + dsl.Required("id") + }) + dsl.JSONRPC(func() {}) + }) + dsl.Method("validated_id", func() { + dsl.Payload(func() { + dsl.ID("id", validatedRequestID) + dsl.Required("id") + }) + dsl.JSONRPC(func() {}) + }) + }) +} diff --git a/jsonrpc/codegen/notification_runtime_test.go b/jsonrpc/codegen/notification_runtime_test.go new file mode 100644 index 0000000000..b3861e2ead --- /dev/null +++ b/jsonrpc/codegen/notification_runtime_test.go @@ -0,0 +1,610 @@ +// This file renders a small JSON-RPC service into a temporary Go module. +// The generated client and server tests send real HTTP requests and check how +// notifications and request IDs control payloads, dispatch, and responses. +package codegen_test + +import ( + "context" + "fmt" + "os" + "os/exec" + "path/filepath" + "testing" + "time" + + "github.com/stretchr/testify/require" + + goacodegen "goa.design/goa/v3/codegen" + "goa.design/goa/v3/codegen/service" + "goa.design/goa/v3/dsl" + "goa.design/goa/v3/eval" + "goa.design/goa/v3/expr" + httpcodegen "goa.design/goa/v3/http/codegen" + jsonrpccodegen "goa.design/goa/v3/jsonrpc/codegen" +) + +// TestGeneratedNotificationAndRequestIDs renders and runs the generated +// client and server so the test covers complete request and response paths. +func TestGeneratedNotificationAndRequestIDs(t *testing.T) { + dir := renderNotificationRuntimeModule(t) + serverTestDir := filepath.Join(dir, "jsonrpc", "request_contract", "server") + require.NoError(t, os.MkdirAll(serverTestDir, 0o750)) + require.NoError(t, os.WriteFile( + filepath.Join(serverTestDir, "notification_runtime_test.go"), + []byte(notificationRuntimeTest), + 0o600, + )) + clientTestDir := filepath.Join(dir, "jsonrpc", "request_contract", "client") + require.NoError(t, os.MkdirAll(clientTestDir, 0o750)) + require.NoError(t, os.WriteFile( + filepath.Join(clientTestDir, "notification_runtime_test.go"), + []byte(notificationClientRuntimeTest), + 0o600, + )) + + ctx, cancel := context.WithTimeout(context.Background(), time.Minute) + defer cancel() + cmd := exec.CommandContext( + ctx, + "go", + "test", + "-mod=mod", + "./jsonrpc/request_contract/client", + "./jsonrpc/request_contract/server", + ) + cmd.Dir = dir + cmd.Env = append(os.Environ(), "GOWORK=off") + output, err := cmd.CombinedOutput() + require.NoError(t, err, string(output)) +} + +// renderNotificationRuntimeModule writes generated service, client, and server +// packages without changing any generated directory in this repository. +func renderNotificationRuntimeModule(t *testing.T) string { + t.Helper() + root := expr.RunDSL(t, notificationRuntimeDSL) + generation, err := goacodegen.NewGeneration("generated.local/gen", []eval.Root{root}) + require.NoError(t, err) + servicePlan, err := service.NewPlan( + root, + generation, + expr.NewExampleGenerator(root.API.RandomizerFactory), + ) + require.NoError(t, err) + httpPlans, err := httpcodegen.NewJSONRPCPlans(generation, httpcodegen.PlanInput{ + Root: root, + Service: servicePlan, + }) + require.NoError(t, err) + jsonPlans, err := jsonrpccodegen.NewPlans(generation, jsonrpccodegen.PlanInput{ + Root: root, + Service: servicePlan, + HTTP: httpPlans[0], + }) + require.NoError(t, err) + require.NoError(t, generation.Freeze()) + require.NoError(t, servicePlan.Link()) + require.NoError(t, httpPlans[0].Link()) + require.NoError(t, jsonPlans[0].Link()) + + files, err := service.Files(servicePlan) + require.NoError(t, err) + files = append(files, jsonPlans[0].ClientFiles()...) + files = append(files, jsonPlans[0].ServerFiles()...) + files = append(files, jsonPlans[0].ClientTypeFiles()...) + files = append(files, jsonPlans[0].ServerTypeFiles()...) + files = append(files, jsonPlans[0].PathFiles()...) + base := t.TempDir() + for _, file := range files { + _, err := file.Render(base) + require.NoError(t, err) + } + + moduleDir := filepath.Join(base, goacodegen.Gendir) + workingDir, err := os.Getwd() + require.NoError(t, err) + repository := filepath.Clean(filepath.Join(workingDir, "..", "..")) + module := fmt.Sprintf( + "module generated.local/gen\n\ngo 1.25\n\nrequire goa.design/goa/v3 v3.0.0\n\nreplace goa.design/goa/v3 => %s\n", + repository, + ) + require.NoError(t, os.WriteFile(filepath.Join(moduleDir, "go.mod"), []byte(module), 0o600)) + return moduleDir +} + +// notificationRuntimeDSL declares the request shapes exercised by the +// generated server test. +func notificationRuntimeDSL() { + requestID := dsl.Type("RequestID", dsl.String) + validatedRequestID := dsl.Type("ValidatedRequestID", dsl.String, func() { + dsl.Pattern(`^req-[0-9]+$`) + }) + dsl.Service("Request Contract", func() { + dsl.JSONRPC(func() { + dsl.POST("/rpc") + }) + dsl.Method("notify", func() { + dsl.Payload(func() { + dsl.Attribute("message", dsl.String) + dsl.Required("message") + }) + dsl.JSONRPC(func() { + dsl.Notification() + }) + }) + dsl.Method("ping", func() { + dsl.JSONRPC(func() {}) + }) + dsl.Method("required_id", func() { + dsl.Payload(func() { + dsl.ID("id", requestID) + dsl.Attribute("message", dsl.String) + dsl.Required("id", "message") + }) + dsl.JSONRPC(func() {}) + }) + dsl.Method("optional_id", func() { + dsl.Payload(func() { + dsl.ID("id", requestID) + }) + dsl.JSONRPC(func() {}) + }) + dsl.Method("defaulted_id", func() { + dsl.Payload(func() { + dsl.ID("id", requestID, func() { + dsl.Default("default-id") + }) + dsl.Required("id") + }) + dsl.JSONRPC(func() {}) + }) + dsl.Method("validated_id", func() { + dsl.Payload(func() { + dsl.ID("id", validatedRequestID) + dsl.Required("id") + }) + dsl.JSONRPC(func() {}) + }) + }) +} + +// notificationClientRuntimeTest checks the generated notification endpoint +// against controlled HTTP acknowledgements. +const notificationClientRuntimeTest = `package client + +import ( + "context" + "encoding/json" + "io" + "net/http" + "strings" + "testing" + + "github.com/stretchr/testify/require" + + service "generated.local/gen/request_contract" + goahttp "goa.design/goa/v3/http" +) + +type doerFunc func(*http.Request) (*http.Response, error) + +type notificationResponseBody struct { + reader io.Reader + reads int + closes int +} + +type notificationEnvelope struct { + JSONRPC string ` + "`json:\"jsonrpc\"`" + ` + Method string ` + "`json:\"method\"`" + ` + ID json.RawMessage ` + "`json:\"id\"`" + ` + Params struct { + Message string ` + "`json:\"message\"`" + ` + ID json.RawMessage ` + "`json:\"id\"`" + ` + } ` + "`json:\"params\"`" + ` +} + +func (do doerFunc) Do(request *http.Request) (*http.Response, error) { + return do(request) +} + +func (body *notificationResponseBody) Read(buffer []byte) (int, error) { + body.reads++ + return body.reader.Read(buffer) +} + +func (body *notificationResponseBody) Close() error { + body.closes++ + return nil +} + +func TestNotificationClientAcceptsSuccessfulAcknowledgementWithoutDecoding(t *testing.T) { + body := ¬ificationResponseBody{reader: strings.NewReader("not JSON-RPC")} + decoderCalls := 0 + client := NewClient( + "http", + "example.test", + doerFunc(func(request *http.Request) (*http.Response, error) { + assertNotificationRequest(t, request) + return &http.Response{ + StatusCode: http.StatusNoContent, + Header: make(http.Header), + Body: body, + }, nil + }), + goahttp.RequestEncoder, + func(response *http.Response) goahttp.Decoder { + decoderCalls++ + return goahttp.ResponseDecoder(response) + }, + false, + ) + + _, err := client.Notify()(context.Background(), &service.NotifyPayload{Message: "ready"}) + + require.NoError(t, err) + require.Zero(t, decoderCalls) + require.Zero(t, body.reads) + require.Equal(t, 1, body.closes) +} + +func TestNotificationClientReturnsNonSuccessAcknowledgementError(t *testing.T) { + body := ¬ificationResponseBody{reader: strings.NewReader("denied")} + decoderCalls := 0 + client := NewClient( + "http", + "example.test", + doerFunc(func(request *http.Request) (*http.Response, error) { + assertNotificationRequest(t, request) + return &http.Response{ + StatusCode: http.StatusBadGateway, + Header: make(http.Header), + Body: body, + }, nil + }), + goahttp.RequestEncoder, + func(response *http.Response) goahttp.Decoder { + decoderCalls++ + return goahttp.ResponseDecoder(response) + }, + false, + ) + + _, err := client.Notify()(context.Background(), &service.NotifyPayload{Message: "ready"}) + + var clientErr *goahttp.ClientError + require.ErrorAs(t, err, &clientErr) + require.Equal(t, "invalid_response", clientErr.Name) + require.Equal(t, "Request Contract", clientErr.Service) + require.Equal(t, "notify", clientErr.Method) + require.ErrorContains(t, err, "invalid response code 502, body: denied") + require.Zero(t, decoderCalls) + require.Greater(t, body.reads, 0) + require.Equal(t, 1, body.closes) +} + +// assertNotificationRequest checks the exact envelope written by the client. +func assertNotificationRequest(t *testing.T, request *http.Request) { + t.Helper() + var envelope notificationEnvelope + require.NoError(t, json.NewDecoder(request.Body).Decode(&envelope)) + require.Equal(t, "2.0", envelope.JSONRPC) + require.Equal(t, "notify", envelope.Method) + require.Nil(t, envelope.ID) + require.Equal(t, "ready", envelope.Params.Message) + require.Nil(t, envelope.Params.ID) +} +` + +const notificationRuntimeTest = `package server + +import ( + "context" + "errors" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/stretchr/testify/require" + + service "generated.local/gen/request_contract" + goahttp "goa.design/goa/v3/http" +) + +type requestContractService struct { + notifyCalls int + notifyMessage string + pingCalls int + requiredIDCalls int + requiredID service.RequestID + optionalIDCalls int + optionalID *service.RequestID + defaultedIDCalls int + defaultedID service.RequestID + validatedIDCalls int + validatedID service.ValidatedRequestID +} + +func (svc *requestContractService) Notify(_ context.Context, payload *service.NotifyPayload) error { + svc.notifyCalls++ + svc.notifyMessage = payload.Message + if payload.Message == "fail" { + return errors.New("notification failed") + } + return nil +} + +func (svc *requestContractService) Ping(context.Context) error { + svc.pingCalls++ + return nil +} + +func (svc *requestContractService) RequiredID(_ context.Context, payload *service.RequiredIDPayload) error { + svc.requiredIDCalls++ + svc.requiredID = payload.ID + return nil +} + +func (svc *requestContractService) OptionalID(_ context.Context, payload *service.OptionalIDPayload) error { + svc.optionalIDCalls++ + svc.optionalID = payload.ID + return nil +} + +func (svc *requestContractService) DefaultedID(_ context.Context, payload *service.DefaultedIDPayload) error { + svc.defaultedIDCalls++ + svc.defaultedID = payload.ID + return nil +} + +func (svc *requestContractService) ValidatedID(_ context.Context, payload *service.ValidatedIDPayload) error { + svc.validatedIDCalls++ + svc.validatedID = payload.ID + return nil +} + +func TestNotificationDispatchesPayloadWithoutResponse(t *testing.T) { + svc := &requestContractService{} + response, reported := serveRequestContract(svc, ` + "`" + `{"jsonrpc":"2.0","method":"notify","params":{"message":"ready"}}` + "`" + `) + require.Empty(t, reported) + require.Empty(t, response.Body.String()) + require.Equal(t, 1, svc.notifyCalls) + require.Equal(t, "ready", svc.notifyMessage) +} + +func TestNotificationErrorsCannotWriteAResponse(t *testing.T) { + tests := []struct { + name string + request string + }{ + {name: "invalid parameters", request: ` + "`" + `{"jsonrpc":"2.0","method":"notify","params":{}}` + "`" + `}, + {name: "service error", request: ` + "`" + `{"jsonrpc":"2.0","method":"notify","params":{"message":"fail"}}` + "`" + `}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + svc := &requestContractService{} + response, reported := serveRequestContract(svc, test.request) + require.Len(t, reported, 1) + require.Error(t, reported[0]) + require.Empty(t, response.Body.String()) + }) + } +} + +func TestNotificationRejectsEveryPresentIDBeforeDispatch(t *testing.T) { + tests := []struct { + name string + request string + wantID string + }{ + {name: "string", request: ` + "`" + `{"jsonrpc":"2.0","id":"request-1","method":"notify","params":{"message":"ready"}}` + "`" + `, wantID: ` + "`" + `"id":"request-1"` + "`" + `}, + {name: "empty string", request: ` + "`" + `{"jsonrpc":"2.0","id":"","method":"notify","params":{"message":"ready"}}` + "`" + `, wantID: ` + "`" + `"id":""` + "`" + `}, + {name: "null", request: ` + "`" + `{"jsonrpc":"2.0","id":null,"method":"notify","params":{"message":"ready"}}` + "`" + `, wantID: ` + "`" + `"id":null` + "`" + `}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + svc := &requestContractService{} + response, reported := serveRequestContract(svc, test.request) + require.Empty(t, reported) + require.Contains(t, response.Body.String(), ` + "`" + `"code":-32600` + "`" + `) + require.Contains(t, response.Body.String(), test.wantID) + require.Zero(t, svc.notifyCalls) + }) + } +} + +func TestOrdinaryVoidRequestReturnsNullWithMatchingID(t *testing.T) { + svc := &requestContractService{} + response, reported := serveRequestContract(svc, ` + "`" + `{"jsonrpc":"2.0","id":"request-1","method":"ping"}` + "`" + `) + require.Empty(t, reported) + require.JSONEq(t, ` + "`" + `{"jsonrpc":"2.0","id":"request-1","result":null}` + "`" + `, response.Body.String()) + require.Equal(t, 1, svc.pingCalls) +} + +func TestOrdinaryMethodRejectsNotificationWithoutAResponse(t *testing.T) { + svc := &requestContractService{} + response, reported := serveRequestContract(svc, ` + "`" + `{"jsonrpc":"2.0","method":"ping"}` + "`" + `) + require.Len(t, reported, 1) + require.ErrorContains(t, reported[0], "notification cannot call request method") + require.Empty(t, response.Body.String()) + require.Zero(t, svc.pingCalls) +} + +func TestOrdinaryMethodRejectsNullIDWithAResponse(t *testing.T) { + svc := &requestContractService{} + response, reported := serveRequestContract(svc, ` + "`" + `{"jsonrpc":"2.0","id":null,"method":"ping"}` + "`" + `) + require.Empty(t, reported) + require.Contains(t, response.Body.String(), ` + "`" + `"code":-32600` + "`" + `) + require.Contains(t, response.Body.String(), ` + "`" + `"id":null` + "`" + `) + require.Zero(t, svc.pingCalls) +} + +func TestOrdinaryMethodAcceptsAnEmptyStringID(t *testing.T) { + svc := &requestContractService{} + response, reported := serveRequestContract(svc, ` + "`" + `{"jsonrpc":"2.0","id":"","method":"ping"}` + "`" + `) + require.Empty(t, reported) + require.JSONEq(t, ` + "`" + `{"jsonrpc":"2.0","id":"","result":null}` + "`" + `, response.Body.String()) + require.Equal(t, 1, svc.pingCalls) +} + +func TestBatchEnforcesEachDeclaredMethodIDContract(t *testing.T) { + svc := &requestContractService{} + response, reported := serveRequestContract(svc, ` + "`" + `[ + {"jsonrpc":"2.0","method":"notify","params":{"message":"ready"}}, + {"jsonrpc":"2.0","id":"wrong","method":"notify","params":{"message":"ignored"}}, + {"jsonrpc":"2.0","method":"ping"}, + {"jsonrpc":"2.0","id":42,"method":"ping"} + ]` + "`" + `) + require.Len(t, reported, 1) + require.ErrorContains(t, reported[0], "notification cannot call request method") + require.JSONEq(t, ` + "`" + `[ + {"jsonrpc":"2.0","id":"wrong","error":{"code":-32600,"message":"Invalid request"}}, + {"jsonrpc":"2.0","id":42,"result":null} + ]` + "`" + `, response.Body.String()) + require.Equal(t, 1, svc.notifyCalls) + require.Equal(t, "ready", svc.notifyMessage) + require.Equal(t, 1, svc.pingCalls) +} + +func TestBatchOfDeclaredNotificationsHasNoResponse(t *testing.T) { + svc := &requestContractService{} + response, reported := serveRequestContract(svc, ` + "`" + `[ + {"jsonrpc":"2.0","method":"notify","params":{"message":"first"}}, + {"jsonrpc":"2.0","method":"notify","params":{"message":"second"}} + ]` + "`" + `) + require.Empty(t, reported) + require.Empty(t, response.Body.String()) + require.Equal(t, 2, svc.notifyCalls) + require.Equal(t, "second", svc.notifyMessage) +} + +func TestMissingAndEmptyMethodNamesReturnDifferentProtocolErrors(t *testing.T) { + tests := []struct { + name string + request string + code string + }{ + {name: "missing", request: ` + "`" + `{"jsonrpc":"2.0","id":"request-1"}` + "`" + `, code: ` + "`" + `"code":-32600` + "`" + `}, + {name: "empty", request: ` + "`" + `{"jsonrpc":"2.0","id":"request-1","method":""}` + "`" + `, code: ` + "`" + `"code":-32601` + "`" + `}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + svc := &requestContractService{} + response, reported := serveRequestContract(svc, test.request) + require.Empty(t, reported) + require.Contains(t, response.Body.String(), test.code) + require.Zero(t, svc.pingCalls) + }) + } +} + +func TestRequiredIDComesOnlyFromEnvelope(t *testing.T) { + svc := &requestContractService{} + response, reported := serveRequestContract(svc, ` + "`" + `{"jsonrpc":"2.0","id":"envelope","method":"required_id","params":{"id":"params","message":"ready"}}` + "`" + `) + require.Empty(t, reported) + require.JSONEq(t, ` + "`" + `{"jsonrpc":"2.0","id":"envelope","result":null}` + "`" + `, response.Body.String()) + require.Equal(t, 1, svc.requiredIDCalls) + require.Equal(t, service.RequestID("envelope"), svc.requiredID) +} + +func TestRequiredNumericIDKeepsItsExactDigits(t *testing.T) { + svc := &requestContractService{} + response, reported := serveRequestContract(svc, ` + "`" + `{"jsonrpc":"2.0","id":9007199254740993123456789,"method":"required_id","params":{"message":"ready"}}` + "`" + `) + require.Empty(t, reported) + require.Contains(t, response.Body.String(), ` + "`" + `"id":9007199254740993123456789` + "`" + `) + require.Equal(t, 1, svc.requiredIDCalls) + require.Equal(t, service.RequestID("9007199254740993123456789"), svc.requiredID) +} + +func TestMappedIDRejectsMissingAndNullEnvelopeIDsBeforeDispatch(t *testing.T) { + tests := []struct { + name string + request string + notification bool + }{ + {name: "missing", request: ` + "`" + `{"jsonrpc":"2.0","method":"required_id","params":{"id":"params","message":"ready"}}` + "`" + `, notification: true}, + {name: "null", request: ` + "`" + `{"jsonrpc":"2.0","id":null,"method":"required_id","params":{"id":"params","message":"ready"}}` + "`" + `}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + svc := &requestContractService{} + response, reported := serveRequestContract(svc, test.request) + require.Zero(t, svc.requiredIDCalls) + if test.notification { + require.Len(t, reported, 1) + require.Empty(t, response.Body.String()) + return + } + require.Empty(t, reported) + require.Contains(t, response.Body.String(), ` + "`" + `"code":-32600` + "`" + `) + require.Contains(t, response.Body.String(), ` + "`" + `"id":null` + "`" + `) + }) + } +} + +func TestOptionalMappedIDDoesNotMakeAnOrdinaryMethodANotification(t *testing.T) { + svc := &requestContractService{} + response, reported := serveRequestContract(svc, ` + "`" + `{"jsonrpc":"2.0","method":"optional_id","params":{"id":"params"}}` + "`" + `) + require.Len(t, reported, 1) + require.Empty(t, response.Body.String()) + require.Zero(t, svc.optionalIDCalls) + require.Nil(t, svc.optionalID) +} + +func TestDefaultedMappedIDDoesNotMakeAnOrdinaryMethodANotification(t *testing.T) { + svc := &requestContractService{} + response, reported := serveRequestContract(svc, ` + "`" + `{"jsonrpc":"2.0","method":"defaulted_id"}` + "`" + `) + require.Len(t, reported, 1) + require.Empty(t, response.Body.String()) + require.Zero(t, svc.defaultedIDCalls) +} + +func TestValidatedIDUsesAuthoredValidationBeforeDispatch(t *testing.T) { + tests := []struct { + name string + id string + wantCalls int + wantError bool + }{ + {name: "valid", id: "req-7", wantCalls: 1}, + {name: "invalid", id: "other", wantError: true}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + svc := &requestContractService{} + request := ` + "`" + `{"jsonrpc":"2.0","id":"` + "`" + ` + test.id + ` + "`" + `","method":"validated_id"}` + "`" + ` + response, reported := serveRequestContract(svc, request) + require.Empty(t, reported) + require.Equal(t, test.wantCalls, svc.validatedIDCalls) + if test.wantError { + require.Contains(t, response.Body.String(), ` + "`" + `"code":-32602` + "`" + `) + require.Contains(t, response.Body.String(), "must match the regexp") + return + } + require.Equal(t, service.ValidatedRequestID(test.id), svc.validatedID) + require.Contains(t, response.Body.String(), ` + "`" + `"result":null` + "`" + `) + }) + } +} + +func serveRequestContract(svc *requestContractService, body string) (*httptest.ResponseRecorder, []error) { + reported := make([]error, 0, 1) + server := New( + service.NewEndpoints(svc), + goahttp.NewMuxer(), + goahttp.RequestDecoder, + goahttp.ResponseEncoder, + func(_ context.Context, writer http.ResponseWriter, err error) { + reported = append(reported, err) + if _, writeErr := writer.Write([]byte("error handler wrote a response")); writeErr != nil { + reported = append(reported, writeErr) + } + }, + ) + response := httptest.NewRecorder() + request := httptest.NewRequest(http.MethodPost, "/rpc", strings.NewReader(body)) + server.ServeHTTP(response, request) + return response, reported +} +` diff --git a/jsonrpc/codegen/package_import_alias_test.go b/jsonrpc/codegen/package_import_alias_test.go new file mode 100644 index 0000000000..77e9ec1f1f --- /dev/null +++ b/jsonrpc/codegen/package_import_alias_test.go @@ -0,0 +1,52 @@ +// This file verifies that transport packages choose import names in their own +// Go package instead of sharing names across the entire generation. +package codegen + +import ( + "path" + "testing" + + "github.com/stretchr/testify/require" + + goacodegen "goa.design/goa/v3/codegen" + "goa.design/goa/v3/codegen/service" + "goa.design/goa/v3/dsl" + "goa.design/goa/v3/eval" + "goa.design/goa/v3/expr" + httpcodegen "goa.design/goa/v3/http/codegen" +) + +// TestTransportCLIPackagesChooseClientAliasesIndependently proves that the +// HTTP and JSON-RPC command packages may both use the natural client alias. +func TestTransportCLIPackagesChooseClientAliasesIndependently(t *testing.T) { + root := expr.RunDSL(t, func() { + dsl.Service("Calc", func() { + dsl.Method("Add", func() { + dsl.HTTP(func() { dsl.POST("/add") }) + dsl.JSONRPC(func() {}) + }) + }) + }) + generation, err := goacodegen.NewGeneration("generated.local/gen", []eval.Root{root}) + require.NoError(t, err) + servicePlan, err := service.NewPlan(root, generation, expr.NewExampleGenerator(root.API.RandomizerFactory)) + require.NoError(t, err) + httpPlans, err := httpcodegen.NewPlans(generation, httpcodegen.PlanInput{Root: root, Service: servicePlan}) + require.NoError(t, err) + jsonHTTPPlans, err := httpcodegen.NewJSONRPCPlans(generation, httpcodegen.PlanInput{Root: root, Service: servicePlan}) + require.NoError(t, err) + _, err = NewPlans(generation, PlanInput{ + Root: root, + Service: servicePlan, + HTTP: jsonHTTPPlans[0], + ApplicationHTTP: httpPlans[0], + }) + require.NoError(t, err) + require.NoError(t, generation.Freeze()) + + serverName := goacodegen.SnakeCase(goacodegen.Goify(root.API.Servers[0].Name, true)) + httpCLI := generation.Package(path.Join(generation.GenPkg(), "http", "cli", serverName)) + jsonrpcCLI := generation.Package(path.Join(generation.GenPkg(), "jsonrpc", "cli", serverName)) + require.Equal(t, "calcc", httpCLI.ImportName(path.Join(generation.GenPkg(), "http", "calc", "client"))) + require.Equal(t, "calcc", jsonrpcCLI.ImportName(path.Join(generation.GenPkg(), "jsonrpc", "calc", "client"))) +} diff --git a/jsonrpc/codegen/params_golden_test.go b/jsonrpc/codegen/params_golden_test.go new file mode 100644 index 0000000000..48acc2a954 --- /dev/null +++ b/jsonrpc/codegen/params_golden_test.go @@ -0,0 +1,255 @@ +// This file checks the generated JSON-RPC parameter shapes and how optional +// direct values keep absence separate from authored empty values. +package codegen + +import ( + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/require" + + "goa.design/goa/v3/codegen" + "goa.design/goa/v3/codegen/testutil" + "goa.design/goa/v3/dsl" +) + +// TestJSONRPCParamsGeneratedSource compares the generated branches for +// positional values, direct containers, and optional unions. +func TestJSONRPCParamsGeneratedSource(t *testing.T) { + _, plan := linkedJSONRPCPlan(t, jsonRPCParamsPlanDSL) + tests := []struct { + name string + files []*codegen.File + file string + section string + contains string + golden string + }{ + { + name: "primitive alias request encoder", + files: plan.ClientFiles(), + file: "encode_decode.go", + section: "jsonrpc-request-encoder", + contains: "func EncodeAliasRequest", + golden: "testdata/golden/params_alias_request_encoder.go.golden", + }, + { + name: "structured array request encoder", + files: plan.ClientFiles(), + file: "encode_decode.go", + section: "jsonrpc-request-encoder", + contains: "func EncodeArrayRequest", + golden: "testdata/golden/params_array_request_encoder.go.golden", + }, + { + name: "optional array request encoder", + files: plan.ClientFiles(), + file: "encode_decode.go", + section: "jsonrpc-request-encoder", + contains: "func EncodeOptionalArrayRequest", + golden: "testdata/golden/params_optional_array_request_encoder.go.golden", + }, + { + name: "required primitive request decoder", + files: plan.ServerFiles(), + file: "encode_decode.go", + section: "jsonrpc-request-decoder", + contains: "func DecodeTextRequest", + golden: "testdata/golden/params_text_request_decoder.go.golden", + }, + { + name: "optional primitive request decoder", + files: plan.ServerFiles(), + file: "encode_decode.go", + section: "jsonrpc-request-decoder", + contains: "func DecodeOptionalTextRequest", + golden: "testdata/golden/params_optional_text_request_decoder.go.golden", + }, + { + name: "optional union request decoder", + files: plan.ServerFiles(), + file: "encode_decode.go", + section: "jsonrpc-request-decoder", + contains: "func DecodeOptionalUnionRequest", + golden: "testdata/golden/params_optional_union_request_decoder.go.golden", + }, + { + name: "primitive stream writer", + files: plan.ServerFiles(), + file: "sse.go", + section: "jsonrpc-sse-server-stream", + contains: "type StreamTextServerStream", + golden: "testdata/golden/params_text_stream_server.go.golden", + }, + { + name: "optional primitive stream reader", + files: plan.ClientFiles(), + file: "stream.go", + section: "jsonrpc-sse-client-stream", + contains: "StreamOptionalTextStreamImpl struct", + golden: "testdata/golden/params_optional_text_stream_client.go.golden", + }, + { + name: "optional primitive stream writer", + files: plan.ServerFiles(), + file: "sse.go", + section: "jsonrpc-sse-server-stream", + contains: "type StreamOptionalTextServerStream", + golden: "testdata/golden/params_optional_text_stream_server.go.golden", + }, + { + name: "defaulted primitive stream reader", + files: plan.ClientFiles(), + file: "stream.go", + section: "jsonrpc-sse-client-stream", + contains: "StreamDefaultTextStreamImpl struct", + golden: "testdata/golden/params_default_text_stream_client.go.golden", + }, + { + name: "structured array stream writer", + files: plan.ServerFiles(), + file: "sse.go", + section: "jsonrpc-sse-server-stream", + contains: "type StreamArrayServerStream", + golden: "testdata/golden/params_array_stream_server.go.golden", + }, + { + name: "optional union stream writer", + files: plan.ServerFiles(), + file: "sse.go", + section: "jsonrpc-sse-server-stream", + contains: "type StreamUnionDataServerStream", + golden: "testdata/golden/params_optional_union_stream_server.go.golden", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + section := paramsGoldenSection(t, test.files, test.file, test.section, test.contains) + testutil.AssertGo(t, test.golden, codegen.SectionCode(t, section)) + }) + } +} + +// TestJSONRPCDefaultedSelectedParamsGeneratedSource checks that generated +// clients preserve explicit empty values and generated servers apply defaults +// only when the selected request value is absent. +func TestJSONRPCDefaultedSelectedParamsGeneratedSource(t *testing.T) { + _, plan := linkedJSONRPCPlan(t, jsonRPCDefaultedParamsGoldenDSL) + tests := []struct { + name string + files []*codegen.File + file string + section string + contains string + golden string + }{ + { + name: "primitive alias client encoder", + files: plan.ClientFiles(), + file: "encode_decode.go", + section: "jsonrpc-request-encoder", + contains: "func EncodeDefaultTextRequest", + golden: "testdata/golden/params_default_text_request_encoder.go.golden", + }, + { + name: "collection client encoder", + files: plan.ClientFiles(), + file: "encode_decode.go", + section: "jsonrpc-request-encoder", + contains: "func EncodeDefaultArrayRequest", + golden: "testdata/golden/params_default_array_request_encoder.go.golden", + }, + { + name: "primitive alias server decoder", + files: plan.ServerFiles(), + file: "encode_decode.go", + section: "jsonrpc-request-decoder", + contains: "func DecodeDefaultTextRequest", + golden: "testdata/golden/params_default_text_request_decoder.go.golden", + }, + { + name: "collection server decoder", + files: plan.ServerFiles(), + file: "encode_decode.go", + section: "jsonrpc-request-decoder", + contains: "func DecodeDefaultArrayRequest", + golden: "testdata/golden/params_default_array_request_decoder.go.golden", + }, + { + name: "primitive alias server constructor", + files: plan.ServerTypeFiles(), + file: "types.go", + section: "server-payload-init", + contains: "func NewDefaultTextPayload", + golden: "testdata/golden/params_default_text_payload_constructor.go.golden", + }, + { + name: "collection server constructor", + files: plan.ServerTypeFiles(), + file: "types.go", + section: "server-payload-init", + contains: "func NewDefaultArrayPayload", + golden: "testdata/golden/params_default_array_payload_constructor.go.golden", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + section := paramsGoldenSection(t, test.files, test.file, test.section, test.contains) + testutil.AssertGo(t, test.golden, codegen.SectionCode(t, section)) + }) + } +} + +// paramsGoldenSection finds one generated section by its function or type +// declaration instead of relying on a method's position in the design. +func paramsGoldenSection(t *testing.T, files []*codegen.File, fileName, sectionName, contains string) *codegen.SectionTemplate { + t.Helper() + for _, file := range files { + if filepath.Base(file.Path) != fileName { + continue + } + for _, section := range file.Section(sectionName) { + if source := codegen.SectionCode(t, section); strings.Contains(source, contains) { + return section + } + } + } + require.Fail(t, "generated section not found", "%s in %s", contains, fileName) + return nil +} + +// jsonRPCDefaultedParamsGoldenDSL declares one scalar alias and one collection +// selected as request bodies so generated source keeps their defaults honest. +func jsonRPCDefaultedParamsGoldenDSL() { + mode := dsl.Type("Mode", dsl.String, func() { + dsl.Pattern("^[a-z]*$") + dsl.Default("safe") + }) + values := dsl.Type("Values", dsl.ArrayOf(dsl.String)) + dsl.Service("defaults", func() { + dsl.JSONRPC(func() { + dsl.POST("/rpc") + }) + dsl.Method("default_text", func() { + dsl.Payload(func() { + dsl.Attribute("value", mode) + }) + dsl.JSONRPC(func() { + dsl.Body("value") + }) + }) + dsl.Method("default_array", func() { + dsl.Payload(func() { + dsl.Attribute("value", values, func() { + dsl.Default([]string{"safe"}) + }) + }) + dsl.JSONRPC(func() { + dsl.Body("value") + }) + }) + }) +} diff --git a/jsonrpc/codegen/params_runtime_dsl_test.go b/jsonrpc/codegen/params_runtime_dsl_test.go new file mode 100644 index 0000000000..20db6c619e --- /dev/null +++ b/jsonrpc/codegen/params_runtime_dsl_test.go @@ -0,0 +1,317 @@ +// This file declares the JSON-RPC request and stream shapes used by the +// generated runtime tests and runs their generated client and server packages. +package codegen_test + +import ( + "context" + "os" + "os/exec" + "testing" + "time" + + "github.com/stretchr/testify/require" + + "goa.design/goa/v3/dsl" +) + +// paramsRuntimeDSL declares the request and stream shapes exercised by the +// generated runtime tests. +func paramsRuntimeDSL() { + alias := dsl.Type("Alias", dsl.String, func() { + dsl.Pattern("^[a-z]+$") + }) + eventID := dsl.Type("EventID", dsl.String, func() { + dsl.Pattern("^[a-z]*$") + }) + eventType := dsl.Type("EventType", dsl.String, func() { + dsl.Enum("", "update") + }) + retry := dsl.Type("Retry", dsl.UInt32, func() { + dsl.Maximum(10) + }) + defaultEventID := dsl.Type("DefaultEventID", dsl.String, func() { + dsl.Default("fallback") + }) + defaultEventType := dsl.Type("DefaultEventType", dsl.String, func() { + dsl.Default("update") + }) + defaultRetry := dsl.Type("DefaultRetry", dsl.UInt32, func() { + dsl.Default(0) + }) + defaultStreamData := dsl.Type("DefaultStreamData", dsl.String, func() { + dsl.Pattern("^[a-z]*$") + dsl.Default("fallback") + }) + resumeID := dsl.Type("ResumeID", dsl.String, func() { + dsl.Default("cursor") + }) + object := dsl.Type("Object", func() { + dsl.Attribute("name", dsl.String) + dsl.Required("name") + }) + objects := dsl.Type("Objects", dsl.ArrayOf(object)) + defaultValues := dsl.Type("DefaultValues", dsl.ArrayOf(dsl.String)) + objectMap := dsl.Type("ObjectMap", dsl.MapOf(dsl.String, object)) + empty := dsl.Type("Empty", func() {}) + details := dsl.Type("Details", func() { + dsl.Attribute("name", dsl.String) + dsl.Required("name") + }) + aliasEvent := dsl.Type("AliasEvent", func() { + dsl.Attribute("data", alias) + dsl.Attribute("event_id", eventID) + dsl.Attribute("event_type", eventType) + dsl.Attribute("retry", retry) + dsl.Required("data") + }) + objectEvent := dsl.Type("ObjectEvent", func() { + dsl.Attribute("data", details) + dsl.Attribute("event_id", dsl.String) + dsl.Attribute("event_type", dsl.String) + dsl.Attribute("retry", dsl.Int64) + dsl.Required("data", "event_id", "event_type", "retry") + }) + defaultEvent := dsl.Type("DefaultEvent", func() { + dsl.Attribute("data", alias) + dsl.Attribute("event_id", defaultEventID) + dsl.Attribute("event_type", defaultEventType) + dsl.Attribute("retry", defaultRetry) + dsl.Required("data") + }) + optionalTextEvent := dsl.Type("OptionalTextEvent", func() { + dsl.Attribute("data", dsl.String, func() { + dsl.Pattern("^[a-z]*$") + }) + }) + defaultTextEvent := dsl.Type("DefaultTextEvent", func() { + dsl.Attribute("data", defaultStreamData) + }) + optionalObjectEvent := dsl.Type("OptionalObjectEvent", func() { + dsl.Attribute("data", object) + }) + optionalArrayEvent := dsl.Type("OptionalArrayEvent", func() { + dsl.Attribute("data", objects) + }) + optionalMapEvent := dsl.Type("OptionalMapEvent", func() { + dsl.Attribute("data", objectMap) + }) + optionalUnionEvent := dsl.Type("OptionalUnionEvent", func() { + dsl.OneOf("data", func() { + dsl.TypeName("OptionalStreamData") + dsl.Field(1, "name", dsl.String) + dsl.Field(2, "count", dsl.Int) + dsl.Field(3, "inactive", empty) + }) + }) + dsl.Service("Param Shapes", func() { + dsl.JSONRPC(func() { + dsl.POST("/rpc") + }) + for _, method := range []struct { + name string + type_ any + }{ + {name: "text", type_: dsl.String}, + {name: "alias", type_: alias}, + {name: "anything", type_: dsl.Any}, + {name: "bytes", type_: dsl.Bytes}, + {name: "object", type_: object}, + {name: "array", type_: dsl.ArrayOf(dsl.String)}, + {name: "map", type_: dsl.MapOf(dsl.String, dsl.Int)}, + } { + dsl.Method(method.name, func() { + dsl.Payload(method.type_) + dsl.JSONRPC(func() {}) + }) + } + dsl.Method("optional_text", func() { + dsl.Payload(func() { + dsl.Attribute("value", dsl.String, func() { + dsl.Pattern("^[a-z]*$") + }) + }) + dsl.JSONRPC(func() { + dsl.Body("value") + }) + }) + dsl.Method("default_text", func() { + dsl.Payload(func() { + dsl.Attribute("value", defaultStreamData) + }) + dsl.JSONRPC(func() { + dsl.Body("value") + }) + }) + dsl.Method("default_array", func() { + dsl.Payload(func() { + dsl.Attribute("value", defaultValues, func() { + dsl.Default([]string{"fallback"}) + }) + }) + dsl.JSONRPC(func() { + dsl.Body("value") + }) + }) + for _, method := range []struct { + name string + type_ any + }{ + {name: "optional_object", type_: object}, + {name: "optional_array", type_: objects}, + {name: "optional_map", type_: objectMap}, + } { + dsl.Method(method.name, func() { + dsl.Payload(func() { + dsl.Attribute("value", method.type_) + }) + dsl.JSONRPC(func() { + dsl.Body("value") + }) + }) + } + dsl.Method("optional_union", func() { + dsl.Payload(func() { + dsl.OneOf("value", func() { + dsl.TypeName("OptionalRequestValue") + dsl.Field(1, "name", dsl.String) + dsl.Field(2, "count", dsl.Int) + dsl.Field(3, "inactive", empty) + }) + }) + dsl.JSONRPC(func() { + dsl.Body("value") + }) + }) + dsl.Method("stream_text", func() { + dsl.StreamingResult(dsl.String) + dsl.JSONRPC(func() { + dsl.ServerSentEvents() + }) + }) + dsl.Method("stream_any", func() { + dsl.StreamingResult(dsl.Any) + dsl.JSONRPC(func() { + dsl.ServerSentEvents() + }) + }) + dsl.Method("stream_array", func() { + dsl.StreamingResult(dsl.ArrayOf(dsl.String)) + dsl.JSONRPC(func() { + dsl.ServerSentEvents() + }) + }) + dsl.Method("stream_alias_fields", func() { + dsl.StreamingResult(aliasEvent) + dsl.JSONRPC(func() { + dsl.ServerSentEvents(func() { + dsl.SSEEventData("data") + dsl.SSEEventID("event_id") + dsl.SSEEventType("event_type") + dsl.SSEEventRetry("retry") + }) + }) + }) + dsl.Method("stream_object_fields", func() { + dsl.StreamingResult(objectEvent) + dsl.JSONRPC(func() { + dsl.ServerSentEvents(func() { + dsl.SSEEventData("data") + dsl.SSEEventID("event_id") + dsl.SSEEventType("event_type") + dsl.SSEEventRetry("retry") + }) + }) + }) + dsl.Method("stream_default_fields", func() { + dsl.StreamingResult(defaultEvent) + dsl.JSONRPC(func() { + dsl.ServerSentEvents(func() { + dsl.SSEEventData("data") + dsl.SSEEventID("event_id") + dsl.SSEEventType("event_type") + dsl.SSEEventRetry("retry") + }) + }) + }) + dsl.Method("stream_optional_text", func() { + dsl.StreamingResult(optionalTextEvent) + dsl.JSONRPC(func() { + dsl.ServerSentEvents(func() { + dsl.SSEEventData("data") + }) + }) + }) + dsl.Method("stream_default_text", func() { + dsl.StreamingResult(defaultTextEvent) + dsl.JSONRPC(func() { + dsl.ServerSentEvents(func() { + dsl.SSEEventData("data") + }) + }) + }) + for _, method := range []struct { + name string + type_ any + }{ + {name: "stream_optional_object", type_: optionalObjectEvent}, + {name: "stream_optional_array", type_: optionalArrayEvent}, + {name: "stream_optional_map", type_: optionalMapEvent}, + {name: "stream_optional_union", type_: optionalUnionEvent}, + } { + dsl.Method(method.name, func() { + dsl.StreamingResult(method.type_) + dsl.JSONRPC(func() { + dsl.ServerSentEvents(func() { + dsl.SSEEventData("data") + }) + }) + }) + } + dsl.Method("required_resume", func() { + dsl.Payload(func() { + dsl.Attribute("last_event_id", dsl.String) + dsl.Required("last_event_id") + }) + dsl.StreamingResult(dsl.String) + dsl.JSONRPC(func() { + dsl.ServerSentEvents(func() { + dsl.SSERequestID("last_event_id") + }) + }) + }) + dsl.Method("optional_resume", func() { + dsl.Payload(func() { + dsl.Attribute("last_event_id", dsl.String) + }) + dsl.StreamingResult(dsl.String) + dsl.JSONRPC(func() { + dsl.ServerSentEvents(func() { + dsl.SSERequestID("last_event_id") + }) + }) + }) + dsl.Method("default_resume", func() { + dsl.Payload(func() { + dsl.Attribute("last_event_id", resumeID) + }) + dsl.StreamingResult(dsl.String) + dsl.JSONRPC(func() { + dsl.ServerSentEvents(func() { + dsl.SSERequestID("last_event_id") + }) + }) + }) + }) +} + +// runParamsRuntimeTests runs the generated package tests with a fixed timeout. +func runParamsRuntimeTests(t *testing.T, dir string) { + t.Helper() + ctx, cancel := context.WithTimeout(context.Background(), time.Minute) + defer cancel() + command := exec.CommandContext(ctx, "go", "test", "-mod=mod", "./jsonrpc/param_shapes/client", "./jsonrpc/param_shapes/server") + command.Dir = dir + command.Env = append(os.Environ(), "GOWORK=off") + output, err := command.CombinedOutput() + require.NoError(t, err, string(output)) +} diff --git a/jsonrpc/codegen/params_runtime_test.go b/jsonrpc/codegen/params_runtime_test.go new file mode 100644 index 0000000000..415b8e20a1 --- /dev/null +++ b/jsonrpc/codegen/params_runtime_test.go @@ -0,0 +1,686 @@ +// This file renders JSON-RPC methods into a temporary Go module. The generated +// tests check positional primitive values, direct structured values, and the +// difference between an absent optional value and an authored empty value. +package codegen_test + +import ( + "fmt" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/require" + + goacodegen "goa.design/goa/v3/codegen" + "goa.design/goa/v3/codegen/service" + "goa.design/goa/v3/eval" + "goa.design/goa/v3/expr" + httpcodegen "goa.design/goa/v3/http/codegen" + jsonrpccodegen "goa.design/goa/v3/jsonrpc/codegen" +) + +// TestGeneratedJSONRPCParams renders and runs the client, server, and stream +// code for single values and for objects, arrays, and maps. +func TestGeneratedJSONRPCParams(t *testing.T) { + dir := renderParamsRuntimeModule(t) + clientDir := filepath.Join(dir, "jsonrpc", "param_shapes", "client") + serverDir := filepath.Join(dir, "jsonrpc", "param_shapes", "server") + require.NoError(t, os.WriteFile(filepath.Join(clientDir, "params_runtime_test.go"), []byte(paramsClientRuntimeTest), 0o600)) + require.NoError(t, os.WriteFile(filepath.Join(serverDir, "params_runtime_test.go"), []byte(paramsServerRuntimeTest), 0o600)) + streamSource, err := os.ReadFile(filepath.Join(clientDir, "stream.go")) + require.NoError(t, err) + require.Contains(t, string(streamSource), "decodeError(response.Error)") + require.NotContains(t, string(streamSource), "decodeError(response.Error, response.ID == nil)") + require.NotContains(t, string(streamSource), "decodeError(response *jsonrpc.RawErrorResponse, ") + runParamsRuntimeTests(t, dir) +} + +// renderParamsRuntimeModule writes generated service, client, and server +// packages without changing any generated directory in this repository. +func renderParamsRuntimeModule(t *testing.T) string { + t.Helper() + root := expr.RunDSL(t, paramsRuntimeDSL) + generation, err := goacodegen.NewGeneration("generated.local/gen", []eval.Root{root}) + require.NoError(t, err) + servicePlan, err := service.NewPlan(root, generation, expr.NewExampleGenerator(root.API.RandomizerFactory)) + require.NoError(t, err) + httpPlans, err := httpcodegen.NewJSONRPCPlans(generation, httpcodegen.PlanInput{Root: root, Service: servicePlan}) + require.NoError(t, err) + jsonPlans, err := jsonrpccodegen.NewPlans(generation, jsonrpccodegen.PlanInput{ + Root: root, + Service: servicePlan, + HTTP: httpPlans[0], + }) + require.NoError(t, err) + require.NoError(t, generation.Freeze()) + require.NoError(t, servicePlan.Link()) + require.NoError(t, httpPlans[0].Link()) + require.NoError(t, jsonPlans[0].Link()) + + files, err := service.Files(servicePlan) + require.NoError(t, err) + files = append(files, jsonPlans[0].ClientFiles()...) + files = append(files, jsonPlans[0].ServerFiles()...) + files = append(files, jsonPlans[0].ClientTypeFiles()...) + files = append(files, jsonPlans[0].ServerTypeFiles()...) + files = append(files, jsonPlans[0].PathFiles()...) + base := t.TempDir() + for _, file := range files { + _, err := file.Render(base) + require.NoError(t, err) + } + + moduleDir := filepath.Join(base, goacodegen.Gendir) + workingDir, err := os.Getwd() + require.NoError(t, err) + repository := filepath.Clean(filepath.Join(workingDir, "..", "..")) + module := fmt.Sprintf( + "module generated.local/gen\n\ngo 1.25\n\nrequire goa.design/goa/v3 v3.0.0\n\nreplace goa.design/goa/v3 => %s\n", + repository, + ) + require.NoError(t, os.WriteFile(filepath.Join(moduleDir, "go.mod"), []byte(module), 0o600)) + return moduleDir +} + +const paramsClientRuntimeTest = `package client + +import ( + "context" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/stretchr/testify/require" + + service "generated.local/gen/param_shapes" + goahttp "goa.design/goa/v3/http" + "goa.design/goa/v3/jsonrpc" +) + +func TestRequestParamsKeepTheirJSONRPCShape(t *testing.T) { + tests := []struct { + name string + value any + encode func(func(*http.Request) goahttp.Encoder) func(*http.Request, any) (string, error) + want string + }{ + {name: "string", value: "value", encode: EncodeTextRequest, want: ` + "`" + `{"params":["value"]}` + "`" + `}, + {name: "primitive alias", value: service.Alias("value"), encode: EncodeAliasRequest, want: ` + "`" + `{"params":["value"]}` + "`" + `}, + {name: "any", value: map[string]any{"name": "value"}, encode: EncodeAnythingRequest, want: ` + "`" + `{"params":[{"name":"value"}]}` + "`" + `}, + {name: "bytes", value: []byte("value"), encode: EncodeBytesRequest, want: ` + "`" + `{"params":["dmFsdWU="]}` + "`" + `}, + {name: "object", value: &service.Object{Name: "value"}, encode: EncodeObjectRequest, want: ` + "`" + `{"params":{"name":"value"}}` + "`" + `}, + {name: "array", value: []string{"one", "two"}, encode: EncodeArrayRequest, want: ` + "`" + `{"params":["one","two"]}` + "`" + `}, + {name: "map", value: map[string]int{"one": 1}, encode: EncodeMapRequest, want: ` + "`" + `{"params":{"one":1}}` + "`" + `}, + {name: "optional primitive body", value: &service.OptionalTextPayload{Value: stringPointer("value")}, encode: EncodeOptionalTextRequest, want: ` + "`" + `{"params":["value"]}` + "`" + `}, + {name: "optional primitive null", value: &service.OptionalTextPayload{}, encode: EncodeOptionalTextRequest, want: ` + "`" + `{"params":[null]}` + "`" + `}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + request := httptest.NewRequest(http.MethodPost, "/rpc", nil) + _, err := test.encode(goahttp.RequestEncoder)(request, test.value) + require.NoError(t, err) + body, err := io.ReadAll(request.Body) + require.NoError(t, err) + require.JSONEq(t, test.want, paramsOnly(body)) + }) + } + +} + +func TestOptionalDirectRequestParamsOmitOnlyNilValues(t *testing.T) { + tests := []struct { + name string + nilValue any + value any + encode func(func(*http.Request) goahttp.Encoder) func(*http.Request, any) (string, error) + want string + }{ + { + name: "named object", + nilValue: &service.OptionalObjectPayload{}, + value: &service.OptionalObjectPayload{Value: &service.Object{Name: "value"}}, + encode: EncodeOptionalObjectRequest, + want: ` + "`" + `{"params":{"name":"value"}}` + "`" + `, + }, + { + name: "named array", + nilValue: &service.OptionalArrayPayload{}, + value: &service.OptionalArrayPayload{Value: service.Objects{&service.Object{Name: "value"}}}, + encode: EncodeOptionalArrayRequest, + want: ` + "`" + `{"params":[{"name":"value"}]}` + "`" + `, + }, + { + name: "named empty array", + nilValue: &service.OptionalArrayPayload{}, + value: &service.OptionalArrayPayload{Value: service.Objects{}}, + encode: EncodeOptionalArrayRequest, + want: ` + "`" + `{"params":[]}` + "`" + `, + }, + { + name: "named map", + nilValue: &service.OptionalMapPayload{}, + value: &service.OptionalMapPayload{Value: service.ObjectMap{"one": &service.Object{Name: "value"}}}, + encode: EncodeOptionalMapRequest, + want: ` + "`" + `{"params":{"one":{"name":"value"}}}` + "`" + `, + }, + { + name: "named empty map", + nilValue: &service.OptionalMapPayload{}, + value: &service.OptionalMapPayload{Value: service.ObjectMap{}}, + encode: EncodeOptionalMapRequest, + want: ` + "`" + `{"params":{}}` + "`" + `, + }, + { + name: "named union", + nilValue: &service.OptionalUnionPayload{}, + value: optionalUnionPayload("value"), + encode: EncodeOptionalUnionRequest, + want: ` + "`" + `{"params":{"type":"name","value":"value"}}` + "`" + `, + }, + { + name: "named union empty message", + nilValue: &service.OptionalUnionPayload{}, + value: optionalUnionInactivePayload(), + encode: EncodeOptionalUnionRequest, + want: ` + "`" + `{"params":{"type":"inactive","value":{}}}` + "`" + `, + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + request := httptest.NewRequest(http.MethodPost, "/rpc", nil) + _, err := test.encode(goahttp.RequestEncoder)(request, test.nilValue) + require.NoError(t, err) + body, err := io.ReadAll(request.Body) + require.NoError(t, err) + require.NotContains(t, string(body), ` + "`" + `"params"` + "`" + `) + + request = httptest.NewRequest(http.MethodPost, "/rpc", nil) + _, err = test.encode(goahttp.RequestEncoder)(request, test.value) + require.NoError(t, err) + body, err = io.ReadAll(request.Body) + require.NoError(t, err) + require.JSONEq(t, test.want, paramsOnly(body)) + }) + } + +} + +func TestDefaultedSelectedRequestClientsPreserveExplicitValues(t *testing.T) { + request := httptest.NewRequest(http.MethodPost, "/rpc", nil) + _, err := EncodeDefaultTextRequest(goahttp.RequestEncoder)(request, &service.DefaultTextPayload{ + Value: service.DefaultStreamData(""), + }) + require.NoError(t, err) + body, err := io.ReadAll(request.Body) + require.NoError(t, err) + require.JSONEq(t, ` + "`" + `{"params":[""]}` + "`" + `, paramsOnly(body)) + + request = httptest.NewRequest(http.MethodPost, "/rpc", nil) + _, err = EncodeDefaultArrayRequest(goahttp.RequestEncoder)(request, &service.DefaultArrayPayload{}) + require.NoError(t, err) + body, err = io.ReadAll(request.Body) + require.NoError(t, err) + require.NotContains(t, string(body), ` + "`" + `"params"` + "`" + `) + + request = httptest.NewRequest(http.MethodPost, "/rpc", nil) + _, err = EncodeDefaultArrayRequest(goahttp.RequestEncoder)(request, &service.DefaultArrayPayload{ + Value: service.DefaultValues{}, + }) + require.NoError(t, err) + body, err = io.ReadAll(request.Body) + require.NoError(t, err) + require.JSONEq(t, ` + "`" + `{"params":[]}` + "`" + `, paramsOnly(body)) +} + +func stringPointer(value string) *string { + return &value +} + +func optionalUnionPayload(value string) *service.OptionalUnionPayload { + payload := &service.OptionalUnionPayload{} + payload.Value.SetName(service.OptionalRequestValueBranchName(value)) + return payload +} + +func optionalUnionInactivePayload() *service.OptionalUnionPayload { + payload := &service.OptionalUnionPayload{} + payload.Value.SetInactive(&service.Empty{}) + return payload +} + +func TestStreamParamsKeepTheirJSONRPCShape(t *testing.T) { + tests := []struct { + name string + event string + receive func(*http.Response) (any, error) + want any + }{ + { + name: "string", + event: "data: {\"jsonrpc\":\"2.0\",\"method\":\"stream_text\",\"params\":[\"value\"]}\n\n", + receive: func(response *http.Response) (any, error) { + return NewStreamTextStream(response, goahttp.ResponseDecoder, "request-1").RecvWithContext(context.Background()) + }, + want: "value", + }, + { + name: "any", + event: "data: {\"jsonrpc\":\"2.0\",\"method\":\"stream_any\",\"params\":[{\"name\":\"value\"}]}\n\n", + receive: func(response *http.Response) (any, error) { + return NewStreamAnyStream(response, goahttp.ResponseDecoder, "request-1").RecvWithContext(context.Background()) + }, + want: map[string]any{"name": "value"}, + }, + { + name: "structured array", + event: "data: {\"jsonrpc\":\"2.0\",\"method\":\"stream_array\",\"params\":[\"one\",\"two\"]}\n\n", + receive: func(response *http.Response) (any, error) { + return NewStreamArrayStream(response, goahttp.ResponseDecoder, "request-1").RecvWithContext(context.Background()) + }, + want: []string{"one", "two"}, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + response := &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader(test.event))} + value, err := test.receive(response) + require.NoError(t, err) + require.Equal(t, test.want, value) + }) + } +} + +func TestMappedStreamFieldsRebuildServiceResults(t *testing.T) { + aliasResponse := &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader( + "id: \nevent: \nretry: 0\ndata: {\"jsonrpc\":\"2.0\",\"method\":\"stream_alias_fields\",\"params\":[\"value\"]}\n\n", + ))} + alias, err := NewStreamAliasFieldsStream(aliasResponse, goahttp.ResponseDecoder, "request-1").Recv() + require.NoError(t, err) + require.Equal(t, service.Alias("value"), alias.Data) + require.NotNil(t, alias.EventID) + require.Empty(t, *alias.EventID) + require.NotNil(t, alias.EventType) + require.Empty(t, *alias.EventType) + require.NotNil(t, alias.Retry) + require.Zero(t, *alias.Retry) + + objectResponse := &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader( + "id: item-1\nevent: update\nretry: 0\ndata: {\"jsonrpc\":\"2.0\",\"method\":\"stream_object_fields\",\"params\":{\"name\":\"value\"}}\n\n", + ))} + object, err := NewStreamObjectFieldsStream(objectResponse, goahttp.ResponseDecoder, "request-1").Recv() + require.NoError(t, err) + require.Equal(t, &service.Details{Name: "value"}, object.Data) + require.Equal(t, "item-1", object.EventID) + require.Equal(t, "update", object.EventType) + require.Zero(t, object.Retry) +} + +func TestMappedStreamFieldsApplyAuthoredDefaultsWhenLinesAreAbsent(t *testing.T) { + response := &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader( + "data: {\"jsonrpc\":\"2.0\",\"method\":\"stream_default_fields\",\"params\":[\"value\"]}\n\n", + ))} + + result, err := NewStreamDefaultFieldsStream(response, goahttp.ResponseDecoder, "request-1").Recv() + + require.NoError(t, err) + require.Equal(t, service.DefaultEventID("fallback"), result.EventID) + require.Equal(t, service.DefaultEventType("update"), result.EventType) + require.Equal(t, service.DefaultRetry(0), result.Retry) +} + +func TestOptionalPrimitiveStreamParamsPreservePresenceAndDefaults(t *testing.T) { + tests := []struct { + name string + method string + params string + receive func(*http.Response) (any, error) + check func(*testing.T, any) + wantErr string + }{ + { + name: "optional omitted", + method: "stream_optional_text", + receive: func(response *http.Response) (any, error) { + return NewStreamOptionalTextStream(response, goahttp.ResponseDecoder, "request-1").Recv() + }, + check: func(t *testing.T, value any) { + require.Nil(t, value.(*service.OptionalTextEvent).Data) + }, + }, + { + name: "optional null", + method: "stream_optional_text", + params: ` + "`" + `[null]` + "`" + `, + receive: func(response *http.Response) (any, error) { + return NewStreamOptionalTextStream(response, goahttp.ResponseDecoder, "request-1").Recv() + }, + check: func(t *testing.T, value any) { + require.Nil(t, value.(*service.OptionalTextEvent).Data) + }, + }, + { + name: "optional empty", + method: "stream_optional_text", + params: ` + "`" + `[""]` + "`" + `, + receive: func(response *http.Response) (any, error) { + return NewStreamOptionalTextStream(response, goahttp.ResponseDecoder, "request-1").Recv() + }, + check: func(t *testing.T, value any) { + result := value.(*service.OptionalTextEvent) + require.NotNil(t, result.Data) + require.Empty(t, *result.Data) + }, + }, + { + name: "optional invalid", + method: "stream_optional_text", + params: ` + "`" + `["1"]` + "`" + `, + receive: func(response *http.Response) (any, error) { + return NewStreamOptionalTextStream(response, goahttp.ResponseDecoder, "request-1").Recv() + }, + wantErr: "must match", + }, + { + name: "default omitted", + method: "stream_default_text", + receive: func(response *http.Response) (any, error) { + return NewStreamDefaultTextStream(response, goahttp.ResponseDecoder, "request-1").Recv() + }, + check: func(t *testing.T, value any) { + require.Equal(t, service.DefaultStreamData("fallback"), value.(*service.DefaultTextEvent).Data) + }, + }, + { + name: "default null", + method: "stream_default_text", + params: ` + "`" + `[null]` + "`" + `, + receive: func(response *http.Response) (any, error) { + return NewStreamDefaultTextStream(response, goahttp.ResponseDecoder, "request-1").Recv() + }, + check: func(t *testing.T, value any) { + require.Equal(t, service.DefaultStreamData("fallback"), value.(*service.DefaultTextEvent).Data) + }, + }, + { + name: "default empty", + method: "stream_default_text", + params: ` + "`" + `[""]` + "`" + `, + receive: func(response *http.Response) (any, error) { + return NewStreamDefaultTextStream(response, goahttp.ResponseDecoder, "request-1").Recv() + }, + check: func(t *testing.T, value any) { + require.Empty(t, value.(*service.DefaultTextEvent).Data) + }, + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + message := ` + "`" + `{"jsonrpc":"2.0","method":"` + "`" + ` + test.method + ` + "`" + `"` + "`" + ` + if test.params != "" { + message += ` + "`" + `,"params":` + "`" + ` + test.params + } + message += "}\n\n" + response := &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader("data: " + message))} + value, err := test.receive(response) + if test.wantErr != "" { + require.ErrorContains(t, err, test.wantErr) + return + } + require.NoError(t, err) + test.check(t, value) + }) + } +} + +func TestOptionalDirectStreamParamsRoundTripAbsentAndPresent(t *testing.T) { + tests := []struct { + name string + method string + params string + receive func(*http.Response) (any, error) + check func(*testing.T, any, bool) + }{ + { + name: "named object", + method: "stream_optional_object", + params: ` + "`" + `{"name":"value"}` + "`" + `, + receive: func(response *http.Response) (any, error) { + return NewStreamOptionalObjectStream(response, goahttp.ResponseDecoder, "request-1").Recv() + }, + check: func(t *testing.T, value any, present bool) { + result := value.(*service.OptionalObjectEvent) + if present { + require.Equal(t, &service.Object{Name: "value"}, result.Data) + } else { + require.Nil(t, result.Data) + } + }, + }, + { + name: "named union empty message", + method: "stream_optional_union", + params: ` + "`" + `{"type":"inactive","value":{}}` + "`" + `, + receive: func(response *http.Response) (any, error) { + return NewStreamOptionalUnionStream(response, goahttp.ResponseDecoder, "request-1").Recv() + }, + check: func(t *testing.T, value any, present bool) { + result := value.(*service.OptionalUnionEvent) + if present { + inactive, ok := result.Data.AsInactive() + require.True(t, ok) + require.Equal(t, &service.Empty{}, inactive) + } else { + require.Empty(t, result.Data.Kind()) + } + }, + }, + { + name: "named array", + method: "stream_optional_array", + params: ` + "`" + `[{"name":"value"}]` + "`" + `, + receive: func(response *http.Response) (any, error) { + return NewStreamOptionalArrayStream(response, goahttp.ResponseDecoder, "request-1").Recv() + }, + check: func(t *testing.T, value any, present bool) { + result := value.(*service.OptionalArrayEvent) + if present { + require.Equal(t, service.Objects{&service.Object{Name: "value"}}, result.Data) + } else { + require.Nil(t, result.Data) + } + }, + }, + { + name: "named map", + method: "stream_optional_map", + params: ` + "`" + `{"one":{"name":"value"}}` + "`" + `, + receive: func(response *http.Response) (any, error) { + return NewStreamOptionalMapStream(response, goahttp.ResponseDecoder, "request-1").Recv() + }, + check: func(t *testing.T, value any, present bool) { + result := value.(*service.OptionalMapEvent) + if present { + require.Equal(t, service.ObjectMap{"one": &service.Object{Name: "value"}}, result.Data) + } else { + require.Nil(t, result.Data) + } + }, + }, + { + name: "named union", + method: "stream_optional_union", + params: ` + "`" + `{"type":"name","value":"value"}` + "`" + `, + receive: func(response *http.Response) (any, error) { + return NewStreamOptionalUnionStream(response, goahttp.ResponseDecoder, "request-1").Recv() + }, + check: func(t *testing.T, value any, present bool) { + result := value.(*service.OptionalUnionEvent) + if present { + name, ok := result.Data.AsName() + require.True(t, ok) + require.Equal(t, "value", string(name)) + } else { + require.Empty(t, result.Data.Kind()) + } + }, + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + for _, present := range []bool{false, true} { + params := "" + if present { + params = ` + "`" + `,"params":` + "`" + ` + test.params + } + event := ` + "`" + `data: {"jsonrpc":"2.0","method":"` + "`" + ` + test.method + ` + "`" + `"` + "`" + ` + params + "}\n\n" + response := &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader(event))} + value, err := test.receive(response) + require.NoError(t, err) + test.check(t, value, present) + } + }) + } + + response := &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader( + "data: {\"jsonrpc\":\"2.0\",\"method\":\"stream_optional_object\",\"params\":null}\n\n", + ))} + _, err := NewStreamOptionalObjectStream(response, goahttp.ResponseDecoder, "request-1").Recv() + require.ErrorContains(t, err, "invalid JSON-RPC notification") +} + +func TestMappedStreamRejectsMalformedMessages(t *testing.T) { + tests := []struct { + name string + event string + error string + }{ + {name: "missing required outer field", event: "event: update\nretry: 0\ndata: {\"jsonrpc\":\"2.0\",\"method\":\"stream_object_fields\",\"params\":{\"name\":\"value\"}}\n\n", error: "has no id"}, + {name: "invalid required retry", event: "id: item-1\nevent: update\nretry: later\ndata: {\"jsonrpc\":\"2.0\",\"method\":\"stream_object_fields\",\"params\":{\"name\":\"value\"}}\n\n", error: "has no retry"}, + {name: "invalid data", event: "id: item-1\nevent: update\nretry: 0\ndata: {\"jsonrpc\":\"2.0\",\"method\":\"stream_alias_fields\",\"params\":[]}\n\n", error: "exactly one value"}, + {name: "missing required data", event: "id: item\nevent: update\nretry: 0\ndata: {\"jsonrpc\":\"2.0\",\"method\":\"stream_alias_fields\",\"params\":[null]}\n\n", error: ` + "`" + `"data" is missing` + "`" + `}, + {name: "data pattern", event: "id: item\nevent: update\nretry: 0\ndata: {\"jsonrpc\":\"2.0\",\"method\":\"stream_alias_fields\",\"params\":[\"BAD\"]}\n\n", error: "must match"}, + {name: "event ID pattern", event: "id: 123\nevent: update\nretry: 0\ndata: {\"jsonrpc\":\"2.0\",\"method\":\"stream_alias_fields\",\"params\":[\"value\"]}\n\n", error: "must match"}, + {name: "event type enum", event: "id: item\nevent: delete\nretry: 0\ndata: {\"jsonrpc\":\"2.0\",\"method\":\"stream_alias_fields\",\"params\":[\"value\"]}\n\n", error: "one of"}, + {name: "retry maximum", event: "id: item\nevent: update\nretry: 11\ndata: {\"jsonrpc\":\"2.0\",\"method\":\"stream_alias_fields\",\"params\":[\"value\"]}\n\n", error: "lesser or equal"}, + {name: "missing method", event: "data: {\"jsonrpc\":\"2.0\",\"params\":[\"value\"]}\n\n", error: "JSON-RPC response contains params"}, + {name: "null method", event: "data: {\"jsonrpc\":\"2.0\",\"method\":null,\"params\":[\"value\"]}\n\n", error: "invalid JSON-RPC notification"}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + response := &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader(test.event))} + var err error + if strings.Contains(test.event, "stream_object_fields") { + _, err = NewStreamObjectFieldsStream(response, goahttp.ResponseDecoder, "request-1").Recv() + } else { + _, err = NewStreamAliasFieldsStream(response, goahttp.ResponseDecoder, "request-1").Recv() + } + require.ErrorContains(t, err, test.error) + }) + } +} + +func TestStreamTerminalMessagesDoNotNeedPrivateEventNames(t *testing.T) { + for _, message := range []string{ + ` + "`" + `{"jsonrpc":"2.0","id":"request-1","result":null}` + "`" + `, + ` + "`" + `{"jsonrpc":"2.0","id":"request-1","error":{"code":-32603,"message":"failed"}}` + "`" + `, + } { + response := &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader("data: " + message + "\n\n"))} + _, err := NewStreamTextStream(response, goahttp.ResponseDecoder, "request-1").Recv() + if strings.Contains(message, ` + "`" + `"result"` + "`" + `) { + require.ErrorIs(t, err, io.EOF) + } else { + var response *jsonrpc.RawErrorResponse + require.ErrorAs(t, err, &response) + require.Equal(t, -32603, response.Code) + require.Equal(t, "failed", response.Message) + } + } +} + +func TestStreamTerminalSuccessRequiresNullResult(t *testing.T) { + response := &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader( + "data: " + ` + "`" + `{"jsonrpc":"2.0","id":"request-1","result":"unexpected"}` + "`" + ` + "\n\n", + ))} + + _, err := NewStreamTextStream(response, goahttp.ResponseDecoder, "request-1").Recv() + + require.EqualError(t, err, "JSON-RPC stream completion result must be null") +} + +func TestUnknownUnaryErrorPreservesJSONRPCDetails(t *testing.T) { + response := &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader( + ` + "`" + `{"jsonrpc":"2.0","id":"request-1","error":{"code":-32099,"message":"upstream failed","data":{"reason":"overload"}}}` + "`" + `, + )), + } + + result, err := DecodeTextResponse(goahttp.ResponseDecoder, false)(response, "request-1") + + require.Nil(t, result) + var rpcError *jsonrpc.RawErrorResponse + require.ErrorAs(t, err, &rpcError) + require.Equal(t, -32099, rpcError.Code) + require.Equal(t, "upstream failed", rpcError.Message) + require.JSONEq(t, ` + "`" + `{"reason":"overload"}` + "`" + `, string(rpcError.Data)) +} + +func TestResumeRequestWritesLastEventIDOutsideParams(t *testing.T) { + tests := []struct { + name string + value any + encode func(func(*http.Request) goahttp.Encoder) func(*http.Request, any) (string, error) + present bool + }{ + {name: "required empty", value: &service.RequiredResumePayload{}, encode: EncodeRequiredResumeRequest, present: true}, + {name: "optional absent", value: &service.OptionalResumePayload{}, encode: EncodeOptionalResumeRequest}, + {name: "optional empty", value: &service.OptionalResumePayload{LastEventID: stringPointer("")}, encode: EncodeOptionalResumeRequest, present: true}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + request := httptest.NewRequest(http.MethodPost, "/rpc", nil) + _, err := test.encode(goahttp.RequestEncoder)(request, test.value) + require.NoError(t, err) + values, present := request.Header["Last-Event-Id"] + require.Equal(t, test.present, present) + if present { + require.Equal(t, []string{""}, values) + } + body, err := io.ReadAll(request.Body) + require.NoError(t, err) + require.NotContains(t, string(body), "last_event_id") + }) + } +} + +func TestResumeRequestRejectsInvalidLastEventID(t *testing.T) { + for _, value := range []string{"one\x00two", "one\rtwo", "one\ntwo"} { + request := httptest.NewRequest(http.MethodPost, "/rpc", nil) + + _, err := EncodeOptionalResumeRequest(goahttp.RequestEncoder)(request, &service.OptionalResumePayload{LastEventID: stringPointer(value)}) + + require.ErrorContains(t, err, "last_event_id") + require.Empty(t, request.Header.Values("Last-Event-ID")) + } +} + +func TestPrimitiveStreamRejectsWrongPositionalCount(t *testing.T) { + for _, params := range []string{"[]", "[\"one\",\"two\"]"} { + event := "data: {\"jsonrpc\":\"2.0\",\"method\":\"stream_text\",\"params\":" + params + "}\n\n" + response := &http.Response{StatusCode: http.StatusOK, Body: io.NopCloser(strings.NewReader(event))} + _, err := NewStreamTextStream(response, goahttp.ResponseDecoder, "request-1").Recv() + require.EqualError(t, err, "params must be an array with exactly one value") + } +} + +func paramsOnly(body []byte) string { + text := string(body) + start := strings.Index(text, ` + "`" + `"params"` + "`" + `) + end := strings.LastIndex(text, ` + "`" + `,"id"` + "`" + `) + return "{" + text[start:end] + "}" +} +` diff --git a/jsonrpc/codegen/params_server_runtime_source_test.go b/jsonrpc/codegen/params_server_runtime_source_test.go new file mode 100644 index 0000000000..32c3e8808d --- /dev/null +++ b/jsonrpc/codegen/params_server_runtime_source_test.go @@ -0,0 +1,459 @@ +// This file contains the generated server tests written into the temporary +// module used to verify JSON-RPC parameter encoding and decoding. +package codegen_test + +const paramsServerRuntimeTest = `package server + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/stretchr/testify/require" + + service "generated.local/gen/param_shapes" + goahttp "goa.design/goa/v3/http" + "goa.design/goa/v3/jsonrpc" +) + +func TestRequestParamsDecodeToServiceValues(t *testing.T) { + request := httptest.NewRequest(http.MethodPost, "/rpc", nil) + + text, err := DecodeTextRequest(nil, goahttp.RequestDecoder)(request, rawParams("[\"value\"]")) + require.NoError(t, err) + require.Equal(t, "value", text) + + alias, err := DecodeAliasRequest(nil, goahttp.RequestDecoder)(request, rawParams("[\"value\"]")) + require.NoError(t, err) + require.Equal(t, service.Alias("value"), alias) + + anything, err := DecodeAnythingRequest(nil, goahttp.RequestDecoder)(request, rawParams("[{\"name\":\"value\"}]")) + require.NoError(t, err) + require.Equal(t, map[string]any{"name": "value"}, anything) + + bytes, err := DecodeBytesRequest(nil, goahttp.RequestDecoder)(request, rawParams("[\"dmFsdWU=\"]")) + require.NoError(t, err) + require.Equal(t, []byte("value"), bytes) + + object, err := DecodeObjectRequest(nil, goahttp.RequestDecoder)(request, rawParams("{\"name\":\"value\"}")) + require.NoError(t, err) + require.Equal(t, &service.Object{Name: "value"}, object) + + array, err := DecodeArrayRequest(nil, goahttp.RequestDecoder)(request, rawParams("[\"one\",\"two\"]")) + require.NoError(t, err) + require.Equal(t, []string{"one", "two"}, array) + + values, err := DecodeMapRequest(nil, goahttp.RequestDecoder)(request, rawParams("{\"one\":1}")) + require.NoError(t, err) + require.Equal(t, map[string]int{"one": 1}, values) + + optional, err := DecodeOptionalTextRequest(nil, goahttp.RequestDecoder)(request, rawParams("")) + require.NoError(t, err) + require.Nil(t, optional.Value) + + optional, err = DecodeOptionalTextRequest(nil, goahttp.RequestDecoder)(request, rawParams("[null]")) + require.NoError(t, err) + require.Nil(t, optional.Value) + + optional, err = DecodeOptionalTextRequest(nil, goahttp.RequestDecoder)(request, rawParams("[\"\"]")) + require.NoError(t, err) + require.NotNil(t, optional.Value) + require.Empty(t, *optional.Value) +} + +func TestOptionalDirectRequestParamsDecodeAbsentAndPresent(t *testing.T) { + request := httptest.NewRequest(http.MethodPost, "/rpc", nil) + + object, err := DecodeOptionalObjectRequest(nil, goahttp.RequestDecoder)(request, rawParams("")) + require.NoError(t, err) + require.Nil(t, object.Value) + object, err = DecodeOptionalObjectRequest(nil, goahttp.RequestDecoder)(request, rawParams(` + "`" + `{"name":"value"}` + "`" + `)) + require.NoError(t, err) + require.Equal(t, &service.Object{Name: "value"}, object.Value) + + array, err := DecodeOptionalArrayRequest(nil, goahttp.RequestDecoder)(request, rawParams("")) + require.NoError(t, err) + require.Nil(t, array.Value) + array, err = DecodeOptionalArrayRequest(nil, goahttp.RequestDecoder)(request, rawParams(` + "`" + `[{"name":"value"}]` + "`" + `)) + require.NoError(t, err) + require.Equal(t, service.Objects{&service.Object{Name: "value"}}, array.Value) + array, err = DecodeOptionalArrayRequest(nil, goahttp.RequestDecoder)(request, rawParams("[]")) + require.NoError(t, err) + require.NotNil(t, array.Value) + require.Empty(t, array.Value) + + values, err := DecodeOptionalMapRequest(nil, goahttp.RequestDecoder)(request, rawParams("")) + require.NoError(t, err) + require.Nil(t, values.Value) + values, err = DecodeOptionalMapRequest(nil, goahttp.RequestDecoder)(request, rawParams(` + "`" + `{"one":{"name":"value"}}` + "`" + `)) + require.NoError(t, err) + require.Equal(t, service.ObjectMap{"one": &service.Object{Name: "value"}}, values.Value) + values, err = DecodeOptionalMapRequest(nil, goahttp.RequestDecoder)(request, rawParams("{}")) + require.NoError(t, err) + require.NotNil(t, values.Value) + require.Empty(t, values.Value) + + choice, err := DecodeOptionalUnionRequest(nil, goahttp.RequestDecoder)(request, rawParams("")) + require.NoError(t, err) + require.Empty(t, choice.Value.Kind()) + choice, err = DecodeOptionalUnionRequest(nil, goahttp.RequestDecoder)(request, rawParams(` + "`" + `{"type":"name","value":"value"}` + "`" + `)) + require.NoError(t, err) + name, ok := choice.Value.AsName() + require.True(t, ok) + require.Equal(t, "value", string(name)) + choice, err = DecodeOptionalUnionRequest(nil, goahttp.RequestDecoder)(request, rawParams(` + "`" + `{"type":"inactive","value":{}}` + "`" + `)) + require.NoError(t, err) + inactive, ok := choice.Value.AsInactive() + require.True(t, ok) + require.Equal(t, &service.Empty{}, inactive) +} + +func TestDefaultedSelectedRequestParamsPreserveAbsenceAndEmptyValues(t *testing.T) { + request := httptest.NewRequest(http.MethodPost, "/rpc", nil) + + text, err := DecodeDefaultTextRequest(nil, goahttp.RequestDecoder)(request, rawParams("")) + require.NoError(t, err) + require.Equal(t, service.DefaultStreamData("fallback"), text.Value) + + text, err = DecodeDefaultTextRequest(nil, goahttp.RequestDecoder)(request, rawParams("[null]")) + require.NoError(t, err) + require.Equal(t, service.DefaultStreamData("fallback"), text.Value) + + text, err = DecodeDefaultTextRequest(nil, goahttp.RequestDecoder)(request, rawParams("[\"\"]")) + require.NoError(t, err) + require.Empty(t, text.Value) + + values, err := DecodeDefaultArrayRequest(nil, goahttp.RequestDecoder)(request, rawParams("")) + require.NoError(t, err) + require.Equal(t, service.DefaultValues{"fallback"}, values.Value) + + values, err = DecodeDefaultArrayRequest(nil, goahttp.RequestDecoder)(request, rawParams("[]")) + require.NoError(t, err) + require.NotNil(t, values.Value) + require.Empty(t, values.Value) +} + +func TestRequiredPrimitiveRejectsInvalidPositionalParams(t *testing.T) { + tests := []struct { + name string + params string + error string + }{ + {name: "object", params: ` + "`" + `{}` + "`" + `, error: "params must be an array with exactly one value"}, + {name: "empty", params: ` + "`" + `[]` + "`" + `, error: "params must be an array with exactly one value"}, + {name: "multiple", params: ` + "`" + `["one","two"]` + "`" + `, error: "params must be an array with exactly one value"}, + {name: "null", params: ` + "`" + `[null]` + "`" + `, error: "missing required payload"}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + request := httptest.NewRequest(http.MethodPost, "/rpc", nil) + _, err := DecodeTextRequest(nil, goahttp.RequestDecoder)(request, rawParams(test.params)) + require.ErrorContains(t, err, test.error) + }) + } +} + +func TestOptionalPrimitiveRejectsInvalidPresentParams(t *testing.T) { + for _, params := range []string{"{}", "[]", "[\"one\",\"two\"]"} { + request := httptest.NewRequest(http.MethodPost, "/rpc", nil) + _, err := DecodeOptionalTextRequest(nil, goahttp.RequestDecoder)(request, rawParams(params)) + require.ErrorContains(t, err, "params must be an array with exactly one value") + } + request := httptest.NewRequest(http.MethodPost, "/rpc", nil) + _, err := DecodeOptionalTextRequest(nil, goahttp.RequestDecoder)(request, rawParams("[\"BAD\"]")) + require.ErrorContains(t, err, "must match") +} + +func TestStreamWritersKeepTheirJSONRPCShape(t *testing.T) { + tests := []struct { + name string + send func(*httptest.ResponseRecorder) error + want string + }{ + { + name: "string", + send: func(recorder *httptest.ResponseRecorder) error { + return (&StreamTextServerStream{sseServerStream: sseServerStream{w: recorder, encoder: goahttp.ResponseEncoder}}).Send("value") + }, + want: ` + "`" + `{"jsonrpc":"2.0","method":"stream_text","params":["value"]}` + "`" + `, + }, + { + name: "any", + send: func(recorder *httptest.ResponseRecorder) error { + return (&StreamAnyServerStream{sseServerStream: sseServerStream{w: recorder, encoder: goahttp.ResponseEncoder}}).Send(map[string]any{"name": "value"}) + }, + want: ` + "`" + `{"jsonrpc":"2.0","method":"stream_any","params":[{"name":"value"}]}` + "`" + `, + }, + { + name: "structured array", + send: func(recorder *httptest.ResponseRecorder) error { + return (&StreamArrayServerStream{sseServerStream: sseServerStream{w: recorder, encoder: goahttp.ResponseEncoder}}).Send([]string{"one", "two"}) + }, + want: ` + "`" + `{"jsonrpc":"2.0","method":"stream_array","params":["one","two"]}` + "`" + `, + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + recorder := httptest.NewRecorder() + require.NoError(t, test.send(recorder)) + body := recorder.Body.String() + require.NotContains(t, body, "event:") + message := strings.TrimSuffix(strings.TrimPrefix(body, "data: "), "\n\n") + require.JSONEq(t, test.want, message) + }) + } +} + +func TestMappedStreamWritersUseCompleteJSONRPCData(t *testing.T) { + emptyID := service.EventID("") + emptyType := service.EventType("") + zero := service.Retry(0) + recorder := httptest.NewRecorder() + stream := &StreamAliasFieldsServerStream{sseServerStream: sseServerStream{w: recorder, encoder: goahttp.ResponseEncoder}} + err := stream.Send(&service.AliasEvent{ + Data: service.Alias("value"), + EventID: &emptyID, + EventType: &emptyType, + Retry: &zero, + }) + require.NoError(t, err) + require.Equal(t, + "id: \nevent: \nretry: 0\ndata: {\"jsonrpc\":\"2.0\",\"method\":\"stream_alias_fields\",\"params\":[\"value\"]}\n\n", + recorder.Body.String(), + ) + + objectRecorder := httptest.NewRecorder() + objectStream := &StreamObjectFieldsServerStream{sseServerStream: sseServerStream{w: objectRecorder, encoder: goahttp.ResponseEncoder}} + err = objectStream.Send(&service.ObjectEvent{ + Data: &service.Details{Name: "value"}, + EventID: "item-1", + EventType: "update", + Retry: 0, + }) + require.NoError(t, err) + require.Equal(t, + "id: item-1\nevent: update\nretry: 0\ndata: {\"jsonrpc\":\"2.0\",\"method\":\"stream_object_fields\",\"params\":{\"name\":\"value\"}}\n\n", + objectRecorder.Body.String(), + ) +} + +func TestOptionalPrimitiveStreamWriterPreservesNilAndEmpty(t *testing.T) { + nilRecorder := httptest.NewRecorder() + nilStream := &StreamOptionalTextServerStream{sseServerStream: sseServerStream{w: nilRecorder, encoder: goahttp.ResponseEncoder}} + require.NoError(t, nilStream.Send(&service.OptionalTextEvent{})) + require.Equal(t, + "data: {\"jsonrpc\":\"2.0\",\"method\":\"stream_optional_text\",\"params\":[null]}\n\n", + nilRecorder.Body.String(), + ) + + empty := "" + emptyRecorder := httptest.NewRecorder() + emptyStream := &StreamOptionalTextServerStream{sseServerStream: sseServerStream{w: emptyRecorder, encoder: goahttp.ResponseEncoder}} + require.NoError(t, emptyStream.Send(&service.OptionalTextEvent{Data: &empty})) + require.Equal(t, + "data: {\"jsonrpc\":\"2.0\",\"method\":\"stream_optional_text\",\"params\":[\"\"]}\n\n", + emptyRecorder.Body.String(), + ) +} + +func TestOptionalDirectStreamWritersOmitOnlyNilParams(t *testing.T) { + tests := []struct { + name string + sendNil func(*httptest.ResponseRecorder) error + sendValue func(*httptest.ResponseRecorder) error + want string + }{ + { + name: "named object", + sendNil: func(recorder *httptest.ResponseRecorder) error { + return (&StreamOptionalObjectServerStream{sseServerStream: sseServerStream{w: recorder, encoder: goahttp.ResponseEncoder}}).Send(&service.OptionalObjectEvent{}) + }, + sendValue: func(recorder *httptest.ResponseRecorder) error { + return (&StreamOptionalObjectServerStream{sseServerStream: sseServerStream{w: recorder, encoder: goahttp.ResponseEncoder}}).Send(&service.OptionalObjectEvent{Data: &service.Object{Name: "value"}}) + }, + want: ` + "`" + `{"jsonrpc":"2.0","method":"stream_optional_object","params":{"name":"value"}}` + "`" + `, + }, + { + name: "named array", + sendNil: func(recorder *httptest.ResponseRecorder) error { + return (&StreamOptionalArrayServerStream{sseServerStream: sseServerStream{w: recorder, encoder: goahttp.ResponseEncoder}}).Send(&service.OptionalArrayEvent{}) + }, + sendValue: func(recorder *httptest.ResponseRecorder) error { + return (&StreamOptionalArrayServerStream{sseServerStream: sseServerStream{w: recorder, encoder: goahttp.ResponseEncoder}}).Send(&service.OptionalArrayEvent{Data: service.Objects{&service.Object{Name: "value"}}}) + }, + want: ` + "`" + `{"jsonrpc":"2.0","method":"stream_optional_array","params":[{"name":"value"}]}` + "`" + `, + }, + { + name: "named empty array", + sendNil: func(recorder *httptest.ResponseRecorder) error { + return (&StreamOptionalArrayServerStream{sseServerStream: sseServerStream{w: recorder, encoder: goahttp.ResponseEncoder}}).Send(&service.OptionalArrayEvent{}) + }, + sendValue: func(recorder *httptest.ResponseRecorder) error { + return (&StreamOptionalArrayServerStream{sseServerStream: sseServerStream{w: recorder, encoder: goahttp.ResponseEncoder}}).Send(&service.OptionalArrayEvent{Data: service.Objects{}}) + }, + want: ` + "`" + `{"jsonrpc":"2.0","method":"stream_optional_array","params":[]}` + "`" + `, + }, + { + name: "named map", + sendNil: func(recorder *httptest.ResponseRecorder) error { + return (&StreamOptionalMapServerStream{sseServerStream: sseServerStream{w: recorder, encoder: goahttp.ResponseEncoder}}).Send(&service.OptionalMapEvent{}) + }, + sendValue: func(recorder *httptest.ResponseRecorder) error { + return (&StreamOptionalMapServerStream{sseServerStream: sseServerStream{w: recorder, encoder: goahttp.ResponseEncoder}}).Send(&service.OptionalMapEvent{Data: service.ObjectMap{"one": &service.Object{Name: "value"}}}) + }, + want: ` + "`" + `{"jsonrpc":"2.0","method":"stream_optional_map","params":{"one":{"name":"value"}}}` + "`" + `, + }, + { + name: "named empty map", + sendNil: func(recorder *httptest.ResponseRecorder) error { + return (&StreamOptionalMapServerStream{sseServerStream: sseServerStream{w: recorder, encoder: goahttp.ResponseEncoder}}).Send(&service.OptionalMapEvent{}) + }, + sendValue: func(recorder *httptest.ResponseRecorder) error { + return (&StreamOptionalMapServerStream{sseServerStream: sseServerStream{w: recorder, encoder: goahttp.ResponseEncoder}}).Send(&service.OptionalMapEvent{Data: service.ObjectMap{}}) + }, + want: ` + "`" + `{"jsonrpc":"2.0","method":"stream_optional_map","params":{}}` + "`" + `, + }, + { + name: "named union", + sendNil: func(recorder *httptest.ResponseRecorder) error { + return (&StreamOptionalUnionServerStream{sseServerStream: sseServerStream{w: recorder, encoder: goahttp.ResponseEncoder}}).Send(&service.OptionalUnionEvent{}) + }, + sendValue: func(recorder *httptest.ResponseRecorder) error { + event := &service.OptionalUnionEvent{} + event.Data.SetName("value") + return (&StreamOptionalUnionServerStream{sseServerStream: sseServerStream{w: recorder, encoder: goahttp.ResponseEncoder}}).Send(event) + }, + want: ` + "`" + `{"jsonrpc":"2.0","method":"stream_optional_union","params":{"type":"name","value":"value"}}` + "`" + `, + }, + { + name: "named union empty message", + sendNil: func(recorder *httptest.ResponseRecorder) error { + return (&StreamOptionalUnionServerStream{sseServerStream: sseServerStream{w: recorder, encoder: goahttp.ResponseEncoder}}).Send(&service.OptionalUnionEvent{}) + }, + sendValue: func(recorder *httptest.ResponseRecorder) error { + event := &service.OptionalUnionEvent{} + event.Data.SetInactive(&service.Empty{}) + return (&StreamOptionalUnionServerStream{sseServerStream: sseServerStream{w: recorder, encoder: goahttp.ResponseEncoder}}).Send(event) + }, + want: ` + "`" + `{"jsonrpc":"2.0","method":"stream_optional_union","params":{"type":"inactive","value":{}}}` + "`" + `, + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + nilRecorder := httptest.NewRecorder() + require.NoError(t, test.sendNil(nilRecorder)) + require.NotContains(t, nilRecorder.Body.String(), ` + "`" + `"params"` + "`" + `) + + valueRecorder := httptest.NewRecorder() + require.NoError(t, test.sendValue(valueRecorder)) + message := strings.TrimSuffix(strings.TrimPrefix(valueRecorder.Body.String(), "data: "), "\n\n") + require.JSONEq(t, test.want, message) + }) + } +} + +func TestResumeRequestReadsLastEventIDBeforeValidation(t *testing.T) { + tests := []struct { + name string + decode func(*http.Request, *jsonrpc.RawRequest) (any, error) + header []string + wantValue *string + wantError string + }{ + { + name: "required empty", + decode: func(request *http.Request, raw *jsonrpc.RawRequest) (any, error) { + return DecodeRequiredResumeRequest(nil, goahttp.RequestDecoder)(request, raw) + }, + header: []string{""}, + wantValue: stringPointer(""), + }, + { + name: "required absent", + decode: func(request *http.Request, raw *jsonrpc.RawRequest) (any, error) { + return DecodeRequiredResumeRequest(nil, goahttp.RequestDecoder)(request, raw) + }, + wantError: "is missing from header", + }, + { + name: "optional absent", + decode: func(request *http.Request, raw *jsonrpc.RawRequest) (any, error) { + return DecodeOptionalResumeRequest(nil, goahttp.RequestDecoder)(request, raw) + }, + }, + { + name: "default absent", + decode: func(request *http.Request, raw *jsonrpc.RawRequest) (any, error) { + return DecodeDefaultResumeRequest(nil, goahttp.RequestDecoder)(request, raw) + }, + wantValue: stringPointer("cursor"), + }, + { + name: "default empty", + decode: func(request *http.Request, raw *jsonrpc.RawRequest) (any, error) { + return DecodeDefaultResumeRequest(nil, goahttp.RequestDecoder)(request, raw) + }, + header: []string{""}, + wantValue: stringPointer(""), + }, + { + name: "optional empty", + decode: func(request *http.Request, raw *jsonrpc.RawRequest) (any, error) { + return DecodeOptionalResumeRequest(nil, goahttp.RequestDecoder)(request, raw) + }, + header: []string{""}, + wantValue: stringPointer(""), + }, + { + name: "optional nul", + decode: func(request *http.Request, raw *jsonrpc.RawRequest) (any, error) { + return DecodeOptionalResumeRequest(nil, goahttp.RequestDecoder)(request, raw) + }, + header: []string{"one\x00two"}, + wantError: "last_event_id", + }, + { + name: "optional line feed", + decode: func(request *http.Request, raw *jsonrpc.RawRequest) (any, error) { + return DecodeOptionalResumeRequest(nil, goahttp.RequestDecoder)(request, raw) + }, + header: []string{"one\ntwo"}, + wantError: "last_event_id", + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + request := httptest.NewRequest(http.MethodPost, "/rpc", nil) + if test.header != nil { + request.Header["Last-Event-Id"] = test.header + } + value, err := test.decode(request, rawParams("")) + if test.wantError != "" { + require.ErrorContains(t, err, test.wantError) + return + } + require.NoError(t, err) + switch payload := value.(type) { + case *service.RequiredResumePayload: + require.Equal(t, *test.wantValue, payload.LastEventID) + case *service.OptionalResumePayload: + require.Equal(t, test.wantValue, payload.LastEventID) + case *service.DefaultResumePayload: + require.Equal(t, service.ResumeID(*test.wantValue), payload.LastEventID) + default: + t.Fatalf("unexpected payload type %T", value) + } + }) + } +} + +func stringPointer(value string) *string { + return &value +} + +func rawParams(params string) *jsonrpc.RawRequest { + return &jsonrpc.RawRequest{Params: json.RawMessage(params)} +} +` diff --git a/jsonrpc/codegen/plan.go b/jsonrpc/codegen/plan.go new file mode 100644 index 0000000000..6100d1b14e --- /dev/null +++ b/jsonrpc/codegen/plan.go @@ -0,0 +1,639 @@ +// This file prepares JSON-RPC files in two calls. NewPlans receives every +// design and requests all Go names. After Goa assigns those names and builds +// the service and HTTP values, Link creates the generated files. +package codegen + +import ( + "fmt" + "path" + "sort" + "strings" + + "goa.design/goa/v3/codegen" + "goa.design/goa/v3/codegen/example" + "goa.design/goa/v3/codegen/service" + "goa.design/goa/v3/expr" + httpcodegen "goa.design/goa/v3/http/codegen" +) + +type ( + // PlanInput supplies one design and the copied values used to generate its + // service, JSON requests, and JSON responses. + PlanInput struct { + // Root is the design that declares the JSON-RPC services. + Root *expr.RootExpr + // Service provides the generated service types and method definitions. + Service *service.Plan + // HTTP provides the JSON request and response types used inside JSON-RPC messages. + HTTP *httpcodegen.Plan + // ApplicationHTTP is the ordinary HTTP plan whose runnable server is + // combined with JSON-RPC. It is nil when Root has no ordinary HTTP services. + ApplicationHTTP *httpcodegen.Plan + } + + // Plan stores the JSON-RPC function names chosen by NewPlans and the files + // created by Link. Goa creates one Plan for each design. + Plan struct { + generation *codegen.Generation + root *expr.RootExpr + service *service.Plan + http *httpcodegen.Plan + applicationHTTP *httpcodegen.Plan + services []*servicePlan + servicesByExpr map[*expr.HTTPServiceExpr]*servicePlan + server []*codegen.File + client []*codegen.File + linked bool + } + + // ExamplePlan builds runnable JSON-RPC programs from server data and + // generated services that came from the same design. + ExamplePlan struct { + transport *Plan + http *httpcodegen.ExamplePlan + } + + // servicePlan stores one service's generated package path, function names, + // HTTP request and response data, and the imports written by each JSON-RPC + // file. + servicePlan struct { + jsonRPCServiceFacts + data httpcodegen.JSONRPCServiceSnapshot + api string + name string + pathName string + endpoints []*endpointPlan + helpers map[string]*viewedHelperDeclarations + clientNames jsonRPCClientNames + serverNames jsonRPCServerNames + bodyDecoder *codegen.NameDeclaration + fileImports map[string]*codegen.GeneratedImportPlan + clientPackage *codegen.GeneratedPackage + serverPackage *codegen.GeneratedPackage + serviceImportPath string + } + + // jsonRPCServiceFacts records the service shapes that decide which source + // files and imports JSON-RPC generation writes. + jsonRPCServiceFacts struct { + hasHTTP bool + hasSSE bool + hasNotification bool + hasViewedResult bool + hasSSERetry bool + hasVariableViewedStream bool + streamUsesServicePackage bool + streamUsesResultViewPackage bool + } + + // endpointPlan contains the HTTP request, HTTP response, and JSON-RPC result + // values for one service method. + endpointPlan struct { + httpcodegen.JSONRPCEndpointSnapshot + viewed *viewedRepresentation + } + + // jsonRPCClientNames stores the Go names written once for one client. + jsonRPCClientNames struct { + bufferPool *codegen.NameDeclaration + } + + // jsonRPCServerNames stores the Go names written once for one server. + jsonRPCServerNames struct { + batchWriter *codegen.NameDeclaration + encodeError *codegen.NameDeclaration + sseStream *codegen.NameDeclaration + sseBuffer *codegen.NameDeclaration + noOutputWriter *codegen.NameDeclaration + } + + // viewedRepresentation lists the JSON body type and constructor used for + // each view that a method may return. + viewedRepresentation struct { + variable bool + fixedView string + branches []viewBranch + decode *codegen.NameDeclaration + encode *codegen.NameDeclaration + streamEncode *codegen.NameDeclaration + viewedResult httpcodegen.JSONRPCViewedResultData + servicePkg string + resultRef string + } + + // viewBranch stores the mapped service field, JSON body types, and client + // constructor for one view. + viewBranch struct { + view string + resultAttr string + serverBody *httpcodegen.JSONRPCBodyData + clientBody *httpcodegen.JSONRPCBodyData + resultInit httpcodegen.InitData + } + + // viewedHelperDeclarations stores the client decoder and server encoder names + // written for one method result. + viewedHelperDeclarations struct { + decode *codegen.NameDeclaration + encode *codegen.NameDeclaration + streamEncode *codegen.NameDeclaration + } + + // jsonRPCNameOrder gives the same Go names the same order on every run. + jsonRPCNameOrder struct { + api string + service string + method string + role uint8 + } +) + +const ( + viewedBodyDecoderRole uint8 = iota + 1 + viewedResultDecoderRole + viewedResultEncoderRole + viewedStreamEncoderRole + jsonRPCBufferPoolRole + jsonRPCBatchWriterRole + jsonRPCEncodeErrorRole + jsonRPCSSEStreamRole + jsonRPCSSEBufferRole + jsonRPCNoOutputWriterRole +) + +// NewPlans checks that inputs contain every design with JSON-RPC services once, +// then creates one Plan for each input. It requests every helper name before +// Goa chooses unique Go names, so generated definitions and calls agree. +func NewPlans(generation *codegen.Generation, inputs ...PlanInput) ([]*Plan, error) { + if generation == nil { + return nil, fmt.Errorf("JSON-RPC plans require a generation") + } + if generation.Frozen() { + return nil, fmt.Errorf("JSON-RPC plans must be collected before generation freeze") + } + if err := validatePlanInputs(generation, inputs); err != nil { + return nil, err + } + plans := make([]*Plan, len(inputs)) + for index, input := range inputs { + plan := &Plan{ + generation: generation, + root: input.Root, + service: input.Service, + http: input.HTTP, + applicationHTTP: input.ApplicationHTTP, + servicesByExpr: make(map[*expr.HTTPServiceExpr]*servicePlan), + } + for _, transport := range input.Root.API.JSONRPC.Services { + planned, err := collectServicePlan(generation, input, transport) + if err != nil { + return nil, err + } + plan.services = append(plan.services, planned) + plan.servicesByExpr[transport] = planned + } + sort.Slice(plan.services, func(i, j int) bool { + return plan.services[i].name < plan.services[j].name + }) + plans[index] = plan + } + return plans, nil +} + +// NewExamplePlan returns an example renderer only when examples contains the +// server data copied from transport's service design. +func NewExamplePlan(transport *Plan, examples *example.Plan) (*ExamplePlan, error) { + if _, ok := examples.Root(transport.service); !ok { + return nil, fmt.Errorf("JSON-RPC examples require server data created from the same service design") + } + httpPlan, err := httpcodegen.NewCombinedExamplePlan(transport.http, transport.applicationHTTP, examples) + if err != nil { + return nil, err + } + return &ExamplePlan{transport: transport, http: httpPlan}, nil +} + +// Root returns the design used to create p. +func (p *Plan) Root() *expr.RootExpr { + return p.root +} + +// Service returns the finalized JSON-RPC data for the exact service used to +// build this plan. Callers must call Link before reading the service data. +func (p *Plan) Service(service *expr.HTTPServiceExpr) (httpcodegen.JSONRPCServiceSnapshot, bool) { + p.requireLinked() + planned, ok := p.servicesByExpr[service] + if !ok { + return httpcodegen.JSONRPCServiceSnapshot{}, false + } + return p.http.JSONRPCService(planned.name) +} + +// Link reads the completed service and HTTP plans and builds every JSON-RPC +// file. The caller must first ask Goa to choose unique Go names and then link +// both input plans so all JSON body types and constructors are available. +func (p *Plan) Link() error { + if !p.generation.Frozen() { + return fmt.Errorf("JSON-RPC plan cannot link before generation freeze") + } + if p.linked { + return fmt.Errorf("JSON-RPC plan is already linked") + } + for _, planned := range p.services { + for _, imports := range planned.fileImports { + if err := imports.Link(); err != nil { + return err + } + } + data, ok := p.http.JSONRPCService(planned.name) + if !ok { + return fmt.Errorf("HTTP plan has no data for JSON-RPC service %q", planned.name) + } + planned.data = data + planned.pathName = data.Service.PathName + for _, endpoint := range data.Endpoints { + helper := planned.helpers[endpoint.Method.Name] + viewed, hasViewedResult := p.http.ViewedResult(planned.name, endpoint.Method.Name) + plannedEndpoint := &endpointPlan{ + JSONRPCEndpointSnapshot: endpoint, + viewed: planViewedRepresentation(&endpoint, viewed, hasViewedResult, helper), + } + planned.endpoints = append(planned.endpoints, plannedEndpoint) + } + } + p.server = serverFiles(p.services) + p.client = clientFiles(p.services) + p.linked = true + return nil +} + +// ServerFiles returns the JSON-RPC server files built by Link. +func (p *Plan) ServerFiles() []*codegen.File { + p.requireLinked() + return p.server +} + +// ClientFiles returns the JSON-RPC client files built by Link. +func (p *Plan) ClientFiles() []*codegen.File { + p.requireLinked() + return p.client +} + +// ServerTypeFiles returns the server JSON body files supplied by the HTTP plan. +func (p *Plan) ServerTypeFiles() []*codegen.File { + p.requireLinked() + return p.http.ServerTypeFiles() +} + +// ClientTypeFiles returns the client JSON body files supplied by the HTTP plan. +func (p *Plan) ClientTypeFiles() []*codegen.File { + p.requireLinked() + return p.http.ClientTypeFiles() +} + +// PathFiles returns the URL path helper files supplied by the HTTP plan. +func (p *Plan) PathFiles() []*codegen.File { + p.requireLinked() + return p.http.PathFiles() +} + +// ClientCLIFiles returns the command-line client files supplied by the HTTP plan. +func (p *Plan) ClientCLIFiles() []*codegen.File { + p.requireLinked() + return p.http.ClientCLIFiles() +} + +// ServerFiles builds runnable servers that mount the saved ordinary HTTP and +// JSON-RPC services for each copied server. +func (p *ExamplePlan) ServerFiles() []*codegen.File { + p.transport.requireLinked() + return p.http.CombinedServerFiles(p.transport.applicationHTTP) +} + +// CLIFiles builds runnable JSON-RPC clients for each copied server. +func (p *ExamplePlan) CLIFiles() []*codegen.File { + p.transport.requireLinked() + return p.http.CLIFiles() +} + +// planViewedRepresentation copies each allowed result view and its JSON body +// type into the values used to write JSON-RPC files. A method fixed to one +// view stores one branch. A method that chooses a view for each result stores +// every branch and includes the selected view name in each response. +func planViewedRepresentation(endpoint *httpcodegen.JSONRPCEndpointSnapshot, viewed httpcodegen.ViewedResultSnapshot, hasViewedResult bool, helpers *viewedHelperDeclarations) *viewedRepresentation { + if !hasViewedResult { + return nil + } + if helpers == nil { + panic(fmt.Sprintf("JSON-RPC viewed endpoint %q has no helper names declared by NewPlans", endpoint.Method.Name)) + } + representation := &viewedRepresentation{ + variable: viewed.Variable, + fixedView: viewed.FixedView, + decode: helpers.decode, + encode: helpers.encode, + streamEncode: helpers.streamEncode, + viewedResult: viewed.Service, + servicePkg: endpoint.ServicePkgName, + resultRef: endpoint.Result.Ref, + } + for _, branch := range viewed.Representations { + representation.branches = append(representation.branches, viewBranch{ + view: branch.View, + resultAttr: branch.ResultAttr, + serverBody: branch.ServerBody, + clientBody: branch.ClientBody, + resultInit: branch.ResultInit, + }) + } + return representation +} + +// servicePlanForOutput copies the service package qualifiers written by one +// JSON-RPC client or server package. The two packages reserve imports +// independently, so a standard-library name used only by the server may suffix +// the generated service import only on that side. +func servicePlanForOutput(planned *servicePlan, client bool) *servicePlan { + copy := *planned + copy.data = planned.data + serviceName := planned.nativeServiceName(client) + if serviceName != "" { + copy.data.Service.PkgName = serviceName + } + copy.data.Endpoints = make([]httpcodegen.JSONRPCEndpointSnapshot, len(planned.data.Endpoints)) + copy.endpoints = make([]*endpointPlan, len(planned.endpoints)) + for index, endpoint := range planned.endpoints { + endpointCopy := *endpoint + if serviceName != "" { + endpointCopy.ServicePkgName = serviceName + } + if endpoint.viewed != nil { + viewedCopy := *endpoint.viewed + if serviceName != "" { + viewedCopy.servicePkg = serviceName + } + endpointCopy.viewed = &viewedCopy + } + copy.endpoints[index] = &endpointCopy + copy.data.Endpoints[index] = endpointCopy.JSONRPCEndpointSnapshot + } + return © +} + +// requireLinked stops callers from reading files before Link has built them. +func (p *Plan) requireLinked() { + if !p.linked { + panic("JSON-RPC files requested before Plan.Link") + } +} + +// validatePlanInputs checks every root and plan before NewPlans submits an +// import or generated helper name. This keeps a rejected input from changing +// names chosen for later generators in the same run. +func validatePlanInputs(generation *codegen.Generation, inputs []PlanInput) error { + roots := make(map[*expr.RootExpr]struct{}) + for _, candidate := range generation.Roots() { + root, ok := candidate.(*expr.RootExpr) + if ok && len(root.API.JSONRPC.Services) > 0 { + roots[root] = struct{}{} + } + } + seen := make(map[*expr.RootExpr]struct{}, len(inputs)) + for _, input := range inputs { + if input.Root == nil || !generation.HasRoot(input.Root) { + return fmt.Errorf("JSON-RPC plan requires a root in this generation") + } + if _, ok := roots[input.Root]; !ok { + return fmt.Errorf("root does not declare JSON-RPC services") + } + if _, ok := seen[input.Root]; ok { + return fmt.Errorf("JSON-RPC root is planned more than once: %s", rootServiceName(input.Root)) + } + seen[input.Root] = struct{}{} + if input.Service == nil { + return fmt.Errorf("JSON-RPC root %s requires a service plan", rootServiceName(input.Root)) + } + if input.Service.Root() != input.Root { + return fmt.Errorf("JSON-RPC service plan does not belong to root %s", rootServiceName(input.Root)) + } + if input.HTTP == nil { + return fmt.Errorf("JSON-RPC root %s requires an HTTP plan for its JSON request and response types", rootServiceName(input.Root)) + } + if !input.HTTP.MatchesJSONRPC(input.Root, input.Service) { + return fmt.Errorf("JSON-RPC HTTP plan does not belong to root %s and its service plan", rootServiceName(input.Root)) + } + hasHTTP := len(input.Root.API.HTTP.Services) > 0 + if hasHTTP && input.ApplicationHTTP == nil { + return fmt.Errorf("JSON-RPC root %s requires its application HTTP plan", rootServiceName(input.Root)) + } + if !hasHTTP && input.ApplicationHTTP != nil { + return fmt.Errorf("JSON-RPC root %s has no ordinary HTTP services", rootServiceName(input.Root)) + } + if input.ApplicationHTTP != nil && !input.ApplicationHTTP.MatchesHTTP(input.Root, input.Service) { + return fmt.Errorf("application HTTP plan does not belong to root %s and its service plan", rootServiceName(input.Root)) + } + } + if len(inputs) != len(roots) { + return fmt.Errorf("JSON-RPC planning requires all %d JSON-RPC roots, got %d", len(roots), len(inputs)) + } + return nil +} + +// rootServiceName returns the name shown in errors after validation has proved +// that root declares a JSON-RPC service. +func rootServiceName(root *expr.RootExpr) string { + return root.Services[0].Name +} + +// isJSONRPCSSEEndpoint reports whether the supplied method writes server-sent +// events. +func isJSONRPCSSEEndpoint(data any) bool { + return jsonRPCEndpoint(data).SSE != nil +} + +// jsonRPCEndpoint returns the method values used to write a generated file. +func jsonRPCEndpoint(data any) *httpcodegen.JSONRPCEndpointSnapshot { + switch endpoint := data.(type) { + case httpcodegen.JSONRPCEndpointSnapshot: + return &endpoint + case *httpcodegen.JSONRPCEndpointSnapshot: + return endpoint + case *endpointPlan: + return &endpoint.JSONRPCEndpointSnapshot + default: + panic(fmt.Sprintf("JSON-RPC template received endpoint data of type %T", data)) + } +} + +// collectJSONRPCServiceFacts reads the service once and saves the choices used +// by name planning, file planning, and rendering. +func collectJSONRPCServiceFacts(service *expr.HTTPServiceExpr) jsonRPCServiceFacts { + var facts jsonRPCServiceFacts + for _, endpoint := range service.HTTPEndpoints { + facts.hasNotification = facts.hasNotification || endpoint.IsJSONRPCNotification() + viewed := jsonRPCMethodHasViewedResult(endpoint.MethodExpr) + facts.hasViewedResult = facts.hasViewedResult || viewed + if !endpoint.UsesSSE() { + facts.hasHTTP = true + continue + } + facts.hasSSE = true + if endpoint.SSE != nil && endpoint.SSE.RetryField != "" { + facts.hasSSERetry = true + } + if viewed { + facts.streamUsesServicePackage = true + facts.streamUsesResultViewPackage = true + if _, fixed := endpoint.MethodExpr.Result.Meta.Last(expr.ViewMetaKey); !fixed { + facts.hasVariableViewedStream = true + } + } + attribute := endpoint.MethodExpr.StreamingResult + if attribute == nil { + attribute = endpoint.MethodExpr.Result + } + if _, userType := attribute.Type.(expr.UserType); userType || len(endpoint.MethodExpr.Errors) > 0 { + facts.streamUsesServicePackage = true + } + } + return facts +} + +// collectServicePlan stores the designed service name and generated package +// path, then requests client decoder and server encoder names for every method +// that returns a result view. Link later adds the HTTP endpoint data used to +// build that service's files. +func collectServicePlan(generation *codegen.Generation, input PlanInput, transport *expr.HTTPServiceExpr) (*servicePlan, error) { + serviceImport, viewsImport, err := input.Service.ServicePackageImports(transport.ServiceExpr) + if err != nil { + return nil, err + } + pathName := path.Base(serviceImport.Path) + clientPath := path.Join(generation.GenPkg(), "jsonrpc", pathName, "client") + serverPath := path.Join(generation.GenPkg(), "jsonrpc", pathName, "server") + client, err := generation.ClaimPackage(clientPath) + if err != nil { + return nil, err + } + server, err := generation.ClaimPackage(serverPath) + if err != nil { + return nil, err + } + planned := &servicePlan{ + jsonRPCServiceFacts: collectJSONRPCServiceFacts(transport), + api: input.Root.API.Name, + name: transport.Name(), + pathName: pathName, + helpers: make(map[string]*viewedHelperDeclarations), + } + declare := func(pkg *codegen.GeneratedPackage, kind codegen.PackageNameKind, preferred string, role uint8) (*codegen.NameDeclaration, error) { + declaration := codegen.NewPreferredName(kind, preferred, codegen.UnexportedName, jsonRPCNameOrder{ + api: planned.api, + service: planned.name, + role: role, + }) + if err := pkg.DeclareName(declaration); err != nil { + return nil, err + } + return declaration, nil + } + planned.clientNames.bufferPool, err = declare(client, codegen.NameVariable, "bufferPool", jsonRPCBufferPoolRole) + if err != nil { + return nil, err + } + planned.serverNames.encodeError, err = declare(server, codegen.NameFunction, "encodeJSONRPCError", jsonRPCEncodeErrorRole) + if err != nil { + return nil, err + } + planned.serverNames.noOutputWriter, err = declare(server, codegen.NameType, "noOutputResponseWriter", jsonRPCNoOutputWriterRole) + if err != nil { + return nil, err + } + if planned.hasHTTP { + planned.serverNames.batchWriter, err = declare(server, codegen.NameType, "batchWriter", jsonRPCBatchWriterRole) + if err != nil { + return nil, err + } + } + if planned.hasSSE { + planned.serverNames.sseStream, err = declare(server, codegen.NameType, "sseServerStream", jsonRPCSSEStreamRole) + if err != nil { + return nil, err + } + planned.serverNames.sseBuffer, err = declare(server, codegen.NameType, "sseEventBuffer", jsonRPCSSEBufferRole) + if err != nil { + return nil, err + } + } + for _, endpoint := range transport.HTTPEndpoints { + method := endpoint.MethodExpr + if _, ok := method.Result.Type.(*expr.ResultTypeExpr); !ok { + continue + } + if planned.bodyDecoder == nil { + planned.bodyDecoder = codegen.NewPreferredName( + codegen.NameFunction, + "decodeJSONRPCResult", + codegen.UnexportedName, + jsonRPCNameOrder{api: planned.api, service: planned.name, role: viewedBodyDecoderRole}, + ) + if err := client.DeclareName(planned.bodyDecoder); err != nil { + return nil, err + } + } + methodName := codegen.Goify(method.Name, true) + helpers := &viewedHelperDeclarations{ + decode: codegen.NewPreferredName( + codegen.NameFunction, + "decode"+methodName+"ViewedResult", + codegen.UnexportedName, + jsonRPCNameOrder{api: planned.api, service: planned.name, method: method.Name, role: viewedResultDecoderRole}, + ), + encode: codegen.NewPreferredName( + codegen.NameFunction, + "encode"+methodName+"ViewedResult", + codegen.UnexportedName, + jsonRPCNameOrder{api: planned.api, service: planned.name, method: method.Name, role: viewedResultEncoderRole}, + ), + } + if method.IsResultStreaming() { + helpers.streamEncode = codegen.NewPreferredName( + codegen.NameFunction, + "encode"+methodName+"Result", + codegen.UnexportedName, + jsonRPCNameOrder{api: planned.api, service: planned.name, method: method.Name, role: viewedStreamEncoderRole}, + ) + } + if err := client.DeclareName(helpers.decode); err != nil { + return nil, err + } + if err := server.DeclareName(helpers.encode); err != nil { + return nil, err + } + if helpers.streamEncode != nil { + if err := server.DeclareName(helpers.streamEncode); err != nil { + return nil, err + } + } + planned.helpers[method.Name] = helpers + } + if err := planJSONRPCFileImports(planned, transport, client, server, serviceImport, viewsImport); err != nil { + return nil, err + } + return planned, nil +} + +// ComparePackageName orders Go declarations by service, method, and use. +func (o jsonRPCNameOrder) ComparePackageName(other codegen.PackageNameOrder) int { + right := other.(jsonRPCNameOrder) + if compared := strings.Compare(o.api, right.api); compared != 0 { + return compared + } + if compared := strings.Compare(o.service, right.service); compared != 0 { + return compared + } + if compared := strings.Compare(o.method, right.method); compared != 0 { + return compared + } + return int(o.role) - int(right.role) +} diff --git a/jsonrpc/codegen/plan_service_test.go b/jsonrpc/codegen/plan_service_test.go new file mode 100644 index 0000000000..3b400aabd5 --- /dev/null +++ b/jsonrpc/codegen/plan_service_test.go @@ -0,0 +1,90 @@ +// This file checks that plugins can read only the finalized JSON-RPC service +// data that belongs to the exact service expression used to build a plan. +package codegen + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/require" + + "goa.design/goa/v3/codegen" + "goa.design/goa/v3/expr" +) + +func TestPlanServiceUsesExactExpressionAfterLink(t *testing.T) { + generation, roots, services, jsonPlans, applicationPlans := jsonRPCPlanningInputs(t) + plans, err := NewPlans( + generation, + PlanInput{ + Root: roots[0], + Service: services[0], + HTTP: jsonPlans[0], + ApplicationHTTP: applicationPlans[0], + }, + PlanInput{ + Root: roots[1], + Service: services[1], + HTTP: jsonPlans[1], + ApplicationHTTP: applicationPlans[1], + }, + ) + require.NoError(t, err) + require.PanicsWithValue(t, "JSON-RPC files requested before Plan.Link", func() { + plans[0].Service(roots[0].API.JSONRPC.Services[0]) + }) + + require.NoError(t, generation.Freeze()) + for _, servicePlan := range services { + require.NoError(t, servicePlan.Link()) + } + for _, httpPlan := range jsonPlans { + require.NoError(t, httpPlan.Link()) + } + for _, plan := range plans { + require.NoError(t, plan.Link()) + } + + data, ok := plans[0].Service(roots[0].API.JSONRPC.Services[0]) + require.True(t, ok) + require.Equal(t, "First", data.Service.Name) + require.NotEmpty(t, data.ClientStructDeclaration.Name()) + require.NotEmpty(t, data.ServerStructDeclaration.Name()) + + foreign := expr.RunDSL(t, jsonRPCPlanningRootDSL("First", "/first")) + data, ok = plans[0].Service(foreign.API.JSONRPC.Services[0]) + require.False(t, ok) + require.Empty(t, data) +} + +// TestPlanServiceReturnsDetachedSnapshot verifies that changing nested values +// returned to one plugin cannot change a later read or the files already +// prepared for generation. +func TestPlanServiceReturnsDetachedSnapshot(t *testing.T) { + _, plan := linkedJSONRPCPlan(t, viewedJSONRPCPlanDSL) + service := plan.root.API.JSONRPC.Services[0] + before := renderJSONRPCFiles(t, plan.ClientFiles()) + + first, ok := plan.Service(service) + require.True(t, ok) + require.NotEmpty(t, first.Endpoints) + require.NotNil(t, first.Endpoints[0].Result) + first.Endpoints[0].Result.Ref = "changed.Result" + + second, ok := plan.Service(service) + require.True(t, ok) + require.NotEqual(t, "changed.Result", second.Endpoints[0].Result.Ref) + require.Equal(t, before, renderJSONRPCFiles(t, plan.ClientFiles())) +} + +// renderJSONRPCFiles writes file sections without changing the plan. +func renderJSONRPCFiles(t *testing.T, files []*codegen.File) string { + t.Helper() + var source strings.Builder + for _, file := range files { + for _, section := range file.SectionTemplates { + require.NoError(t, section.Write(&source)) + } + } + return source.String() +} diff --git a/jsonrpc/codegen/plan_test.go b/jsonrpc/codegen/plan_test.go new file mode 100644 index 0000000000..9a9ae965ab --- /dev/null +++ b/jsonrpc/codegen/plan_test.go @@ -0,0 +1,940 @@ +// This file verifies standalone JSON-RPC planning includes the HTTP codecs and +// helpers that JSON-RPC rendering reuses. +package codegen + +import ( + "path" + "strings" + "testing" + + "github.com/stretchr/testify/require" + + "goa.design/goa/v3/codegen" + "goa.design/goa/v3/codegen/example" + "goa.design/goa/v3/codegen/service" + "goa.design/goa/v3/dsl" + "goa.design/goa/v3/eval" + "goa.design/goa/v3/expr" + httpcodegen "goa.design/goa/v3/http/codegen" +) + +func TestPlanIncludesSharedHTTPImportAliases(t *testing.T) { + root := expr.RunDSL(t, func() { + dsl.Service("UUID", func() { + dsl.Method("Read", func() { + dsl.Result(func() { + dsl.Attribute("value", dsl.String) + }) + dsl.JSONRPC(func() {}) + }) + }) + }) + generation, err := codegen.NewGeneration("generated.local/gen", []eval.Root{root}) + require.NoError(t, err) + servicePlan, err := service.NewPlan(root, generation, expr.NewExampleGenerator(root.API.RandomizerFactory)) + require.NoError(t, err) + httpPlans, err := httpcodegen.NewJSONRPCPlans(generation, httpcodegen.PlanInput{Root: root, Service: servicePlan}) + require.NoError(t, err) + _, err = NewPlans(generation, PlanInput{Root: root, Service: servicePlan, HTTP: httpPlans[0]}) + require.NoError(t, err) + require.NoError(t, generation.Freeze()) + require.NoError(t, servicePlan.Link()) + services := servicePlan.Services() + + clientOutput := "generated.local/gen/jsonrpc/uuid/client" + require.Equal(t, "uuid2", services.ServiceImport(clientOutput, "UUID").Name) +} + +// TestPlanRecordsTransportKindsBeforeFreeze verifies file planning saves the +// service shapes that decide which JSON-RPC files and helpers are generated. +func TestPlanRecordsTransportKindsBeforeFreeze(t *testing.T) { + root := expr.RunDSL(t, jsonRPCFileImportsDSL) + generation, err := codegen.NewGeneration("generated.local/gen", []eval.Root{root}) + require.NoError(t, err) + servicePlan, err := service.NewPlan(root, generation, expr.NewExampleGenerator(root.API.RandomizerFactory)) + require.NoError(t, err) + httpPlans, err := httpcodegen.NewJSONRPCPlans(generation, httpcodegen.PlanInput{Root: root, Service: servicePlan}) + require.NoError(t, err) + plans, err := NewPlans(generation, PlanInput{Root: root, Service: servicePlan, HTTP: httpPlans[0]}) + require.NoError(t, err) + + require.True(t, plans[0].services[0].hasHTTP) + require.True(t, plans[0].services[0].hasSSE) +} + +// TestPlanRetainsServicePackageImport verifies JSON-RPC output packages use +// the service package selected before later expression changes. The retained +// preferred name must still resolve around the client's sync import. +func TestPlanRetainsServicePackageImport(t *testing.T) { + root := expr.RunDSL(t, func() { + dsl.Service("Sync", func() { + dsl.Method("Read", func() { + dsl.Result(func() { + dsl.Attribute("value", dsl.String) + }) + dsl.JSONRPC(func() {}) + }) + }) + }) + generation, err := codegen.NewGeneration("generated.local/gen", []eval.Root{root}) + require.NoError(t, err) + servicePlan, err := service.NewPlan(root, generation, expr.NewExampleGenerator(root.API.RandomizerFactory)) + require.NoError(t, err) + httpPlans, err := httpcodegen.NewJSONRPCPlans(generation, httpcodegen.PlanInput{Root: root, Service: servicePlan}) + require.NoError(t, err) + + transport := root.API.JSONRPC.Services[0] + transport.ServiceExpr.Name = "Changed" + _, err = NewPlans(generation, PlanInput{Root: root, Service: servicePlan, HTTP: httpPlans[0]}) + require.NoError(t, err) + require.NoError(t, generation.Freeze()) + + client := generation.Package("generated.local/gen/jsonrpc/sync/client") + require.Equal(t, "sync2", client.ImportName("generated.local/gen/sync")) +} + +// TestPlanSelectsPositionalParams checks that strings, bytes, and Any values +// are wrapped once while objects, arrays, maps, and unions keep their JSON shape. +func TestPlanSelectsPositionalParams(t *testing.T) { + _, transport := linkedJSONRPCPlan(t, jsonRPCParamsPlanDSL) + endpoints := transport.services[0].data.Endpoints + + want := map[string]struct { + request bool + positional bool + allowAbsent bool + omitAbsent bool + stream bool + }{ + "text": {request: true, positional: true}, + "alias": {request: true, positional: true}, + "anything": {request: true, positional: true}, + "bytes": {request: true, positional: true}, + "object": {request: true}, + "array": {request: true}, + "map": {request: true}, + "union": {request: true}, + "optional_text": {request: true, positional: true, allowAbsent: true}, + "optional_object": {request: true, allowAbsent: true, omitAbsent: true}, + "optional_array": {request: true, allowAbsent: true, omitAbsent: true}, + "optional_map": {request: true, allowAbsent: true, omitAbsent: true}, + "optional_union": {request: true, allowAbsent: true, omitAbsent: true}, + "stream_text": {stream: true, positional: true}, + "stream_any": {stream: true, positional: true}, + "stream_array": {stream: true}, + "stream_optional_text": {stream: true, positional: true, allowAbsent: true}, + "stream_default_text": {stream: true, positional: true, allowAbsent: true}, + "stream_object_data": {stream: true, allowAbsent: true, omitAbsent: true}, + "stream_array_data": {stream: true, allowAbsent: true, omitAbsent: true}, + "stream_map_data": {stream: true, allowAbsent: true, omitAbsent: true}, + "stream_union_data": {stream: true, allowAbsent: true, omitAbsent: true}, + } + require.Len(t, endpoints, len(want)) + for _, endpoint := range endpoints { + expected, ok := want[endpoint.Method.Name] + require.True(t, ok, endpoint.Method.Name) + request := endpoint.Payload != nil && endpoint.Payload.Request != nil && endpoint.Payload.Request.Params != nil + stream := endpoint.SSE != nil && endpoint.SSE.Params != nil + require.Equal(t, expected.request, request, endpoint.Method.Name) + if expected.request { + require.Equal(t, expected.positional, endpoint.Payload.Request.Params.Positional, endpoint.Method.Name) + require.Equal(t, expected.allowAbsent, endpoint.Payload.Request.Params.AllowAbsent, endpoint.Method.Name) + require.Equal(t, expected.omitAbsent, endpoint.Payload.Request.Params.OmitAbsent, endpoint.Method.Name) + } + require.Equal(t, expected.stream, stream, endpoint.Method.Name) + if expected.stream { + require.Equal(t, expected.positional, endpoint.SSE.Params.Positional, endpoint.Method.Name) + require.Equal(t, expected.allowAbsent, endpoint.SSE.Params.AllowAbsent, endpoint.Method.Name) + require.Equal(t, expected.omitAbsent, endpoint.SSE.Params.OmitAbsent, endpoint.Method.Name) + } + } +} + +// TestNewExamplePlanRejectsAnotherServicePlan checks that server names and +// URLs cannot come from a different design with the same authored names. +func TestNewExamplePlanRejectsAnotherServicePlan(t *testing.T) { + _, transport := linkedJSONRPCPlan(t, viewedJSONRPCPlanDSL) + otherRoot := expr.RunDSL(t, viewedJSONRPCPlanDSL) + otherGeneration, err := codegen.NewGeneration("other.local/gen", []eval.Root{otherRoot}) + require.NoError(t, err) + otherService, err := service.NewPlan(otherRoot, otherGeneration, expr.NewExampleGenerator(otherRoot.API.RandomizerFactory)) + require.NoError(t, err) + examples, err := example.NewPlan(otherGeneration, otherService) + require.NoError(t, err) + + _, err = NewExamplePlan(transport, examples) + require.EqualError(t, err, "JSON-RPC examples require server data created from the same service design") +} + +// TestPlanReservesGeneratedJSONRPCPackages verifies that the JSON-RPC client, +// server, and CLI imports are frozen by their complete generated paths. +func TestPlanReservesGeneratedJSONRPCPackages(t *testing.T) { + root := expr.RunDSL(t, func() { + for _, name := range []string{"Foo", "Fooc", "Foojssvr"} { + dsl.Service(name, func() { + dsl.Method("Read", func() { dsl.JSONRPC(func() {}) }) + }) + } + }) + generation, err := codegen.NewGeneration("generated.local/gen", []eval.Root{root}) + require.NoError(t, err) + servicePlan, err := service.NewPlan(root, generation, expr.NewExampleGenerator(root.API.RandomizerFactory)) + require.NoError(t, err) + httpPlans, err := httpcodegen.NewJSONRPCPlans(generation, httpcodegen.PlanInput{Root: root, Service: servicePlan}) + require.NoError(t, err) + plans, err := NewPlans(generation, PlanInput{Root: root, Service: servicePlan, HTTP: httpPlans[0]}) + require.NoError(t, err) + examples, err := example.NewPlan(generation, servicePlan) + require.NoError(t, err) + _, err = NewExamplePlan(plans[0], examples) + require.NoError(t, err) + require.NoError(t, generation.Freeze()) + require.NoError(t, servicePlan.Link()) + services := servicePlan.Services() + + cliOutput := path.Join( + "generated.local/gen/jsonrpc/cli", + codegen.SnakeCase(codegen.Goify(root.API.Servers[0].Name, true)), + ) + client := services.PackageImport(cliOutput, "generated.local/gen/jsonrpc/foo/client") + serverOutput := path.Join("generated.local", "cmd", codegen.SnakeCase(codegen.Goify(root.API.Servers[0].Name, true))) + server := services.PackageImport(serverOutput, "generated.local/gen/jsonrpc/foo/server") + require.Equal(t, "fooc", client.Name) + require.Equal(t, "foojssvr2", server.Name) +} + +// TestNewPlansRequiresEveryJSONRPCRoot verifies that planning cannot reserve +// names from only part of a generation. The caller must supply each root that +// declares a JSON-RPC service exactly once. +func TestNewPlansRequiresEveryJSONRPCRoot(t *testing.T) { + generation, roots, services, jsonPlans, applicationPlans := jsonRPCPlanningInputs(t) + + _, err := NewPlans(generation, PlanInput{ + Root: roots[0], + Service: services[0], + HTTP: jsonPlans[0], + ApplicationHTTP: applicationPlans[0], + }) + require.EqualError(t, err, "JSON-RPC planning requires all 2 JSON-RPC roots, got 1") + assertViewedHelperNameAvailable(t, generation) +} + +// TestNewPlansRejectsDuplicateRoot verifies that two inputs cannot plan the +// same JSON-RPC root. The rejected call must not consume a helper name that a +// later generator can use. +func TestNewPlansRejectsDuplicateRoot(t *testing.T) { + generation, roots, services, jsonPlans, applicationPlans := jsonRPCPlanningInputs(t) + input := PlanInput{ + Root: roots[0], + Service: services[0], + HTTP: jsonPlans[0], + ApplicationHTTP: applicationPlans[0], + } + + _, err := NewPlans(generation, input, input) + require.EqualError(t, err, "JSON-RPC root is planned more than once: First") + assertViewedHelperNameAvailable(t, generation) +} + +// TestNewPlansRejectsRootWithoutJSONRPC verifies that inputs contain only +// roots that declare JSON-RPC services. Ordinary HTTP roots are planned by the +// HTTP generator and must not influence JSON-RPC names. +func TestNewPlansRejectsRootWithoutJSONRPC(t *testing.T) { + generation, roots, services, jsonPlans, applicationPlans := jsonRPCPlanningInputs(t) + + _, err := NewPlans(generation, + PlanInput{Root: roots[0], Service: services[0], HTTP: jsonPlans[0], ApplicationHTTP: applicationPlans[0]}, + PlanInput{Root: roots[1], Service: services[1], HTTP: jsonPlans[1], ApplicationHTTP: applicationPlans[1]}, + PlanInput{Root: roots[2], Service: services[2], HTTP: jsonPlans[0], ApplicationHTTP: applicationPlans[2]}, + ) + require.EqualError(t, err, "root does not declare JSON-RPC services") + assertViewedHelperNameAvailable(t, generation) +} + +// TestNewPlansRejectsMismatchedInputPlans verifies that every service and HTTP +// plan belongs to the root in the same input. Validation finishes before any +// JSON-RPC helper name is submitted. +func TestNewPlansRejectsMismatchedInputPlans(t *testing.T) { + tests := []struct { + name string + change func([]*expr.RootExpr, []*service.Plan, []*httpcodegen.Plan, []*httpcodegen.Plan) []PlanInput + error string + }{ + { + name: "service", + change: func(roots []*expr.RootExpr, services []*service.Plan, jsonPlans, applicationPlans []*httpcodegen.Plan) []PlanInput { + return []PlanInput{ + {Root: roots[0], Service: services[1], HTTP: jsonPlans[1], ApplicationHTTP: applicationPlans[1]}, + {Root: roots[1], Service: services[1], HTTP: jsonPlans[1], ApplicationHTTP: applicationPlans[1]}, + } + }, + error: "JSON-RPC service plan does not belong to root First", + }, + { + name: "JSON-RPC HTTP", + change: func(roots []*expr.RootExpr, services []*service.Plan, jsonPlans, applicationPlans []*httpcodegen.Plan) []PlanInput { + return []PlanInput{ + {Root: roots[0], Service: services[0], HTTP: jsonPlans[1], ApplicationHTTP: applicationPlans[0]}, + {Root: roots[1], Service: services[1], HTTP: jsonPlans[1], ApplicationHTTP: applicationPlans[1]}, + } + }, + error: "JSON-RPC HTTP plan does not belong to root First and its service plan", + }, + { + name: "ordinary HTTP plan in JSON-RPC role", + change: func(roots []*expr.RootExpr, services []*service.Plan, jsonPlans, applicationPlans []*httpcodegen.Plan) []PlanInput { + return []PlanInput{ + {Root: roots[0], Service: services[0], HTTP: applicationPlans[0], ApplicationHTTP: applicationPlans[0]}, + {Root: roots[1], Service: services[1], HTTP: jsonPlans[1], ApplicationHTTP: applicationPlans[1]}, + } + }, + error: "JSON-RPC HTTP plan does not belong to root First and its service plan", + }, + { + name: "application HTTP", + change: func(roots []*expr.RootExpr, services []*service.Plan, jsonPlans, applicationPlans []*httpcodegen.Plan) []PlanInput { + return []PlanInput{ + {Root: roots[0], Service: services[0], HTTP: jsonPlans[0], ApplicationHTTP: applicationPlans[1]}, + {Root: roots[1], Service: services[1], HTTP: jsonPlans[1], ApplicationHTTP: applicationPlans[1]}, + } + }, + error: "application HTTP plan does not belong to root First and its service plan", + }, + { + name: "JSON-RPC plan in application HTTP role", + change: func(roots []*expr.RootExpr, services []*service.Plan, jsonPlans, applicationPlans []*httpcodegen.Plan) []PlanInput { + return []PlanInput{ + {Root: roots[0], Service: services[0], HTTP: jsonPlans[0], ApplicationHTTP: jsonPlans[0]}, + {Root: roots[1], Service: services[1], HTTP: jsonPlans[1], ApplicationHTTP: applicationPlans[1]}, + } + }, + error: "application HTTP plan does not belong to root First and its service plan", + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + generation, roots, services, jsonPlans, applicationPlans := jsonRPCPlanningInputs(t) + _, err := NewPlans(generation, test.change(roots, services, jsonPlans, applicationPlans)...) + require.EqualError(t, err, test.error) + assertViewedHelperNameAvailable(t, generation) + }) + } +} + +// TestPlanEmitsViewedEncoderForJSONRPCMethodOnHTTPService verifies that a +// service with ordinary HTTP and JSON-RPC methods writes the viewed-result +// encoder called by its generated JSON-RPC server. +func TestPlanEmitsViewedEncoderForJSONRPCMethodOnHTTPService(t *testing.T) { + root := expr.RunDSL(t, viewedJSONRPCWithHTTPServiceDSL) + plan := CreateJSONRPCPlan(root) + require.Len(t, plan.services, 1) + require.Len(t, plan.services[0].endpoints, 1) + require.NotNil(t, plan.services[0].endpoints[0].viewed) + helper := plan.services[0].helpers["JSONRPC"].encode.Name() + + var source strings.Builder + for _, file := range plan.ServerFiles() { + for _, section := range file.SectionTemplates { + require.NoError(t, section.Write(&source)) + } + } + require.Contains(t, source.String(), "func "+helper+"(") +} + +// TestPlanRequiresLinkBeforeRender verifies callers cannot read files before +// Link finishes or ask Link to build the same files twice. +func TestPlanRequiresLinkBeforeRender(t *testing.T) { + root := expr.RunDSL(t, viewedJSONRPCPlanDSL) + generation, err := codegen.NewGeneration("generated.local/gen", []eval.Root{root}) + require.NoError(t, err) + servicePlan, err := service.NewPlan(root, generation, expr.NewExampleGenerator(root.API.RandomizerFactory)) + require.NoError(t, err) + httpPlans, err := httpcodegen.NewJSONRPCPlans(generation, httpcodegen.PlanInput{Root: root, Service: servicePlan}) + require.NoError(t, err) + plans, err := NewPlans(generation, PlanInput{Root: root, Service: servicePlan, HTTP: httpPlans[0]}) + require.NoError(t, err) + require.PanicsWithValue(t, "JSON-RPC files requested before Plan.Link", func() { + plans[0].ServerFiles() + }) + + require.NoError(t, generation.Freeze()) + require.NoError(t, servicePlan.Link()) + require.NoError(t, httpPlans[0].Link()) + require.NoError(t, plans[0].Link()) + require.EqualError(t, plans[0].Link(), "JSON-RPC plan is already linked") +} + +// TestPlanBuildsCombinedExampleWithoutChangingHTTP verifies Link creates a new +// runnable server with ordinary HTTP and JSON-RPC services and leaves the HTTP +// plan's file unchanged. +func TestPlanBuildsCombinedExampleWithoutChangingHTTP(t *testing.T) { + root := expr.RunDSL(t, func() { + dsl.Service("mixed", func() { + dsl.Method("read", func() { + dsl.HTTP(func() { dsl.GET("/read") }) + dsl.JSONRPC(func() {}) + }) + }) + }) + generation, err := codegen.NewGeneration("generated.local/gen", []eval.Root{root}) + require.NoError(t, err) + servicePlan, err := service.NewPlan(root, generation, expr.NewExampleGenerator(root.API.RandomizerFactory)) + require.NoError(t, err) + applicationPlans, err := httpcodegen.NewPlans(generation, httpcodegen.PlanInput{Root: root, Service: servicePlan}) + require.NoError(t, err) + httpPlans, err := httpcodegen.NewJSONRPCPlans(generation, httpcodegen.PlanInput{Root: root, Service: servicePlan}) + require.NoError(t, err) + plans, err := NewPlans(generation, PlanInput{ + Root: root, + Service: servicePlan, + HTTP: httpPlans[0], + ApplicationHTTP: applicationPlans[0], + }) + require.NoError(t, err) + examplePlan, err := example.NewPlan(generation, servicePlan) + require.NoError(t, err) + httpExamples, err := httpcodegen.NewExamplePlan(applicationPlans[0], examplePlan) + require.NoError(t, err) + examples, err := NewExamplePlan(plans[0], examplePlan) + require.NoError(t, err) + require.NoError(t, generation.Freeze()) + require.NoError(t, servicePlan.Link()) + require.NoError(t, applicationPlans[0].Link()) + require.NoError(t, httpPlans[0].Link()) + + httpFile := httpExamples.ServerFiles()[0] + httpImports := append([]*codegen.ImportSpec(nil), httpFile.SectionTemplates[0].Data.(map[string]any)["Imports"].([]*codegen.ImportSpec)...) + require.NoError(t, plans[0].Link()) + + require.Equal(t, httpImports, httpFile.SectionTemplates[0].Data.(map[string]any)["Imports"]) + for _, section := range httpFile.SectionTemplates { + switch section.Name { + case "server-http-start", "server-http-init", "server-http-end": + require.Empty(t, section.Data.(map[string]any)["JSONRPCServices"]) + } + } + combined := examples.ServerFiles()[0] + require.NotSame(t, httpFile, combined) + for _, section := range combined.SectionTemplates { + switch section.Name { + case "server-http-start", "server-http-init", "server-http-end": + require.Len(t, section.Data.(map[string]any)["JSONRPCServices"], 1) + } + } +} + +// TestPlanUsesHTTPViewedRepresentationBranches verifies each method uses the +// body types and constructors that the HTTP plan prepared for its result views. +func TestPlanUsesHTTPViewedRepresentationBranches(t *testing.T) { + shared, plan := linkedJSONRPCPlan(t, viewedJSONRPCPlanDSL) + require.Len(t, plan.services, 1) + + endpoints := make(map[string]*endpointPlan) + for _, endpoint := range plan.services[0].endpoints { + endpoints[endpoint.Method.Name] = endpoint + } + variable := endpoints["fetch"].viewed + require.True(t, variable.variable) + httpViewed, ok := shared.ViewedResult("retained", "fetch") + require.True(t, ok) + require.Len(t, variable.branches, len(httpViewed.Representations)) + for index, branch := range variable.branches { + require.Equal(t, httpViewed.Representations[index].View, branch.view) + } + for _, branch := range variable.branches { + require.NotNil(t, branch.serverBody) + require.NotNil(t, branch.clientBody) + require.NotEmpty(t, branch.resultInit.Name) + } + + fixed := endpoints["fixed"].viewed + require.False(t, fixed.variable) + require.Equal(t, "detailed", fixed.fixedView) + require.Len(t, fixed.branches, 1) + require.Equal(t, "detailed", fixed.branches[0].view) +} + +// TestPlanUsesEveryViewForMappedResultField verifies that a JSON-RPC response +// mapped to one result field still carries and checks every view that the +// service method may return. Each branch uses the mapped field's JSON body and +// result constructor supplied by the HTTP plan. +func TestPlanUsesEveryViewForMappedResultField(t *testing.T) { + shared, plan := linkedJSONRPCPlan(t, viewedJSONRPCMappedFieldPlanDSL) + httpViewed, ok := shared.ViewedResult("mapped", "fetch") + require.True(t, ok) + representations := httpViewed.Representations + require.Len(t, representations, 2) + require.Len(t, plan.services, 1) + require.Len(t, plan.services[0].endpoints, 1) + viewed := plan.services[0].endpoints[0].viewed + require.True(t, viewed.variable) + require.Empty(t, viewed.fixedView) + require.Len(t, viewed.branches, 2) + for index, name := range []string{"summary", "default"} { + require.Equal(t, name, viewed.branches[index].view) + require.Equal(t, representations[index].ServerBody, viewed.branches[index].serverBody) + require.Equal(t, representations[index].ClientBody, viewed.branches[index].clientBody) + require.Equal(t, representations[index].ResultInit, viewed.branches[index].resultInit) + } + require.Equal(t, viewed.branches[0].serverBody, viewed.branches[1].serverBody) + require.Equal(t, viewed.branches[0].clientBody, viewed.branches[1].clientBody) + require.Equal(t, viewed.branches[0].resultInit, viewed.branches[1].resultInit) +} + +// TestPlanTreatsSoleResultViewAsFixed verifies that a result type with one view +// produces a response body without a per-response view field. The service plan +// supplies that one view name, and JSON-RPC copies it without deriving a value +// from the first response branch. +func TestPlanTreatsSoleResultViewAsFixed(t *testing.T) { + shared, plan := linkedJSONRPCPlan(t, viewedJSONRPCSoleViewPlanDSL) + httpViewed, ok := shared.ViewedResult("sole", "fetch") + require.True(t, ok) + representations := httpViewed.Representations + require.Len(t, representations, 1) + require.Len(t, plan.services, 1) + require.Len(t, plan.services[0].endpoints, 1) + viewed := plan.services[0].endpoints[0].viewed + require.False(t, viewed.variable) + require.Equal(t, "default", viewed.fixedView) + require.Len(t, viewed.branches, 1) + require.Equal(t, "default", viewed.branches[0].view) +} + +// TestPlanUsesAssignedViewedHelperNames verifies that result-view helpers use +// the unique names assigned when two method spellings produce the same Go name +// or another generated function already uses the requested name. +func TestPlanUsesAssignedViewedHelperNames(t *testing.T) { + root := expr.RunDSL(t, viewedJSONRPCCollisionPlanDSL) + generation, err := codegen.NewGeneration("generated.local/gen", []eval.Root{root}) + require.NoError(t, err) + servicePlan, err := service.NewPlan(root, generation, expr.NewExampleGenerator(root.API.RandomizerFactory)) + require.NoError(t, err) + client, err := generation.ClaimPackage("generated.local/gen/jsonrpc/collisions/client") + require.NoError(t, err) + server, err := generation.ClaimPackage("generated.local/gen/jsonrpc/collisions/server") + require.NoError(t, err) + require.NoError(t, client.DeclareName(codegen.NewPreferredName( + codegen.NameFunction, + "decodeFetchItemViewedResult", + codegen.UnexportedName, + jsonRPCNameOrder{role: viewedResultDecoderRole}, + ))) + require.NoError(t, client.DeclareName(codegen.NewPreferredName( + codegen.NameFunction, + "decodeJSONRPCResult", + codegen.UnexportedName, + jsonRPCNameOrder{}, + ))) + require.NoError(t, server.DeclareName(codegen.NewPreferredName( + codegen.NameFunction, + "encodeFetchItemViewedResult", + codegen.UnexportedName, + jsonRPCNameOrder{role: viewedResultEncoderRole}, + ))) + + httpPlans, err := httpcodegen.NewJSONRPCPlans(generation, httpcodegen.PlanInput{Root: root, Service: servicePlan}) + require.NoError(t, err) + plans, err := NewPlans(generation, PlanInput{Root: root, Service: servicePlan, HTTP: httpPlans[0]}) + require.NoError(t, err) + availableUnaryStreamName := codegen.NewPreferredName( + codegen.NameFunction, + "encodeFetchItemResult", + codegen.UnexportedName, + jsonRPCNameOrder{role: 255}, + ) + require.NoError(t, server.DeclareName(availableUnaryStreamName)) + require.NoError(t, generation.Freeze()) + require.NoError(t, servicePlan.Link()) + require.NoError(t, httpPlans[0].Link()) + require.NoError(t, plans[0].Link()) + + helpers := plans[0].services[0].helpers + require.Equal(t, "decodeJSONRPCResult2", plans[0].services[0].bodyDecoder.Name()) + require.Equal(t, "decodeFetchItemViewedResult2", helpers["fetch-item"].decode.Name()) + require.Equal(t, "encodeFetchItemViewedResult2", helpers["fetch-item"].encode.Name()) + require.Nil(t, helpers["fetch-item"].streamEncode) + require.Equal(t, "encodeFetchItemResult", availableUnaryStreamName.Name()) + require.NotEqual(t, helpers["fetch-item"].decode.Name(), helpers["fetch_item"].decode.Name()) + require.NotEqual(t, helpers["fetch-item"].encode.Name(), helpers["fetch_item"].encode.Name()) +} + +// linkedJSONRPCPlan evaluates one design, assigns all generated Go names, and +// links the service, HTTP, and JSON-RPC plans used by a test. +func linkedJSONRPCPlan(t *testing.T, design func()) (*httpcodegen.Plan, *Plan) { + t.Helper() + root := expr.RunDSL(t, design) + generation, err := codegen.NewGeneration("generated.local/gen", []eval.Root{root}) + require.NoError(t, err) + servicePlan, err := service.NewPlan(root, generation, expr.NewExampleGenerator(root.API.RandomizerFactory)) + require.NoError(t, err) + httpPlans, err := httpcodegen.NewJSONRPCPlans(generation, httpcodegen.PlanInput{Root: root, Service: servicePlan}) + require.NoError(t, err) + plans, err := NewPlans(generation, PlanInput{Root: root, Service: servicePlan, HTTP: httpPlans[0]}) + require.NoError(t, err) + require.NoError(t, generation.Freeze()) + require.NoError(t, servicePlan.Link()) + require.NoError(t, httpPlans[0].Link()) + require.NoError(t, plans[0].Link()) + return httpPlans[0], plans[0] +} + +// jsonRPCPlanningInputs builds two roots and their matching service, ordinary +// HTTP, and JSON-RPC HTTP plans. Tests change one input before calling NewPlans +// to prove the constructor rejects an incomplete or mismatched set. +func jsonRPCPlanningInputs(t *testing.T) (*codegen.Generation, []*expr.RootExpr, []*service.Plan, []*httpcodegen.Plan, []*httpcodegen.Plan) { + t.Helper() + roots := []*expr.RootExpr{ + expr.RunDSL(t, jsonRPCPlanningRootDSL("First", "/first")), + expr.RunDSL(t, jsonRPCPlanningRootDSL("Second", "/second")), + expr.RunDSL(t, ordinaryHTTPPlanningRootDSL), + } + generation, err := codegen.NewGeneration("generated.local/gen", []eval.Root{roots[0], roots[1], roots[2]}) + require.NoError(t, err) + servicePlans, err := service.NewPlans(generation, + service.PlanInput{Root: roots[0], Examples: expr.NewExampleGenerator(roots[0].API.RandomizerFactory)}, + service.PlanInput{Root: roots[1], Examples: expr.NewExampleGenerator(roots[1].API.RandomizerFactory)}, + service.PlanInput{Root: roots[2], Examples: expr.NewExampleGenerator(roots[2].API.RandomizerFactory)}, + ) + require.NoError(t, err) + httpInputs := []httpcodegen.PlanInput{ + {Root: roots[0], Service: servicePlans[0]}, + {Root: roots[1], Service: servicePlans[1]}, + {Root: roots[2], Service: servicePlans[2]}, + } + applicationPlans, err := httpcodegen.NewPlans(generation, httpInputs...) + require.NoError(t, err) + jsonPlans, err := httpcodegen.NewJSONRPCPlans(generation, httpInputs[:2]...) + require.NoError(t, err) + return generation, roots, servicePlans, jsonPlans, applicationPlans +} + +// assertViewedHelperNameAvailable submits the helper name that a rejected plan +// would have used and verifies no earlier JSON-RPC input consumed it. +func assertViewedHelperNameAvailable(t *testing.T, generation *codegen.Generation) { + t.Helper() + client, err := generation.ClaimPackage(path.Join("generated.local/gen/jsonrpc/first/client")) + require.NoError(t, err) + declaration := codegen.NewPreferredName( + codegen.NameFunction, + "decodeReadViewedResult", + codegen.UnexportedName, + jsonRPCNameOrder{service: "zzzz", method: "read", role: viewedResultDecoderRole}, + ) + require.NoError(t, client.DeclareName(declaration)) + require.NoError(t, generation.Freeze()) + require.Equal(t, "decodeReadViewedResult", declaration.Name()) +} + +// jsonRPCPlanningRootDSL defines one viewed method exposed through both +// ordinary HTTP and JSON-RPC so tests can also validate ApplicationHTTP. +func jsonRPCPlanningRootDSL(name, route string) func() { + return func() { + result := dsl.ResultType("application/vnd."+strings.ToLower(name), func() { + dsl.Attribute("id", dsl.String) + dsl.Required("id") + dsl.View("default", func() { + dsl.Attribute("id") + }) + }) + dsl.Service(name, func() { + dsl.Method("read", func() { + dsl.Result(result) + dsl.HTTP(func() { + dsl.GET(route) + }) + dsl.JSONRPC(func() {}) + }) + }) + } +} + +// ordinaryHTTPPlanningRootDSL defines a root that must be excluded from +// JSON-RPC inputs even though it participates in the same generation. +func ordinaryHTTPPlanningRootDSL() { + dsl.Service("Ordinary", func() { + dsl.Method("read", func() { + dsl.HTTP(func() { + dsl.GET("/ordinary") + }) + }) + }) +} + +// viewedJSONRPCWithHTTPServiceDSL defines one service whose ordinary HTTP and +// JSON-RPC methods return the same one-view result type. +func viewedJSONRPCWithHTTPServiceDSL() { + result := dsl.ResultType("application/vnd.viewed-jsonrpc-http-service", func() { + dsl.Attribute("value", dsl.String) + dsl.Required("value") + dsl.View("default", func() { + dsl.Attribute("value") + }) + }) + dsl.Service("ViewedHTTPJSON", func() { + dsl.Method("HTTP", func() { + dsl.Result(result) + dsl.HTTP(func() { + dsl.GET("/http") + }) + }) + dsl.Method("JSONRPC", func() { + dsl.Result(result) + dsl.JSONRPC(func() {}) + }) + }) +} + +// viewedJSONRPCPlanDSL defines one variable and one fixed viewed result. +func viewedJSONRPCPlanDSL() { + result := dsl.ResultType("application/vnd.retained-view", func() { + dsl.TypeName("RetainedView") + dsl.Attribute("id", dsl.String) + dsl.Attribute("detail", dsl.String) + dsl.Required("id", "detail") + dsl.View("summary", func() { + dsl.Attribute("id") + }) + dsl.View("detailed", func() { + dsl.Attribute("id") + dsl.Attribute("detail") + }) + }) + dsl.Service("retained", func() { + dsl.JSONRPC(func() { + dsl.POST("/rpc") + }) + dsl.Method("fetch", func() { + dsl.Result(result) + dsl.JSONRPC(func() {}) + }) + dsl.Method("fixed", func() { + dsl.Result(result, func() { + dsl.View("detailed") + }) + dsl.JSONRPC(func() {}) + }) + }) +} + +// jsonRPCParamsPlanDSL declares single and container request and stream values +// so planning tests can compare their JSON-RPC parameter shape. +func jsonRPCParamsPlanDSL() { + alias := dsl.Type("Alias", dsl.String) + object := dsl.Type("Object", func() { + dsl.Attribute("name", dsl.String) + dsl.Required("name") + }) + union := dsl.Type("Choice", func() { + dsl.OneOf("value", func() { + dsl.Field(1, "name", dsl.String) + dsl.Field(2, "count", dsl.Int) + }) + }) + objects := dsl.Type("Objects", dsl.ArrayOf(object)) + objectMap := dsl.Type("ObjectMap", dsl.MapOf(dsl.String, object)) + objectEvent := dsl.Type("ObjectEvent", func() { + dsl.Attribute("data", object) + }) + arrayEvent := dsl.Type("ArrayEvent", func() { + dsl.Attribute("data", objects) + }) + mapEvent := dsl.Type("MapEvent", func() { + dsl.Attribute("data", objectMap) + }) + unionEvent := dsl.Type("UnionEvent", func() { + dsl.OneOf("data", func() { + dsl.TypeName("StreamUnionData") + dsl.Field(1, "name", dsl.String) + dsl.Field(2, "count", dsl.Int) + }) + }) + optionalTextEvent := dsl.Type("OptionalTextEvent", func() { + dsl.Attribute("data", dsl.String, func() { + dsl.Pattern("^[a-z]*$") + }) + }) + defaultStreamData := dsl.Type("DefaultStreamData", dsl.String, func() { + dsl.Pattern("^[a-z]*$") + dsl.Default("fallback") + }) + defaultTextEvent := dsl.Type("DefaultTextEvent", func() { + dsl.Attribute("data", defaultStreamData) + }) + dsl.Service("params", func() { + for _, method := range []struct { + name string + type_ any + }{ + {name: "text", type_: dsl.String}, + {name: "alias", type_: alias}, + {name: "anything", type_: dsl.Any}, + {name: "bytes", type_: dsl.Bytes}, + {name: "object", type_: object}, + {name: "array", type_: dsl.ArrayOf(dsl.String)}, + {name: "map", type_: dsl.MapOf(dsl.String, dsl.Int)}, + {name: "union", type_: union}, + } { + dsl.Method(method.name, func() { + dsl.Payload(method.type_) + dsl.JSONRPC(func() {}) + }) + } + dsl.Method("optional_text", func() { + dsl.Payload(func() { + dsl.Attribute("value", dsl.String) + }) + dsl.JSONRPC(func() { + dsl.Body("value") + }) + }) + for _, method := range []struct { + name string + type_ any + }{ + {name: "optional_object", type_: object}, + {name: "optional_array", type_: objects}, + {name: "optional_map", type_: objectMap}, + } { + dsl.Method(method.name, func() { + dsl.Payload(func() { + dsl.Attribute("value", method.type_) + }) + dsl.JSONRPC(func() { + dsl.Body("value") + }) + }) + } + dsl.Method("optional_union", func() { + dsl.Payload(func() { + dsl.OneOf("value", func() { + dsl.TypeName("OptionalRequestValue") + dsl.Field(1, "name", dsl.String) + dsl.Field(2, "count", dsl.Int) + }) + }) + dsl.JSONRPC(func() { + dsl.Body("value") + }) + }) + dsl.Method("stream_text", func() { + dsl.StreamingResult(dsl.String) + dsl.JSONRPC(func() { + dsl.ServerSentEvents() + }) + }) + dsl.Method("stream_any", func() { + dsl.StreamingResult(dsl.Any) + dsl.JSONRPC(func() { + dsl.ServerSentEvents() + }) + }) + dsl.Method("stream_array", func() { + dsl.StreamingResult(dsl.ArrayOf(dsl.String)) + dsl.JSONRPC(func() { + dsl.ServerSentEvents() + }) + }) + dsl.Method("stream_optional_text", func() { + dsl.StreamingResult(optionalTextEvent) + dsl.JSONRPC(func() { + dsl.ServerSentEvents(func() { + dsl.SSEEventData("data") + }) + }) + }) + dsl.Method("stream_default_text", func() { + dsl.StreamingResult(defaultTextEvent) + dsl.JSONRPC(func() { + dsl.ServerSentEvents(func() { + dsl.SSEEventData("data") + }) + }) + }) + for _, method := range []struct { + name string + type_ any + }{ + {name: "stream_object_data", type_: objectEvent}, + {name: "stream_array_data", type_: arrayEvent}, + {name: "stream_map_data", type_: mapEvent}, + {name: "stream_union_data", type_: unionEvent}, + } { + dsl.Method(method.name, func() { + dsl.StreamingResult(method.type_) + dsl.JSONRPC(func() { + dsl.ServerSentEvents(func() { + dsl.SSEEventData("data") + }) + }) + }) + } + }) +} + +// viewedJSONRPCCollisionPlanDSL defines two viewed methods whose authored +// names normalize to the same preferred Go helper spelling. +func viewedJSONRPCCollisionPlanDSL() { + result := dsl.ResultType("application/vnd.retained-collision", func() { + dsl.Attribute("id", dsl.String) + dsl.Required("id") + dsl.View("summary", func() { + dsl.Attribute("id") + }) + dsl.View("detailed", func() { + dsl.Attribute("id") + }) + }) + dsl.Service("collisions", func() { + dsl.JSONRPC(func() { + dsl.POST("/rpc") + }) + for _, name := range []string{"fetch-item", "fetch_item"} { + dsl.Method(name, func() { + dsl.Result(result) + dsl.JSONRPC(func() {}) + }) + } + }) +} + +// viewedJSONRPCMappedFieldPlanDSL defines a result whose JSON-RPC response body +// contains only the id field while the selected view still accompanies the +// response and must be either the generated default view or the summary view. +func viewedJSONRPCMappedFieldPlanDSL() { + result := dsl.ResultType("application/vnd.mapped-view", func() { + dsl.Attribute("id", dsl.String) + dsl.Attribute("detail", dsl.String) + dsl.Required("id") + dsl.View("summary", func() { + dsl.Attribute("id") + }) + }) + dsl.Service("mapped", func() { + dsl.Method("fetch", func() { + dsl.Result(result) + dsl.JSONRPC(func() { + dsl.Response(func() { + dsl.Body("id") + }) + }) + }) + }) +} + +// viewedJSONRPCSoleViewPlanDSL defines a result whose only legal view is the +// default view and does not repeat that choice on the method. +func viewedJSONRPCSoleViewPlanDSL() { + result := dsl.ResultType("application/vnd.sole-view", func() { + dsl.Attribute("id", dsl.String) + dsl.Required("id") + dsl.View("default", func() { + dsl.Attribute("id") + }) + }) + dsl.Service("sole", func() { + dsl.Method("fetch", func() { + dsl.Result(result) + dsl.JSONRPC(func() {}) + }) + }) +} diff --git a/jsonrpc/codegen/server.go b/jsonrpc/codegen/server.go index ec2d94802c..1cca5ea995 100644 --- a/jsonrpc/codegen/server.go +++ b/jsonrpc/codegen/server.go @@ -1,3 +1,5 @@ +// This file writes JSON-RPC server handlers, request decoders, and response +// encoders for each service. Each file imports only the types it uses. package codegen import ( @@ -6,157 +8,155 @@ import ( "strings" "goa.design/goa/v3/codegen" - "goa.design/goa/v3/expr" httpcodegen "goa.design/goa/v3/http/codegen" ) -// ServerFiles returns the generated JSON-RPC server files if any. -func ServerFiles(genpkg string, data *httpcodegen.ServicesData) []*codegen.File { - jsvcs := data.Root.API.JSONRPC.Services - files := make([]*codegen.File, 0, len(jsvcs)*3) - for _, svc := range jsvcs { - files = append(files, serverFile(genpkg, svc, data)) - // Generate either WebSocket or SSE file based on transport type - if hasJSONRPCSSE(svc) { - if f := sseServerFile(genpkg, svc, data); f != nil { - files = append(files, f) +type ( + // serverTemplateData stores the service values and extra Go names used to + // write one server package. + serverTemplateData struct { + httpcodegen.JSONRPCServiceSnapshot + // BatchWriter is the type that joins responses for one batch request. + BatchWriter *codegen.NameDeclaration + // EncodeError is the function that writes a JSON-RPC error response. + EncodeError *codegen.NameDeclaration + // SSEStream is the stream shared by all server-sent-event methods. + SSEStream *codegen.NameDeclaration + // SSEBuffer stores an encoded event before the response starts. + SSEBuffer *codegen.NameDeclaration + // NoOutputWriter accepts notification output without sending a response. + NoOutputWriter *codegen.NameDeclaration + } +) + +// serverFiles builds server, stream, and JSON conversion files from the +// services recorded before every generated Go name was assigned. +func serverFiles(services []*servicePlan) []*codegen.File { + files := make([]*codegen.File, 0, len(services)*3) + for _, planned := range services { + renderPlan := servicePlanForOutput(planned, false) + files = append(files, setFileImports(serverFile(renderPlan), renderPlan)) + if renderPlan.hasSSE { + if f := sseServerFile(renderPlan); f != nil { + files = append(files, setFileImports(f, renderPlan)) } - } else if f := websocketServerFile(genpkg, svc, data); f != nil { - files = append(files, f) } } - for _, svc := range jsvcs { - f := httpcodegen.ServerEncodeDecodeFile(genpkg, svc, data) + for _, planned := range services { + f := planned.data.ServerCodecFile() if f == nil { continue } for _, s := range f.SectionTemplates { - // Add the JSON-RPC imports. - if s.Name == "source-header" { - codegen.AddImport(s, &codegen.ImportSpec{Path: "bytes"}) - codegen.AddImport(s, &codegen.ImportSpec{Path: "io"}) - codegen.AddImport(s, codegen.GoaImport("jsonrpc")) - } s.Name = "jsonrpc-" + s.Name } - files = append(files, f) + files = append(files, setFileImports(f, planned)) } return files } // serverFile returns the file implementing the JSON-RPC server. -func serverFile(genpkg string, svc *expr.HTTPServiceExpr, services *httpcodegen.ServicesData) *codegen.File { - data := services.Get(svc.Name()) +func serverFile(planned *servicePlan) *codegen.File { + data := planned.data + renderData := &serverTemplateData{ + JSONRPCServiceSnapshot: data, + BatchWriter: planned.serverNames.batchWriter, + EncodeError: planned.serverNames.encodeError, + SSEStream: planned.serverNames.sseStream, + SSEBuffer: planned.serverNames.sseBuffer, + NoOutputWriter: planned.serverNames.noOutputWriter, + } svcName := data.Service.PathName fpath := filepath.Join(codegen.Gendir, "jsonrpc", svcName, "server", "server.go") - title := fmt.Sprintf("%s JSON-RPC server", svc.Name()) + title := fmt.Sprintf("%s JSON-RPC server", planned.name) funcs := map[string]any{ - "isWebSocketEndpoint": httpcodegen.IsWebSocketEndpoint, - "isSSEEndpoint": httpcodegen.IsSSEEndpoint, - "lowerInitial": lowerInitial, - "hasMixedTransports": func() bool { return hasMixedJSONRPCTransports(svc) }, + "isSSEEndpoint": isJSONRPCSSEEndpoint, + "lowerInitial": lowerInitial, + "encodeErrorName": planned.encodeErrorName, + "sseStreamName": planned.sseStreamName, + "noOutputWriterName": planned.noOutputWriterName, + "hasMixedTransports": planned.hasMixedTransports, + } + for name, function := range viewedResultFuncs(planned) { + funcs[name] = function } - imports := make([]*codegen.ImportSpec, 0, 15+len(data.Service.UserTypeImports)) - imports = append(imports, - &codegen.ImportSpec{Path: "bufio"}, - &codegen.ImportSpec{Path: "bytes"}, - &codegen.ImportSpec{Path: "context"}, - &codegen.ImportSpec{Path: "errors"}, - &codegen.ImportSpec{Path: "fmt"}, - &codegen.ImportSpec{Path: "io"}, - &codegen.ImportSpec{Path: "mime/multipart"}, - &codegen.ImportSpec{Path: "net/http"}, - &codegen.ImportSpec{Path: "path"}, - &codegen.ImportSpec{Path: "strings"}, - codegen.GoaImport(""), - codegen.GoaImport("jsonrpc"), - codegen.GoaNamedImport("http", "goahttp"), - &codegen.ImportSpec{Path: genpkg + "/" + svcName, Name: data.Service.PkgName}, - &codegen.ImportSpec{Path: genpkg + "/" + svcName + "/" + "views", Name: data.Service.ViewsPkg}, - ) - imports = append(imports, data.Service.UserTypeImports...) sections := []*codegen.SectionTemplate{ - codegen.Header(title, "server", imports), + codegen.Header(title, "server", nil), } sections = append(sections, - &codegen.SectionTemplate{Name: "jsonrpc-server-struct", Source: jsonrpcTemplates.Read(serverStructT), FuncMap: funcs, Data: data}, - &codegen.SectionTemplate{Name: "jsonrpc-server-init", Source: jsonrpcTemplates.Read(serverInitT), Data: data, FuncMap: funcs}, - &codegen.SectionTemplate{Name: "jsonrpc-server-service", Source: httpcodegen.ReadTemplate(serverServiceT), Data: data}, - &codegen.SectionTemplate{Name: "jsonrpc-server-use", Source: jsonrpcTemplates.Read(serverUseT), Data: data}, - &codegen.SectionTemplate{Name: "jsonrpc-server-method-names", Source: httpcodegen.ReadTemplate(serverMethodNamesT), Data: data}, + &codegen.SectionTemplate{Name: "jsonrpc-server-writers", Source: jsonrpcTemplates.Read(sseServerStreamBaseT), Data: renderData}, + &codegen.SectionTemplate{Name: "jsonrpc-server-struct", Source: jsonrpcTemplates.Read(serverStructT), FuncMap: funcs, Data: renderData}, + &codegen.SectionTemplate{Name: "jsonrpc-server-init", Source: jsonrpcTemplates.Read(serverInitT), Data: renderData, FuncMap: funcs}, + &codegen.SectionTemplate{Name: "jsonrpc-server-service", Source: jsonrpcTemplates.Read(serverServiceT), Data: renderData}, + &codegen.SectionTemplate{Name: "jsonrpc-server-use", Source: jsonrpcTemplates.Read(serverUseT), Data: renderData}, + &codegen.SectionTemplate{Name: "jsonrpc-server-method-names", Source: jsonrpcTemplates.Read(serverMethodNamesT), Data: renderData}, ) - // Use appropriate server handler based on transport + // Add the request handlers needed by this service. switch { - case hasMixedJSONRPCTransports(svc): - // For mixed transports, we need a unified handler with content negotiation - sections = append(sections, &codegen.SectionTemplate{Name: "jsonrpc-mixed-server-handler", Source: jsonrpcTemplates.Read(mixedServerHandlerT), FuncMap: funcs, Data: data}) - // Include the standard HTTP handlers that the mixed handler delegates to - sections = append(sections, &codegen.SectionTemplate{Name: "jsonrpc-server-handler", Source: jsonrpcTemplates.Read(serverHandlerT), FuncMap: funcs, Data: data}) - // Also include SSE handler for SSE-specific logic - sections = append(sections, &codegen.SectionTemplate{Name: "jsonrpc-sse-server-handler", Source: jsonrpcTemplates.Read(sseServerHandlerT), FuncMap: funcs, Data: data}) - case hasJSONRPCSSE(svc): - sections = append(sections, &codegen.SectionTemplate{Name: "jsonrpc-sse-server-handler", Source: jsonrpcTemplates.Read(sseServerHandlerT), FuncMap: funcs, Data: data}) - case httpcodegen.HasWebSocket(data): - sections = append(sections, &codegen.SectionTemplate{Name: "jsonrpc-websocket-server-handler", Source: jsonrpcTemplates.Read(websocketServerHandlerT), FuncMap: funcs, Data: data}) + case planned.hasHTTP && planned.hasSSE: + // ServeHTTP chooses an ordinary JSON-RPC response or server-sent events + // from the request's Accept header. + sections = append(sections, &codegen.SectionTemplate{Name: "jsonrpc-mixed-server-handler", Source: jsonrpcTemplates.Read(mixedServerHandlerT), FuncMap: funcs, Data: renderData}) + // Add both handlers called by ServeHTTP. + sections = append(sections, &codegen.SectionTemplate{Name: "jsonrpc-server-handler", Source: jsonrpcTemplates.Read(serverHandlerT), FuncMap: funcs, Data: renderData}) + sections = append(sections, &codegen.SectionTemplate{Name: "jsonrpc-sse-server-handler", Source: jsonrpcTemplates.Read(sseServerHandlerT), FuncMap: funcs, Data: renderData}) + case planned.hasSSE: + sections = append(sections, &codegen.SectionTemplate{Name: "jsonrpc-sse-server-handler", Source: jsonrpcTemplates.Read(sseServerHandlerT), FuncMap: funcs, Data: renderData}) default: - sections = append(sections, &codegen.SectionTemplate{Name: "jsonrpc-server-handler", Source: jsonrpcTemplates.Read(serverHandlerT), FuncMap: funcs, Data: data}) + sections = append(sections, &codegen.SectionTemplate{Name: "jsonrpc-server-handler", Source: jsonrpcTemplates.Read(serverHandlerT), FuncMap: funcs, Data: renderData}) } - // Add transport flags to data + // Record which request handlers this service needs. mountData := struct { - *httpcodegen.ServiceData + httpcodegen.JSONRPCServiceSnapshot HasSSE bool HasMixed bool }{ - ServiceData: data, - HasSSE: hasJSONRPCSSE(svc), - HasMixed: hasMixedJSONRPCTransports(svc), + JSONRPCServiceSnapshot: data, + HasSSE: planned.hasSSE, + HasMixed: planned.hasHTTP && planned.hasSSE, } sections = append(sections, &codegen.SectionTemplate{Name: "jsonrpc-server-mount", Source: jsonrpcTemplates.Read(serverMountT), Data: mountData}, ) - for _, e := range data.Endpoints { + for _, e := range planned.endpoints { sections = append(sections, - &codegen.SectionTemplate{Name: "jsonrpc-server-handler-init", Source: jsonrpcTemplates.Read(serverHandlerInitT), FuncMap: funcs, Data: e}) + &codegen.SectionTemplate{Name: "jsonrpc-server-handler-init", Source: jsonrpcTemplates.Read(serverHandlerInitT), FuncMap: funcs, Data: &e.JSONRPCEndpointSnapshot}) } + sections = append(sections, serverViewedResultSections(planned)...) - if !httpcodegen.HasWebSocket(data) { - sections = append(sections, &codegen.SectionTemplate{Name: "jsonrpc-server-encode-error", Source: jsonrpcTemplates.Read(serverEncodeErrorT)}) - } + sections = append(sections, &codegen.SectionTemplate{Name: "jsonrpc-server-encode-error", Source: jsonrpcTemplates.Read(serverEncodeErrorT), Data: renderData}) return &codegen.File{Path: fpath, SectionTemplates: sections} } -// lowerInitial returns the string with the first letter in lowercase. -func lowerInitial(s string) string { - return strings.ToLower(s[:1]) + s[1:] +// encodeErrorName returns the function that writes a JSON-RPC error response. +func (s *servicePlan) encodeErrorName() string { + return s.serverNames.encodeError.Name() } -// hasJSONRPCSSE returns true if the service uses SSE for JSON-RPC streaming. -func hasJSONRPCSSE(svc *expr.HTTPServiceExpr) bool { - for _, e := range svc.HTTPEndpoints { - if e.MethodExpr.IsStreaming() && e.IsJSONRPC() && e.SSE != nil { - return true - } - } - return false +// sseStreamName returns the shared server-sent-event stream type. +func (s *servicePlan) sseStreamName() string { + return s.serverNames.sseStream.Name() } -// hasJSONRPCHTTP returns true if the service has non-streaming JSON-RPC endpoints. -func hasJSONRPCHTTP(svc *expr.HTTPServiceExpr) bool { - for _, e := range svc.HTTPEndpoints { - if e.IsJSONRPC() && !e.MethodExpr.IsStreaming() { - return true - } - } - return false +// noOutputWriterName returns the writer type used while a notification runs +// the service without producing an HTTP response. +func (s *servicePlan) noOutputWriterName() string { + return s.serverNames.noOutputWriter.Name() } -// hasMixedJSONRPCTransports returns true if the service has both HTTP and SSE JSON-RPC endpoints. -func hasMixedJSONRPCTransports(svc *expr.HTTPServiceExpr) bool { - return hasJSONRPCHTTP(svc) && hasJSONRPCSSE(svc) +// hasMixedTransports reports whether the server accepts ordinary JSON-RPC +// requests and server-sent-event requests on the same HTTP path. +func (s *servicePlan) hasMixedTransports() bool { + return s.hasHTTP && s.hasSSE +} + +// lowerInitial returns the string with the first letter in lowercase. +func lowerInitial(s string) string { + return strings.ToLower(s[:1]) + s[1:] } diff --git a/jsonrpc/codegen/server_error_contract_test.go b/jsonrpc/codegen/server_error_contract_test.go new file mode 100644 index 0000000000..f7b383221a --- /dev/null +++ b/jsonrpc/codegen/server_error_contract_test.go @@ -0,0 +1,71 @@ +// This file verifies how generated JSON-RPC servers report request, service, +// and stream errors to callers. +package codegen + +import ( + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/require" + + "goa.design/goa/v3/codegen" + "goa.design/goa/v3/expr" + "goa.design/goa/v3/jsonrpc/codegen/testdata" +) + +// TestServerErrorResponses verifies the transport writes request and service +// failures without exposing JSON-RPC error methods through the service stream. +func TestServerErrorResponses(t *testing.T) { + root := expr.RunDSL(t, testdata.JSONRPCKitchenSinkDSL) + plan := CreateJSONRPCPlan(root) + + feedServer := renderPlannedFile(t, plan.ServerFiles(), "feed", "server.go") + require.Contains(t, feedServer, "return strm.sendError(ctx, req.ID, jsonrpc.InvalidParams, err.Error(), nil)") + require.Contains(t, feedServer, "response := jsonrpc.MakeSuccessResponse(req.ID, nil)") + require.Contains(t, feedServer, "return strm.sendSSEEvent(ctx, response, nil, nil, nil)") + require.Contains(t, feedServer, "jsonrpc.MakeSuccessResponse(req.ID, res)") + require.Equal(t, 1, strings.Count(feedServer, `mux.Handle("POST", "/feed", h.ServeHTTP)`)) + require.NotContains(t, feedServer, "SendError") + + feedStream := renderPlannedFile(t, plan.ServerFiles(), "feed", "sse.go") + require.Contains(t, feedStream, "func (s *WatchServerStream) Send(event *feed.WatchResult) error") + require.Contains(t, feedStream, "func (s *WatchServerStream) SendWithContext(ctx context.Context, event *feed.WatchResult) error") + require.Contains(t, feedStream, "func (s *WatchServerStream) Close() error") + require.NotContains(t, feedStream, "SendAndClose") + require.NotContains(t, feedStream, "SendError") + + calcServer := renderPlannedFile(t, plan.ServerFiles(), "calc", "server.go") + require.Contains(t, calcServer, "if err != nil {") + require.Contains(t, calcServer, "encodeJSONRPCError(ctx, w, req,") +} + +// TestNamedSSEPayloadReceivesLastEventID verifies a named payload receives the +// event ID in its designed pointer field before the endpoint runs. +func TestNamedSSEPayloadReceivesLastEventID(t *testing.T) { + root := expr.RunDSL(t, testdata.JSONRPCKitchenSinkDSL) + plan := CreateJSONRPCPlan(root) + decoder := paramsGoldenSection(t, plan.ServerFiles(), "encode_decode.go", "jsonrpc-request-decoder", "func DecodeWatchRequest") + feedServer := codegen.SectionCode(t, decoder) + + require.Contains(t, feedServer, `lastEventIDValues, lastEventIDPresent := r.Header["Last-Event-Id"]`) + require.Contains(t, feedServer, "payload = NewWatchPayload(lastEventID, requestID)") +} + +// renderPlannedFile renders one file stored by the plan into memory without +// writing generated output to the repository. +func renderPlannedFile(t *testing.T, files []*codegen.File, service, name string) string { + t.Helper() + for _, file := range files { + if filepath.Base(file.Path) != name || filepath.Base(filepath.Dir(filepath.Dir(file.Path))) != service { + continue + } + var source strings.Builder + for _, section := range file.SectionTemplates { + require.NoError(t, section.Write(&source)) + } + return source.String() + } + t.Errorf("generated %s/%s file not found", service, name) + return "" +} diff --git a/jsonrpc/codegen/server_protocol_runtime_test.go b/jsonrpc/codegen/server_protocol_runtime_test.go new file mode 100644 index 0000000000..6b10e56635 --- /dev/null +++ b/jsonrpc/codegen/server_protocol_runtime_test.go @@ -0,0 +1,446 @@ +// This file renders a JSON-RPC server and runs requests that exercise request +// IDs, batches, invalid messages, and event streams. +package codegen_test + +import "testing" + +// TestGeneratedServerFollowsJSONRPCRequestRules checks the request forms that +// determine whether the server sends one response, a batch, or an event stream. +func TestGeneratedServerFollowsJSONRPCRequestRules(t *testing.T) { + dir := renderViewedResultRuntimeModule(t) + writeViewedResultServerRuntimeTest(t, dir, "protocol", protocolRuntimeTest) + runViewedResultRuntimeTests(t, dir, "./jsonrpc/protocol/server") +} + +// TestGeneratedPureSSEServerClosesRequestBodies checks that an event-only +// server closes the body supplied by the HTTP server after the stream ends. +func TestGeneratedPureSSEServerClosesRequestBodies(t *testing.T) { + dir := renderViewedResultRuntimeModule(t) + writeViewedResultServerRuntimeTest(t, dir, "sse_decode", pureSSEBodyRuntimeTest) + runViewedResultRuntimeTests(t, dir, "./jsonrpc/sse_decode/server") +} + +const protocolRuntimeTest = `package server + +import ( + "context" + "errors" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/stretchr/testify/require" + + goahttp "goa.design/goa/v3/http" + goa "goa.design/goa/v3/pkg" +) + +func TestOrdinaryMethodsRequireNonNullRequestIDs(t *testing.T) { + tests := []struct { + name string + body string + want string + notification bool + }{ + {name: "missing ID", body: ` + "`" + `{"jsonrpc":"2.0","method":"ping"}` + "`" + `, notification: true}, + {name: "empty string ID", body: ` + "`" + `{"jsonrpc":"2.0","id":"","method":"ping"}` + "`" + `, want: ` + "`" + `{"jsonrpc":"2.0","id":"","result":null}` + "`" + `}, + {name: "null ID", body: ` + "`" + `{"jsonrpc":"2.0","id":null,"method":"ping"}` + "`" + `, want: ` + "`" + `{"jsonrpc":"2.0","id":null,"error":{"code":-32600,"message":"Invalid request"}}` + "`" + `}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + response, reported := serveProtocolWithReports(test.body, "") + if test.notification { + require.Len(t, reported, 1) + require.Empty(t, response.Body.String()) + return + } + require.Empty(t, reported) + require.JSONEq(t, test.want, response.Body.String()) + }) + } +} + +func TestRequestIDPresenceControlsErrors(t *testing.T) { + tests := []struct { + name string + body string + want string + }{ + {name: "missing ID", body: ` + "`" + `{"jsonrpc":"2.0","method":"missing"}` + "`" + `}, + {name: "empty string ID", body: ` + "`" + `{"jsonrpc":"2.0","id":"","method":"missing"}` + "`" + `, want: ` + "`" + `{"jsonrpc":"2.0","id":"","error":{"code":-32601,"message":"Method not found"}}` + "`" + `}, + {name: "null ID", body: ` + "`" + `{"jsonrpc":"2.0","id":null,"method":"missing"}` + "`" + `, want: ` + "`" + `{"jsonrpc":"2.0","id":null,"error":{"code":-32601,"message":"Method not found"}}` + "`" + `}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + response := serveProtocol(test.body, "") + if test.want == "" { + require.Empty(t, response.Body.String()) + return + } + require.JSONEq(t, test.want, response.Body.String()) + }) + } +} + +func TestBatchFormFollowsJSONWhitespaceAndEmptyArrayRules(t *testing.T) { + response := serveProtocol(" \n\t["+` + "`" + `{"jsonrpc":"2.0","id":"one","method":"ping"}` + "`" + `+"]", "") + require.JSONEq(t, ` + "`" + `[{"jsonrpc":"2.0","id":"one","result":null}]` + "`" + `, response.Body.String()) + + response = serveProtocol("[]", "") + require.JSONEq(t, ` + "`" + `{"jsonrpc":"2.0","id":null,"error":{"code":-32600,"message":"Invalid request"}}` + "`" + `, response.Body.String()) + + response, reported := serveProtocolWithReports(` + "`" + `[{"jsonrpc":"2.0","method":"ping"}]` + "`" + `, "") + require.Len(t, reported, 1) + require.Empty(t, response.Body.String()) +} + +func TestInvalidRequestsReturnErrors(t *testing.T) { + for _, body := range []string{ + ` + "`" + `{}` + "`" + `, + ` + "`" + `{"jsonrpc":"1.0","method":"ping"}` + "`" + `, + } { + response := serveProtocol(body, "") + require.JSONEq(t, ` + "`" + `{"jsonrpc":"2.0","id":null,"error":{"code":-32600,"message":"Invalid request"}}` + "`" + `, response.Body.String()) + } +} + +func TestBatchProcessesInvalidMembersIndependently(t *testing.T) { + response := serveProtocol("[1]", "") + require.JSONEq(t, ` + "`" + `[{"jsonrpc":"2.0","id":null,"error":{"code":-32600,"message":"Invalid request"}}]` + "`" + `, response.Body.String()) + + response, reported := serveProtocolWithReports(` + "`" + `[{"jsonrpc":"2.0","id":"one","method":"ping"},1,{"jsonrpc":"2.0","method":"ping"}]` + "`" + `, "") + require.Len(t, reported, 1) + require.JSONEq(t, ` + "`" + `[ + {"jsonrpc":"2.0","id":"one","result":null}, + {"jsonrpc":"2.0","id":null,"error":{"code":-32600,"message":"Invalid request"}} + ]` + "`" + `, response.Body.String()) +} + +func TestAcceptQualityControlsEventStreamSelection(t *testing.T) { + response := serveProtocol(` + "`" + `{"jsonrpc":"2.0","id":"one","method":"ping"}` + "`" + `, "text/event-stream;q=0, application/json") + require.JSONEq(t, ` + "`" + `{"jsonrpc":"2.0","id":"one","result":null}` + "`" + `, response.Body.String()) + + response = serveProtocol(` + "`" + `{"jsonrpc":"2.0","id":"one","method":"ping"}` + "`" + `, "Text/Event-Stream;Q=0.5") + require.Equal(t, http.StatusNotAcceptable, response.Code) + require.Empty(t, response.Body.String()) + + response = serveProtocol(` + "`" + `{"jsonrpc":"2.0","id":"one","method":"ping"}` + "`" + `, "application/json, text/event-stream;q=0.5") + require.JSONEq(t, ` + "`" + `{"jsonrpc":"2.0","id":"one","result":null}` + "`" + `, response.Body.String()) +} + +func TestMixedServerSelectsTheRequestedMethodsResponseType(t *testing.T) { + tests := []struct { + name string + method string + accept string + wantCode int + wantEvent bool + wantPing int + wantWatch int + }{ + {name: "unary with both", method: "ping", accept: "application/json, text/event-stream", wantCode: http.StatusOK, wantPing: 1}, + {name: "stream with both", method: "watch", accept: "application/json, text/event-stream", wantCode: http.StatusOK, wantEvent: true, wantWatch: 1}, + {name: "unary with events only", method: "ping", accept: "text/event-stream", wantCode: http.StatusNotAcceptable}, + {name: "stream with JSON only", method: "watch", accept: "application/json", wantCode: http.StatusNotAcceptable}, + {name: "unary without accept", method: "ping", wantCode: http.StatusOK, wantPing: 1}, + {name: "stream without accept", method: "watch", wantCode: http.StatusOK, wantEvent: true, wantWatch: 1}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + body := ` + "`" + `{"jsonrpc":"2.0","id":"one","method":"` + "`" + ` + test.method + ` + "`" + `"}` + "`" + ` + response, calls := serveProtocolWithCalls(body, test.accept) + require.Equal(t, test.wantCode, response.Code) + require.Equal(t, test.wantPing, calls.ping) + require.Equal(t, test.wantWatch, calls.watch) + if test.wantEvent { + require.Contains(t, response.Body.String(), ` + "`" + `"jsonrpc":"2.0"` + "`" + `) + require.Contains(t, response.Body.String(), ` + "`" + `"result":null` + "`" + `) + require.NotContains(t, response.Body.String(), "event:") + } + if test.wantCode == http.StatusNotAcceptable { + require.Empty(t, response.Body.String()) + } + }) + } +} + +func TestMixedServerChoosesAFormatForRequestErrors(t *testing.T) { + tests := []struct { + name string + body string + accept string + wantCode int + wantEvent bool + wantBody bool + }{ + {name: "malformed prefers JSON", body: "{", accept: "application/json, text/event-stream", wantCode: http.StatusOK, wantBody: true}, + {name: "malformed uses events", body: "{", accept: "text/event-stream", wantCode: http.StatusOK, wantEvent: true, wantBody: true}, + {name: "malformed unsupported", body: "{", accept: "application/xml", wantCode: http.StatusNotAcceptable}, + {name: "invalid prefers JSON", body: ` + "`" + `{}` + "`" + `, accept: "application/json, text/event-stream", wantCode: http.StatusOK, wantBody: true}, + {name: "unknown prefers JSON", body: ` + "`" + `{"jsonrpc":"2.0","id":"one","method":"missing"}` + "`" + `, accept: "application/json, text/event-stream", wantCode: http.StatusOK, wantBody: true}, + {name: "unknown uses events", body: ` + "`" + `{"jsonrpc":"2.0","id":"one","method":"missing"}` + "`" + `, accept: "text/event-stream", wantCode: http.StatusOK, wantEvent: true, wantBody: true}, + {name: "unknown notification has no response", body: ` + "`" + `{"jsonrpc":"2.0","method":"missing"}` + "`" + `, accept: "text/event-stream", wantCode: http.StatusOK}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + response, calls := serveProtocolWithCalls(test.body, test.accept) + require.Equal(t, test.wantCode, response.Code) + require.Zero(t, calls.ping) + require.Zero(t, calls.watch) + require.Equal(t, test.wantBody, response.Body.Len() > 0) + if test.wantEvent { + require.Contains(t, response.Body.String(), ` + "`" + `"jsonrpc":"2.0"` + "`" + `) + require.Contains(t, response.Body.String(), ` + "`" + `"error":` + "`" + `) + require.NotContains(t, response.Body.String(), "event:") + } + }) + } +} + +func TestMixedServerKeepsBatchesOnJSON(t *testing.T) { + body := ` + "`" + `[ + {"jsonrpc":"2.0","id":"one","method":"ping"}, + {"jsonrpc":"2.0","id":"two","method":"watch"}, + {"jsonrpc":"2.0","method":"watch"}, + {"jsonrpc":"2.0","id":"three","method":"ping"} + ]` + "`" + ` + response, calls, reported := serveProtocolBody(io.NopCloser(strings.NewReader(body)), "application/json, text/event-stream") + require.Equal(t, http.StatusOK, response.Code) + require.Len(t, reported, 1) + require.Equal(t, 2, calls.ping) + require.Zero(t, calls.watch) + require.JSONEq(t, ` + "`" + `[ + {"jsonrpc":"2.0","id":"one","result":null}, + {"jsonrpc":"2.0","id":"two","error":{"code":-32601,"message":"Method is not available in a batch request"}}, + {"jsonrpc":"2.0","id":"three","result":null} + ]` + "`" + `, response.Body.String()) + + response, calls = serveProtocolWithCalls(body, "text/event-stream") + require.Equal(t, http.StatusNotAcceptable, response.Code) + require.Zero(t, calls.ping) + require.Zero(t, calls.watch) + require.Empty(t, response.Body.String()) +} + +func TestMixedServerClosesTheOriginalBodyOnce(t *testing.T) { + tests := []struct { + name string + body string + accept string + }{ + {name: "single", body: ` + "`" + `{"jsonrpc":"2.0","id":"one","method":"ping"}` + "`" + `}, + {name: "batch", body: ` + "`" + `[{"jsonrpc":"2.0","id":"one","method":"ping"}]` + "`" + `}, + {name: "parse error", body: "{"}, + {name: "not acceptable", body: ` + "`" + `{"jsonrpc":"2.0","id":"one","method":"ping"}` + "`" + `, accept: "text/event-stream"}, + {name: "stream completion", body: ` + "`" + `{"jsonrpc":"2.0","id":"one","method":"watch"}` + "`" + `, accept: "text/event-stream"}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + body := &trackedRequestBody{Reader: strings.NewReader(test.body)} + serveProtocolBody(body, test.accept) + require.Equal(t, 1, body.closes) + }) + } +} + +func TestMixedServerReportsReadAndCloseErrors(t *testing.T) { + readErr := errors.New("read failed") + closeErr := errors.New("close failed") + body := &trackedRequestBody{Reader: errorReader{err: readErr}, closeErr: closeErr} + _, _, reported := serveProtocolBody(body, "application/json") + require.ErrorIs(t, errors.Join(reported...), readErr) + require.ErrorIs(t, errors.Join(reported...), closeErr) + require.Equal(t, 1, body.closes) +} + +func TestUndeclaredServiceErrorIsInternal(t *testing.T) { + err := goa.NewServiceError(errors.New("failed"), "invalid_params", false, false, false) + response, _, reported := serveProtocolBodyWithDecoder( + io.NopCloser(strings.NewReader(` + "`" + `{"jsonrpc":"2.0","id":"one","method":"ping"}` + "`" + `)), + "application/json, text/event-stream", + goahttp.RequestDecoder, + err, + ) + require.Empty(t, reported) + require.JSONEq(t, ` + "`" + `{"jsonrpc":"2.0","id":"one","error":{"code":-32603,"message":"failed"}}` + "`" + `, response.Body.String()) +} + +func TestMixedServerClosesTheBodySuppliedByTheHTTPServer(t *testing.T) { + original := &trackedRequestBody{Reader: strings.NewReader(` + "`" + `{"jsonrpc":"2.0","id":"one","method":"ping"}` + "`" + `)} + replacement := &trackedRequestBody{Reader: strings.NewReader("")} + decoder := func(r *http.Request) goahttp.Decoder { + result := goahttp.RequestDecoder(r) + r.Body = replacement + return result + } + + serveProtocolBodyWithDecoder(original, "application/json", decoder, nil) + + require.Equal(t, 1, original.closes) + require.Zero(t, replacement.closes) +} + +func serveProtocol(body, accept string) *httptest.ResponseRecorder { + response, _ := serveProtocolWithCalls(body, accept) + return response +} + +func serveProtocolWithReports(body, accept string) (*httptest.ResponseRecorder, []error) { + response, _, reported := serveProtocolBody(io.NopCloser(strings.NewReader(body)), accept) + return response, reported +} + +type protocolCalls struct { + ping int + watch int +} + +type trackedRequestBody struct { + io.Reader + closeErr error + closes int +} + +type errorReader struct { + err error +} + +func (reader errorReader) Read([]byte) (int, error) { + return 0, reader.err +} + +func (body *trackedRequestBody) Close() error { + body.closes++ + return body.closeErr +} + +func serveProtocolWithCalls(body, accept string) (*httptest.ResponseRecorder, *protocolCalls) { + response, calls, _ := serveProtocolBody(io.NopCloser(strings.NewReader(body)), accept) + return response, calls +} + +func serveProtocolBody(body io.ReadCloser, accept string) (*httptest.ResponseRecorder, *protocolCalls, []error) { + return serveProtocolBodyWithDecoder(body, accept, goahttp.RequestDecoder, nil) +} + +func serveProtocolBodyWithDecoder(body io.ReadCloser, accept string, decoder func(*http.Request) goahttp.Decoder, pingError error) (*httptest.ResponseRecorder, *protocolCalls, []error) { + encoder := goahttp.ResponseEncoder + var reported []error + errhandler := func(_ context.Context, _ http.ResponseWriter, err error) { + reported = append(reported, err) + } + calls := &protocolCalls{} + server := &Server{ + Ping: NewPingHandler( + goa.Endpoint(func(context.Context, any) (any, error) { + calls.ping++ + return nil, pingError + }), + goahttp.NewMuxer(), + decoder, + encoder, + errhandler, + ), + Watch: NewWatchHandler( + goa.Endpoint(func(context.Context, any) (any, error) { + calls.watch++ + return nil, nil + }), + goahttp.NewMuxer(), + decoder, + encoder, + errhandler, + ), + decoder: decoder, + encoder: encoder, + errhandler: errhandler, + } + request := httptest.NewRequest(http.MethodPost, "/protocol", nil) + request.Body = body + request.Header.Set("Accept", accept) + response := httptest.NewRecorder() + server.ServeHTTP(response, request) + return response, calls, reported +} +` + +const pureSSEBodyRuntimeTest = `package server + +import ( + "context" + "errors" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/stretchr/testify/require" + + goahttp "goa.design/goa/v3/http" + goa "goa.design/goa/v3/pkg" +) + +type trackedRequestBody struct { + io.Reader + closeErr error + closes int +} + +type errorReader struct { + err error +} + +func (reader errorReader) Read([]byte) (int, error) { + return 0, reader.err +} + +func (body *trackedRequestBody) Close() error { + body.closes++ + return body.closeErr +} + +func TestPureSSEServerClosesTheOriginalBodyOnce(t *testing.T) { + body := &trackedRequestBody{Reader: strings.NewReader(` + "`" + `{"jsonrpc":"2.0","id":"one","method":"watch","params":{"topic":"alerts"}}` + "`" + `)} + _, reported := servePureSSE(body) + require.Empty(t, reported) + require.Equal(t, 1, body.closes) +} + +func TestPureSSEServerReportsReadAndCloseErrors(t *testing.T) { + readErr := errors.New("read failed") + closeErr := errors.New("close failed") + body := &trackedRequestBody{Reader: errorReader{err: readErr}, closeErr: closeErr} + _, reported := servePureSSE(body) + require.ErrorIs(t, errors.Join(reported...), readErr) + require.ErrorIs(t, errors.Join(reported...), closeErr) + require.Equal(t, 1, body.closes) +} + +func servePureSSE(body io.ReadCloser) (*httptest.ResponseRecorder, []error) { + encoder := goahttp.ResponseEncoder + var reported []error + errhandler := func(_ context.Context, _ http.ResponseWriter, err error) { + reported = append(reported, err) + } + server := &Server{ + Watch: NewWatchHandler( + goa.Endpoint(func(context.Context, any) (any, error) { return nil, nil }), + goahttp.NewMuxer(), + goahttp.RequestDecoder, + encoder, + errhandler, + ), + decoder: goahttp.RequestDecoder, + encoder: encoder, + errhandler: errhandler, + } + request := httptest.NewRequest(http.MethodPost, "/decode", nil) + request.Body = body + response := httptest.NewRecorder() + server.handleSSE(response, request) + return response, reported +} +` diff --git a/jsonrpc/codegen/service_imports.go b/jsonrpc/codegen/service_imports.go new file mode 100644 index 0000000000..b2cef6ad72 --- /dev/null +++ b/jsonrpc/codegen/service_imports.go @@ -0,0 +1,239 @@ +// This file records every package referenced by JSON-RPC templates before Go +// names become final, then writes those packages into each output file. +package codegen + +import ( + "path" + "strings" + + "goa.design/goa/v3/codegen" + "goa.design/goa/v3/expr" +) + +// setFileImports replaces a file's header imports with the JSON-RPC imports +// and shared HTTP imports recorded for that exact path. +func setFileImports(file *codegen.File, service *servicePlan) *codegen.File { + header := file.SectionTemplates[0] + header.Data.(map[string]any)["Imports"] = []*codegen.ImportSpec(nil) + codegen.AddImport(header, service.fileImportSpecs(file.Path)...) + codegen.AddImport(header, service.data.FileImports(file.Path)...) + return file +} + +// planJSONRPCFileImports records the packages referenced by each JSON-RPC file +// and reserves their names in the package that will write the file. +func planJSONRPCFileImports( + service *servicePlan, + transport *expr.HTTPServiceExpr, + client, server *codegen.GeneratedPackage, + serviceImport, viewsImport *codegen.ImportSpec, +) error { + service.fileImports = make(map[string]*codegen.GeneratedImportPlan) + service.clientPackage = client + service.serverPackage = server + service.serviceImportPath = serviceImport.Path + base := path.Join(codegen.Gendir, "jsonrpc", service.pathName) + clientPath := path.Join(base, "client") + serverPath := path.Join(base, "server") + + if err := service.recordFileImports(client, path.Join(clientPath, "client.go"), jsonRPCClientImports(service.jsonRPCServiceFacts), nil); err != nil { + return err + } + serverGenerated := []*codegen.ImportSpec{serviceImport} + if service.hasViewedResult { + serverGenerated = append(serverGenerated, viewsImport) + } + if err := service.recordFileImports(server, path.Join(serverPath, "server.go"), jsonRPCServerImports(service.jsonRPCServiceFacts), serverGenerated); err != nil { + return err + } + + var clientCodecGenerated []*codegen.ImportSpec + if service.hasViewedResult { + clientCodecGenerated = []*codegen.ImportSpec{serviceImport, viewsImport} + } + if err := service.recordFileImports(client, path.Join(clientPath, "encode_decode.go"), jsonRPCClientCodecImports(transport), clientCodecGenerated); err != nil { + return err + } + if err := service.recordFileImports(server, path.Join(serverPath, "encode_decode.go"), nil, nil); err != nil { + return err + } + + if !service.hasSSE { + return nil + } + streamGenerated := jsonRPCStreamGeneratedImports(service.jsonRPCServiceFacts, serviceImport, viewsImport) + if err := service.recordFileImports(client, path.Join(clientPath, "stream.go"), jsonRPCClientStreamImports(service.jsonRPCServiceFacts), streamGenerated); err != nil { + return err + } + return service.recordFileImports(server, path.Join(serverPath, "sse.go"), jsonRPCServerStreamImports(service.jsonRPCServiceFacts), streamGenerated) +} + +// nativeServiceName returns the service package name used by JSON-RPC +// templates in one output package. It returns an empty string when those +// templates do not refer to the service package. +func (s *servicePlan) nativeServiceName(client bool) string { + if client { + if !s.hasViewedResult && !s.streamUsesServicePackage { + return "" + } + return s.clientPackage.ImportName(s.serviceImportPath) + } + return s.serverPackage.ImportName(s.serviceImportPath) +} + +// recordFileImports reserves runtime and generated package names, then saves +// the packages used by one source file. +func (s *servicePlan) recordFileImports( + output *codegen.GeneratedPackage, + filePath string, + runtimePackages, generatedPackages []*codegen.ImportSpec, +) error { + imports := codegen.NewGeneratedImportPlan(output) + if err := imports.Require(runtimePackages...); err != nil { + return err + } + if err := imports.AddGenerated(generatedPackages...); err != nil { + return err + } + s.fileImports[jsonRPCFilePath(filePath)] = imports + return nil +} + +// fileImportSpecs returns the final package names recorded for one generated +// file. Unknown paths are generator bugs. +func (s *servicePlan) fileImportSpecs(filePath string) []*codegen.ImportSpec { + planned, ok := s.fileImports[jsonRPCFilePath(filePath)] + if !ok { + panic("JSON-RPC file has no import plan") + } + return planned.Imports() +} + +// jsonRPCClientImports returns packages named by the generated client file. +func jsonRPCClientImports(facts jsonRPCServiceFacts) []*codegen.ImportSpec { + paths := []string{"bytes", "context", "net/http", "sync", codegen.GoaNamedImport("http", "goahttp").Path, codegen.GoaImport("").Path} + if facts.hasSSE { + paths = append(paths, "errors", "fmt", "io", "mime") + } + if facts.hasNotification { + paths = append(paths, "errors", "io") + } + return jsonRPCImports(paths...) +} + +// jsonRPCServerImports returns packages named by the generated server file. +func jsonRPCServerImports(facts jsonRPCServiceFacts) []*codegen.ImportSpec { + paths := []string{ + "context", "fmt", "net/http", codegen.GoaNamedImport("http", "goahttp").Path, + codegen.GoaImport("jsonrpc").Path, codegen.GoaImport("").Path, + } + if facts.hasHTTP { + paths = append(paths, "bufio", "errors", "io") + } + if facts.hasSSE { + paths = append(paths, "bytes", "errors", "io", "sync") + } + if facts.hasHTTP && facts.hasSSE { + paths = append(paths, "mime", "strconv", "strings") + } + return jsonRPCImports(paths...) +} + +// jsonRPCClientCodecImports returns packages named by JSON-RPC response and +// viewed-result decoders added to the shared HTTP codec file. +func jsonRPCClientCodecImports(service *expr.HTTPServiceExpr) []*codegen.ImportSpec { + var paths []string + for _, endpoint := range service.HTTPEndpoints { + if !endpoint.UsesSSE() && !endpoint.IsJSONRPCNotification() { + paths = append(paths, "bytes", "errors", "io", "net/http", codegen.GoaNamedImport("http", "goahttp").Path, codegen.GoaImport("jsonrpc").Path) + } + if !jsonRPCMethodHasViewedResult(endpoint.MethodExpr) { + continue + } + paths = append(paths, "bytes", "encoding/json", "io", "net/http", codegen.GoaNamedImport("http", "goahttp").Path, codegen.GoaImport("").Path) + if endpoint.SSE != nil && (endpoint.SSE.IDField != "" || endpoint.SSE.EventField != "" || endpoint.SSE.RetryField != "") { + paths = append(paths, "fmt") + } + if endpoint.SSE != nil && endpoint.SSE.RetryField != "" { + paths = append(paths, "strconv") + } + } + return jsonRPCImports(paths...) +} + +// jsonRPCClientStreamImports returns packages named by every generated event +// stream client and adds strconv only when a retry value is decoded. +func jsonRPCClientStreamImports(facts jsonRPCServiceFacts) []*codegen.ImportSpec { + paths := []string{ + "bufio", "bytes", "context", "encoding/json", "errors", "fmt", "io", "net/http", "strings", "sync", + codegen.GoaNamedImport("http", "goahttp").Path, codegen.GoaImport("jsonrpc").Path, + } + if facts.hasSSERetry { + paths = append(paths, "strconv") + } + return jsonRPCImports(paths...) +} + +// jsonRPCServerStreamImports returns packages named by the generated event +// stream server and adds packages used only by retry or variable-view code. +func jsonRPCServerStreamImports(facts jsonRPCServiceFacts) []*codegen.ImportSpec { + paths := []string{"context", codegen.GoaImport("jsonrpc").Path} + if facts.hasSSERetry { + paths = append(paths, "fmt") + } + if facts.hasVariableViewedStream { + paths = append(paths, codegen.GoaImport("").Path) + } + return jsonRPCImports(paths...) +} + +// jsonRPCStreamGeneratedImports returns generated service and view packages +// referenced by event types and viewed-result code in stream files. +func jsonRPCStreamGeneratedImports( + facts jsonRPCServiceFacts, + serviceImport, viewsImport *codegen.ImportSpec, +) []*codegen.ImportSpec { + if !facts.streamUsesServicePackage { + return nil + } + imports := []*codegen.ImportSpec{serviceImport} + if facts.streamUsesResultViewPackage { + imports = append(imports, viewsImport) + } + return imports +} + +// jsonRPCImports returns one import for each path using the package names that +// JSON-RPC templates write directly. +func jsonRPCImports(paths ...string) []*codegen.ImportSpec { + imports := make([]*codegen.ImportSpec, 0, len(paths)) + for _, importPath := range paths { + var spec *codegen.ImportSpec + switch importPath { + case codegen.GoaImport("").Path: + spec = codegen.GoaImport("") + case codegen.GoaNamedImport("http", "goahttp").Path: + spec = codegen.GoaNamedImport("http", "goahttp") + default: + spec = codegen.SimpleImport(importPath) + } + imports = append(imports, spec) + } + return imports +} + +// jsonRPCMethodHasViewedResult reports whether method's result comes from a +// result type that defines views. +func jsonRPCMethodHasViewedResult(method *expr.MethodExpr) bool { + userType, ok := method.Result.Type.(expr.UserType) + if !ok { + return false + } + _, ok = userType.Origin().(*expr.ResultTypeExpr) + return ok +} + +// jsonRPCFilePath returns the stable map key used for a generated source file. +func jsonRPCFilePath(filePath string) string { + return strings.ReplaceAll(filePath, "\\", "/") +} diff --git a/jsonrpc/codegen/single_endpoint_test.go b/jsonrpc/codegen/single_endpoint_test.go index 497253ba0c..95825aba30 100644 --- a/jsonrpc/codegen/single_endpoint_test.go +++ b/jsonrpc/codegen/single_endpoint_test.go @@ -25,7 +25,6 @@ func TestJSONRPCSingleEndpoint(t *testing.T) { dsl.Attribute("b", dsl.Int) }) dsl.Result(func() { - dsl.ID("id") dsl.Attribute("sum", dsl.Int) }) dsl.JSONRPC(func() {}) @@ -38,7 +37,6 @@ func TestJSONRPCSingleEndpoint(t *testing.T) { dsl.Attribute("b", dsl.Int) }) dsl.Result(func() { - dsl.ID("id") dsl.Attribute("diff", dsl.Int) }) dsl.JSONRPC(func() {}) @@ -96,7 +94,6 @@ func TestJSONRPCSingleEndpoint(t *testing.T) { dsl.Attribute("b", dsl.Int) }) dsl.Result(func() { - dsl.ID("id") dsl.Attribute("product", dsl.Int) }) dsl.JSONRPC(func() {}) // Should auto-enable service @@ -110,43 +107,4 @@ func TestJSONRPCSingleEndpoint(t *testing.T) { require.NotNil(t, svc.Meta) assert.NotNil(t, svc.Meta["jsonrpc:service"], "service should be auto-marked as JSON-RPC") }) - - t.Run("WebSocket forces GET", func(t *testing.T) { - root := expr.RunDSL(t, func() { - dsl.Service("stream", func() { - dsl.JSONRPC(func() {}) - - dsl.Method("echo", func() { - dsl.StreamingPayload(func() { - dsl.ID("id") - dsl.Attribute("msg", dsl.String) - }) - dsl.StreamingResult(func() { - dsl.ID("id") - dsl.Attribute("echo", dsl.String) - }) - dsl.JSONRPC(func() {}) - }) - }) - }) - - // Check route method - httpSvc := root.API.JSONRPC.Service("stream") - require.NotNil(t, httpSvc) - - // Prepare the service to create routes - httpSvc.Prepare() - - // Find first endpoint with route - var route *expr.RouteExpr - for _, e := range httpSvc.HTTPEndpoints { - if e.IsJSONRPC() && len(e.Routes) > 0 { - route = e.Routes[0] - break - } - } - - require.NotNil(t, route) - assert.Equal(t, "GET", route.Method, "WebSocket should force GET method") - }) } diff --git a/jsonrpc/codegen/sse.go b/jsonrpc/codegen/sse.go index 08c64733df..50808f2511 100644 --- a/jsonrpc/codegen/sse.go +++ b/jsonrpc/codegen/sse.go @@ -1,3 +1,5 @@ +// This file renders JSON-RPC server-sent-event clients and servers. Each file +// imports only the generated service types used by its stream methods. package codegen import ( @@ -9,113 +11,101 @@ import ( httpcodegen "goa.design/goa/v3/http/codegen" ) -// sseServerFile returns the file implementing the JSON-RPC SSE server -// streams if any. The file contains the shared SSE stream machinery followed -// by one stream implementation per SSE endpoint. -func sseServerFile(genpkg string, svc *expr.HTTPServiceExpr, services *httpcodegen.ServicesData) *codegen.File { - data := services.Get(svc.Name()) - if data == nil { - return nil - } - if !hasSSEEndpoint(data) { +// sseServerFile returns the JSON-RPC server-sent-event file when the service +// has a method that sends events. The file writes one stream type for each +// method; server.go contains the shared writer used by those streams. +func sseServerFile(planned *servicePlan) *codegen.File { + data := planned.data + if !planned.hasSSE { return nil } path := filepath.Join(codegen.Gendir, "jsonrpc", data.Service.PathName, "server", "sse.go") - title := fmt.Sprintf("%s SSE server streaming", svc.Name()) - imports := make([]*codegen.ImportSpec, 0, 9+len(data.Service.UserTypeImports)) - imports = append(imports, - &codegen.ImportSpec{Path: "context"}, - &codegen.ImportSpec{Path: "errors"}, - &codegen.ImportSpec{Path: "fmt"}, - &codegen.ImportSpec{Path: "net/http"}, - &codegen.ImportSpec{Path: "sync"}, - codegen.GoaImport(""), - codegen.GoaImport("jsonrpc"), - codegen.GoaNamedImport("http", "goahttp"), - &codegen.ImportSpec{Path: genpkg + "/" + data.Service.PathName, Name: data.Service.PkgName}, - ) - imports = append(imports, data.Service.UserTypeImports...) + title := fmt.Sprintf("%s SSE server streaming", planned.name) sections := []*codegen.SectionTemplate{ - codegen.Header(title, "server", imports), - { - Name: "jsonrpc-sse-server-stream-base", - Source: jsonrpcTemplates.Read(sseServerStreamBaseT), - }, + codegen.Header(title, "server", nil), } - for _, ed := range data.Endpoints { + funcs := viewedResultFuncs(planned) + funcs["sseStreamName"] = planned.sseStreamName + funcs["sseRetrySigned"] = sseRetrySigned + for _, ed := range planned.endpoints { if ed.SSE == nil { continue } sections = append(sections, &codegen.SectionTemplate{ - Name: "jsonrpc-sse-server-stream", - Source: jsonrpcTemplates.Read(sseServerStreamT), - Data: ed, + Name: "jsonrpc-sse-server-stream", + Source: jsonrpcTemplates.Read(sseServerStreamT), + Data: ed, + FuncMap: funcs, }) } return &codegen.File{Path: path, SectionTemplates: sections} } -// sseClientFile returns the file implementing the SSE client streaming implementation if any. -func sseClientFile(genpkg string, svc *expr.HTTPServiceExpr, services *httpcodegen.ServicesData) *codegen.File { - data := services.Get(svc.Name()) - if data == nil { - return nil - } - if !hasSSEEndpoint(data) { +// sseClientFile returns the server-sent-event client file when the service has +// a method that receives events. +func sseClientFile(planned *servicePlan) *codegen.File { + data := planned.data + if !planned.hasSSE { return nil } path := filepath.Join(codegen.Gendir, "jsonrpc", data.Service.PathName, "client", "stream.go") - tmplSections := sseClientStreamSections(data) + tmplSections := sseClientStreamSections(planned) sections := make([]*codegen.SectionTemplate, 0, 1+len(tmplSections)) sections = append(sections, codegen.Header( "stream", "client", - []*codegen.ImportSpec{ - {Path: "bufio"}, - {Path: "bytes"}, - {Path: "context"}, - {Path: "encoding/json"}, - {Path: "fmt"}, - {Path: "io"}, - {Path: "net/http"}, - {Path: "strings"}, - {Path: "sync"}, - codegen.GoaImport("jsonrpc"), - codegen.GoaNamedImport("http", "goahttp"), - {Path: genpkg + "/" + data.Service.PathName, Name: data.Service.PkgName}, - }, + nil, ), ) sections = append(sections, tmplSections...) return &codegen.File{Path: path, SectionTemplates: sections} } -// sseClientStreamSections returns section templates for SSE client endpoints. -func sseClientStreamSections(data *httpcodegen.ServiceData) []*codegen.SectionTemplate { +// sseClientStreamSections returns the generated code for each method that +// receives server-sent events. +func sseClientStreamSections(service *servicePlan) []*codegen.SectionTemplate { sections := make([]*codegen.SectionTemplate, 0) - for _, ed := range data.Endpoints { + for _, ed := range service.endpoints { if ed.SSE == nil { continue } - // Generate SSE client stream struct and methods + // Write the client stream type and its methods. sections = append(sections, &codegen.SectionTemplate{ Name: "jsonrpc-sse-client-stream", - Source: jsonrpcTemplates.Read(sseClientStreamT), + Source: jsonrpcTemplates.Read(sseClientStreamT, singleResponseP, queryTypeConversionP, elementSliceConversionP, sliceItemConversionP), Data: ed, + FuncMap: map[string]any{ + "buildResponseData": buildJSONRPCResponseData, + "viewedDecodeName": viewedResultFuncs(service)["viewedDecodeName"], + "sseRetryBits": sseRetryBits, + "sseRetrySigned": sseRetrySigned, + }, }) } return sections } -// hasSSEEndpoint returns true if any endpoint of the service uses SSE. -func hasSSEEndpoint(data *httpcodegen.ServiceData) bool { - for _, ed := range data.Endpoints { - if ed.SSE != nil { - return true - } +// sseRetrySigned reports whether a retry field uses a signed integer. +func sseRetrySigned(value *httpcodegen.SSEValueData) bool { + switch value.Kind { + case expr.IntKind, expr.Int32Kind, expr.Int64Kind: + return true + default: + return false + } +} + +// sseRetryBits returns the integer width used to parse a retry line. +func sseRetryBits(value *httpcodegen.SSEValueData) int { + switch value.Kind { + case expr.Int32Kind, expr.UInt32Kind: + return 32 + case expr.Int64Kind, expr.UInt64Kind: + return 64 + default: + return 0 } - return false } diff --git a/jsonrpc/codegen/sse_dedup_test.go b/jsonrpc/codegen/sse_dedup_test.go index 503aae83c6..bf9d47dad7 100644 --- a/jsonrpc/codegen/sse_dedup_test.go +++ b/jsonrpc/codegen/sse_dedup_test.go @@ -17,33 +17,39 @@ import ( // endpoint. func TestJSONRPCSSE_DedupEventTypes(t *testing.T) { root := expr.RunDSL(t, testdata.JSONRPCSSEDuplicateEventDSL) - services := CreateJSONRPCServices(root) + plan := CreateJSONRPCPlan(root) // Generate JSON-RPC server files (includes the SSE streams file) - fs := ServerFiles("", services) + fs := plan.ServerFiles() require.NotEmpty(t, fs) - // Render the SSE streams file (sse.go) - var code string + // Render the shared writer and the endpoint streams. + var serverCode, streamCode string for _, f := range fs { - if filepath.Base(f.Path) == "sse.go" && filepath.Base(filepath.Dir(f.Path)) == "server" { + name := filepath.Base(f.Path) + if (name == "server.go" || name == "sse.go") && filepath.Base(filepath.Dir(f.Path)) == "server" { t.Logf("file: %s", f.Path) - // Render all sections into a single source string var b strings.Builder for _, s := range f.SectionTemplates { require.NoError(t, s.Write(&b)) } - code = b.String() - break + if name == "server.go" { + serverCode = b.String() + } else { + streamCode = b.String() + } } } - require.NotEmpty(t, code, "sse.go content not found") + require.NotEmpty(t, serverCode, "server.go content not found") + require.NotEmpty(t, streamCode, "sse.go content not found") // The shared machinery must be declared exactly once. - require.Equal(t, 1, strings.Count(code, "type sseServerStream struct"), "expected a single sseServerStream declaration\n%s", code) - require.Equal(t, 1, strings.Count(code, "type sseEventWriter struct"), "expected a single sseEventWriter declaration\n%s", code) + require.Equal(t, 1, strings.Count(serverCode, "sseServerStream struct"), "expected a single sseServerStream declaration\n%s", serverCode) + require.Equal(t, 1, strings.Count(serverCode, "sseEventBuffer struct"), "expected a single sseEventBuffer declaration\n%s", serverCode) + require.NotContains(t, streamCode, "sseServerStream struct") + require.NotContains(t, streamCode, "sseEventBuffer struct") // Each endpoint gets its own stream type even when sharing the event type. - require.Equal(t, 1, strings.Count(code, "type StreamAServerStream struct"), "expected a single StreamA stream declaration\n%s", code) - require.Equal(t, 1, strings.Count(code, "type StreamBServerStream struct"), "expected a single StreamB stream declaration\n%s", code) + require.Equal(t, 1, strings.Count(streamCode, "type StreamAServerStream struct"), "expected a single StreamA stream declaration\n%s", streamCode) + require.Equal(t, 1, strings.Count(streamCode, "type StreamBServerStream struct"), "expected a single StreamB stream declaration\n%s", streamCode) } diff --git a/jsonrpc/codegen/sse_integration_test.go b/jsonrpc/codegen/sse_integration_test.go index a16f91ab80..8958c42f26 100644 --- a/jsonrpc/codegen/sse_integration_test.go +++ b/jsonrpc/codegen/sse_integration_test.go @@ -20,11 +20,11 @@ func TestJSONRPCSSEIntegration(t *testing.T) { // Run the DSL root := expr.RunDSL(t, testdata.JSONRPCSSEObjectDSL) - services := CreateJSONRPCServices(root) + plan := CreateJSONRPCPlan(root) // Generate all files - serverFiles := ServerFiles("", services) - clientFiles := ClientFiles("", services) + serverFiles := plan.ServerFiles() + clientFiles := plan.ClientFiles() // Combine all files allFiles := make([]*codegen.File, 0, len(serverFiles)+len(clientFiles)) diff --git a/jsonrpc/codegen/sse_notification_output_runtime_test.go b/jsonrpc/codegen/sse_notification_output_runtime_test.go new file mode 100644 index 0000000000..cdddfc5d47 --- /dev/null +++ b/jsonrpc/codegen/sse_notification_output_runtime_test.go @@ -0,0 +1,124 @@ +// This file renders a JSON-RPC SSE server and proves that streaming methods +// require request IDs before any service code runs. +package codegen_test + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/require" +) + +// TestGeneratedSSERequiresRequestID checks both rejected missing IDs and a +// valid stream with a response ID. +func TestGeneratedSSERequiresRequestID(t *testing.T) { + dir := renderParamsRuntimeModule(t) + serverDir := filepath.Join(dir, "jsonrpc", "param_shapes", "server") + require.NoError(t, os.WriteFile( + filepath.Join(serverDir, "sse_notification_output_runtime_test.go"), + []byte(sseRequestIDRuntimeTest), + 0o600, + )) + runParamsRuntimeTests(t, dir) +} + +const sseRequestIDRuntimeTest = `package server + +import ( + "context" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/stretchr/testify/require" + + service "generated.local/gen/param_shapes" + goahttp "goa.design/goa/v3/http" + goa "goa.design/goa/v3/pkg" +) + +func TestSSEMethodRejectsNotificationWithoutAResponse(t *testing.T) { + response, calls, sends, encodes, reported := serveStreamText( + ` + "`" + `{"jsonrpc":"2.0","method":"stream_text"}` + "`" + `, + ) + require.Zero(t, calls) + require.NoError(t, sends) + require.Zero(t, encodes) + require.Len(t, reported, 1) + require.Empty(t, response.Body.String()) +} + +func TestSSEMethodRejectsNullIDWithAResponse(t *testing.T) { + response, calls, sends, encodes, reported := serveStreamText( + ` + "`" + `{"jsonrpc":"2.0","id":null,"method":"stream_text"}` + "`" + `, + ) + require.Zero(t, calls) + require.NoError(t, sends) + require.Equal(t, 1, encodes) + require.Empty(t, reported) + require.Equal(t, "text/event-stream", response.Header().Get("Content-Type")) + require.Contains(t, response.Body.String(), ` + "`" + `"id":null` + "`" + `) + require.Contains(t, response.Body.String(), ` + "`" + `"code":-32600` + "`" + `) + require.True(t, response.Flushed) +} + +func TestSSERequestStillStreams(t *testing.T) { + response, calls, sends, encodes, reported := serveStreamText( + ` + "`" + `{"jsonrpc":"2.0","id":"request-1","method":"stream_text"}` + "`" + `, + ) + require.Equal(t, 1, calls) + require.NoError(t, sends) + require.Equal(t, 2, encodes) + require.Empty(t, reported) + require.Equal(t, "text/event-stream", response.Header().Get("Content-Type")) + events := strings.Split(strings.TrimSpace(response.Body.String()), "\n\n") + require.Len(t, events, 2) + require.JSONEq(t, + ` + "`" + `{"jsonrpc":"2.0","method":"stream_text","params":["value"]}` + "`" + `, + strings.TrimPrefix(events[0], "data: "), + ) + require.JSONEq(t, + ` + "`" + `{"jsonrpc":"2.0","id":"request-1","result":null}` + "`" + `, + strings.TrimPrefix(events[1], "data: "), + ) + require.True(t, response.Flushed) +} + +func serveStreamText(body string) (*httptest.ResponseRecorder, int, error, int, []error) { + var calls int + var sendErr error + var encodes int + encoder := func(ctx context.Context, writer http.ResponseWriter) goahttp.Encoder { + encodes++ + return goahttp.ResponseEncoder(ctx, writer) + } + reported := make([]error, 0, 1) + errhandler := func(_ context.Context, _ http.ResponseWriter, err error) { + reported = append(reported, err) + } + server := &Server{ + StreamText: NewStreamTextHandler( + goa.Endpoint(func(_ context.Context, value any) (any, error) { + calls++ + input := value.(*service.StreamTextEndpointInput) + sendErr = input.Stream.Send("value") + return nil, nil + }), + goahttp.NewMuxer(), + goahttp.RequestDecoder, + encoder, + errhandler, + ), + decoder: goahttp.RequestDecoder, + encoder: encoder, + errhandler: errhandler, + } + request := httptest.NewRequest(http.MethodPost, "/rpc", strings.NewReader(body)) + request.Header.Set("Accept", "text/event-stream") + response := httptest.NewRecorder() + server.ServeHTTP(response, request) + return response, calls, sendErr, encodes, reported +} +` diff --git a/jsonrpc/codegen/sse_test.go b/jsonrpc/codegen/sse_test.go index 546d88e028..ef7c684ac9 100644 --- a/jsonrpc/codegen/sse_test.go +++ b/jsonrpc/codegen/sse_test.go @@ -24,10 +24,10 @@ func TestJSONRPCSSE(t *testing.T) { for _, c := range cases { t.Run(c.Name, func(t *testing.T) { root := expr.RunDSL(t, c.DSL) - services := CreateJSONRPCServices(root) + plan := CreateJSONRPCPlan(root) // Generate server files (includes the SSE streams file) - fs := ServerFiles("", services) + fs := plan.ServerFiles() require.NotEmpty(t, fs, "expected server files to be generated") // Debug: print all generated files diff --git a/jsonrpc/codegen/templates.go b/jsonrpc/codegen/templates.go index 0956843ea5..c288540b39 100644 --- a/jsonrpc/codegen/templates.go +++ b/jsonrpc/codegen/templates.go @@ -21,23 +21,13 @@ const ( mixedServerHandlerT = "mixed_server_handler" // Client - clientStructT = "client_struct" - clientInitT = "client_init" - clientEndpointInitT = "client_endpoint_init" - responseDecoderT = "response_decoder" - - // WebSocket templates - websocketServerStreamT = "websocket_server_stream" - websocketServerStreamWrapperT = "websocket_server_stream_wrapper" - websocketServerHandlerT = "websocket_server_handler" - websocketServerSendT = "websocket_server_send" - websocketServerRecvT = "websocket_server_recv" - websocketServerCloseT = "websocket_server_close" - - // JSON-RPC WebSocket client templates - websocketClientConnT = "websocket_client_conn" - websocketClientStreamT = "websocket_client_stream" - websocketStreamErrorTypesT = "websocket_stream_error_types" + clientStructT = "client_struct" + clientInitT = "client_init" + clientEndpointInitT = "client_endpoint_init" + responseDecoderT = "response_decoder" + viewedResultBodyDecodeT = "viewed_result_body_decode" + viewedResultDecodeT = "viewed_result_decode" + viewedResultEncodeT = "viewed_result_encode" // SSE templates sseServerStreamBaseT = "sse_server_stream_base" diff --git a/jsonrpc/codegen/templates/client_endpoint_init.go.tpl b/jsonrpc/codegen/templates/client_endpoint_init.go.tpl index 9c21167db8..45c29f9012 100644 --- a/jsonrpc/codegen/templates/client_endpoint_init.go.tpl +++ b/jsonrpc/codegen/templates/client_endpoint_init.go.tpl @@ -1,64 +1,40 @@ -{{- $retry := and .Method.Idempotent (eq .Method.StreamKind 1) (not .Method.SkipRequestBodyEncodeDecode) (not (isWebSocketEndpoint .)) (not (isSSEEndpoint .)) }} +{{- $retry := and .Method.Idempotent (eq .Method.StreamKind 1) (not .Method.SkipRequestBodyEncodeDecode) (not (isSSEEndpoint .)) }} +{{- if .IsJSONRPCNotification }} +{{ printf "%s returns an endpoint that sends JSON-RPC notifications to the %s service %s method." .EndpointInit .ServiceName .Method.Name | comment }} +{{- else }} {{ printf "%s returns an endpoint that makes JSON-RPC requests to the %s service %s method." .EndpointInit .ServiceName .Method.Name | comment }} -func (c *{{ .ClientStruct }}) {{ .EndpointInit }}() goa.Endpoint { -{{- if not (isWebSocketEndpoint .) }} +{{- end }} +func (c *{{ .ClientStructDeclaration.Name }}) {{ .EndpointInit }}() goa.Endpoint { var ( - {{- if .RequestEncoder }} - encodeRequest = {{ .RequestEncoder }}(c.encoder) + {{- if .RequestEncoderDeclaration }} + encodeRequest = {{ .RequestEncoderDeclaration.Name }}(c.encoder) {{- end }} - {{- if not (isSSEEndpoint .) }} - decodeResponse = {{ .ResponseDecoder }}(c.decoder, c.RestoreResponseBody) + {{- if and (not (isSSEEndpoint .)) (not .IsJSONRPCNotification) }} + decodeResponse = {{ .ResponseDecoderDeclaration.Name }}(c.decoder, c.RestoreResponseBody) {{- end }} ) -{{- end }} {{- if $retry }} endpoint := func(ctx context.Context, v any) (any, error) { {{- else }} return func(ctx context.Context, v any) (any, error) { {{- end }} -{{- if not (isWebSocketEndpoint .) }} - req, err := c.{{ .RequestInit.Name }}(ctx, {{ range .RequestInit.ClientArgs }}{{ .Ref }}, {{ end }}) + req, err := c.{{ .RequestInit.Declaration.Name }}(ctx, {{ range .RequestInit.ClientArgs }}{{ .Ref }}, {{ end }}) if err != nil { return nil, err } - {{- if .RequestEncoder }} + {{- if .RequestEncoderDeclaration }} + {{- if .IsJSONRPCNotification }} if err := encodeRequest(req, v); err != nil { return nil, err } - {{- end }} -{{- end }} -{{- if isWebSocketEndpoint . }} - {{- if and .ClientWebSocket.RecvName .ClientWebSocket.RecvTypeRef }} - // For WebSocket, pass the base decoder to the stream and decode inner results - decodeResponse := c.decoder - {{- end }} - - // Get direct WebSocket connection - ws, err := c.getConn(ctx) + {{- else }} + requestID, err := encodeRequest(req, v) if err != nil { return nil, err } - - // Create context with cancellation for the stream - streamCtx, cancel := context.WithCancel(ctx) - - // Create the stream with direct WebSocket handling - stream := &{{ .ClientWebSocket.VarName }}{ - ws: ws, - ctx: streamCtx, - cancel: cancel, - done: make(chan struct{}), - config: c.streamConfig, - {{- if and .ClientWebSocket.RecvName .ClientWebSocket.RecvTypeRef }} - decoder: decodeResponse, - {{- end }} - } - - // Start background response handler - go stream.responseHandler() - - return stream, nil -{{- else if isSSEEndpoint . }} + {{- end }} + {{- end }} +{{- if isSSEEndpoint . }} // For SSE endpoints, send JSON-RPC request and establish stream resp, err := c.Doer.Do(req) if err != nil { @@ -66,31 +42,49 @@ func (c *{{ .ClientStruct }}) {{ .EndpointInit }}() goa.Endpoint { } if resp.StatusCode != http.StatusOK { - body, _ := io.ReadAll(resp.Body) - resp.Body.Close() + body, readErr := io.ReadAll(resp.Body) + closeErr := resp.Body.Close() + if err := errors.Join(readErr, closeErr); err != nil { + return nil, goahttp.ErrDecodingError("{{ .ServiceName }}", "{{ .Method.Name }}", err) + } return nil, goahttp.ErrInvalidResponse("{{ .ServiceName }}", "{{ .Method.Name }}", resp.StatusCode, string(body)) } contentType := resp.Header.Get("Content-Type") - if contentType != "" && !strings.HasPrefix(contentType, "text/event-stream") { - resp.Body.Close() - return nil, fmt.Errorf("unexpected content type: %s (expected text/event-stream)", contentType) + mediaType, _, parseErr := mime.ParseMediaType(contentType) + if parseErr != nil || mediaType != "text/event-stream" { + contentTypeErr := fmt.Errorf("unexpected content type: %s (expected text/event-stream)", contentType) + if err := resp.Body.Close(); err != nil { + return nil, errors.Join(contentTypeErr, goahttp.ErrDecodingError("{{ .ServiceName }}", "{{ .Method.Name }}", err)) + } + return nil, contentTypeErr } // Create the SSE client stream - stream := &{{ .Method.VarName }}ClientStream{ - resp: resp, - reader: bufio.NewReader(resp.Body), - decoder: c.decoder, + return {{ .SSE.ClientInitDeclaration.Name }}(resp, c.decoder, requestID), nil +{{- else if .IsJSONRPCNotification }} + resp, err := c.Doer.Do(req) + if err != nil { + return nil, goahttp.ErrRequestError("{{ .ServiceName }}", "{{ .Method.Name }}", err) } - - return stream, nil + if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices { + body, readErr := io.ReadAll(resp.Body) + closeErr := resp.Body.Close() + if err := errors.Join(readErr, closeErr); err != nil { + return nil, goahttp.ErrDecodingError("{{ .ServiceName }}", "{{ .Method.Name }}", err) + } + return nil, goahttp.ErrInvalidResponse("{{ .ServiceName }}", "{{ .Method.Name }}", resp.StatusCode, string(body)) + } + if err := resp.Body.Close(); err != nil { + return nil, goahttp.ErrDecodingError("{{ .ServiceName }}", "{{ .Method.Name }}", err) + } + return nil, nil {{- else }} resp, err := c.Doer.Do(req) if err != nil { return nil, goahttp.ErrRequestError("{{ .ServiceName }}", "{{ .Method.Name }}", err) } - return decodeResponse(resp) + return decodeResponse(resp, requestID) {{- end }} } {{- if $retry }} diff --git a/jsonrpc/codegen/templates/client_init.go.tpl b/jsonrpc/codegen/templates/client_init.go.tpl index 4fa24f350c..72405ff856 100644 --- a/jsonrpc/codegen/templates/client_init.go.tpl +++ b/jsonrpc/codegen/templates/client_init.go.tpl @@ -1,23 +1,13 @@ -{{ printf "New%s instantiates HTTP clients for all the %s service servers." .ClientStruct .Service.Name | comment }} -func New{{ .ClientStruct }}( +{{ printf "%s creates HTTP clients for all the %s service servers." .ClientInitDeclaration.Name .Service.Name | comment }} +func {{ .ClientInitDeclaration.Name }}( scheme string, host string, doer goahttp.Doer, enc func(*http.Request) goahttp.Encoder, dec func(*http.Response) goahttp.Decoder, restoreBody bool, - {{- if hasWebSocket . }} - dialer goahttp.Dialer, - cfn goahttp.ConnConfigureFunc, - streamOpts ...jsonrpc.StreamConfigOption, - {{- end }} -) *{{ .ClientStruct }} { - {{- if hasWebSocket . }} - // Create stream configuration from options - streamConfig := jsonrpc.NewStreamConfig(streamOpts...) - {{- end }} - - return &{{ .ClientStruct }}{ +) *{{ .ClientStructDeclaration.Name }} { + return &{{ .ClientStructDeclaration.Name }}{ Doer: doer, {{- range .Endpoints }} {{- if isSSEEndpoint . }} @@ -29,10 +19,5 @@ func New{{ .ClientStruct }}( host: host, decoder: dec, encoder: enc, - {{- if hasWebSocket . }} - dialer: dialer, - configfn: cfn, - streamConfig: streamConfig, - {{- end }} } } diff --git a/jsonrpc/codegen/templates/client_struct.go.tpl b/jsonrpc/codegen/templates/client_struct.go.tpl index 3670bf2ef0..067a01507f 100644 --- a/jsonrpc/codegen/templates/client_struct.go.tpl +++ b/jsonrpc/codegen/templates/client_struct.go.tpl @@ -1,5 +1,5 @@ -{{ printf "%s lists the %s service endpoint HTTP clients." .ClientStruct .Service.Name | comment }} -type {{ .ClientStruct }} struct { +{{ printf "%s lists the %s service endpoint HTTP clients." .ClientStructDeclaration.Name .Service.Name | comment }} +type {{ .ClientStructDeclaration.Name }} struct { {{ printf "Doer is the HTTP client used to make requests to the %s service." .Service.Name | comment }} Doer goahttp.Doer {{- range .Endpoints }} @@ -16,21 +16,8 @@ type {{ .ClientStruct }} struct { host string encoder func(*http.Request) goahttp.Encoder decoder func(*http.Response) goahttp.Decoder - {{- if hasWebSocket . }} - dialer goahttp.Dialer - configfn goahttp.ConnConfigureFunc - - connMu sync.RWMutex - conn *websocket.Conn - closed atomic.Bool - - // Stream configuration (shared by all WebSocket streams) - streamConfig *jsonrpc.StreamConfig - {{- end }} } -{{- if not (hasWebSocket .) }} -// bufferPool is a pool of bytes.Buffers for encoding requests. -var bufferPool = sync.Pool{ +{{ printf "%s reuses byte buffers while requests are encoded." .BufferPool.Name | comment }} +var {{ .BufferPool.Name }} = sync.Pool{ New: func() any { return new(bytes.Buffer) }, } -{{- end }} diff --git a/jsonrpc/codegen/templates/mixed_server_handler.go.tpl b/jsonrpc/codegen/templates/mixed_server_handler.go.tpl index 6390f0966e..aeab6c2be0 100644 --- a/jsonrpc/codegen/templates/mixed_server_handler.go.tpl +++ b/jsonrpc/codegen/templates/mixed_server_handler.go.tpl @@ -1,13 +1,147 @@ -// ServeHTTP handles JSON-RPC requests with content negotiation for mixed HTTP/SSE transports. -func (s *{{ .ServerStruct }}) ServeHTTP(w http.ResponseWriter, r *http.Request) { - // Check Accept header for SSE - accept := r.Header.Get("Accept") - if strings.Contains(accept, "text/event-stream") { - // Route to SSE handler for streaming methods - s.handleSSE(w, r) +// ServeHTTP decodes one request and uses the response type designed for its method. +func (s *{{ .ServerStructDeclaration.Name }}) ServeHTTP(w http.ResponseWriter, r *http.Request) { + acceptJSON := false + acceptSSE := false + acceptValues := r.Header.Values("Accept") + if len(acceptValues) == 0 || len(acceptValues) == 1 && strings.TrimSpace(acceptValues[0]) == "" { + acceptJSON = true + acceptSSE = true + } else { + for _, header := range acceptValues { + for _, value := range strings.Split(header, ",") { + mediaType, params, err := mime.ParseMediaType(value) + if err != nil { + continue + } + quality := 1.0 + if value, ok := params["q"]; ok { + quality, err = strconv.ParseFloat(value, 64) + if err != nil { + continue + } + } + if quality <= 0 { + continue + } + switch mediaType { + case "*/*": + acceptJSON = true + acceptSSE = true + case "application/json", "application/*": + acceptJSON = true + case "text/event-stream", "text/*": + acceptSSE = true + } + } + } + } + + originalBody := r.Body + bufReader := bufio.NewReader(originalBody) + var peek []byte + for { + var err error + peek, err = bufReader.Peek(1) + if err != nil && err != io.EOF { + closeErr := originalBody.Close() + s.errhandler(r.Context(), w, fmt.Errorf("failed to read request body: %w", errors.Join(err, closeErr))) + return + } + if len(peek) == 0 || (peek[0] != ' ' && peek[0] != '\t' && peek[0] != '\n' && peek[0] != '\r') { + break + } + if _, err := bufReader.Discard(1); err != nil { + closeErr := originalBody.Close() + s.errhandler(r.Context(), w, fmt.Errorf("failed to read request body: %w", errors.Join(err, closeErr))) + return + } + } + r.Body = io.NopCloser(bufReader) + + // Request arrays always use ordinary JSON-RPC responses. Streaming methods + // in an array receive one method error and are not called. + if len(peek) > 0 && peek[0] == '[' { + defer func() { + if err := originalBody.Close(); err != nil { + s.errhandler(r.Context(), w, fmt.Errorf("failed to close request body: %w", err)) + } + }() + if !acceptJSON { + w.WriteHeader(http.StatusNotAcceptable) + return + } + s.handleBatch(w, r) + return + } + + // Decode the request once so the generated method switch below can choose + // both the handler and its response type. + var req jsonrpc.RawRequest + if err := s.decoder(r).Decode(&req); err != nil { + closeErr := originalBody.Close() + s.errhandler(r.Context(), w, fmt.Errorf("failed to read request body: %w", errors.Join(err, closeErr))) + switch { + case acceptJSON: + response := jsonrpc.MakeErrorResponse(nil, jsonrpc.ParseError, "Parse error", nil) + if encErr := s.encoder(r.Context(), w).Encode(response); encErr != nil { + s.errhandler(r.Context(), w, fmt.Errorf("failed to encode parse error response: %w", encErr)) + } + case acceptSSE: + stream := &{{ .SSEStream.Name }}{w: w, encoder: s.encoder} + if sendErr := stream.sendError(r.Context(), nil, jsonrpc.ParseError, "Parse error", nil); sendErr != nil { + s.errhandler(r.Context(), w, fmt.Errorf("write parse error event: %w", sendErr)) + } + default: + w.WriteHeader(http.StatusNotAcceptable) + } return } - - // Otherwise handle as regular JSON-RPC HTTP request - s.handleHTTP(w, r) + defer func() { + if err := originalBody.Close(); err != nil { + s.errhandler(r.Context(), w, fmt.Errorf("failed to close request body: %w", err)) + } + }() + + // Invalid and unknown requests do not have a designed response type. Use + // JSON when the client accepts it, then events, or reject the response. + if req.Invalid || req.JSONRPC != "2.0" || !req.HasMethod { + switch { + case acceptJSON: + s.processRequest(r.Context(), r, &req, w) + case acceptSSE: + s.processSSERequest(r.Context(), r, &req, w) + default: + w.WriteHeader(http.StatusNotAcceptable) + } + return + } + + switch req.Method { +{{- range .Endpoints }} + {{- if .SSE }} + case {{ printf "%q" .Method.Name }}: + if !acceptSSE { + w.WriteHeader(http.StatusNotAcceptable) + return + } + s.processSSERequest(r.Context(), r, &req, w) + {{- else }} + case {{ printf "%q" .Method.Name }}: + if !acceptJSON { + w.WriteHeader(http.StatusNotAcceptable) + return + } + s.processRequest(r.Context(), r, &req, w) + {{- end }} +{{- end }} + default: + switch { + case acceptJSON: + s.processRequest(r.Context(), r, &req, w) + case acceptSSE: + s.processSSERequest(r.Context(), r, &req, w) + default: + w.WriteHeader(http.StatusNotAcceptable) + } + } } diff --git a/jsonrpc/codegen/templates/partial/element_slice_conversion.go.tpl b/jsonrpc/codegen/templates/partial/element_slice_conversion.go.tpl index c9658f58c4..1e984ffd67 100644 --- a/jsonrpc/codegen/templates/partial/element_slice_conversion.go.tpl +++ b/jsonrpc/codegen/templates/partial/element_slice_conversion.go.tpl @@ -1,4 +1,4 @@ - {{ .VarName }} = make({{ goTypeRef .Type }}, len({{ .VarName }}Raw)) + {{ .VarName }} = make({{ .TypeRef }}, len({{ .VarName }}Raw)) for i, rv := range {{ .VarName }}Raw { {{- template "partial_slice_item_conversion" . }} } diff --git a/jsonrpc/codegen/templates/partial/query_type_conversion.go.tpl b/jsonrpc/codegen/templates/partial/query_type_conversion.go.tpl index 9765e1e141..e367786266 100644 --- a/jsonrpc/codegen/templates/partial/query_type_conversion.go.tpl +++ b/jsonrpc/codegen/templates/partial/query_type_conversion.go.tpl @@ -1,6 +1,6 @@ - {{- if eq .Type.Name "bytes" }} + {{- if eq .TypeName "bytes" }} {{ .VarName }} = []byte({{.VarName}}Raw) - {{- else if eq .Type.Name "int" }} + {{- else if eq .TypeName "int" }} v, err2 := strconv.ParseInt({{ .VarName }}Raw, 10, strconv.IntSize) if err2 != nil { err = goa.MergeErrors(err, goa.InvalidFieldTypeError({{ printf "%q" .Name }}, {{ .VarName}}Raw, "integer")) @@ -11,7 +11,7 @@ {{- else }} {{ .VarName }} = {{ if .TypeRef }}{{ .TypeRef }}{{ else }}int{{ end }}(v) {{- end }} - {{- else if eq .Type.Name "int32" }} + {{- else if eq .TypeName "int32" }} v, err2 := strconv.ParseInt({{ .VarName }}Raw, 10, 32) if err2 != nil { err = goa.MergeErrors(err, goa.InvalidFieldTypeError({{ printf "%q" .Name }}, {{ .VarName}}Raw, "integer")) @@ -22,13 +22,13 @@ {{- else }} {{ .VarName }} = {{ if .TypeRef }}{{ .TypeRef }}{{ else }}int32{{ end }}(v) {{- end }} - {{- else if eq .Type.Name "int64" }} + {{- else if eq .TypeName "int64" }} v, err2 := strconv.ParseInt({{ .VarName }}Raw, 10, 64) if err2 != nil { err = goa.MergeErrors(err, goa.InvalidFieldTypeError({{ printf "%q" .Name }}, {{ .VarName}}Raw, "integer")) } {{ if and (ne .TypeRef nil) (and (ne .TypeRef "int64") (ne .TypeRef "*int64")) }}{{ .VarName }} = ({{.TypeRef}})({{ if .Pointer }}&{{ end }}v){{ else }}{{ .VarName }} = {{ if .Pointer }}&{{ end }}v{{ end }} - {{- else if eq .Type.Name "uint" }} + {{- else if eq .TypeName "uint" }} v, err2 := strconv.ParseUint({{ .VarName }}Raw, 10, strconv.IntSize) if err2 != nil { err = goa.MergeErrors(err, goa.InvalidFieldTypeError({{ printf "%q" .Name }}, {{ .VarName}}Raw, "unsigned integer")) @@ -39,7 +39,7 @@ {{- else }} {{ .VarName }} = {{ if .TypeRef }}{{ .TypeRef }}{{ else }}uint{{ end }}(v) {{- end }} - {{- else if eq .Type.Name "uint32" }} + {{- else if eq .TypeName "uint32" }} v, err2 := strconv.ParseUint({{ .VarName }}Raw, 10, 32) if err2 != nil { err = goa.MergeErrors(err, goa.InvalidFieldTypeError({{ printf "%q" .Name }}, {{ .VarName}}Raw, "unsigned integer")) @@ -50,13 +50,13 @@ {{- else }} {{ .VarName }} = {{ if .TypeRef }}{{ .TypeRef }}{{ else }}uint32{{ end }}(v) {{- end }} - {{- else if eq .Type.Name "uint64" }} + {{- else if eq .TypeName "uint64" }} v, err2 := strconv.ParseUint({{ .VarName }}Raw, 10, 64) if err2 != nil { err = goa.MergeErrors(err, goa.InvalidFieldTypeError({{ printf "%q" .Name }}, {{ .VarName}}Raw, "unsigned integer")) } {{ if and (ne .TypeRef nil) (and (ne .TypeRef "uint64") (ne .TypeRef "*uint64")) }}{{ .VarName }} = ({{.TypeRef}})({{ if .Pointer }}&{{ end }}v){{ else }}{{ .VarName }} = {{ if .Pointer }}&{{ end }}v{{ end }} - {{- else if eq .Type.Name "float32" }} + {{- else if eq .TypeName "float32" }} v, err2 := strconv.ParseFloat({{ .VarName }}Raw, 32) if err2 != nil { err = goa.MergeErrors(err, goa.InvalidFieldTypeError({{ printf "%q" .Name }}, {{ .VarName}}Raw, "float")) @@ -67,18 +67,18 @@ {{- else }} {{ .VarName }} = {{ if .TypeRef }}{{ .TypeRef }}{{ else }}float32{{ end }}(v) {{- end }} - {{- else if eq .Type.Name "float64" }} + {{- else if eq .TypeName "float64" }} v, err2 := strconv.ParseFloat({{ .VarName }}Raw, 64) if err2 != nil { err = goa.MergeErrors(err, goa.InvalidFieldTypeError({{ printf "%q" .Name }}, {{ .VarName}}Raw, "float")) } {{ if and (ne .TypeRef nil) (and (ne .TypeRef "float64") (ne .TypeRef "*float64")) }}{{ .VarName }} = ({{.TypeRef}})({{ if .Pointer }}&{{ end }}v){{ else }}{{ .VarName }} = {{ if .Pointer }}&{{ end }}v{{ end }} - {{- else if eq .Type.Name "boolean" }} + {{- else if eq .TypeName "boolean" }} v, err2 := strconv.ParseBool({{ .VarName }}Raw) if err2 != nil { err = goa.MergeErrors(err, goa.InvalidFieldTypeError({{ printf "%q" .Name }}, {{ .VarName}}Raw, "boolean")) } {{ if and (ne .TypeRef nil) (and (ne .TypeRef "bool") (ne .TypeRef "*bool")) }}{{ .VarName }} = ({{.TypeRef}})({{ if .Pointer }}&{{ end }}v){{ else }}{{ .VarName }} = {{ if .Pointer }}&{{ end }}v{{ end }} {{- else }} - // unsupported type {{ .Type.Name }} for var {{ .VarName }} + // The Goa design must use bytes, a number, or a boolean for this HTTP response value. {{- end }} diff --git a/jsonrpc/codegen/templates/partial/single_response.go.tpl b/jsonrpc/codegen/templates/partial/single_response.go.tpl index 764446207d..47bbd745da 100644 --- a/jsonrpc/codegen/templates/partial/single_response.go.tpl +++ b/jsonrpc/codegen/templates/partial/single_response.go.tpl @@ -1,14 +1,22 @@ {{- with .Data }} {{- if .ClientBody }} var ( - body {{ .ClientBody.VarName }} + body {{ if .ClientBody.Declaration }}{{ .ClientBody.Declaration.Name }}{{ else }}{{ .ClientBody.VarName }}{{ end }} err error ) err = decoder(resp).Decode(&body) if err != nil { return nil, goahttp.ErrDecodingError("{{ $.ServiceName }}", "{{ $.Method.Name }}", err) } - {{- if .ClientBody.ValidateRef }} + {{- if $.SSE }} + {{- template "viewed_sse_outer_fields" $ }} + {{- end }} + {{- if and .ClientBody.ValidatorDeclaration .ClientBody.ValidationTarget }} + err = {{ .ClientBody.ValidatorDeclaration.Name }}({{ .ClientBody.ValidationTarget }}) + if err != nil { + return nil, goahttp.ErrValidationError("{{ $.ServiceName }}", "{{ $.Method.Name }}", err) + } + {{- else if .ClientBody.ValidateRef }} {{ .ClientBody.ValidateRef }} if err != nil { return nil, goahttp.ErrValidationError("{{ $.ServiceName }}", "{{ $.Method.Name }}", err) @@ -29,19 +37,19 @@ ) {{- range .Headers }} - {{- if (or (eq .Type.Name "string") (eq .Type.Name "any")) }} + {{- if (or (eq .TypeName "string") (eq .TypeName "any")) }} {{ .VarName }}Raw := resp.Header.Get("{{ .CanonicalName }}") {{- if .Required }} if {{ .VarName }}Raw == "" { err = goa.MergeErrors(err, goa.MissingFieldError("{{ .Name }}", "header")) } - {{ .VarName }} = {{ if and (eq .Type.Name "string") .Pointer }}&{{ end }}{{ .VarName }}Raw + {{ .VarName }} = {{ if and (eq .TypeName "string") .Pointer }}&{{ end }}{{ .VarName }}Raw {{- else }} if {{ .VarName }}Raw != "" { - {{ .VarName }} = {{ if and (eq .Type.Name "string") .Pointer }}&{{ end }}{{ .VarName }}Raw + {{ .VarName }} = {{ if and (eq .TypeName "string") .Pointer }}&{{ end }}{{ .VarName }}Raw } {{- if .DefaultValue }} else { - {{ .VarName }} = {{ if eq .Type.Name "string" }}{{ printf "%q" .DefaultValue }}{{ else }}{{ printf "%#v" .DefaultValue }}{{ end }} + {{ .VarName }} = {{ if eq .TypeName "string" }}{{ printf "%q" .DefaultValue }}{{ else }}{{ printf "%#v" .DefaultValue }}{{ end }} } {{- end }} {{- end }} @@ -135,18 +143,18 @@ } {{- range .Cookies }} - {{- if (or (eq .Type.Name "string") (eq .Type.Name "any")) }} + {{- if (or (eq .TypeName "string") (eq .TypeName "any")) }} {{- if .Required }} if {{ .VarName }}Raw == "" { err = goa.MergeErrors(err, goa.MissingFieldError("{{ .Name }}", "cookie")) } - {{ .VarName }} = {{ if and (eq .Type.Name "string") .Pointer }}&{{ end }}{{ .VarName }}Raw + {{ .VarName }} = {{ if and (eq .TypeName "string") .Pointer }}&{{ end }}{{ .VarName }}Raw {{- else }} if {{ .VarName }}Raw != "" { - {{ .VarName }} = {{ if and (eq .Type.Name "string") .Pointer }}&{{ end }}{{ .VarName }}Raw + {{ .VarName }} = {{ if and (eq .TypeName "string") .Pointer }}&{{ end }}{{ .VarName }}Raw } {{- if .DefaultValue }} else { - {{ .VarName }} = {{ if eq .Type.Name "string" }}{{ printf "%q" .DefaultValue }}{{ else }}{{ printf "%#v" .DefaultValue }}{{ end }} + {{ .VarName }} = {{ if eq .TypeName "string" }}{{ printf "%q" .DefaultValue }}{{ else }}{{ printf "%#v" .DefaultValue }}{{ end }} } {{- end }} {{- end }} diff --git a/jsonrpc/codegen/templates/partial/slice_item_conversion.go.tpl b/jsonrpc/codegen/templates/partial/slice_item_conversion.go.tpl index ece0457571..3ab157de32 100644 --- a/jsonrpc/codegen/templates/partial/slice_item_conversion.go.tpl +++ b/jsonrpc/codegen/templates/partial/slice_item_conversion.go.tpl @@ -1,63 +1,63 @@ - {{- if eq .Type.ElemType.Type.Name "string" }} - {{ .VarName }}[i] = rv - {{- else if eq .Type.ElemType.Type.Name "bytes" }} - {{ .VarName }}[i] = []byte(rv) - {{- else if eq .Type.ElemType.Type.Name "int" }} + {{- if eq .ElemTypeName "string" }} + {{ .VarName }}[i] = {{ .ElemTypeRef }}(rv) + {{- else if eq .ElemTypeName "bytes" }} + {{ .VarName }}[i] = {{ .ElemTypeRef }}([]byte(rv)) + {{- else if eq .ElemTypeName "int" }} v, err2 := strconv.ParseInt(rv, 10, strconv.IntSize) if err2 != nil { err = goa.MergeErrors(err, goa.InvalidFieldTypeError({{ printf "%q" .Name }}, {{ .VarName}}Raw, "array of integers")) } - {{ .VarName }}[i] = int(v) - {{- else if eq .Type.ElemType.Type.Name "int32" }} + {{ .VarName }}[i] = {{ .ElemTypeRef }}(v) + {{- else if eq .ElemTypeName "int32" }} v, err2 := strconv.ParseInt(rv, 10, 32) if err2 != nil { err = goa.MergeErrors(err, goa.InvalidFieldTypeError({{ printf "%q" .Name }}, {{ .VarName}}Raw, "array of integers")) } - {{ .VarName }}[i] = int32(v) - {{- else if eq .Type.ElemType.Type.Name "int64" }} + {{ .VarName }}[i] = {{ .ElemTypeRef }}(v) + {{- else if eq .ElemTypeName "int64" }} v, err2 := strconv.ParseInt(rv, 10, 64) if err2 != nil { err = goa.MergeErrors(err, goa.InvalidFieldTypeError({{ printf "%q" .Name }}, {{ .VarName}}Raw, "array of integers")) } - {{ .VarName }}[i] = v - {{- else if eq .Type.ElemType.Type.Name "uint" }} + {{ .VarName }}[i] = {{ .ElemTypeRef }}(v) + {{- else if eq .ElemTypeName "uint" }} v, err2 := strconv.ParseUint(rv, 10, strconv.IntSize) if err2 != nil { err = goa.MergeErrors(err, goa.InvalidFieldTypeError({{ printf "%q" .Name }}, {{ .VarName}}Raw, "array of unsigned integers")) } - {{ .VarName }}[i] = uint(v) - {{- else if eq .Type.ElemType.Type.Name "uint32" }} + {{ .VarName }}[i] = {{ .ElemTypeRef }}(v) + {{- else if eq .ElemTypeName "uint32" }} v, err2 := strconv.ParseUint(rv, 10, 32) if err2 != nil { err = goa.MergeErrors(err, goa.InvalidFieldTypeError({{ printf "%q" .Name }}, {{ .VarName}}Raw, "array of unsigned integers")) } - {{ .VarName }}[i] = uint32(v) - {{- else if eq .Type.ElemType.Type.Name "uint64" }} + {{ .VarName }}[i] = {{ .ElemTypeRef }}(v) + {{- else if eq .ElemTypeName "uint64" }} v, err2 := strconv.ParseUint(rv, 10, 64) if err2 != nil { err = goa.MergeErrors(err, goa.InvalidFieldTypeError({{ printf "%q" .Name }}, {{ .VarName}}Raw, "array of unsigned integers")) } - {{ .VarName }}[i] = v - {{- else if eq .Type.ElemType.Type.Name "float32" }} + {{ .VarName }}[i] = {{ .ElemTypeRef }}(v) + {{- else if eq .ElemTypeName "float32" }} v, err2 := strconv.ParseFloat(rv, 32) if err2 != nil { err = goa.MergeErrors(err, goa.InvalidFieldTypeError({{ printf "%q" .Name }}, {{ .VarName}}Raw, "array of floats")) } - {{ .VarName }}[i] = float32(v) - {{- else if eq .Type.ElemType.Type.Name "float64" }} + {{ .VarName }}[i] = {{ .ElemTypeRef }}(v) + {{- else if eq .ElemTypeName "float64" }} v, err2 := strconv.ParseFloat(rv, 64) if err2 != nil { err = goa.MergeErrors(err, goa.InvalidFieldTypeError({{ printf "%q" .Name }}, {{ .VarName}}Raw, "array of floats")) } - {{ .VarName }}[i] = v - {{- else if eq .Type.ElemType.Type.Name "boolean" }} + {{ .VarName }}[i] = {{ .ElemTypeRef }}(v) + {{- else if eq .ElemTypeName "boolean" }} v, err2 := strconv.ParseBool(rv) if err2 != nil { err = goa.MergeErrors(err, goa.InvalidFieldTypeError({{ printf "%q" .Name }}, {{ .VarName}}Raw, "array of booleans")) } - {{ .VarName }}[i] = v - {{- else if eq .Type.ElemType.Type.Name "any" }} - {{ .VarName }}[i] = rv + {{ .VarName }}[i] = {{ .ElemTypeRef }}(v) + {{- else if eq .ElemTypeName "any" }} + {{ .VarName }}[i] = {{ .ElemTypeRef }}(rv) {{- else }} - // unsupported slice type {{ .Type.ElemType.Type.Name }} for var {{ .VarName }} + // The Goa design must use primitive array elements for this HTTP response value. {{- end }} diff --git a/jsonrpc/codegen/templates/response_decoder.go.tpl b/jsonrpc/codegen/templates/response_decoder.go.tpl index f53b85a866..8dab477f2f 100644 --- a/jsonrpc/codegen/templates/response_decoder.go.tpl +++ b/jsonrpc/codegen/templates/response_decoder.go.tpl @@ -1,20 +1,30 @@ -{{ printf "%s returns a decoder for responses returned by the %s service %s JSON-RPC method. restoreBody controls whether the response body should be restored after having been read." .ResponseDecoder .ServiceName .Method.Name | comment }} -func {{ .ResponseDecoder }}(decoder func(*http.Response) goahttp.Decoder, restoreBody bool) func(*http.Response) (any, error) { - return func(resp *http.Response) (any, error) { +{{ printf "%s returns a decoder for responses returned by the %s service %s JSON-RPC method. The decoder rejects responses that do not repeat requestID. restoreBody controls whether the response body should be restored after having been read." .ResponseDecoderDeclaration.Name .ServiceName .Method.Name | comment }} +func {{ .ResponseDecoderDeclaration.Name }}(decoder func(*http.Response) goahttp.Decoder, restoreBody bool) func(*http.Response, string) (any, error) { + return func(resp *http.Response, requestID string) (result any, decodeErr error) { + responseBody := resp.Body if restoreBody { - b, err := io.ReadAll(resp.Body) - if err != nil { - return nil, err + b, readErr := io.ReadAll(responseBody) + closeErr := responseBody.Close() + if err := errors.Join(readErr, closeErr); err != nil { + return nil, goahttp.ErrDecodingError("{{ .ServiceName }}", "{{ .Method.Name }}", err) } resp.Body = io.NopCloser(bytes.NewBuffer(b)) defer func() { resp.Body = io.NopCloser(bytes.NewBuffer(b)) }() + } else { + defer func() { + if err := responseBody.Close(); err != nil { + decodeErr = errors.Join(decodeErr, goahttp.ErrDecodingError("{{ .ServiceName }}", "{{ .Method.Name }}", err)) + } + }() } - defer resp.Body.Close() if resp.StatusCode != http.StatusOK { - body, _ := io.ReadAll(resp.Body) + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, goahttp.ErrDecodingError("{{ .ServiceName }}", "{{ .Method.Name }}", err) + } return nil, goahttp.ErrInvalidResponse("{{ .ServiceName }}", "{{ .Method.Name }}", resp.StatusCode, string(body)) } @@ -22,64 +32,64 @@ func {{ .ResponseDecoder }}(decoder func(*http.Response) goahttp.Decoder, restor if err := decoder(resp).Decode(&jresp); err != nil { return nil, goahttp.ErrDecodingError("{{ .ServiceName }}", "{{ .Method.Name }}", err) } + if err := jresp.Validate(requestID); err != nil { + return nil, goahttp.ErrInvalidResponse("{{ .ServiceName }}", "{{ .Method.Name }}", resp.StatusCode, err.Error()) + } if jresp.Error != nil { + {{- if .Errors }} + serviceErrorName, serviceErrorBody, ok := jsonrpc.DecodeServiceErrorData(jresp.Error.Data) + if !ok { + return nil, jresp.Error + } switch jresp.Error.Code { {{- range .Errors }} - {{- range .Errors }} - {{- with .Response }} case {{ .StatusCode }}: - resp.Body = io.NopCloser(bytes.NewBuffer(jresp.Error.Data)) + switch serviceErrorName { + {{- range $mapped := .Errors }} + {{- with $mapped.Response }} + case {{ printf "%q" $mapped.Name }}: + {{- if or (eq .Code -32700) (eq .Code -32600) }} + if jresp.ID == nil { + return nil, goahttp.ErrInvalidResponse("{{ $.ServiceName }}", "{{ $.Method.Name }}", resp.StatusCode, "response id is null") + } + {{- end }} + resp.Body = io.NopCloser(bytes.NewReader(serviceErrorBody)) + {{- if not .ClientBody }} + if !bytes.Equal(bytes.TrimSpace(serviceErrorBody), []byte("null")) { + return nil, jresp.Error + } + {{- end }} {{- template "partial_single_response" (buildResponseData . $.ServiceName $.Method) }} {{- if .ResultInit }} - return nil, {{ .ResultInit.Name }}({{ range .ResultInit.ClientArgs }}{{ .Ref }},{{ end }}) + return nil, {{ .ResultInit.Declaration.Name }}({{ range .ResultInit.ClientArgs }}{{ .Ref }},{{ end }}) {{- else if .ClientBody }} - return nil, body + return nil, body {{- else }} - return nil, nil + return nil, nil {{- end }} {{- end }} {{- end }} + default: + return nil, jresp.Error + } {{- end }} default: - body, _ := io.ReadAll(resp.Body) - return nil, goahttp.ErrInvalidResponse({{ printf "%q" .ServiceName }}, {{ printf "%q" .Method.Name }}, resp.StatusCode, string(body)) + return nil, jresp.Error } + {{- else }} + return nil, jresp.Error + {{- end }} } -{{- with index .Result.Responses 0 }} + {{- if .Method.ViewedResult }} + return {{ viewedDecodeName .Method.Name }}(decoder, resp, jresp.Result{{ if .SSE }}{{ if .SSE.IDField }}, "", false{{ end }}{{ if .SSE.EventField }}, "", false{{ end }}{{ if .SSE.RetryField }}, "", false{{ end }}{{ end }}) + {{- else }} +{{- with index .Result.Responses 0 }} resp.Body = io.NopCloser(bytes.NewBuffer(jresp.Result)) {{- template "partial_single_response" (buildResponseData . $.ServiceName $.Method) }} {{- if .ResultInit }} - {{- if .ViewedResult }} - p := {{ .ResultInit.Name }}({{ range .ResultInit.ClientArgs }}{{ .Ref }},{{ end }}) - {{- if .TagName }} - tmp := {{ printf "%q" .TagValue }} - p.{{ .TagName }} = &tmp - {{- end }} - {{- if $.Method.ViewedResult.ViewName }} - view := {{ printf "%q" $.Method.ViewedResult.ViewName }} - {{- else }} - view := resp.Header.Get("goa-view") - {{- end }} - vres := {{ if not $.Method.ViewedResult.IsCollection }}&{{ end }}{{ $.Method.ViewedResult.ViewsPkg}}.{{ $.Method.ViewedResult.VarName }}{Projected: p, View: view} - {{- if .ClientBody }} - if err = {{ $.Method.ViewedResult.ViewsPkg}}.Validate{{ $.Method.Result }}(vres); err != nil { - return nil, goahttp.ErrValidationError("{{ $.ServiceName }}", "{{ $.Method.Name }}", err) - } - {{- end }} - res := {{ $.ServicePkgName }}.{{ $.Method.ViewedResult.ResultInit.Name }}(vres) - {{- else }} - res := {{ .ResultInit.Name }}({{ range .ResultInit.ClientArgs }}{{ .Ref }},{{ end }}) - {{- end }} - {{- if and .TagName (not .ViewedResult) }} - {{- if .TagPointer }} - tmp := {{ printf "%q" .TagValue }} - res.{{ .TagName }} = &tmp - {{- else }} - res.{{ .TagName }} = {{ printf "%q" .TagValue }} - {{- end }} - {{- end }} + res := {{ .ResultInit.Declaration.Name }}({{ range .ResultInit.ClientArgs }}{{ .Ref }},{{ end }}) return res, nil {{- else if .ClientBody }} return body, nil @@ -91,5 +101,8 @@ func {{ .ResponseDecoder }}(decoder func(*http.Response) goahttp.Decoder, restor return nil, nil {{- end }} {{- end }} + {{- end }} } } + +{{- define "viewed_sse_outer_fields" }}{{ end }} diff --git a/jsonrpc/codegen/templates/server_encode_error.go.tpl b/jsonrpc/codegen/templates/server_encode_error.go.tpl index ecacfa13ab..dcc49cc409 100644 --- a/jsonrpc/codegen/templates/server_encode_error.go.tpl +++ b/jsonrpc/codegen/templates/server_encode_error.go.tpl @@ -1,10 +1,10 @@ -{{ printf "encodeJSONRPCError creates and sends a JSON-RPC error response (handles nil ID gracefully)" | comment }} -func (s *Server) encodeJSONRPCError(ctx context.Context, w http.ResponseWriter, req *jsonrpc.RawRequest, code jsonrpc.Code, message string, data any) { - encodeJSONRPCError(ctx, w, req, code, message, data, s.encoder, s.errhandler) +{{ printf "encodeJSONRPCError writes one error, copying the request ID or using null when none is available." | comment }} +func (s *{{ .ServerStructDeclaration.Name }}) encodeJSONRPCError(ctx context.Context, w http.ResponseWriter, req *jsonrpc.RawRequest, code jsonrpc.Code, message string, data any) { + {{ .EncodeError.Name }}(ctx, w, req, code, message, data, s.encoder, s.errhandler) } -{{ printf "encodeJSONRPCError creates and sends a JSON-RPC error response (handles nil ID gracefully)" | comment }} -func encodeJSONRPCError( +{{ printf "%s writes one error, copying the request ID or using null when none is available." .EncodeError.Name | comment }} +func {{ .EncodeError.Name }}( ctx context.Context, w http.ResponseWriter, req *jsonrpc.RawRequest, @@ -14,10 +14,8 @@ func encodeJSONRPCError( encoder func(context.Context, http.ResponseWriter) goahttp.Encoder, errhandler func(context.Context, http.ResponseWriter, error), ) { - if req.ID != nil { - response := jsonrpc.MakeErrorResponse(req.ID, code, message, data) - if err := encoder(ctx, w).Encode(response); err != nil { - errhandler(ctx, w, fmt.Errorf("failed to encode JSON-RPC response: %w", err)) - } + response := jsonrpc.MakeErrorResponse(req.ID, code, message, data) + if err := encoder(ctx, w).Encode(response); err != nil { + errhandler(ctx, w, fmt.Errorf("failed to encode JSON-RPC response: %w", err)) } } diff --git a/jsonrpc/codegen/templates/server_handler.go.tpl b/jsonrpc/codegen/templates/server_handler.go.tpl index 9f894b05a4..4fed6553bf 100644 --- a/jsonrpc/codegen/templates/server_handler.go.tpl +++ b/jsonrpc/codegen/templates/server_handler.go.tpl @@ -1,36 +1,45 @@ -{{- if and (not (isWebSocketEndpoint (index .Endpoints 0))) (not (hasMixedTransports)) }} +{{- if not (hasMixedTransports) }} // ServeHTTP handles JSON-RPC requests. -func (s *{{ .ServerStruct }}) ServeHTTP(w http.ResponseWriter, r *http.Request) { +func (s *{{ .ServerStructDeclaration.Name }}) ServeHTTP(w http.ResponseWriter, r *http.Request) { s.handleHTTP(w, r) } -{{- end }} -{{- comment "handleHTTP handles JSON-RPC requests." }} -func (s *{{ .ServerStruct }}) handleHTTP(w http.ResponseWriter, r *http.Request) { - // Peek at the first byte to determine request type - bufReader := bufio.NewReader(r.Body) - peek, err := bufReader.Peek(1) - if err != nil && err != io.EOF { - r.Body.Close() - s.errhandler(r.Context(), w, fmt.Errorf("failed to read request body: %w", err)) - return +{{ comment "handleHTTP reads one JSON-RPC request object or one array of requests." }} +func (s *{{ .ServerStructDeclaration.Name }}) handleHTTP(w http.ResponseWriter, r *http.Request) { + originalBody := r.Body + + // Find the first JSON byte so leading whitespace does not change whether the + // body is decoded as one request or an array. + bufReader := bufio.NewReader(originalBody) + var peek []byte + for { + var err error + peek, err = bufReader.Peek(1) + if err != nil && err != io.EOF { + closeErr := originalBody.Close() + s.errhandler(r.Context(), w, fmt.Errorf("failed to read request body: %w", errors.Join(err, closeErr))) + return + } + if len(peek) == 0 || (peek[0] != ' ' && peek[0] != '\t' && peek[0] != '\n' && peek[0] != '\r') { + break + } + if _, err := bufReader.Discard(1); err != nil { + closeErr := originalBody.Close() + s.errhandler(r.Context(), w, fmt.Errorf("failed to read request body: %w", errors.Join(err, closeErr))) + return + } } - // Wrap the buffered reader with the original closer - r.Body = struct { - io.Reader - io.Closer - }{ - Reader: bufReader, - Closer: r.Body, - } - defer func(r *http.Request) { - if err := r.Body.Close(); err != nil { + // The generated handler owns the original body. Decoders receive a wrapper + // whose Close method cannot close it a second time. + r.Body = io.NopCloser(bufReader) + defer func() { + if err := originalBody.Close(); err != nil { s.errhandler(r.Context(), w, fmt.Errorf("failed to close request body: %w", err)) } - }(r) + }() - // Route to appropriate handler + // A leading '[' starts an array of requests. if len(peek) > 0 && peek[0] == '[' { s.handleBatch(w, r) return @@ -38,11 +47,11 @@ func (s *{{ .ServerStruct }}) handleHTTP(w http.ResponseWriter, r *http.Request) s.handleSingle(w, r) } -// handleSingle handles a single JSON-RPC request. -func (s *Server) handleSingle(w http.ResponseWriter, r *http.Request) { +// handleSingle decodes and runs one JSON-RPC request. +func (s *{{ .ServerStructDeclaration.Name }}) handleSingle(w http.ResponseWriter, r *http.Request) { var req jsonrpc.RawRequest if err := s.decoder(r).Decode(&req); err != nil { - // JSON-RPC parse error with null id and generic message + // A request that cannot be decoded receives the JSON-RPC parse error. response := jsonrpc.MakeErrorResponse(nil, jsonrpc.ParseError, "Parse error", nil) if encErr := s.encoder(r.Context(), w).Encode(response); encErr != nil { s.errhandler(r.Context(), w, fmt.Errorf("failed to encode parse error response: %w", encErr)) @@ -51,86 +60,136 @@ func (s *Server) handleSingle(w http.ResponseWriter, r *http.Request) { } s.processRequest(r.Context(), r, &req, w) } +{{- end }} -// handleBatch handles a batch of JSON-RPC requests. -func (s *Server) handleBatch(w http.ResponseWriter, r *http.Request) { +// handleBatch handles an array of JSON-RPC values and writes the required responses. +func (s *{{ .ServerStructDeclaration.Name }}) handleBatch(w http.ResponseWriter, r *http.Request) { var reqs []jsonrpc.RawRequest if err := s.decoder(r).Decode(&reqs); err != nil { - // JSON-RPC parse error for batch with null id and generic message + // An array that cannot be decoded receives the JSON-RPC parse error. response := jsonrpc.MakeErrorResponse(nil, jsonrpc.ParseError, "Parse error", nil) if encErr := s.encoder(r.Context(), w).Encode(response); encErr != nil { s.errhandler(r.Context(), w, fmt.Errorf("failed to encode parse error response: %w", encErr)) } return } + if len(reqs) == 0 { + // JSON-RPC defines an empty request array as one invalid request. + response := jsonrpc.MakeErrorResponse(nil, jsonrpc.InvalidRequest, "Invalid request", nil) + if err := s.encoder(r.Context(), w).Encode(response); err != nil { + s.errhandler(r.Context(), w, fmt.Errorf("failed to encode invalid request response: %w", err)) + } + return + } - // Write responses + // Write every response into one JSON array. w.Header().Set("Content-Type", "application/json") - writer := &batchWriter{Writer: w} + writer := &{{ .BatchWriter.Name }}{Writer: w} for _, req := range reqs { - // Process the request with batch writer + // The writer inserts the array separators around each response. s.processRequest(r.Context(), r, &req, writer) } - // Close the batch array + // Write the closing bracket only when at least one request produced a response. if writer.written { - writer.Writer.Write([]byte{']'}) + if _, err := writer.Writer.Write([]byte{']'}); err != nil { + s.errhandler(r.Context(), w, fmt.Errorf("failed to close JSON-RPC batch response: %w", err)) + } } } -// ProcessRequest processes a single JSON-RPC request. -func (s *Server) processRequest(ctx context.Context, r *http.Request, req *jsonrpc.RawRequest, w http.ResponseWriter) { - if req.JSONRPC != "2.0" { +// processRequest validates the JSON-RPC version and method, then calls the matching handler. +func (s *{{ .ServerStructDeclaration.Name }}) processRequest(ctx context.Context, r *http.Request, req *jsonrpc.RawRequest, w http.ResponseWriter) { + if req.Invalid || req.JSONRPC != "2.0" { s.encodeJSONRPCError(ctx, w, req, jsonrpc.InvalidRequest, "Invalid request", nil) return } - if req.Method == "" { + if !req.HasMethod { s.encodeJSONRPCError(ctx, w, req, jsonrpc.InvalidRequest, "Missing method field", nil) return } switch req.Method { {{- range .Endpoints }} + {{- if not .SSE }} case {{ printf "%q" .Method.Name }}: + {{- if .IsJSONRPCNotification }} + if req.HasID { + s.encodeJSONRPCError(ctx, w, req, jsonrpc.InvalidRequest, "Invalid request", nil) + return + } + {{- else }} + if !req.HasID { + s.reportRejectedNotification(ctx, req) + return + } + if req.ID == nil { + s.encodeJSONRPCError(ctx, w, req, jsonrpc.InvalidRequest, "Invalid request", nil) + return + } + {{- end }} if err := s.{{ .Method.VarName }}(ctx, r, req, w); err != nil { s.errhandler(ctx, w, fmt.Errorf("handler error for %s: %w", {{ printf "%q" .Method.Name }}, err)) } + {{- else }} + case {{ printf "%q" .Method.Name }}: + {{- if .IsJSONRPCNotification }} + if req.HasID { + s.encodeJSONRPCError(ctx, w, req, jsonrpc.InvalidRequest, "Invalid request", nil) + return + } + {{- else }} + if !req.HasID { + s.reportRejectedNotification(ctx, req) + return + } + if req.ID == nil { + s.encodeJSONRPCError(ctx, w, req, jsonrpc.InvalidRequest, "Invalid request", nil) + return + } + {{- end }} + s.encodeJSONRPCError(ctx, w, req, jsonrpc.MethodNotFound, "Method is not available in a batch request", nil) + {{- end }} {{- end }} default: - s.encodeJSONRPCError(ctx, w, req, jsonrpc.MethodNotFound, "Method not found", nil) + if req.HasID { + s.encodeJSONRPCError(ctx, w, req, jsonrpc.MethodNotFound, "Method not found", nil) + } } } -// batchWriter is a helper type that implements http.ResponseWriter for writing multiple JSON-RPC responses -type batchWriter struct { +{{ printf "%s inserts JSON array separators around responses from one request array." .BatchWriter.Name | comment }} +type {{ .BatchWriter.Name }} struct { io.Writer header http.Header statusCode int written bool } -func (rb *batchWriter) Header() http.Header { +func (rb *{{ .BatchWriter.Name }}) Header() http.Header { if rb.header == nil { rb.header = make(http.Header) } return rb.header } -func (rb *batchWriter) WriteHeader(statusCode int) { +func (rb *{{ .BatchWriter.Name }}) WriteHeader(statusCode int) { if rb.written { return } rb.statusCode = statusCode } -func (rb *batchWriter) Write(data []byte) (int, error) { +func (rb *{{ .BatchWriter.Name }}) Write(data []byte) (int, error) { + separator := byte(',') if !rb.written { - rb.written = true - rb.Writer.Write([]byte{'['}) - } else { - rb.Writer.Write([]byte{','}) + separator = '[' + } + if _, err := rb.Writer.Write([]byte{separator}); err != nil { + return 0, err } + rb.written = true return rb.Writer.Write(data) } diff --git a/jsonrpc/codegen/templates/server_handler_init.go.tpl b/jsonrpc/codegen/templates/server_handler_init.go.tpl index 14d6d1305f..2d35d44af0 100644 --- a/jsonrpc/codegen/templates/server_handler_init.go.tpl +++ b/jsonrpc/codegen/templates/server_handler_init.go.tpl @@ -3,127 +3,93 @@ func {{ .HandlerInit }}( endpoint goa.Endpoint, mux goahttp.Muxer, decoder func(*http.Request) goahttp.Decoder, -{{- if not (isWebSocketEndpoint .) }} encoder func(context.Context, http.ResponseWriter) goahttp.Encoder, errhandler func(context.Context, http.ResponseWriter, error), -{{- end }} -) func(context.Context, *http.Request, *jsonrpc.RawRequest{{ if not (isWebSocketEndpoint .) }}, http.ResponseWriter{{ end }}) {{ if isWebSocketEndpoint . }}(any, error){{ else }}error{{ end }} { +) func(context.Context, *http.Request, *jsonrpc.RawRequest, http.ResponseWriter) error { {{- if and (not (isSSEEndpoint .)) .Payload.Ref }} - {{- if not (and (isWebSocketEndpoint .) .Method.ServerStream (or (eq .Method.ServerStream.Kind 3) (eq .Method.ServerStream.Kind 4))) }} - decodeParams := {{ .RequestDecoder }}(mux, decoder) - {{- end }} + decodeParams := {{ .RequestDecoderDeclaration.Name }}(mux, decoder) {{- end }} - return func(ctx context.Context, r *http.Request, req *jsonrpc.RawRequest{{ if not (isWebSocketEndpoint .) }}, w http.ResponseWriter{{ end }}) {{ if isWebSocketEndpoint . }}(any, error){{ else }}error{{ end }} { + return func(ctx context.Context, r *http.Request, req *jsonrpc.RawRequest, w http.ResponseWriter) error { ctx = context.WithValue(ctx, goa.MethodKey, {{ printf "%q" .Method.Name }}) ctx = context.WithValue(ctx, goa.ServiceKey, {{ printf "%q" .ServiceName }}) - + {{- if .IsJSONRPCNotification }} + // A declared notification reports failures without writing an HTTP response. + outputWriter := &{{ noOutputWriterName }}{header: make(http.Header)} + {{- end }} {{- if isSSEEndpoint . }} - // Initialize SSE stream early so decode errors can be sent as SSE error events - strm := &{{ .SSE.StructName }}{ - sseServerStream: sseServerStream{ - w: w, - r: r, + // Create the stream before decoding so request failures can be sent on the + // same HTTP response. + strm := &{{ .SSE.StructDeclaration.Name }}{ + {{ sseStreamName }}: {{ sseStreamName }}{ + w: {{ if .IsJSONRPCNotification }}outputWriter{{ else }}w{{ end }}, encoder: encoder, }, - requestID: req.ID, } {{- if .Payload.Ref }} - decodeParams := {{ .RequestDecoder }}(mux, decoder) - params, err := decodeParams(r, req) - if err != nil { - // Send error via SSE (JSON-RPC error event) to match SSE transport semantics - if req.ID != nil && req.ID != "" { - strm.SendError(ctx, jsonrpc.IDToString(req.ID), err) - } - return nil - } - {{- if .Payload.IDAttribute }} - {{- if .Payload.IDAttributeRequired }} - if req.ID != nil { - params.{{ .Payload.IDAttribute }} = jsonrpc.IDToString(req.ID) - } - {{- else }} - if req.ID != nil { - idStr := jsonrpc.IDToString(req.ID) - params.{{ .Payload.IDAttribute }} = &idStr - } - {{- end }} - {{- end }} - {{- end }} - {{- if .SSE.RequestIDField }} - // Set Last-Event-ID header if present - if lastEventID := r.Header.Get("Last-Event-ID"); lastEventID != "" { - ctx = context.WithValue(ctx, "last-event-id", lastEventID) - {{- if .Payload.Ref }} - {{- if .Payload.Request }} - {{- if eq .Payload.Request.PayloadType.Name "Object" }} - params.{{ .SSE.RequestIDField }} = lastEventID - {{- end }} + decodeParams := {{ .RequestDecoderDeclaration.Name }}(mux, decoder) + params, err := decodeParams(r, req) + if err != nil { + {{- if .IsJSONRPCNotification }} + errhandler(ctx, outputWriter, fmt.Errorf("failed to decode parameters: %w", err)) + return nil + {{- else }} + return strm.sendError(ctx, req.ID, jsonrpc.InvalidParams, err.Error(), nil) {{- end }} - {{- end }} - } + } {{- end }} v := &{{ .ServicePkgName }}.{{ .Method.ServerStream.EndpointStruct }}{ Stream: strm, {{- if .Payload.Ref }} Payload: params, {{- end }} - } - if _, err := endpoint(ctx, v); err != nil { - // Send the error as a JSON-RPC error event; SendError applies the - // design-driven error code mapping. - if req.ID != nil && req.ID != "" { - return strm.SendError(ctx, jsonrpc.IDToString(req.ID), err) - } - return nil } - return nil -{{- else }} - {{- if .Payload.Ref }} - {{- if and (isWebSocketEndpoint .) .Method.ServerStream (or (eq .Method.ServerStream.Kind 3) (eq .Method.ServerStream.Kind 4)) }} - decodeParams := {{ .RequestDecoder }}(mux, decoder) + {{- if .Payload.Ref }} + _, err = endpoint(ctx, v) + {{- else }} + _, err := endpoint(ctx, v) {{- end }} - params, err := decodeParams(r, req) if err != nil { - {{- if isWebSocketEndpoint . }} - return nil, err - {{- else }} - // Only send error response if request has ID (not nil or empty string) - if req.ID != nil && req.ID != "" { - code := jsonrpc.InternalError - if _, ok := err.(*goa.ServiceError); ok { - code = jsonrpc.InvalidParams + {{- if .IsJSONRPCNotification }} + errhandler(ctx, outputWriter, fmt.Errorf("endpoint error: %w", err)) + return nil + {{- else }} + {{- if .Errors }} + var named goa.GoaErrorNamer + if errors.As(err, &named) { + switch named.GoaErrorName() { + {{- range $group := .Errors }} + {{- range $mapped := $group.Errors }} + case {{ printf "%q" $mapped.Name }}: + {{- with $mapped.Response }} + {{- template "designed_error_data" $mapped }} + return strm.sendError(ctx, req.ID, {{ .Code }}, err.Error(), data) + {{- end }} + {{- end }} + {{- end }} } - encodeJSONRPCError(ctx, w, req, code, err.Error(), nil, encoder, errhandler) - } else { - // No ID means notification - just log error - errhandler(ctx, w, fmt.Errorf("failed to decode parameters: %w", err)) } - return nil - {{- end }} - } - {{- if .Payload.IDAttribute }} - {{- if .Payload.IDAttributeRequired }} - if req.ID != nil { - params.{{ .Payload.IDAttribute }} = jsonrpc.IDToString(req.ID) + {{- end }} + return strm.sendError(ctx, req.ID, jsonrpc.InternalError, err.Error(), nil) + {{- end }} } + {{- if .IsJSONRPCNotification }} + return nil {{- else }} - if req.ID != nil { - idStr := jsonrpc.IDToString(req.ID) - params.{{ .Payload.IDAttribute }} = &idStr - } - {{- end }} + response := jsonrpc.MakeSuccessResponse(req.ID, nil) + return strm.sendSSEEvent(ctx, response, nil, nil, nil) {{- end }} +{{- else }} + {{- if .Payload.Ref }} + params, err := decodeParams(r, req) + if err != nil { + {{- if .IsJSONRPCNotification }} + errhandler(ctx, outputWriter, fmt.Errorf("failed to decode parameters: %w", err)) + {{- else }} + {{ encodeErrorName }}(ctx, w, req, jsonrpc.InvalidParams, err.Error(), nil, encoder, errhandler) + {{- end }} + return nil + } {{- end }} - {{- if and (isWebSocketEndpoint .) .Method.ServerStream (or (eq .Method.ServerStream.Kind 3) (eq .Method.ServerStream.Kind 4)) }} - // For {{ if eq .Method.ServerStream.Kind 3 }}server{{ else }}bidirectional{{ end }} streaming, we need to return the payload - // The actual streaming will be handled when the stream is passed to the endpoint - {{- if .Payload.Ref }} - return params, nil - {{- else }} - return nil, nil - {{- end }} - {{- else }} {{- if not .Result.Ref }} {{- if .Payload.Ref }} _, err = endpoint(ctx, params) @@ -133,54 +99,37 @@ func {{ .HandlerInit }}( {{- else }} res, err := endpoint(ctx, {{ if .Payload.Ref }}params{{ else }}nil{{ end }}) {{- end }} - {{- end }} - {{- if isWebSocketEndpoint . }} - {{- if not (and .Method.ServerStream (or (eq .Method.ServerStream.Kind 3) (eq .Method.ServerStream.Kind 4))) }} - return res, err - {{- end }} - {{- else }} if err != nil { - // Only send error response if request has ID (not nil or empty string) - if req.ID != nil && req.ID != "" { + {{- if .IsJSONRPCNotification }} + errhandler(ctx, outputWriter, fmt.Errorf("endpoint error: %w", err)) + {{- else }} + {{- if .Errors }} var en goa.GoaErrorNamer - if !errors.As(err, &en) { - encodeJSONRPCError(ctx, w, req, jsonrpc.InternalError, err.Error(), nil, encoder, errhandler) - return nil - } - switch en.GoaErrorName() { + if errors.As(err, &en) { + switch en.GoaErrorName() { {{- range $gerr := .Errors }} {{- range $err := $gerr.Errors }} - case {{ printf "%q" .Name }}: + case {{ printf "%q" .Name }}: {{- with .Response}} - encodeJSONRPCError(ctx, w, req, {{ .Code }}, err.Error(), err, encoder, errhandler) + {{- template "designed_error_data" $err }} + {{ encodeErrorName }}(ctx, w, req, {{ .Code }}, err.Error(), data, encoder, errhandler) + return nil {{- end }} {{- end }} {{- end }} - case "invalid_params": - encodeJSONRPCError(ctx, w, req, jsonrpc.InvalidParams, err.Error(), nil, encoder, errhandler) - case "method_not_found": - encodeJSONRPCError(ctx, w, req, jsonrpc.MethodNotFound, err.Error(), nil, encoder, errhandler) - default: - code := jsonrpc.InternalError - if _, ok := err.(*goa.ServiceError); ok { - code = jsonrpc.InvalidParams } - encodeJSONRPCError(ctx, w, req, code, err.Error(), nil, encoder, errhandler) } - } else { - // No ID means notification - just log error - errhandler(ctx, w, fmt.Errorf("endpoint error: %w", err)) - } + {{- end }} + {{ encodeErrorName }}(ctx, w, req, jsonrpc.InternalError, err.Error(), nil, encoder, errhandler) + {{- end }} return nil } - - // For methods with no result, check if this is a notification - {{- if not .Result.Ref }} - if req.ID == nil || req.ID == "" { - // Notification - no response + {{- if .IsJSONRPCNotification }} + // A declared notification receives no response. return nil - } - // Request with no result - send empty success response + {{- else }} + {{- if not .Result.Ref }} + // A method with no result returns a JSON null result. response := jsonrpc.MakeSuccessResponse(req.ID, nil) if err := encoder(ctx, w).Encode(response); err != nil { errhandler(ctx, w, fmt.Errorf("failed to encode JSON-RPC response: %w", err)) @@ -188,53 +137,52 @@ func {{ .HandlerInit }}( return nil {{- else }} - // For methods with results, determine the ID to use for the response - var id any - {{- if .Result.IDAttribute }} - // Result has an ID field - use it if set, otherwise fall back to request ID - actual := res.({{ .Result.Ref }}) - {{- if .Result.IDAttributeRequired }} - if actual.{{ .Result.IDAttribute }} != "" { - id = actual.{{ .Result.IDAttribute }} - } else { - id = req.ID - } - {{- else }} - if actual.{{ .Result.IDAttribute }} != nil && *actual.{{ .Result.IDAttribute }} != "" { - id = *actual.{{ .Result.IDAttribute }} - } else { - id = req.ID - } - {{- end }} - {{- else }} - // No ID field in result - use request ID - id = req.ID - {{- end }} - - if id == nil || id == "" { - // Notification - no response - return nil - } - - // Send response with the result - {{- if and .Result.Ref (index .Result.Responses 0).ServerBody (index (index .Result.Responses 0).ServerBody 0).Init }} - // Convert result to response body with proper JSON tags + // The response repeats the exact request ID. {{- if .Method.ViewedResult }} viewedRes := res.({{ .Method.ViewedResult.FullRef }}) - body := {{ (index (index .Result.Responses 0).ServerBody 0).Init.Name }}(viewedRes.Projected) - {{- else }} - body := {{ (index (index .Result.Responses 0).ServerBody 0).Init.Name }}(res.({{ .Result.Ref }})) - {{- end }} - response := jsonrpc.MakeSuccessResponse(id, body) + body, err := {{ viewedEncodeName .Method.Name }}(viewedRes) + if err != nil { + return err + } + response := jsonrpc.MakeSuccessResponse(req.ID, body) + {{- else if and .Result.Ref (index .Result.Responses 0).ServerBody (index (index .Result.Responses 0).ServerBody 0).Init }} + // Build the response body with the fields and JSON names declared by the service. + body := {{ (index (index .Result.Responses 0).ServerBody 0).Init.Declaration.Name }}(res.({{ .Result.Ref }})) + response := jsonrpc.MakeSuccessResponse(req.ID, body) {{- else }} - response := jsonrpc.MakeSuccessResponse(id, res) + response := jsonrpc.MakeSuccessResponse(req.ID, res) {{- end }} if err := encoder(ctx, w).Encode(response); err != nil { errhandler(ctx, w, fmt.Errorf("failed to encode JSON-RPC response: %w", err)) } return nil {{- end }} - {{- end }} + {{- end }} {{- end }} } } + +{{- define "designed_error_data" }} + var res {{ .Ref }} + if !errors.As(err, &res) { + panic("JSON-RPC error name does not match its generated service error type") + } + {{- if .Response.ServerBody }} + {{- with index .Response.ServerBody 0 }} + {{- if .Init }} + body := {{ .Init.Declaration.Name }}({{ range .Init.ServerArgs }}{{ .Ref }},{{ end }}) + {{- else }} + body := res{{ if $.Response.ResultAttr }}.{{ $.Response.ResultAttr }}{{ end }} + {{- end }} + {{- end }} + {{- else }} + var body *struct{} + {{- end }} + data := struct { + Name string `json:"name"` + Body {{ if .Response.ServerBody }}{{ (index .Response.ServerBody 0).Ref }}{{ else }}*struct{}{{ end }} `json:"body"` + }{ + Name: {{ printf "%q" .Name }}, + Body: body, + } +{{- end }} diff --git a/jsonrpc/codegen/templates/server_init.go.tpl b/jsonrpc/codegen/templates/server_init.go.tpl index 83e6896e32..02051ed7c3 100644 --- a/jsonrpc/codegen/templates/server_init.go.tpl +++ b/jsonrpc/codegen/templates/server_init.go.tpl @@ -1,54 +1,32 @@ -{{ printf "%s creates a JSON-RPC server which loads HTTP requests and calls the %q service methods." .ServerInit .Service.Name | comment }} -func {{ .ServerInit }}( -{{- if isWebSocketEndpoint (index .Endpoints 0) }} - streamHandler func(context.Context, {{ .Service.PkgName }}.Stream) error, -{{- end }} - endpoints *{{ .Service.PkgName }}.Endpoints, +{{ printf "%s creates a JSON-RPC server which loads HTTP requests and calls the %q service methods." .ServerInitDeclaration.Name .Service.Name | comment }} +func {{ .ServerInitDeclaration.Name }}( + endpoints *{{ .Service.PkgName }}.{{ .Service.EndpointsDeclaration.Name }}, mux goahttp.Muxer, decoder func(*http.Request) goahttp.Decoder, encoder func(context.Context, http.ResponseWriter) goahttp.Encoder, errhandler func(context.Context, http.ResponseWriter, error), - {{- if isWebSocketEndpoint (index .Endpoints 0) }} - upgrader goahttp.Upgrader, - configfn goahttp.ConnConfigureFunc, - {{- end }} -) *{{ .ServerStruct }} { - s := &{{ .ServerStruct }}{ +) *{{ .ServerStructDeclaration.Name }} { + s := &{{ .ServerStructDeclaration.Name }}{ Methods: []string{ {{- range .Endpoints }} {{ printf "%q" .Method.Name }}, {{- end }} }, -{{- if isWebSocketEndpoint (index .Endpoints 0) }} - StreamHandler: streamHandler, -{{- end }} {{- range .Endpoints }} - {{- if isWebSocketEndpoint . }} - {{ lowerInitial .Method.VarName }}: {{ .HandlerInit }}(endpoints.{{ .Method.VarName }}, mux, decoder), - {{- if and .Method.ServerStream (or (eq .Method.ServerStream.Kind 3) (eq .Method.ServerStream.Kind 4)) }} - {{ lowerInitial .Method.VarName }}Endpoint: endpoints.{{ .Method.VarName }}, - {{- end }} - {{- else }} {{ .Method.VarName }}: {{ .HandlerInit }}(endpoints.{{ .Method.VarName }}, mux, decoder, encoder, errhandler), - {{- end }} {{- end }} decoder: decoder, encoder: encoder, errhandler: errhandler, - {{- if isWebSocketEndpoint (index .Endpoints 0) }} - upgrader: upgrader, - configfn: configfn, - {{- end }} } - // Default HTTP handler per transport kind - {{- if isWebSocketEndpoint (index .Endpoints 0) }} - // WebSocket services implement ServeHTTP for upgrade + // Install the request handler required by this service's methods. + {{- if hasMixedTransports }} s.Handler = http.HandlerFunc(s.ServeHTTP) {{- else if isSSEEndpoint (index .Endpoints 0) }} - // SSE-only services route via handleSSE + // handleSSE writes each result as a server-sent event. s.Handler = http.HandlerFunc(s.handleSSE) {{- else }} - // Plain HTTP JSON-RPC + // ServeHTTP handles ordinary JSON-RPC request bodies. s.Handler = http.HandlerFunc(s.ServeHTTP) {{- end }} return s diff --git a/jsonrpc/codegen/templates/server_method_names.go.tpl b/jsonrpc/codegen/templates/server_method_names.go.tpl new file mode 100644 index 0000000000..d6a7ddc2aa --- /dev/null +++ b/jsonrpc/codegen/templates/server_method_names.go.tpl @@ -0,0 +1,2 @@ +{{ printf "MethodNames returns the methods served." | comment }} +func (s *{{ .ServerStructDeclaration.Name }}) MethodNames() []string { return {{ .Service.PkgName }}.{{ .Service.MethodNamesDeclaration.Name }}[:] } diff --git a/jsonrpc/codegen/templates/server_mount.go.tpl b/jsonrpc/codegen/templates/server_mount.go.tpl index ecabd73a18..3bc32d2b08 100644 --- a/jsonrpc/codegen/templates/server_mount.go.tpl +++ b/jsonrpc/codegen/templates/server_mount.go.tpl @@ -1,26 +1,24 @@ -{{ printf "%s configures the mux to serve the JSON-RPC %s service methods." .MountServer .Service.Name | comment }} -func {{ .MountServer }}(mux goahttp.Muxer, h *{{ .ServerStruct }}) { +{{ printf "%s configures the mux to serve the JSON-RPC %s service methods." .MountServerDeclaration.Name .Service.Name | comment }} +func {{ .MountServerDeclaration.Name }}(mux goahttp.Muxer, h *{{ .ServerStructDeclaration.Name }}) { {{- if .HasMixed }} - // Mixed transports: mount unified handler that negotiates HTTP vs SSE by Accept header + // ServeHTTP chooses ordinary JSON-RPC handling or server-sent events. {{- range (index .Endpoints 0).Routes }} mux.Handle("{{ .Verb }}", "{{ .Path }}", h.ServeHTTP) {{- end }} {{- else if .HasSSE }} - // SSE only: mount SSE handler - {{- range .Endpoints }} - {{- range .Routes }} + // This server handles every method through server-sent events. + {{- range (index .Endpoints 0).Routes }} mux.Handle("{{ .Verb }}", "{{ .Path }}", h.handleSSE) - {{- end }} {{- end }} {{- else }} - // HTTP only + // This server handles ordinary JSON-RPC request bodies. {{- range (index .Endpoints 0).Routes }} mux.Handle("{{ .Verb }}", "{{ .Path }}", h.ServeHTTP) {{- end }} {{- end }} } -{{ printf "%s configures the mux to serve the JSON-RPC %s service methods." .MountServer .Service.Name | comment }} -func (s *{{ .ServerStruct }}) {{ .MountServer }}(mux goahttp.Muxer) { - {{ .MountServer }}(mux, s) +{{ printf "%s configures the mux to serve the JSON-RPC %s service methods." .MountServerDeclaration.Name .Service.Name | comment }} +func (s *{{ .ServerStructDeclaration.Name }}) {{ .MountServerDeclaration.Name }}(mux goahttp.Muxer) { + {{ .MountServerDeclaration.Name }}(mux, s) } diff --git a/jsonrpc/codegen/templates/server_service.go.tpl b/jsonrpc/codegen/templates/server_service.go.tpl new file mode 100644 index 0000000000..e19de9a953 --- /dev/null +++ b/jsonrpc/codegen/templates/server_service.go.tpl @@ -0,0 +1,9 @@ +{{ printf "%s returns the name of the service served." .ServerService | comment }} +func (s *{{ .ServerStructDeclaration.Name }}) {{ .ServerService }}() string { return "{{ .Service.Name }}" } + +// reportRejectedNotification reports a notification sent to a request-only +// method without allowing the error handler to write a response. +func (s *{{ .ServerStructDeclaration.Name }}) reportRejectedNotification(ctx context.Context, req *jsonrpc.RawRequest) { + outputWriter := &{{ .NoOutputWriter.Name }}{header: make(http.Header)} + s.errhandler(ctx, outputWriter, fmt.Errorf("JSON-RPC notification cannot call request method %q", req.Method)) +} diff --git a/jsonrpc/codegen/templates/server_struct.go.tpl b/jsonrpc/codegen/templates/server_struct.go.tpl index eb3325b737..ca10934849 100644 --- a/jsonrpc/codegen/templates/server_struct.go.tpl +++ b/jsonrpc/codegen/templates/server_struct.go.tpl @@ -1,29 +1,14 @@ -{{ printf "%s handles JSON-RPC requests for the %s service." .ServerStruct .Service.Name | comment }} -type {{ .ServerStruct }} struct { +{{ printf "%s handles JSON-RPC requests for the %s service." .ServerStructDeclaration.Name .Service.Name | comment }} +type {{ .ServerStructDeclaration.Name }} struct { http.Handler // Methods is the list of methods served by this server. Methods []string -{{- if isWebSocketEndpoint (index .Endpoints 0) }} - // StreamHandler is the handler for the streaming service. - StreamHandler func(context.Context, {{ .Service.PkgName }}.Stream) error -{{- end }} {{ range .Endpoints }} - {{- if isWebSocketEndpoint . }} - {{ lowerInitial .Method.VarName }} func(context.Context, *http.Request, *jsonrpc.RawRequest) (any, error) - {{- if and .Method.ServerStream (or (eq .Method.ServerStream.Kind 3) (eq .Method.ServerStream.Kind 4)) }} - {{ lowerInitial .Method.VarName }}Endpoint goa.Endpoint - {{- end }} - {{- else }} {{ printf "%s is the handler for the %s method." .Method.VarName .Method.Name | comment }} {{ .Method.VarName }} func(context.Context, *http.Request, *jsonrpc.RawRequest, http.ResponseWriter) error - {{- end }} {{- end }} decoder func(*http.Request) goahttp.Decoder encoder func(context.Context, http.ResponseWriter) goahttp.Encoder errhandler func(context.Context, http.ResponseWriter, error) -{{- if isWebSocketEndpoint (index .Endpoints 0) }} - upgrader goahttp.Upgrader - configfn goahttp.ConnConfigureFunc -{{- end }} } diff --git a/jsonrpc/codegen/templates/server_use.go.tpl b/jsonrpc/codegen/templates/server_use.go.tpl index 384309dd77..0e70742086 100644 --- a/jsonrpc/codegen/templates/server_use.go.tpl +++ b/jsonrpc/codegen/templates/server_use.go.tpl @@ -1,4 +1,4 @@ {{ printf "Use wraps the server handlers with the given middleware." | comment }} -func (s *{{ .ServerStruct }}) Use(m func(http.Handler) http.Handler) { +func (s *{{ .ServerStructDeclaration.Name }}) Use(m func(http.Handler) http.Handler) { s.Handler = m(s.Handler) } diff --git a/jsonrpc/codegen/templates/sse_client_stream.go.tpl b/jsonrpc/codegen/templates/sse_client_stream.go.tpl index a9f80fbe9c..ca10364935 100644 --- a/jsonrpc/codegen/templates/sse_client_stream.go.tpl +++ b/jsonrpc/codegen/templates/sse_client_stream.go.tpl @@ -1,190 +1,499 @@ -{{ printf "%sClientStream implements the %s.%sClientStream interface using Server-Sent Events." .Method.VarName .ServicePkgName .Method.VarName | comment }} -type {{ .Method.VarName }}ClientStream struct { - resp *http.Response // HTTP response object - reader *bufio.Reader // Buffered reader for SSE parsing - decoder func(*http.Response) goahttp.Decoder // User-provided decoder - closed bool // Whether the stream has been closed - lock sync.Mutex // Mutex to protect state +{{- $checksNullID := false }} +{{- range .Errors }} + {{- if or (eq .StatusCode "-32700") (eq .StatusCode "-32600") }} + {{- $checksNullID = true }} + {{- end }} +{{- end }} +type ( + {{ printf "%s reads results sent as server-sent events." .SSE.ClientInterfaceDeclaration.Name | comment }} + {{ .SSE.ClientInterfaceDeclaration.Name }} interface { + {{ .Method.ClientStream.RecvName }}() ({{ .SSE.EventTypeRef }}, error) + {{ .Method.ClientStream.RecvWithContextName }}(context.Context) ({{ .SSE.EventTypeRef }}, error) + Close() error + } + + {{ printf "%s reads and decodes events for %s." .SSE.ClientStructDeclaration.Name .Method.Name | comment }} + {{ .SSE.ClientStructDeclaration.Name }} struct { + // resp is the open server response. + resp *http.Response + // reader reads one line at a time from resp. + reader *bufio.Reader + // decoder converts each result into its service type. + decoder func(*http.Response) goahttp.Decoder + // requestID is the ID every final response must repeat. + requestID string + // lastEventID is the last valid id field read from the stream. + lastEventID string + // hasLastEventID distinguishes no id from an explicitly empty id. + hasLastEventID bool + // firstLine is true until the optional stream byte-order marker is read. + firstLine bool + // closed records whether Close was called or the response ended. + closed bool + // closeOnce ensures the response body is closed only once. + closeOnce sync.Once + // closeErr stores the response body close error. + closeErr error + // lock prevents two calls from reading or closing the response at once. + lock sync.Mutex + } +) + +{{ printf "%s creates a stream that reads server-sent events from resp. Final responses must repeat requestID." .SSE.ClientInitDeclaration.Name | comment }} +func {{ .SSE.ClientInitDeclaration.Name }}(resp *http.Response, decoder func(*http.Response) goahttp.Decoder, requestID string) {{ .SSE.ClientInterfaceDeclaration.Name }} { + return &{{ .SSE.ClientStructDeclaration.Name }}{ + resp: resp, + reader: bufio.NewReader(resp.Body), + decoder: decoder, + requestID: requestID, + firstLine: true, + } } -// parseSSEEvent parses a single SSE event from the stream -func (s *{{ .Method.VarName }}ClientStream) parseSSEEvent() (eventType string, data []byte, err error) { - var event strings.Builder +// parseSSEEvent reads one complete event from the response. Ending ctx closes +// the response body so a blocked read returns. +func (s *{{ .SSE.ClientStructDeclaration.Name }}) parseSSEEvent(ctx context.Context) (id string, hasID bool, eventType string, hasEventType bool, retry string, hasRetry bool, data []byte, err error) { + closeResult := make(chan struct{}, 1) + stopClose := context.AfterFunc(ctx, func() { + s.closeBody() + closeResult <- struct{}{} + }) + defer func() { + if stopClose() { + return + } + <-closeResult + if contextErr := ctx.Err(); contextErr != nil { + id = "" + hasID = false + eventType = "" + hasEventType = false + retry = "" + hasRetry = false + data = nil + err = contextErr + } + }() var dataLines []string - + for { - line, err := s.reader.ReadString('\n') + line, err := s.readSSELine() if err != nil { - if err == io.EOF && len(dataLines) > 0 { - // Process final event - break - } - return "", nil, err + return "", false, "", false, "", false, nil, err } - - line = strings.TrimSuffix(line, "\n") - line = strings.TrimSuffix(line, "\r") - + if line == "" { - // Empty line marks end of event + // A blank line ends the current event. if len(dataLines) > 0 { break } + // An event without data is not delivered. Its event and retry fields + // must not appear on the next delivered event. + eventType = "" + hasEventType = false + retry = "" + hasRetry = false continue } - if strings.HasPrefix(line, "event:") { - event.WriteString(strings.TrimSpace(line[6:])) - } else if strings.HasPrefix(line, "data:") { - dataLines = append(dataLines, strings.TrimSpace(line[5:])) + if strings.HasPrefix(line, ":") { + continue + } + field, value, found := strings.Cut(line, ":") + if !found { + value = "" + } else { + value = strings.TrimPrefix(value, " ") + } + switch field { + case "id": + if !strings.ContainsRune(value, '\x00') { + s.lastEventID = value + s.hasLastEventID = true + } + case "event": + eventType = value + hasEventType = true + case "retry": + // An invalid retry field does not replace an earlier valid field. + validRetry := len(value) > 0 + for index := 0; validRetry && index < len(value); index++ { + validRetry = value[index] >= '0' && value[index] <= '9' + } + if !validRetry { + continue + } + retry = value + hasRetry = true + case "data": + dataLines = append(dataLines, value) } - // Ignore other fields like id:, retry: } if len(dataLines) > 0 { data = []byte(strings.Join(dataLines, "\n")) } - return event.String(), data, nil + return s.lastEventID, s.hasLastEventID, eventType, hasEventType, retry, hasRetry, data, nil +} + +// readSSELine reads one line ending in CR, LF, or CRLF. It returns io.EOF for +// a final line that is not terminated, so the caller cannot dispatch an +// incomplete event. +func (s *{{ .SSE.ClientStructDeclaration.Name }}) readSSELine() (string, error) { + var line strings.Builder + for { + character, err := s.reader.ReadByte() + if err != nil { + return "", err + } + switch character { + case '\n': + return s.finishSSELine(line.String()), nil + case '\r': + if next, err := s.reader.Peek(1); err == nil && next[0] == '\n' { + if _, err := s.reader.Discard(1); err != nil { + return "", err + } + } + return s.finishSSELine(line.String()), nil + default: + line.WriteByte(character) + } + } +} + +// finishSSELine removes the optional byte-order marker only at the start of +// the stream. +func (s *{{ .SSE.ClientStructDeclaration.Name }}) finishSSELine(line string) string { + if !s.firstLine { + return line + } + s.firstLine = false + return strings.TrimPrefix(line, "\uFEFF") } {{ comment .Method.ClientStream.RecvDesc }} -func (s *{{ .Method.VarName }}ClientStream) {{ .Method.ClientStream.RecvName }}(ctx context.Context) ({{ .Result.Ref }}, error) { +func (s *{{ .SSE.ClientStructDeclaration.Name }}) {{ .Method.ClientStream.RecvName }}() ({{ .SSE.EventTypeRef }}, error) { + return s.{{ .Method.ClientStream.RecvWithContextName }}(context.Background()) +} + +{{ comment .Method.ClientStream.RecvWithContextDesc }} +func (s *{{ .SSE.ClientStructDeclaration.Name }}) {{ .Method.ClientStream.RecvWithContextName }}(ctx context.Context) ({{ .SSE.EventTypeRef }}, error) { s.lock.Lock() defer s.lock.Unlock() - var zero {{ .Result.Ref }} + var zero {{ .SSE.EventTypeRef }} if s.closed { return zero, io.EOF } for { - eventType, data, err := s.parseSSEEvent() + {{ if .SSE.IDField }}eventID, hasEventID{{ else }}_, _{{ end }}, {{ if .SSE.EventField }}eventType, hasEventType{{ else }}_, _{{ end }}, {{ if .SSE.RetryField }}eventRetry, hasEventRetry{{ else }}_, _{{ end }}, data, err := s.parseSSEEvent(ctx) if err != nil { - s.closed = true - return zero, err + return zero, s.endStream(err) } - - switch eventType { - case "notification": - // Parse JSON-RPC notification - var notification struct { - JSONRPC string `json:"jsonrpc"` - Method string `json:"method"` - Params json.RawMessage `json:"params"` + + var message struct { + Method json.RawMessage `json:"method"` + Params json.RawMessage `json:"params"` + Result json.RawMessage `json:"result"` + Error json.RawMessage `json:"error"` + ID json.RawMessage `json:"id"` + } + if err := json.Unmarshal(data, &message); err != nil { + return zero, s.endStream(fmt.Errorf("failed to parse JSON-RPC message: %w", err)) + } + if len(message.Method) > 0 { + // Read the streamed service result from the notification parameters. + if len(message.Result) > 0 || len(message.Error) > 0 || len(message.ID) > 0 { + return zero, s.endStream(fmt.Errorf("invalid JSON-RPC notification")) } + var notification jsonrpc.RawRequest if err := json.Unmarshal(data, ¬ification); err != nil { - return zero, fmt.Errorf("failed to parse notification: %w", err) + return zero, s.endStream(fmt.Errorf("failed to parse notification: %w", err)) + } + if notification.Invalid { + return zero, s.endStream(fmt.Errorf("invalid JSON-RPC notification")) } - - // Validate notification if notification.JSONRPC != "2.0" { - return zero, fmt.Errorf("invalid JSON-RPC version: %s", notification.JSONRPC) + return zero, s.endStream(fmt.Errorf("invalid JSON-RPC version: %s", notification.JSONRPC)) + } + if notification.HasID { + return zero, s.endStream(fmt.Errorf("notification contains an id")) + } + if !notification.HasMethod { + return zero, s.endStream(fmt.Errorf("notification has no method")) } - if notification.Method != {{ printf "%q" .Method.Name }} { - // Skip notifications for other methods - continue + return zero, s.endStream(fmt.Errorf("received notification for JSON-RPC method %q", notification.Method)) + } + {{- if not .SSE.Params.AllowAbsent }} + if len(notification.Params) == 0 { + return zero, s.endStream(fmt.Errorf("notification has no params")) + } + {{- end }} + {{- if and .SSE.Params .SSE.Params.Positional }} + {{- if .SSE.Params.AllowAbsent }} + var params json.RawMessage + if len(notification.Params) > 0 { + params, err = jsonrpc.SinglePositionalParam(notification.Params) + if err != nil { + return zero, s.endStream(err) + } } - - // Decode the result from params - {{- if .Method.Result }} - result, err := s.decodeResult(notification.Params) + {{- else }} + params := notification.Params + params, err = jsonrpc.SinglePositionalParam(params) if err != nil { - return zero, fmt.Errorf("failed to decode result: %w", err) + return zero, s.endStream(err) } - return result, nil + {{- end }} + result, err := s.decodeResult(params{{ if .SSE.IDField }}, eventID, hasEventID{{ end }}{{ if .SSE.EventField }}, eventType, hasEventType{{ end }}{{ if .SSE.RetryField }}, eventRetry, hasEventRetry{{ end }}) {{- else }} - // Method has no result - return zero, nil + result, err := s.decodeResult(notification.Params{{ if .SSE.IDField }}, eventID, hasEventID{{ end }}{{ if .SSE.EventField }}, eventType, hasEventType{{ end }}{{ if .SSE.RetryField }}, eventRetry, hasEventRetry{{ end }}) {{- end }} - - case "response": - // Final response - parse and return - var response jsonrpc.Response + if err != nil { + return zero, s.endStream(err) + } + return result, nil + } + + if len(message.Params) > 0 { + return zero, s.endStream(fmt.Errorf("JSON-RPC response contains params")) + } + { + // A response completes the stream. Stream values arrive in the + // notifications handled above. + var response jsonrpc.RawResponse if err := json.Unmarshal(data, &response); err != nil { - return zero, fmt.Errorf("failed to parse response: %w", err) + return zero, s.endStream(fmt.Errorf("failed to parse response: %w", err)) } - - if response.Error != nil { - return zero, response.Error + if err := response.Validate(s.requestID); err != nil { + return zero, s.endStream(err) } - - {{- if .Method.Result }} - // Decode the final result - if response.Result == nil { - return zero, fmt.Errorf("missing result in response") + if response.Error == nil && !bytes.Equal(bytes.TrimSpace(response.Result), []byte("null")) { + return zero, s.endStream(fmt.Errorf("JSON-RPC stream completion result must be null")) } - // Convert response.Result to json.RawMessage - resultBytes, err := json.Marshal(response.Result) - if err != nil { - return zero, fmt.Errorf("failed to marshal result: %w", err) + if response.Error != nil { + serviceErr, decodeErr := s.decodeError(response.Error{{ if $checksNullID }}, response.ID == nil{{ end }}) + if decodeErr != nil { + return zero, s.endStream(decodeErr) + } + return zero, s.endStream(serviceErr) } - result, err := s.decodeResult(json.RawMessage(resultBytes)) - if err != nil { - return zero, fmt.Errorf("failed to decode final result: %w", err) + return zero, s.endStream(io.EOF) + } + } +} + +// decodeError rebuilds one designed service error from its JSON-RPC code and data. +func (s *{{ .SSE.ClientStructDeclaration.Name }}) decodeError(response *jsonrpc.RawErrorResponse{{ if $checksNullID }}, nullID bool{{ end }}) (error, error) { + {{- if .Errors }} + serviceErrorName, serviceErrorBody, ok := jsonrpc.DecodeServiceErrorData(response.Data) + if !ok { + return response, nil + } + switch response.Code { + {{- range .Errors }} + case {{ .StatusCode }}: + switch serviceErrorName { + {{- range $mapped := .Errors }} + {{- with $mapped.Response }} + case {{ printf "%q" $mapped.Name }}: + {{- if or (eq .Code -32700) (eq .Code -32600) }} + if nullID { + return nil, goahttp.ErrInvalidResponse("{{ $.ServiceName }}", "{{ $.Method.Name }}", http.StatusOK, "response id is null") } - - // Mark stream as closed after final response - s.closed = true - return result, nil - {{- else }} - // Method has no result - s.closed = true - return zero, nil {{- end }} - - case "error": - // Error response - var response jsonrpc.Response - if err := json.Unmarshal(data, &response); err != nil { - return zero, fmt.Errorf("failed to parse error response: %w", err) + resp := &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(bytes.NewReader(serviceErrorBody)), } - - s.closed = true - if response.Error != nil { - return zero, response.Error + {{- if not .ClientBody }} + if !bytes.Equal(bytes.TrimSpace(serviceErrorBody), []byte("null")) { + return response, nil } - return zero, fmt.Errorf("unexpected error response") - + {{- end }} + decoder := s.decoder + {{- template "partial_single_response" (buildResponseData . $.ServiceName $.Method) }} + {{- if .ResultInit }} + return {{ .ResultInit.Declaration.Name }}({{ range .ResultInit.ClientArgs }}{{ .Ref }},{{ end }}), nil + {{- else if .ClientBody }} + return body, nil + {{- else }} + return nil, nil + {{- end }} + {{- end }} + {{- end }} default: - // Ignore unknown event types - continue + return response, nil } + {{- end }} + default: + return response, nil + } + {{- else }} + return response, nil + {{- end }} +} + +// closeBody closes the HTTP response body once and returns its close error. +func (s *{{ .SSE.ClientStructDeclaration.Name }}) closeBody() error { + s.closeOnce.Do(func() { + s.closeErr = s.resp.Body.Close() + }) + return s.closeErr +} + +// endStream marks the stream closed and preserves both the receive error and +// any error returned while closing the HTTP response body. +func (s *{{ .SSE.ClientStructDeclaration.Name }}) endStream(err error) error { + s.closed = true + if closeErr := s.closeBody(); closeErr != nil { + return errors.Join(err, closeErr) } + return err } -{{- if .Method.Result }} -// decodeResult decodes JSON-RPC result data using the user-provided decoder -func (s *{{ .Method.VarName }}ClientStream) decodeResult(data json.RawMessage) ({{ .Result.Ref }}, error) { - // Create minimal HTTP response with raw JSON data for user's decoder +// decodeResult passes one successful stream item to the decoder configured by NewClient. +func (s *{{ .SSE.ClientStructDeclaration.Name }}) decodeResult(data json.RawMessage{{ if .SSE.IDField }}, eventID string, hasEventID bool{{ end }}{{ if .SSE.EventField }}, eventType string, hasEventType bool{{ end }}{{ if .SSE.RetryField }}, eventRetry string, hasEventRetry bool{{ end }}) ({{ .SSE.EventTypeRef }}, error) { + {{- if .Method.ViewedResult }} + // The HTTP 200 status tells the configured decoder that this stream item is + // a successful JSON-RPC result. Streaming results cannot carry HTTP headers or cookies. + resp := &http.Response{StatusCode: http.StatusOK, Header: make(http.Header)} + return {{ viewedDecodeName .Method.Name }}(s.decoder, resp, data{{ if .SSE.IDField }}, eventID, hasEventID{{ end }}{{ if .SSE.EventField }}, eventType, hasEventType{{ end }}{{ if .SSE.RetryField }}, eventRetry, hasEventRetry{{ end }}) + {{- else }} + {{- with .SSE.Response.ClientBody }} resp := &http.Response{ StatusCode: http.StatusOK, Body: io.NopCloser(bytes.NewReader(data)), } - - // Use the user-provided decoder to decode the result decoder := s.decoder(resp) - var result {{ .Result.Ref }} - if err := decoder.Decode(&result); err != nil { - return result, err + var ( + body {{ if .Declaration }}{{ .Declaration.Name }}{{ else }}{{ .VarName }}{{ end }} + err error + ) + {{- if $.SSE.DataField }} + {{- if $.SSE.Params.AllowAbsent }} + if len(data) > 0 { + {{- end }} + if err = decoder.Decode(&body.{{ $.SSE.DataField }}); err != nil { + {{- else }} + if err = decoder.Decode(&body); err != nil { + {{- end }} + var zero {{ $.SSE.EventTypeRef }} + return zero, goahttp.ErrDecodingError("{{ $.ServiceName }}", "{{ $.Method.Name }}", err) } - + {{- if $.SSE.Params.AllowAbsent }} + } + {{- end }} + {{- if $.SSE.IDField }} + {{- if or $.SSE.ID.HasDefault (not $.SSE.ID.Pointer) }} + if !hasEventID { + {{- if $.SSE.ID.HasDefault }} + value := {{ $.SSE.ID.ClientTypeRef }}({{ printf "%q" $.SSE.ID.DefaultValue }}) + body.{{ $.SSE.IDField }} = {{ if $.SSE.ClientIDPointer }}&{{ end }}value + {{- else }} + var zero {{ $.SSE.EventTypeRef }} + return zero, goahttp.ErrValidationError("{{ $.ServiceName }}", "{{ $.Method.Name }}", fmt.Errorf("server-sent event has no id for result field {{ $.SSE.IDField }}")) + {{- end }} + } else { + {{- else }} + if hasEventID { + {{- end }} + value := {{ $.SSE.ID.ClientTypeRef }}(eventID) + body.{{ $.SSE.IDField }} = {{ if $.SSE.ClientIDPointer }}&{{ end }}value + } + {{- end }} + {{- if $.SSE.EventField }} + {{- if or $.SSE.Event.HasDefault (not $.SSE.Event.Pointer) }} + if !hasEventType { + {{- if $.SSE.Event.HasDefault }} + value := {{ $.SSE.Event.ClientTypeRef }}({{ printf "%q" $.SSE.Event.DefaultValue }}) + body.{{ $.SSE.EventField }} = {{ if $.SSE.ClientEventPointer }}&{{ end }}value + {{- else }} + var zero {{ $.SSE.EventTypeRef }} + return zero, goahttp.ErrValidationError("{{ $.ServiceName }}", "{{ $.Method.Name }}", fmt.Errorf("server-sent event has no type for result field {{ $.SSE.EventField }}")) + {{- end }} + } else { + {{- else }} + if hasEventType { + {{- end }} + value := {{ $.SSE.Event.ClientTypeRef }}(eventType) + body.{{ $.SSE.EventField }} = {{ if $.SSE.ClientEventPointer }}&{{ end }}value + } + {{- end }} + {{- if $.SSE.RetryField }} + {{- if or $.SSE.Retry.HasDefault (not $.SSE.Retry.Pointer) }} + if !hasEventRetry { + {{- if $.SSE.Retry.HasDefault }} + value := {{ $.SSE.Retry.ClientTypeRef }}({{ printf "%v" $.SSE.Retry.DefaultValue }}) + body.{{ $.SSE.RetryField }} = {{ if $.SSE.Retry.ClientPointer }}&{{ end }}value + {{- else }} + var zero {{ $.SSE.EventTypeRef }} + return zero, goahttp.ErrValidationError("{{ $.ServiceName }}", "{{ $.Method.Name }}", fmt.Errorf("server-sent event has no retry for result field {{ $.SSE.RetryField }}")) + {{- end }} + } else { + {{- else }} + if hasEventRetry { + {{- end }} + {{- if sseRetrySigned $.SSE.Retry }} + parsed, parseErr := strconv.ParseInt(eventRetry, 10, {{ sseRetryBits $.SSE.Retry }}) + if parseErr != nil || parsed < 0 { + var zero {{ $.SSE.EventTypeRef }} + return zero, goahttp.ErrDecodingError("{{ $.ServiceName }}", "{{ $.Method.Name }}", fmt.Errorf("invalid server-sent event retry %q", eventRetry)) + } + {{- else }} + parsed, parseErr := strconv.ParseUint(eventRetry, 10, {{ sseRetryBits $.SSE.Retry }}) + if parseErr != nil { + var zero {{ $.SSE.EventTypeRef }} + return zero, goahttp.ErrDecodingError("{{ $.ServiceName }}", "{{ $.Method.Name }}", fmt.Errorf("invalid server-sent event retry %q", eventRetry)) + } + {{- end }} + value := {{ $.SSE.Retry.ClientTypeRef }}(parsed) + body.{{ $.SSE.RetryField }} = {{ if $.SSE.Retry.ClientPointer }}&{{ end }}value + } + {{- end }} + {{- if and .ValidatorDeclaration .ValidationTarget }} + err = {{ .ValidatorDeclaration.Name }}({{ .ValidationTarget }}) + if err != nil { + var zero {{ $.SSE.EventTypeRef }} + return zero, goahttp.ErrValidationError("{{ $.ServiceName }}", "{{ $.Method.Name }}", err) + } + {{- else if .ValidateRef }} + {{ .ValidateRef }} + if err != nil { + var zero {{ $.SSE.EventTypeRef }} + return zero, goahttp.ErrValidationError("{{ $.ServiceName }}", "{{ $.Method.Name }}", err) + } + {{- end }} + {{- if $.SSE.ClientEventCode }} + {{ $.SSE.ClientEventCode }} + {{- else if $.SSE.Response.ResultInit }} + result := {{ $.SSE.Response.ResultInit.Declaration.Name }}({{ range $.SSE.Response.ResultInit.ClientArgs }}{{ .Ref }},{{ end }}) + {{- else }} + result := body + {{- end }} return result, nil + {{- else }} + var zero {{ .SSE.EventTypeRef }} + return zero, nil + {{- end }} + {{- end }} } -{{- end }} {{ comment "Close closes the stream." }} -func (s *{{ .Method.VarName }}ClientStream) Close() error { +func (s *{{ .SSE.ClientStructDeclaration.Name }}) Close() error { s.lock.Lock() defer s.lock.Unlock() - if !s.closed { - s.closed = true - if s.resp != nil && s.resp.Body != nil { - return s.resp.Body.Close() - } - } + if !s.closed { + s.closed = true + return s.closeBody() + } return nil } + +{{- define "viewed_sse_outer_fields" }}{{ end }} diff --git a/jsonrpc/codegen/templates/sse_server_handler.go.tpl b/jsonrpc/codegen/templates/sse_server_handler.go.tpl index 03ef92f715..515089ccd6 100644 --- a/jsonrpc/codegen/templates/sse_server_handler.go.tpl +++ b/jsonrpc/codegen/templates/sse_server_handler.go.tpl @@ -1,59 +1,94 @@ -// handleSSE handles JSON-RPC SSE requests by dispatching to the appropriate method. -func (s *Server) handleSSE(w http.ResponseWriter, r *http.Request) { +{{- if not (hasMixedTransports) }} +// handleSSE finds the requested method and writes its results as server-sent events. +func (s *{{ .ServerStructDeclaration.Name }}) handleSSE(w http.ResponseWriter, r *http.Request) { ctx := r.Context() + originalBody := r.Body + r.Body = io.NopCloser(originalBody) - // Read the JSON-RPC request + // Read the JSON-RPC request. var req jsonrpc.RawRequest if err := s.decoder(r).Decode(&req); err != nil { - // Emit JSON-RPC parse error as SSE event - stream := &sseServerStream{w: w, r: r, encoder: s.encoder} - _ = stream.sendError(ctx, nil, jsonrpc.ParseError, "Parse error", nil) + closeErr := originalBody.Close() + s.errhandler(ctx, w, fmt.Errorf("failed to read request body: %w", errors.Join(err, closeErr))) + // Write the parse error as a server-sent event. + stream := &{{ .SSEStream.Name }}{w: w, encoder: s.encoder} + if err := stream.sendError(ctx, nil, jsonrpc.ParseError, "Parse error", nil); err != nil { + s.errhandler(ctx, w, fmt.Errorf("write parse error event: %w", err)) + } return } + defer func() { + if err := originalBody.Close(); err != nil { + s.errhandler(ctx, w, fmt.Errorf("failed to close request body: %w", err)) + } + }() + s.processSSERequest(ctx, r, &req, w) +} +{{- end }} + +// processSSERequest validates and runs one server-sent-event request. +func (s *{{ .ServerStructDeclaration.Name }}) processSSERequest(ctx context.Context, r *http.Request, req *jsonrpc.RawRequest, w http.ResponseWriter) { - // Validate JSON-RPC request - if req.JSONRPC != "2.0" { - stream := &sseServerStream{w: w, r: r, encoder: s.encoder} - _ = stream.sendError(ctx, req.ID, jsonrpc.InvalidRequest, "Invalid request", nil) + // Reject requests that do not use JSON-RPC 2.0. + if req.Invalid || req.JSONRPC != "2.0" { + stream := &{{ .SSEStream.Name }}{w: w, encoder: s.encoder} + if err := stream.sendError(ctx, req.ID, jsonrpc.InvalidRequest, "Invalid request", nil); err != nil { + s.errhandler(ctx, w, fmt.Errorf("write invalid request event: %w", err)) + } return } - if req.Method == "" { - stream := &sseServerStream{w: w, r: r, encoder: s.encoder} - _ = stream.sendError(ctx, req.ID, jsonrpc.InvalidRequest, "Invalid request", nil) + if !req.HasMethod { + stream := &{{ .SSEStream.Name }}{w: w, encoder: s.encoder} + if err := stream.sendError(ctx, req.ID, jsonrpc.InvalidRequest, "Invalid request", nil); err != nil { + s.errhandler(ctx, w, fmt.Errorf("write invalid request event: %w", err)) + } return } - // Find the appropriate handler based on method name + // Find the function for the requested method. var handler func(context.Context, *http.Request, *jsonrpc.RawRequest, http.ResponseWriter) error switch req.Method { {{- range .Endpoints }} {{- if .SSE }} case {{ printf "%q" .Method.Name }}: + {{- if .IsJSONRPCNotification }} + if req.HasID { + stream := &{{ $.SSEStream.Name }}{w: w, encoder: s.encoder} + if err := stream.sendError(ctx, req.ID, jsonrpc.InvalidRequest, "Invalid request", nil); err != nil { + s.errhandler(ctx, w, fmt.Errorf("write invalid request event: %w", err)) + } + return + } + {{- else }} + if !req.HasID { + s.reportRejectedNotification(ctx, req) + return + } + if req.ID == nil { + stream := &{{ $.SSEStream.Name }}{w: w, encoder: s.encoder} + if err := stream.sendError(ctx, req.ID, jsonrpc.InvalidRequest, "Invalid request", nil); err != nil { + s.errhandler(ctx, w, fmt.Errorf("write invalid request event: %w", err)) + } + return + } + {{- end }} handler = s.{{ .Method.VarName }} {{- end }} -{{- end }} + {{- end }} default: - stream := &sseServerStream{w: w, r: r, encoder: s.encoder} - _ = stream.sendError(ctx, req.ID, jsonrpc.MethodNotFound, "Method not found", nil) + if !req.HasID { + return + } + stream := &{{ .SSEStream.Name }}{w: w, encoder: s.encoder} + if err := stream.sendError(ctx, req.ID, jsonrpc.MethodNotFound, "Method not found", nil); err != nil { + s.errhandler(ctx, w, fmt.Errorf("write method not found event: %w", err)) + } return } - // Call the handler for the specific method - if err := handler(ctx, r, &req, w); err != nil { + // Call the requested method. + if err := handler(ctx, r, req, w); err != nil { s.errhandler(ctx, w, fmt.Errorf("handler error for %s: %w", req.Method, err)) - return - } - - // For notifications (requests without ID) that don't stream, return 204 No Content - switch req.Method { -{{- range .Endpoints }} - {{- if and .SSE (not .Method.ServerStream) }} - case {{ printf "%q" .Method.Name }}: - if req.ID == nil { - w.WriteHeader(http.StatusNoContent) - } - {{- end }} -{{- end }} } -} \ No newline at end of file +} diff --git a/jsonrpc/codegen/templates/sse_server_stream.go.tpl b/jsonrpc/codegen/templates/sse_server_stream.go.tpl index a61f6324ae..7e04b00f88 100644 --- a/jsonrpc/codegen/templates/sse_server_stream.go.tpl +++ b/jsonrpc/codegen/templates/sse_server_stream.go.tpl @@ -1,138 +1,126 @@ -{{ comment (printf "%s implements the %s.%s interface using Server-Sent Events." .SSE.StructName .ServicePkgName .Method.ServerStream.Interface) }} -type {{ .SSE.StructName }} struct { - // sseServerStream provides the shared SSE event encoding machinery - sseServerStream - // requestID is the JSON-RPC request ID for sending final response - requestID any - // closed indicates if the stream has been closed via SendAndClose - closed bool - // mu protects the closed flag - mu sync.Mutex -} - -{{ comment "Send sends a JSON-RPC notification to the client." }} -{{ comment "Notifications do not expect a response from the client." }} -func (s *{{ .SSE.StructName }}) Send(ctx context.Context, event {{ .ServicePkgName }}.{{ .Method.VarName }}Event) error { - {{ comment "Check if stream is closed" }} - s.mu.Lock() - if s.closed { - s.mu.Unlock() - return fmt.Errorf("stream closed") - } - s.mu.Unlock() - - {{ comment "Type assert to the specific result type" }} - result, ok := event.({{ .SSE.EventTypeRef }}) - if !ok { - return fmt.Errorf("unexpected event type: %T", event) - } - - {{- if and .Result (index .Result.Responses 0).ServerBody (index (index .Result.Responses 0).ServerBody 0).Init }} - {{ comment "Convert to response body type for proper JSON encoding" }} - body := {{ (index (index .Result.Responses 0).ServerBody 0).Init.Name }}(result) - {{- else }} - body := result +{{ comment (printf "%s implements the %s.%s interface using Server-Sent Events." .SSE.StructDeclaration.Name .ServicePkgName .Method.ServerStream.Interface) }} +type {{ .SSE.StructDeclaration.Name }} struct { + // {{ sseStreamName }} writes JSON-RPC messages as server-sent events. + {{ sseStreamName }} + {{- if and .Method.ViewedResult (not .Method.ViewedResult.ViewName) }} + // view is the result view used to encode later stream values. + view string + {{ comment "sentView is the result view used by the first event. Later sends must use the same view." }} + sentView string {{- end }} +} - {{ comment "Send as notification (no ID)" }} - message := map[string]any{ - "jsonrpc": "2.0", - "method": {{ printf "%q" .Method.Name }}, - "params": body, - } +{{- if and .Method.ViewedResult (not .Method.ViewedResult.ViewName) }} +{{ comment "SetView selects the result view used by later stream values." }} +func (s *{{ .SSE.StructDeclaration.Name }}) SetView(view string) { + s.view = view +} +{{- end }} - return s.sendSSEEvent("notification", message) +{{ comment .Method.ServerStream.SendDesc }} +func (s *{{ .SSE.StructDeclaration.Name }}) {{ .Method.ServerStream.SendName }}(event {{ .SSE.EventTypeRef }}) error { + return s.{{ .Method.ServerStream.SendWithContextName }}(context.Background(), event) } -{{ comment "SendAndClose sends a final JSON-RPC response to the client and closes the stream." }} -{{ comment "The response will include the original request ID unless the result has an ID field populated." }} -{{ comment "After calling this method, no more events can be sent on this stream." }} -func (s *{{ .SSE.StructName }}) SendAndClose(ctx context.Context, event {{ .ServicePkgName }}.{{ .Method.VarName }}Event) error { - {{ comment "Check if stream is already closed" }} - s.mu.Lock() - if s.closed { - s.mu.Unlock() - return fmt.Errorf("stream already closed") - } - s.closed = true - s.mu.Unlock() +{{ comment .Method.ServerStream.SendWithContextDesc }} +func (s *{{ .SSE.StructDeclaration.Name }}) {{ .Method.ServerStream.SendWithContextName }}(ctx context.Context, event {{ .SSE.EventTypeRef }}) error { + {{- $directData := and .SSE.DataField .SSE.Params .SSE.Params.Positional }} + result := event - {{ comment "Type assert to the specific result type" }} - result, ok := event.({{ .SSE.EventTypeRef }}) - if !ok { - return fmt.Errorf("unexpected event type: %T", event) + {{- if and (not $directData) (not .SSE.Params.OmitAbsent) }} + {{- if .Method.ViewedResult }} + {{- if not .Method.ViewedResult.ViewName }} + view := s.view + if view == "" { + view = "default" } - - {{ comment "Determine the ID to use for the response" }} - var id any = s.requestID - {{- if .Result.IDAttribute }} - {{- if .Result.IDAttributeRequired }} - if result.{{ .Result.IDAttribute }} != "" { - {{ comment "Use the ID from the result if provided" }} - id = result.{{ .Result.IDAttribute }} - {{ comment "Clear the ID field so it's not duplicated in the result" }} - result.{{ .Result.IDAttribute }} = "" + if s.sentView != "" && view != s.sentView { + return goa.InvalidEnumValueError("view", view, []any{s.sentView}) } - {{- else }} - if result.{{ .Result.IDAttribute }} != nil && *result.{{ .Result.IDAttribute }} != "" { - {{ comment "Use the ID from the result if provided" }} - id = *result.{{ .Result.IDAttribute }} - {{ comment "Clear the ID field so it's not duplicated in the result" }} - result.{{ .Result.IDAttribute }} = nil + {{- end }} + body, err := {{ viewedStreamEncodeName .Method.Name }}(result{{ if not .Method.ViewedResult.ViewName }}, view{{ end }}) + if err != nil { + return err } - {{- end }} + {{- if not .Method.ViewedResult.ViewName }} + s.sentView = view {{- end }} - - {{- if and .Result (index .Result.Responses 0).ServerBody (index (index .Result.Responses 0).ServerBody 0).Init }} - {{ comment "Convert to response body type for proper JSON encoding" }} - body := {{ (index (index .Result.Responses 0).ServerBody 0).Init.Name }}(result) + {{- else if and .SSE.HasResponseBody .SSE.Response (index .SSE.Response.ServerBody 0).Init }} + body := {{ (index .SSE.Response.ServerBody 0).Init.Declaration.Name }}(result) {{- else }} body := result {{- end }} - - {{ comment "Send as response with ID" }} - message := map[string]any{ - "jsonrpc": "2.0", - "id": id, - "result": body, + {{- end }} + {{- if .SSE.IDField }} + var eventID *string + {{- if .SSE.ID.Pointer }} + if result.{{ .SSE.IDField }} != nil { + value := string(*result.{{ .SSE.IDField }}) + eventID = &value } - - return s.sendSSEEvent("response", message) -} - -{{ comment "SendError sends a JSON-RPC error response." }} -func (s *{{ .SSE.StructName }}) SendError(ctx context.Context, id string, err error) error { - {{- if .Errors }} - var en goa.GoaErrorNamer - if !errors.As(err, &en) { - code := jsonrpc.InternalError - if _, ok := err.(*goa.ServiceError); ok { - code = jsonrpc.InvalidParams - } - return s.sendError(ctx, id, code, err.Error(), nil) + {{- else }} + valueID := string(result.{{ .SSE.IDField }}) + eventID = &valueID + {{- end }} + {{- end }} + {{- if .SSE.EventField }} + var eventType *string + {{- if .SSE.Event.Pointer }} + if result.{{ .SSE.EventField }} != nil { + value := string(*result.{{ .SSE.EventField }}) + eventType = &value } - switch en.GoaErrorName() { - {{- range $gerr := .Errors }} - {{- range $err := $gerr.Errors }} - case {{ printf "%q" $err.Name }}: - {{- with $err.Response}} - return s.sendError(ctx, id, {{ .Code }}, err.Error(), err) - {{- end }} - {{- end }} + {{- else }} + valueType := string(result.{{ .SSE.EventField }}) + eventType = &valueType {{- end }} - default: - code := jsonrpc.InternalError - if _, ok := err.(*goa.ServiceError); ok { - code = jsonrpc.InvalidParams + {{- end }} + {{- if .SSE.RetryField }} + var eventRetry *string + {{- if .SSE.Retry.Pointer }} + if result.{{ .SSE.RetryField }} != nil { + {{- if sseRetrySigned .SSE.Retry }} + if *result.{{ .SSE.RetryField }} < 0 { + return fmt.Errorf("server-sent event retry cannot be negative") } - return s.sendError(ctx, id, code, err.Error(), nil) + {{- end }} + value := fmt.Sprintf("%d", *result.{{ .SSE.RetryField }}) + eventRetry = &value + } + {{- else }} + {{- if sseRetrySigned .SSE.Retry }} + if result.{{ .SSE.RetryField }} < 0 { + return fmt.Errorf("server-sent event retry cannot be negative") + } + {{- end }} + valueRetry := fmt.Sprintf("%d", result.{{ .SSE.RetryField }}) + eventRetry = &valueRetry + {{- end }} + {{- end }} + + {{ if .SSE.Params.OmitAbsent }} + message := &jsonrpc.Request{ + JSONRPC: "2.0", + Method: {{ printf "%q" .Method.Name }}, } - {{- else }} - {{ comment "No custom errors defined - check if it's a validation error, otherwise use internal error" }} - code := jsonrpc.InternalError - if _, ok := err.(*goa.ServiceError); ok { - code = jsonrpc.InvalidParams - } - return s.sendError(ctx, id, code, err.Error(), nil) - {{- end }} + if {{ if .SSE.Data.Union }}result.{{ .SSE.DataField }}.Kind() != ""{{ else }}result.{{ .SSE.DataField }} != nil{{ end }} { + {{- if and .SSE.HasResponseBody .SSE.Response (index .SSE.Response.ServerBody 0).Init }} + body := {{ (index .SSE.Response.ServerBody 0).Init.Declaration.Name }}(result) + message.Params = body.{{ .SSE.DataField }} + {{- else }} + message.Params = result.{{ .SSE.DataField }} + {{- end }} + } + {{- else }} + message := jsonrpc.MakeNotification( + {{ printf "%q" .Method.Name }}, + {{ if and .SSE.Params .SSE.Params.Positional }}[]{{ .SSE.Params.TypeRef }}{ {{- if $directData }}result.{{ .SSE.DataField }}{{ else }}body{{ end }} }{{ else }}body{{ if .SSE.DataField }}.{{ .SSE.DataField }}{{ end }}{{ end }}, + ) + {{- end }} + return s.sendSSEEvent(ctx, message, {{ if .SSE.IDField }}eventID{{ else }}nil{{ end }}, {{ if .SSE.EventField }}eventType{{ else }}nil{{ end }}, {{ if .SSE.RetryField }}eventRetry{{ else }}nil{{ end }}) +} + +{{ comment "Close does nothing because the HTTP response closes when the service method returns." }} +func (s *{{ .SSE.StructDeclaration.Name }}) Close() error { + return nil } diff --git a/jsonrpc/codegen/templates/sse_server_stream_base.go.tpl b/jsonrpc/codegen/templates/sse_server_stream_base.go.tpl index 8b469d2394..4fe8f1da75 100644 --- a/jsonrpc/codegen/templates/sse_server_stream_base.go.tpl +++ b/jsonrpc/codegen/templates/sse_server_stream_base.go.tpl @@ -1,44 +1,66 @@ -{{ comment "sseServerStream provides the SSE event encoding machinery shared by all JSON-RPC SSE server streams of the service." }} -type sseServerStream struct { - // once ensures the headers are written once. - once sync.Once - // w is the HTTP response writer used to send the SSE events. - w http.ResponseWriter - // r is the HTTP request. - r *http.Request - // encoder is the response encoder. - encoder func(context.Context, http.ResponseWriter) goahttp.Encoder +{{- if or .SSEStream .NoOutputWriter }} +type ( + {{- if .SSEStream }} + {{ printf "%s writes JSON-RPC messages as server-sent events." .SSEStream.Name | comment }} + {{ .SSEStream.Name }} struct { + // once writes the HTTP headers only for the first event. + once sync.Once + // w receives the HTTP headers and event bytes. + w http.ResponseWriter + // encoder turns one JSON-RPC message into bytes. + encoder func(context.Context, http.ResponseWriter) goahttp.Encoder + } + + {{ printf "%s stores an encoded event before any HTTP output is written." .SSEBuffer.Name | comment }} + {{ .SSEBuffer.Name }} struct { + bytes.Buffer + header http.Header + } + {{- end }} + + {{- if .NoOutputWriter }} + {{ printf "%s accepts notification output without storing or sending it." .NoOutputWriter.Name | comment }} + {{ .NoOutputWriter.Name }} struct { + header http.Header + } + {{- end }} +) +{{- end }} + +{{- if .SSEStream }} +// Header returns the headers written while the event is being encoded. +func (b *{{ .SSEBuffer.Name }}) Header() http.Header { + return b.header } -{{ comment "sseEventWriter wraps http.ResponseWriter to format output as SSE events." }} -type sseEventWriter struct { - w http.ResponseWriter - eventType string - started bool +// WriteHeader leaves the response status for the real HTTP response writer. +func (b *{{ .SSEBuffer.Name }}) WriteHeader(int) { } +{{- end }} -func (s *sseEventWriter) Header() http.Header { return s.w.Header() } -func (s *sseEventWriter) WriteHeader(statusCode int) { s.w.WriteHeader(statusCode) } -func (s *sseEventWriter) Write(data []byte) (int, error) { - if !s.started { - s.started = true - if s.eventType != "" { - fmt.Fprintf(s.w, "event: %s\n", s.eventType) - } - s.w.Write([]byte("data: ")) - } - return s.w.Write(data) +{{- if .NoOutputWriter }} +// Header returns the private headers written while a notification is encoded. +func (w *{{ .NoOutputWriter.Name }}) Header() http.Header { + return w.header } -func (s *sseEventWriter) finish() { - if s.started { - s.w.Write([]byte("\n\n")) - http.NewResponseController(s.w).Flush() - } +// Write accepts notification bytes without storing or sending them. +func (w *{{ .NoOutputWriter.Name }}) Write(data []byte) (int, error) { + return len(data), nil } -// initSSEHeaders initializes the SSE response headers -func (s *sseServerStream) initSSEHeaders() { +// WriteHeader accepts a notification status without sending it. +func (w *{{ .NoOutputWriter.Name }}) WriteHeader(int) { +} + +// Flush completes successfully because notification bytes are not sent. +func (w *{{ .NoOutputWriter.Name }}) Flush() { +} +{{- end }} + +{{- if .SSEStream }} +// initSSEHeaders writes the response headers before the first event. +func (s *{{ .SSEStream.Name }}) initSSEHeaders() { s.once.Do(func() { header := s.w.Header() header.Set("Content-Type", "text/event-stream") @@ -49,24 +71,81 @@ func (s *sseServerStream) initSSEHeaders() { }) } -// sendSSEEvent sends a single SSE event by creating an encoder that writes to the event writer -func (s *sseServerStream) sendSSEEvent(eventType string, v any) error { - s.initSSEHeaders() - - // Create SSE event writer that wraps the response writer - ew := &sseEventWriter{w: s.w, eventType: eventType} - - // Create encoder with the event writer and encode the value - err := s.encoder(context.Background(), ew).Encode(v) +// sendSSEEvent encodes one complete JSON-RPC message before starting the +// response, then writes its mapped SSE fields and data. +func (s *{{ .SSEStream.Name }}) sendSSEEvent(ctx context.Context, value any, id, eventType, retry *string) error { + if id != nil { + for _, character := range *id { + if character == 0 || character == '\r' || character == '\n' { + return fmt.Errorf("server-sent event id contains a forbidden character") + } + } + } + if eventType != nil { + for _, character := range *eventType { + if character == '\r' || character == '\n' { + return fmt.Errorf("server-sent event name contains a line break") + } + } + } + if retry != nil { + if *retry == "" { + return fmt.Errorf("server-sent event retry must contain only decimal digits") + } + for index := 0; index < len(*retry); index++ { + if (*retry)[index] < '0' || (*retry)[index] > '9' { + return fmt.Errorf("server-sent event retry must contain only decimal digits") + } + } + } - // Finish the SSE event (adds newlines and flushes) - ew.finish() + event := &{{ .SSEBuffer.Name }}{header: make(http.Header)} + if err := s.encoder(ctx, event).Encode(value); err != nil { + return err + } - return err + s.initSSEHeaders() + if id != nil { + if _, err := fmt.Fprintf(s.w, "id: %s\n", *id); err != nil { + return fmt.Errorf("write server-sent event id: %w", err) + } + } + if eventType != nil { + if _, err := fmt.Fprintf(s.w, "event: %s\n", *eventType); err != nil { + return fmt.Errorf("write server-sent event name: %w", err) + } + } + if retry != nil { + if _, err := fmt.Fprintf(s.w, "retry: %s\n", *retry); err != nil { + return fmt.Errorf("write server-sent event retry: %w", err) + } + } + data := bytes.ReplaceAll(event.Bytes(), []byte("\r\n"), []byte("\n")) + data = bytes.ReplaceAll(data, []byte("\r"), []byte("\n")) + data = bytes.TrimSuffix(data, []byte("\n")) + for _, line := range bytes.Split(data, []byte("\n")) { + if _, err := s.w.Write([]byte("data: ")); err != nil { + return fmt.Errorf("write server-sent event data label: %w", err) + } + if _, err := s.w.Write(line); err != nil { + return fmt.Errorf("write server-sent event data: %w", err) + } + if _, err := s.w.Write([]byte("\n")); err != nil { + return fmt.Errorf("finish server-sent event data line: %w", err) + } + } + if _, err := s.w.Write([]byte("\n")); err != nil { + return fmt.Errorf("finish server-sent event: %w", err) + } + if err := http.NewResponseController(s.w).Flush(); err != nil { + return fmt.Errorf("flush server-sent event: %w", err) + } + return nil } -// sendError sends a JSON-RPC error response to the SSE stream -func (s *sseServerStream) sendError(ctx context.Context, id any, code jsonrpc.Code, message string, data any) error { +// sendError writes one JSON-RPC error as a server-sent event. +func (s *{{ .SSEStream.Name }}) sendError(ctx context.Context, id any, code jsonrpc.Code, message string, data any) error { response := jsonrpc.MakeErrorResponse(id, code, message, data) - return s.sendSSEEvent("error", response) + return s.sendSSEEvent(ctx, response, nil, nil, nil) } +{{- end }} diff --git a/jsonrpc/codegen/templates/viewed_result_body_decode.go.tpl b/jsonrpc/codegen/templates/viewed_result_body_decode.go.tpl new file mode 100644 index 0000000000..e3e0f1c08e --- /dev/null +++ b/jsonrpc/codegen/templates/viewed_result_body_decode.go.tpl @@ -0,0 +1,10 @@ +{{ printf "%s decodes one JSON-RPC result value with the configured HTTP decoder." .Name | comment }} +func {{ .Name }}(decoder func(*http.Response) goahttp.Decoder, data json.RawMessage, target any) error { + // A JSON-RPC result is a successful HTTP value even when it arrived inside + // a server-sent event. + resp := &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(bytes.NewReader(data)), + } + return decoder(resp).Decode(target) +} diff --git a/jsonrpc/codegen/templates/viewed_result_decode.go.tpl b/jsonrpc/codegen/templates/viewed_result_decode.go.tpl new file mode 100644 index 0000000000..066ad7b6a9 --- /dev/null +++ b/jsonrpc/codegen/templates/viewed_result_decode.go.tpl @@ -0,0 +1,151 @@ +{{ printf "%s decodes the JSON body selected by the result view for the %s service %s method." .Decode.Name .ServiceName .MethodName | comment }} +func {{ .Decode.Name }}(decoder func(*http.Response) goahttp.Decoder, resp *http.Response, data json.RawMessage{{ if .SSE }}{{ if .SSE.IDField }}, eventID string, hasEventID bool{{ end }}{{ if .SSE.EventField }}, eventType string, hasEventType bool{{ end }}{{ if .SSE.RetryField }}, eventRetry string, hasEventRetry bool{{ end }}{{ end }}) ({{ .ResultRef }}, error) { + {{- if .Variable }} + var representation struct { + View *string `json:"view"` + Body *json.RawMessage `json:"body"` + } + if err := {{ .BodyDecoder.Name }}(decoder, data, &representation); err != nil { + return nil, goahttp.ErrDecodingError("{{ .ServiceName }}", "{{ .MethodName }}", err) + } + if representation.View == nil { + return nil, goahttp.ErrValidationError("{{ .ServiceName }}", "{{ .MethodName }}", goa.MissingFieldError("view", "result")) + } + view := *representation.View + switch view { + {{- range .Branches }} + case {{ printf "%q" .View }}: + {{- if .ClientBody }} + if representation.Body == nil { + return nil, goahttp.ErrValidationError("{{ $.ServiceName }}", "{{ $.MethodName }}", goa.MissingFieldError("body", "result")) + } + resp.Body = io.NopCloser(bytes.NewBuffer(*representation.Body)) + {{- end }} + {{- template "partial_single_response" (viewedResponseData . $.ServiceName $.MethodName $.SSE) }} + projected := {{ .ResultInit.Declaration.Name }}({{ range .ResultInit.ClientArgs }}{{ .Ref }},{{ end }}) + {{ $.ViewedValue }} := {{ if not $.IsCollection }}&{{ end }}{{ $.ViewedPkg }}.{{ $.ViewedVarName }}{ + Projected: projected, + View: view, + } + if err := {{ $.ViewedPkg }}.{{ $.ViewedValidator }}({{ $.ViewedValue }}); err != nil { + return nil, goahttp.ErrValidationError("{{ $.ServiceName }}", "{{ $.MethodName }}", err) + } + result := {{ $.ServicePkg }}.{{ $.ServiceResultConstructor }}({{ $.ViewedValue }}) + {{- if $.SSE }} + {{- template "viewed_sse_result_fields" $ }} + {{- end }} + return result, nil + {{- end }} + default: + return nil, goahttp.ErrValidationError("{{ .ServiceName }}", "{{ .MethodName }}", goa.InvalidEnumValueError("view", view, []any{ + {{- range .Branches }}{{ printf "%q" .View }},{{ end }} + })) + } + {{- else }} + {{- with index .Branches 0 }} + {{- if .ClientBody }} + resp.Body = io.NopCloser(bytes.NewBuffer(data)) + {{- end }} + {{- template "partial_single_response" (viewedResponseData . $.ServiceName $.MethodName $.SSE) }} + projected := {{ .ResultInit.Declaration.Name }}({{ range .ResultInit.ClientArgs }}{{ .Ref }},{{ end }}) + {{ $.ViewedValue }} := {{ if not $.IsCollection }}&{{ end }}{{ $.ViewedPkg }}.{{ $.ViewedVarName }}{ + Projected: projected, + View: {{ printf "%q" $.FixedView }}, + } + if err := {{ $.ViewedPkg }}.{{ $.ViewedValidator }}({{ $.ViewedValue }}); err != nil { + return nil, goahttp.ErrValidationError("{{ $.ServiceName }}", "{{ $.MethodName }}", err) + } + result := {{ $.ServicePkg }}.{{ $.ServiceResultConstructor }}({{ $.ViewedValue }}) + {{- if $.SSE }} + {{- template "viewed_sse_result_fields" $ }} + {{- end }} + return result, nil + {{- end }} + {{- end }} +} + +{{- define "viewed_sse_outer_fields" }} + {{- if .SSE.IDField }} + if !hasEventID { + {{- if .SSE.ID.HasDefault }} + value := {{ .SSE.ID.ClientTypeRef }}({{ printf "%q" .SSE.ID.DefaultValue }}) + body.{{ .SSE.IDField }} = {{ if .SSE.ClientIDPointer }}&{{ end }}value + {{- else if not .SSE.ID.Pointer }} + return nil, goahttp.ErrValidationError("{{ .ServiceName }}", "{{ .MethodName }}", fmt.Errorf("server-sent event has no id for result field {{ .SSE.IDField }}")) + {{- end }} + } else { + value := {{ .SSE.ID.ClientTypeRef }}(eventID) + body.{{ .SSE.IDField }} = {{ if .SSE.ClientIDPointer }}&{{ end }}value + } + {{- end }} + {{- if .SSE.EventField }} + if !hasEventType { + {{- if .SSE.Event.HasDefault }} + value := {{ .SSE.Event.ClientTypeRef }}({{ printf "%q" .SSE.Event.DefaultValue }}) + body.{{ .SSE.EventField }} = {{ if .SSE.ClientEventPointer }}&{{ end }}value + {{- else if not .SSE.Event.Pointer }} + return nil, goahttp.ErrValidationError("{{ .ServiceName }}", "{{ .MethodName }}", fmt.Errorf("server-sent event has no type for result field {{ .SSE.EventField }}")) + {{- end }} + } else { + value := {{ .SSE.Event.ClientTypeRef }}(eventType) + body.{{ .SSE.EventField }} = {{ if .SSE.ClientEventPointer }}&{{ end }}value + } + {{- end }} + {{- if .SSE.RetryField }} + if !hasEventRetry { + {{- if .SSE.Retry.HasDefault }} + value := {{ .SSE.Retry.ClientTypeRef }}({{ printf "%v" .SSE.Retry.DefaultValue }}) + body.{{ .SSE.RetryField }} = {{ if .SSE.Retry.ClientPointer }}&{{ end }}value + {{- else if not .SSE.Retry.Pointer }} + return nil, goahttp.ErrValidationError("{{ .ServiceName }}", "{{ .MethodName }}", fmt.Errorf("server-sent event has no retry for result field {{ .SSE.RetryField }}")) + {{- end }} + } else { + {{- if sseRetrySigned .SSE.Retry }} + parsed, parseErr := strconv.ParseInt(eventRetry, 10, {{ sseRetryBits .SSE.Retry }}) + if parseErr != nil || parsed < 0 { + return nil, goahttp.ErrDecodingError("{{ .ServiceName }}", "{{ .MethodName }}", fmt.Errorf("invalid server-sent event retry %q", eventRetry)) + } + {{- else }} + parsed, parseErr := strconv.ParseUint(eventRetry, 10, {{ sseRetryBits .SSE.Retry }}) + if parseErr != nil { + return nil, goahttp.ErrDecodingError("{{ .ServiceName }}", "{{ .MethodName }}", fmt.Errorf("invalid server-sent event retry %q", eventRetry)) + } + {{- end }} + value := {{ .SSE.Retry.ClientTypeRef }}(parsed) + body.{{ .SSE.RetryField }} = {{ if .SSE.Retry.ClientPointer }}&{{ end }}value + } + {{- end }} +{{- end }} + +{{- define "viewed_sse_result_fields" }} + {{- if .SSE.IDField }} + {{- if .SSE.ID.Pointer }} + if body.{{ .SSE.IDField }} != nil { + value := {{ .SSE.ID.TypeRef }}(*body.{{ .SSE.IDField }}) + result.{{ .SSE.IDField }} = &value + } + {{- else }} + result.{{ .SSE.IDField }} = {{ .SSE.ID.TypeRef }}(*body.{{ .SSE.IDField }}) + {{- end }} + {{- end }} + {{- if .SSE.EventField }} + {{- if .SSE.Event.Pointer }} + if body.{{ .SSE.EventField }} != nil { + value := {{ .SSE.Event.TypeRef }}(*body.{{ .SSE.EventField }}) + result.{{ .SSE.EventField }} = &value + } + {{- else }} + result.{{ .SSE.EventField }} = {{ .SSE.Event.TypeRef }}(*body.{{ .SSE.EventField }}) + {{- end }} + {{- end }} + {{- if .SSE.RetryField }} + {{- if .SSE.Retry.Pointer }} + if body.{{ .SSE.RetryField }} != nil { + value := {{ .SSE.Retry.TypeRef }}(*body.{{ .SSE.RetryField }}) + result.{{ .SSE.RetryField }} = &value + } + {{- else }} + result.{{ .SSE.RetryField }} = {{ .SSE.Retry.TypeRef }}(*body.{{ .SSE.RetryField }}) + {{- end }} + {{- end }} +{{- end }} diff --git a/jsonrpc/codegen/templates/viewed_result_encode.go.tpl b/jsonrpc/codegen/templates/viewed_result_encode.go.tpl new file mode 100644 index 0000000000..1832f0601c --- /dev/null +++ b/jsonrpc/codegen/templates/viewed_result_encode.go.tpl @@ -0,0 +1,57 @@ +{{ printf "%s builds the JSON body selected by the result view for the %s service %s method." .Encode.Name .ServiceName .MethodName | comment }} +func {{ .Encode.Name }}(viewed {{ .ViewedTypeRef }}) (any, error) { + if err := {{ .ViewedPkg }}.{{ .ViewedValidator }}(viewed); err != nil { + return nil, err + } + {{- if .Variable }} + switch viewed.View { + {{- range .Branches }} + case {{ printf "%q" .View }}: + {{- if .ServerBody }} + {{- if .ServerBody.Init }} + res := viewed + body := {{ .ServerBody.Init.Declaration.Name }}({{ range .ServerBody.Init.ServerArgs }}{{ .Ref }},{{ end }}) + {{- else }} + body := viewed.Projected{{ if .ResultAttr }}.{{ .ResultAttr }}{{ end }} + {{- end }} + return struct { + View string `json:"view"` + Body {{ .ServerBody.Ref }} `json:"body"` + }{ + View: {{ printf "%q" .View }}, + Body: body, + }, nil + {{- else }} + return struct { + View string `json:"view"` + }{ + View: {{ printf "%q" .View }}, + }, nil + {{- end }} + {{- end }} + default: + panic("validated viewed result has no JSON-RPC representation") + } + {{- else }} + {{- with index .Branches 0 }} + {{- if .ServerBody }} + {{- if .ServerBody.Init }} + res := viewed + return {{ .ServerBody.Init.Declaration.Name }}({{ range .ServerBody.Init.ServerArgs }}{{ .Ref }},{{ end }}), nil + {{- else }} + return viewed.Projected{{ if .ResultAttr }}.{{ .ResultAttr }}{{ end }}, nil + {{- end }} + {{- else }} + return nil, nil + {{- end }} + {{- end }} + {{- end }} +} + +{{- if .StreamEncode }} +{{ printf "%s builds and validates the selected result view before JSON-RPC encoding." .StreamEncode.Name | comment }} +func {{ .StreamEncode.Name }}(result {{ .ResultRef }}{{ if .Variable }}, view string{{ end }}) (any, error) { + viewed := {{ .ServicePkg }}.{{ .ServiceViewedConstructor }}(result, {{ if .Variable }}view{{ else }}{{ printf "%q" .FixedView }}{{ end }}) + return {{ .Encode.Name }}(viewed) +} +{{- end }} diff --git a/jsonrpc/codegen/templates/websocket_client_conn.go.tpl b/jsonrpc/codegen/templates/websocket_client_conn.go.tpl deleted file mode 100644 index c2a77f8dc5..0000000000 --- a/jsonrpc/codegen/templates/websocket_client_conn.go.tpl +++ /dev/null @@ -1,95 +0,0 @@ -{{/* -websocket_client_conn.go.tpl generates WebSocket connection management methods for JSON-RPC clients. - -This template provides connection lifecycle management including: -- Connection establishment with health checking -- Connection reuse and automatic reconnection -- Thread-safe connection access with read/write locking -- Proper cleanup on client close - -Template variables: -- .ClientStruct: Name of the generated client struct -*/}} -// getConn returns the current WebSocket connection or creates a new one -func (c *{{ .ClientStruct }}) getConn(ctx context.Context) (*websocket.Conn, error) { - c.connMu.RLock() - conn := c.conn - if conn != nil { - // Check if connection is still alive - if err := conn.WriteControl(websocket.PingMessage, []byte{}, time.Now().Add(5*time.Second)); err == nil { - c.connMu.RUnlock() - return conn, nil - } - // Connection is dead, need new one - } - c.connMu.RUnlock() - - // Create new connection - c.connMu.Lock() - defer c.connMu.Unlock() - - // Double-check after acquiring write lock - if c.conn != nil { - if err := c.conn.WriteControl(websocket.PingMessage, []byte{}, time.Now().Add(5*time.Second)); err == nil { - return c.conn, nil - } - // Close the dead connection - c.conn.Close() - } - - // Convert scheme for WebSocket - wsScheme := "ws" - if c.scheme == "https" { - wsScheme = "wss" - } - - // Find the WebSocket path from the service endpoints - {{- $found := false }} - {{- range .Endpoints }} - {{- range .Routes }} - {{- if and (eq .Verb "GET") (ne .Path "/") (not $found) }} - url := wsScheme + "://" + c.host + {{ printf "%q" .Path }} - {{ $found = true }} - {{- end }} - {{- end }} - {{- end }} - {{- if not $found }} - url := wsScheme + "://" + c.host - {{- end }} - - ws, _, err := c.dialer.DialContext(ctx, url, nil) - if err != nil { - return nil, goahttp.ErrRequestError("{{ .Service.Name }}", "connect", err) - } - - if c.configfn != nil { - ws = c.configfn(ws, nil) - } - - // Store the direct WebSocket connection - c.conn = ws - - return c.conn, nil -} - -// Close closes the WebSocket connection and marks the client as closed -func (c *{{ .ClientStruct }}) Close() error { - if c.closed.Swap(true) { - return nil // Already closed - } - - c.connMu.Lock() - defer c.connMu.Unlock() - - if c.conn != nil { - err := c.conn.Close() - c.conn = nil - return err - } - return nil -} - -// IsClosed returns true if the client connection has been closed -func (c *{{ .ClientStruct }}) IsClosed() bool { - return c.closed.Load() -} diff --git a/jsonrpc/codegen/templates/websocket_client_stream.go.tpl b/jsonrpc/codegen/templates/websocket_client_stream.go.tpl deleted file mode 100644 index eab73ce0b3..0000000000 --- a/jsonrpc/codegen/templates/websocket_client_stream.go.tpl +++ /dev/null @@ -1,428 +0,0 @@ -{{/* -websocket_client_stream.go.tpl generates JSON-RPC WebSocket streaming client implementations. - -This template creates stream types that handle direct WebSocket connections for JSON-RPC -streaming endpoints, providing: -- Direct WebSocket transport without intermediate wrappers -- Dual ID correlation (user payload ID + JSON-RPC request ID) -- Comprehensive error handling with user-configurable error handlers -- Generated decoder integration for consistent response parsing -- Thread-safe operations with proper lifecycle management - -Template variables: -- .VarName: Name of the generated stream struct -- .Endpoint.Method.Name: Name of the endpoint method -- .SendName/.SendTypeRef: Send method name and payload type (if stream accepts input) -- .RecvName/.RecvTypeRef: Receive method name and result type (if stream produces output) -- .Endpoint.ServiceVarName: Service name for JSON-RPC method naming - -The template handles three streaming patterns: -1. Client streaming (send-only): $hasSend && !$hasRecv -2. Server streaming (recv-only): !$hasSend && $hasRecv -3. Bidirectional streaming: $isBidirectional ($hasSend && $hasRecv) -*/}} -{{ printf "%s implements the %s client stream with direct WebSocket handling." .VarName .Endpoint.Method.Name | comment }} -{{- $hasRecv := and .RecvName .RecvTypeRef }} -{{- $hasSend := .SendName }} -{{- $isBidirectional := and $hasSend $hasRecv }} -type {{ .VarName }} struct { - // Direct WebSocket transport - ws *websocket.Conn - writeMu sync.Mutex // Serialize WebSocket writes - - // JSON-RPC correlation - pending sync.Map // map[jsonrpcID]*{{ .VarName }}PendingRequest - idGenerator atomic.Uint64 // JSON-RPC request ID generator - - // Lifecycle management - ctx context.Context - cancel context.CancelFunc - done chan struct{} // Signals stream closure - closeOnce sync.Once - - // Error handling - errorOnce sync.Once - lastError atomic.Value // Last error encountered - - // Stream configuration - config *jsonrpc.StreamConfig // Stream configuration options - {{- if $hasRecv }} - decoder func(*http.Response) goahttp.Decoder // User-provided decoder for result bodies - {{- end }} -} - - -// Stream-specific types for {{ .VarName }} -type {{ .VarName }}PendingRequest struct { - userID string // User-provided payload ID - resultChan chan {{ .VarName }}StreamResult // Buffered result delivery - timeout *time.Timer // Request timeout handling -} - -type {{ .VarName }}StreamResult struct { -{{- if $hasRecv }} - result {{ .RecvTypeRef }} -{{- end }} - err error -} - -{{- if $hasSend }} -{{ printf "%s sends streaming data to the %s endpoint with dual ID correlation." .SendName .Endpoint.Method.Name | comment }} -func (s *{{ .VarName }}) {{ .SendName }}(v {{ .SendTypeRef }}) error { - return s.{{ .SendName }}WithContext(s.ctx, v) -} - -{{ printf "%sWithContext sends streaming data to the %s endpoint with context." .SendName .Endpoint.Method.Name | comment }} -func (s *{{ .VarName }}) {{ .SendName }}WithContext(ctx context.Context, v {{ .SendTypeRef }}) error { - // Check for stream-level errors first - if err := s.getError(); err != nil { - return err - } - -{{- if $isBidirectional }} - // Honor user-provided ID or generate one - userID := "" -{{- if .SendTypeRef }} - {{- if .Endpoint.Payload }} - // Honor user-provided ID if it exists in the payload - userID = s.generateUserID() - {{- end }} -{{- else }} - userID = s.generateUserID() -{{- end }} - - // Generate JSON-RPC protocol ID - jsonrpcID := strconv.FormatUint(s.idGenerator.Add(1), 10) - // Create pending request tracking for bidirectional streaming - pending := &{{ .VarName }}PendingRequest{ - userID: userID, - resultChan: make(chan {{ .VarName }}StreamResult, s.config.ResultChannelBuffer), - timeout: time.NewTimer(s.config.RequestTimeout), - } - - s.pending.Store(jsonrpcID, pending) - - // Construct JSON-RPC request - request := &jsonrpc.Request{ - JSONRPC: "2.0", - Method: "{{ .Endpoint.Method.Name }}", - Params: v, - ID: &jsonrpcID, - } -{{- else }} - // For payload-only streaming, use notification (fire-and-forget) - request := &jsonrpc.Request{ - JSONRPC: "2.0", - Method: "{{ .Endpoint.Method.Name }}", - Params: v, - // No ID field for notifications - } -{{- end }} - - // Send with write protection - s.writeMu.Lock() - err := s.ws.WriteJSON(request) - s.writeMu.Unlock() - - if err != nil { -{{- if $isBidirectional }} - s.pending.Delete(jsonrpcID) - pending.timeout.Stop() -{{- end }} - s.setError(err) - // Report connection errors - s.handleError(jsonrpc.StreamErrorConnection, err, nil) - return fmt.Errorf("failed to send request: %w", err) - } - - return nil -} -{{- end }} - -{{- if $hasRecv }} -{{ printf "%s receives streaming data from the %s endpoint." .RecvName .Endpoint.Method.Name | comment }} -func (s *{{ .VarName }}) {{ .RecvName }}() ({{ .RecvTypeRef }}, error) { - return s.{{ .RecvName }}WithContext(s.ctx) -} - -{{ printf "%sWithContext receives streaming data from the %s endpoint with context." .RecvName .Endpoint.Method.Name | comment }} -func (s *{{ .VarName }}) {{ .RecvName }}WithContext(ctx context.Context) ({{ .RecvTypeRef }}, error) { - // Check for stream-level errors first - if err := s.getError(); err != nil { - return nil, err - } - -{{- if $isBidirectional }} - // Find the oldest pending request (FIFO ordering) - var oldestPending *{{ .VarName }}PendingRequest - var oldestKey string - - s.pending.Range(func(key, value any) bool { - pending := value.(*{{ .VarName }}PendingRequest) - if oldestPending == nil { - oldestPending = pending - oldestKey = key.(string) - } - return false // Take first one for FIFO - }) - - if oldestPending == nil { - return nil, fmt.Errorf("no pending requests - call {{ .SendName }}() first") - } - - // Wait for result with context cancellation - select { - case result := <-oldestPending.resultChan: - s.pending.Delete(oldestKey) - oldestPending.timeout.Stop() - return result.result, result.err - - case <-oldestPending.timeout.C: - s.pending.Delete(oldestKey) - timeoutErr := fmt.Errorf("request timeout after %v", s.config.RequestTimeout) - // Report timeout errors - s.handleError(jsonrpc.StreamErrorTimeout, timeoutErr, nil) - return nil, timeoutErr - - case <-ctx.Done(): - return nil, ctx.Err() - - case <-s.done: - if err := s.getError(); err != nil { - return nil, err - } - return nil, fmt.Errorf("stream closed") - } -{{- else }} - // For result-only streaming, make direct call - jsonrpcID := strconv.FormatUint(s.idGenerator.Add(1), 10) - - request := &jsonrpc.Request{ - JSONRPC: "2.0", - Method: "{{ .Endpoint.Method.Name }}", - Params: nil, - ID: &jsonrpcID, - } - - // Create result channel for this request - resultChan := make(chan {{ .VarName }}StreamResult, s.config.ResultChannelBuffer) - pending := &{{ .VarName }}PendingRequest{ - userID: jsonrpcID, - resultChan: resultChan, - timeout: time.NewTimer(s.config.RequestTimeout), - } - - s.pending.Store(jsonrpcID, pending) - defer func() { - s.pending.Delete(jsonrpcID) - pending.timeout.Stop() - }() - - // Send request - s.writeMu.Lock() - err := s.ws.WriteJSON(request) - s.writeMu.Unlock() - - if err != nil { - s.setError(err) - // Report connection errors - s.handleError(jsonrpc.StreamErrorConnection, err, nil) - return nil, fmt.Errorf("failed to send request: %w", err) - } - - // Wait for response - select { - case result := <-resultChan: - return result.result, result.err - case <-pending.timeout.C: - timeoutErr := fmt.Errorf("request timeout after %v", s.config.RequestTimeout) - // Report timeout errors - s.handleError(jsonrpc.StreamErrorTimeout, timeoutErr, nil) - return nil, timeoutErr - case <-ctx.Done(): - return nil, ctx.Err() - case <-s.done: - if err := s.getError(); err != nil { - return nil, err - } - return nil, fmt.Errorf("stream closed") - } -{{- end }} -} -{{- end }} - -// responseHandler processes incoming WebSocket messages in a background goroutine -func (s *{{ .VarName }}) responseHandler() { - defer close(s.done) - - for { - select { - case <-s.ctx.Done(): - s.cleanupPendingRequests(s.ctx.Err()) - return - default: - var response jsonrpc.RawResponse - if err := s.ws.ReadJSON(&response); err != nil { - connectionErr := fmt.Errorf("failed to read response: %w", err) - s.setError(connectionErr) - - // Report connection errors - s.handleError(jsonrpc.StreamErrorConnection, connectionErr, nil) - - s.cleanupPendingRequests(connectionErr) - return - } - - s.handleResponse(&response) - } - } -} - -func (s *{{ .VarName }}) handleResponse(response *jsonrpc.RawResponse) { - if response.ID == nil { - // This is a server-initiated notification - // For now, just report it as an event via the error handler - // In the future, we could add a dedicated notification handler - if s.config.ErrorHandler != nil { - s.config.ErrorHandler(s.ctx, jsonrpc.StreamErrorNotification, - fmt.Errorf("received server notification"), response) - } - return - } - - jsonrpcID := response.ID - pendingInterface, exists := s.pending.LoadAndDelete(jsonrpcID) - if !exists { - // Orphaned response - report to error handler - s.handleError(jsonrpc.StreamErrorOrphaned, fmt.Errorf("received response for unknown ID: %s", jsonrpcID), response) - return - } - - pending := pendingInterface.(*{{ .VarName }}PendingRequest) - pending.timeout.Stop() - - var result {{ .VarName }}StreamResult - - if response.Error != nil { - result.err = response.Error - // Report protocol-level JSON-RPC errors - s.handleError(jsonrpc.StreamErrorProtocol, response.Error, response) - } else { -{{- if $hasRecv }} - // Use generated decoder for consistent response parsing - parsedResult, err := s.decodeResponse(response.Result) - if err != nil { - result.err = fmt.Errorf("failed to decode response: %w", err) - // Report parsing errors - s.handleError(jsonrpc.StreamErrorParsing, err, response) - } else { - {{- if .Endpoint.Result.IDAttribute }} - // Backfill the result ID from the envelope when missing - {{- if .Endpoint.Result.IDAttributeRequired }} - if parsedResult.{{ .Endpoint.Result.IDAttribute }} == "" { - parsedResult.{{ .Endpoint.Result.IDAttribute }} = jsonrpc.IDToString(response.ID) - } - {{- else }} - if parsedResult.{{ .Endpoint.Result.IDAttribute }} == nil || *parsedResult.{{ .Endpoint.Result.IDAttribute }} == "" { - idCopy := jsonrpc.IDToString(response.ID) - parsedResult.{{ .Endpoint.Result.IDAttribute }} = &idCopy - } - {{- end }} - {{- end }} - result.result = parsedResult - } -{{- end }} - } - - // Non-blocking send to result channel - select { - case pending.resultChan <- result: - default: - // Channel full - should not happen with buffer size 1 - } -} - -// Helper methods -func (s *{{ .VarName }}) generateUserID() string { - return fmt.Sprintf("user-%d-%d", time.Now().UnixNano(), s.idGenerator.Load()) -} - -// handleError calls the user-provided error handler if available -func (s *{{ .VarName }}) handleError(errorType jsonrpc.StreamErrorType, err error, response *jsonrpc.RawResponse) { - if s.config.ErrorHandler != nil { - s.config.ErrorHandler(s.ctx, errorType, err, response) - } -} - - -{{- if $hasRecv }} -// decodeResponse decodes JSON-RPC response data using the user-provided decoder -func (s *{{ .VarName }}) decodeResponse(data json.RawMessage) ({{ .RecvTypeRef }}, error) { - // Create minimal HTTP response with raw JSON data for user's decoder - resp := &http.Response{ - StatusCode: http.StatusOK, - Body: io.NopCloser(bytes.NewReader(data)), - } - - // Use user-provided decoder to decode the result (expects inner result JSON) - dec := s.decoder(resp) - var out {{ .RecvTypeRef }} - if err := dec.Decode(&out); err != nil { - return nil, err - } - return out, nil -} -{{- end }} - -func (s *{{ .VarName }}) setError(err error) { - s.errorOnce.Do(func() { - s.lastError.Store(err) - s.cancel() // Cancel context to signal error state - }) -} - -func (s *{{ .VarName }}) getError() error { - if err, ok := s.lastError.Load().(error); ok { - return err - } - return nil -} - -func (s *{{ .VarName }}) cleanupPendingRequests(err error) { - s.pending.Range(func(key, value any) bool { - pending := value.(*{{ .VarName }}PendingRequest) - pending.timeout.Stop() - - select { - case pending.resultChan <- {{ .VarName }}StreamResult{err: err}: - default: - } - - s.pending.Delete(key) - return true - }) -} - -{{ printf "Close closes the stream and cleans up resources." | comment }} -func (s *{{ .VarName }}) Close() error { - var err error - s.closeOnce.Do(func() { - s.cancel() - - // Wait for response handler to finish - select { - case <-s.done: - case <-time.After(s.config.CloseTimeout): - // Force close if handler doesn't respond - } - - // Clean up any remaining pending requests - s.cleanupPendingRequests(fmt.Errorf("stream closed")) - - // Close the WebSocket connection - if s.ws != nil { - err = s.ws.Close() - } - }) - return err -} diff --git a/jsonrpc/codegen/templates/websocket_server_close.go.tpl b/jsonrpc/codegen/templates/websocket_server_close.go.tpl deleted file mode 100644 index 84741806db..0000000000 --- a/jsonrpc/codegen/templates/websocket_server_close.go.tpl +++ /dev/null @@ -1,15 +0,0 @@ -{{ printf "Close closes the %s service websocket connection." .Service.Name | comment }} -func (s *{{ lowerInitial .Service.StructName }}Stream) Close() error { - var err error - if s.conn == nil { - return nil - } - if err = s.conn.WriteControl( - websocket.CloseMessage, - websocket.FormatCloseMessage(websocket.CloseNormalClosure, "server closing connection"), - time.Now().Add(time.Second), - ); err != nil { - return err - } - return s.conn.Close() -} diff --git a/jsonrpc/codegen/templates/websocket_server_handler.go.tpl b/jsonrpc/codegen/templates/websocket_server_handler.go.tpl deleted file mode 100644 index cc5ad4c51a..0000000000 --- a/jsonrpc/codegen/templates/websocket_server_handler.go.tpl +++ /dev/null @@ -1,28 +0,0 @@ -// ServeHTTP handles WebSocket JSON-RPC requests. -func (s *{{ .ServerStruct }}) ServeHTTP(w http.ResponseWriter, r *http.Request) { - ctx, cancel := context.WithCancel(r.Context()) - conn, err := s.upgrader.Upgrade(w, r, nil) - if err != nil { - s.errhandler(r.Context(), w, fmt.Errorf("failed to upgrade to WebSocket: %w", err)) - cancel() - return - } - if s.configfn != nil { - conn = s.configfn(conn, cancel) - } - defer conn.Close() - - stream := &{{ lowerInitial .Service.StructName }}Stream{ - {{- range .Endpoints }} - {{ lowerInitial .Method.VarName }}: s.{{ lowerInitial .Method.VarName }}, - {{- if and .Method.ServerStream (or (eq .Method.ServerStream.Kind 3) (eq .Method.ServerStream.Kind 4)) }} - {{ lowerInitial .Method.VarName }}Endpoint: s.{{ lowerInitial .Method.VarName }}Endpoint, - {{- end }} - {{- end }} - r: r, - w: w, - conn: conn, - cancel: cancel, - } - s.StreamHandler(ctx, stream) -} diff --git a/jsonrpc/codegen/templates/websocket_server_recv.go.tpl b/jsonrpc/codegen/templates/websocket_server_recv.go.tpl deleted file mode 100644 index c4b27eeaf2..0000000000 --- a/jsonrpc/codegen/templates/websocket_server_recv.go.tpl +++ /dev/null @@ -1,110 +0,0 @@ -{{ printf "Recv reads JSON-RPC requests from the %s service stream." .Service.Name | comment }} -func (s *{{ lowerInitial .Service.StructName }}Stream) Recv(ctx context.Context) error { - var req jsonrpc.RawRequest - if err := s.conn.ReadJSON(&req); err != nil { - // Handle different types of errors gracefully - if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway, websocket.CloseAbnormalClosure) { - // Network/connection errors - terminate connection - return err - } - - // JSON parse errors - send Parse Error response and continue - if err := s.sendError(ctx, nil, jsonrpc.ParseError, "Parse error", nil); err != nil { - // If we can't send error response, connection is broken - return fmt.Errorf("failed to send parse error: %w", err) - } - // Continue processing after sending parse error - return nil - } - return s.processRequest(ctx, &req) -} - -func (s *{{ lowerInitial .Service.StructName }}Stream) processRequest(ctx context.Context, req *jsonrpc.RawRequest) error { - if req.JSONRPC != "2.0" { - if req.HasID { - return s.sendError(ctx, req.ID, jsonrpc.InvalidRequest, "Invalid request", nil) - } - return nil - } - - if req.Method == "" { - if req.HasID { - return s.sendError(ctx, req.ID, jsonrpc.InvalidRequest, "Invalid request", nil) - } - return nil - } - - switch req.Method { - {{- range .Endpoints }} - case {{ printf "%q" .Method.Name }}: - {{- if and .Method.ServerStream (or (eq .Method.ServerStream.Kind 3) (eq .Method.ServerStream.Kind 4)) }} - // {{ if eq .Method.ServerStream.Kind 3 }}Server{{ else }}Bidirectional{{ end }} streaming: decode payload and create stream wrapper - {{- if .Payload.Ref }} - payload, err := s.{{ lowerInitial .Method.VarName }}(ctx, s.r, req) - {{- else }} - _, err := s.{{ lowerInitial .Method.VarName }}(ctx, s.r, req) - {{- end }} - if err != nil { - return fmt.Errorf("handler error for %s: %w", {{ printf "%q" .Method.Name }}, err) - } - // Create wrapper that implements the method-specific stream interface - streamWrapper := &{{ lowerInitial .Method.VarName }}StreamWrapper{ - stream: s, - requestID: req.ID, - } - // Call the endpoint with payload and stream wrapper - endpointInput := &{{ .ServicePkgName }}.{{ .Method.ServerStream.EndpointStruct }}{ - {{- if .Payload.Ref }} - Payload: payload.({{ .Payload.Ref }}), - {{- end }} - Stream: streamWrapper, - } - if _, err := s.{{ lowerInitial .Method.VarName }}Endpoint(ctx, endpointInput); err != nil { - // For streaming endpoints, send error as JSON-RPC error response - if req.HasID { - // Send error response to client - if sendErr := streamWrapper.SendError(ctx, err); sendErr != nil { - return fmt.Errorf("failed to send error response: %w", sendErr) - } - // Continue processing other requests - return nil - } - // For notifications (no ID), just log and continue - return nil - } - return nil - {{- else }} - res, err := s.{{ lowerInitial .Method.VarName }}(ctx, s.r, req) - if err != nil { - // For non-streaming, send JSON-RPC error if request has an ID; otherwise continue - if req.HasID { - if sendErr := s.SendError(ctx, req.ID, err); sendErr != nil { - return fmt.Errorf("failed to send error response: %w", sendErr) - } - } - return nil - } - // Only send a response if the request has an ID (i.e., it's not a notification) - if req.HasID { - if res == nil { - return s.sendError(ctx, req.ID, jsonrpc.InternalError, "Internal error", nil) - } - if r, ok := res.({{ printf "*%s.%sResult" .ServicePkgName .Method.VarName }}); ok { - if err := s.Send{{ .Method.VarName }}Response(ctx, req.ID, r); err != nil { - return fmt.Errorf("send response error for %s: %w", {{ printf "%q" .Method.Name }}, err) - } - } else { - return s.sendError(ctx, req.ID, jsonrpc.InternalError, "Internal error", nil) - } - } - return nil - {{- end }} - {{- end }} - default: - if req.HasID { - return s.sendError(ctx, req.ID, jsonrpc.MethodNotFound, "Method not found", nil) - } - return nil - } -} - diff --git a/jsonrpc/codegen/templates/websocket_server_send.go.tpl b/jsonrpc/codegen/templates/websocket_server_send.go.tpl deleted file mode 100644 index 80185bfaa5..0000000000 --- a/jsonrpc/codegen/templates/websocket_server_send.go.tpl +++ /dev/null @@ -1,75 +0,0 @@ -{{- range .Endpoints }} - {{- if .Result.Ref }} -{{ printf "Send%sNotification sends a JSON-RPC notification for the %s method." .Method.VarName .Method.Name | comment }} -func (s *{{ lowerInitial $.Service.StructName }}Stream) Send{{ .Method.VarName }}Notification(ctx context.Context, result {{ .Result.Ref }}) error { - {{- if and .Result (index .Result.Responses 0).ServerBody (index (index .Result.Responses 0).ServerBody 0).Init }} - body := {{ (index (index .Result.Responses 0).ServerBody 0).Init.Name }}(result) - {{- else }} - body := result - {{- end }} - return s.conn.WriteJSON(jsonrpc.MakeNotification({{ printf "%q" .Method.Name }}, body)) -} - -{{ printf "Send%sResponse sends a JSON-RPC response for the %s method." .Method.VarName .Method.Name | comment }} -func (s *{{ lowerInitial $.Service.StructName }}Stream) Send{{ .Method.VarName }}Response(ctx context.Context, id any, result {{ .Result.Ref }}) error { - {{- if and .Result (index .Result.Responses 0).ServerBody (index (index .Result.Responses 0).ServerBody 0).Init }} - body := {{ (index (index .Result.Responses 0).ServerBody 0).Init.Name }}(result) - {{- else }} - body := result - {{- end }} - return s.conn.WriteJSON(jsonrpc.MakeSuccessResponse(id, body)) -} - {{- end }} -{{- end }} - - -{{ printf "SendError streams JSON-RPC errors." | comment }} -func (s *{{ lowerInitial $.Service.StructName }}Stream) SendError(ctx context.Context, id any, err error) error { - {{- if allErrors . }} - var en goa.GoaErrorNamer - if !errors.As(err, &en) { - code := jsonrpc.InternalError - if _, ok := err.(*goa.ServiceError); ok { - code = jsonrpc.InvalidParams - } - return s.sendError(ctx, id, code, err.Error(), nil) - } - switch en.GoaErrorName() { - {{- range allErrors . }} - case {{ printf "%q" .Name }}: - {{- with .Response}} - return s.sendError(ctx, id, {{ .Code }}, err.Error(), err) - {{- end }} - {{- end }} - default: - code := jsonrpc.InternalError - if _, ok := err.(*goa.ServiceError); ok { - code = jsonrpc.InvalidParams - } - return s.sendError(ctx, id, code, err.Error(), nil) - } - {{- else }} - // No custom errors defined - check if it's a validation error, otherwise use internal error - code := jsonrpc.InternalError - if _, ok := err.(*goa.ServiceError); ok { - code = jsonrpc.InvalidParams - } - return s.sendError(ctx, id, code, err.Error(), nil) - {{- end }} -} - -{{ printf "send writes a JSON-RPC response to the websocket connection." | comment }} -func (s *{{ lowerInitial $.Service.StructName }}Stream) send(id any, method string, result any) error { - // If there's no ID, send as a notification instead of a response - // A JSON-RPC result with no ID is invalid per the spec - if id == nil || id == "" { - return s.conn.WriteJSON(jsonrpc.MakeNotification(method, result)) - } - return s.conn.WriteJSON(jsonrpc.MakeSuccessResponse(id, result)) -} - -{{ printf "sendError sends a JSON-RPC error response to the websocket connection." | comment }} -func (s *{{ lowerInitial $.Service.StructName }}Stream) sendError(ctx context.Context, id any, code jsonrpc.Code, message string, data any) error { - response := jsonrpc.MakeErrorResponse(id, code, message, data) - return s.conn.WriteJSON(response) -} diff --git a/jsonrpc/codegen/templates/websocket_server_stream.go.tpl b/jsonrpc/codegen/templates/websocket_server_stream.go.tpl deleted file mode 100644 index cc1470b656..0000000000 --- a/jsonrpc/codegen/templates/websocket_server_stream.go.tpl +++ /dev/null @@ -1,19 +0,0 @@ -{{ printf "%sStream implements the Stream interface." (lowerInitial .Service.StructName) | comment }} -type {{ lowerInitial .Service.StructName }}Stream struct { -{{- range .Endpoints }} - {{ printf "%s decodes requests for the %s method" (lowerInitial .Method.VarName) .Method.Name | comment }} - {{ lowerInitial .Method.VarName }} func(context.Context, *http.Request, *jsonrpc.RawRequest) (any, error) - {{- if and .Method.ServerStream (or (eq .Method.ServerStream.Kind 3) (eq .Method.ServerStream.Kind 4)) }} - {{ printf "%sEndpoint is the endpoint for the %s method" (lowerInitial .Method.VarName) .Method.Name | comment }} - {{ lowerInitial .Method.VarName }}Endpoint goa.Endpoint - {{- end }} -{{- end }} - {{ comment "cancel is the context cancellation function which cancels the request context when invoked." }} - cancel context.CancelFunc - {{ comment "w is the HTTP response writer used in upgrading the connection." }} - w http.ResponseWriter - {{ comment "r is the HTTP request." }} - r *http.Request - {{ comment "conn is the underlying websocket connection." }} - conn *websocket.Conn -} diff --git a/jsonrpc/codegen/templates/websocket_server_stream_wrapper.go.tpl b/jsonrpc/codegen/templates/websocket_server_stream_wrapper.go.tpl deleted file mode 100644 index 0492fc1415..0000000000 --- a/jsonrpc/codegen/templates/websocket_server_stream_wrapper.go.tpl +++ /dev/null @@ -1,29 +0,0 @@ -{{- range .Endpoints }} - {{- if and .Method.ServerStream (or (eq .Method.ServerStream.Kind 3) (eq .Method.ServerStream.Kind 4)) }} -// {{ lowerInitial .Method.VarName }}StreamWrapper wraps the JSON-RPC stream to provide a method-specific interface. -type {{ lowerInitial .Method.VarName }}StreamWrapper struct { - stream *{{ lowerInitial $.Service.StructName }}Stream - requestID any // Store the JSON-RPC request ID for responses -} - -// SendNotification sends a notification to the client (no response expected). -func (w *{{ lowerInitial .Method.VarName }}StreamWrapper) SendNotification(ctx context.Context, res {{ .Result.Ref }}) error { - return w.stream.Send{{ .Method.VarName }}Notification(ctx, res) -} - -// SendResponse sends a response to the client for the original request. -func (w *{{ lowerInitial .Method.VarName }}StreamWrapper) SendResponse(ctx context.Context, res {{ .Result.Ref }}) error { - return w.stream.Send{{ .Method.VarName }}Response(ctx, w.requestID, res) -} - -// SendError sends an error response to the client. -func (w *{{ lowerInitial .Method.VarName }}StreamWrapper) SendError(ctx context.Context, err error) error { - return w.stream.SendError(ctx, w.requestID, err) -} - -// Close closes the underlying JSON-RPC stream. -func (w *{{ lowerInitial .Method.VarName }}StreamWrapper) Close() error { - return w.stream.Close() -} - {{- end }} -{{- end }} \ No newline at end of file diff --git a/jsonrpc/codegen/templates/websocket_stream_error_types.go.tpl b/jsonrpc/codegen/templates/websocket_stream_error_types.go.tpl deleted file mode 100644 index b5f91fa648..0000000000 --- a/jsonrpc/codegen/templates/websocket_stream_error_types.go.tpl +++ /dev/null @@ -1,13 +0,0 @@ -// Stream error types for comprehensive error reporting -type StreamErrorType int - -const ( - StreamErrorConnection StreamErrorType = iota // WebSocket connection errors - StreamErrorProtocol // Invalid JSON-RPC protocol - StreamErrorParsing // Failed to parse/decode response - StreamErrorOrphaned // Response with no matching request - StreamErrorTimeout // Request timeout -) - -// StreamErrorHandler allows users to handle stream errors -type StreamErrorHandler func(ctx context.Context, errorType StreamErrorType, err error, response *jsonrpc.RawResponse) diff --git a/jsonrpc/codegen/testdata/golden/designed_error_unary_client.go.golden b/jsonrpc/codegen/testdata/golden/designed_error_unary_client.go.golden new file mode 100644 index 0000000000..f0f60c92fc --- /dev/null +++ b/jsonrpc/codegen/testdata/golden/designed_error_unary_client.go.golden @@ -0,0 +1,179 @@ +// DecodeRunResponse returns a decoder for responses returned by the Error +// Service service run JSON-RPC method. The decoder rejects responses that do +// not repeat requestID. restoreBody controls whether the response body should +// be restored after having been read. +func DecodeRunResponse(decoder func(*http.Response) goahttp.Decoder, restoreBody bool) func(*http.Response, string) (any, error) { + return func(resp *http.Response, requestID string) (result any, decodeErr error) { + responseBody := resp.Body + if restoreBody { + b, readErr := io.ReadAll(responseBody) + closeErr := responseBody.Close() + if err := errors.Join(readErr, closeErr); err != nil { + return nil, goahttp.ErrDecodingError("Error Service", "run", err) + } + resp.Body = io.NopCloser(bytes.NewBuffer(b)) + defer func() { + resp.Body = io.NopCloser(bytes.NewBuffer(b)) + }() + } else { + defer func() { + if err := responseBody.Close(); err != nil { + decodeErr = errors.Join(decodeErr, goahttp.ErrDecodingError("Error Service", "run", err)) + } + }() + } + + if resp.StatusCode != http.StatusOK { + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, goahttp.ErrDecodingError("Error Service", "run", err) + } + return nil, goahttp.ErrInvalidResponse("Error Service", "run", resp.StatusCode, string(body)) + } + + var jresp jsonrpc.RawResponse + if err := decoder(resp).Decode(&jresp); err != nil { + return nil, goahttp.ErrDecodingError("Error Service", "run", err) + } + if err := jresp.Validate(requestID); err != nil { + return nil, goahttp.ErrInvalidResponse("Error Service", "run", resp.StatusCode, err.Error()) + } + + if jresp.Error != nil { + serviceErrorName, serviceErrorBody, ok := jsonrpc.DecodeServiceErrorData(jresp.Error.Data) + if !ok { + return nil, jresp.Error + } + switch jresp.Error.Code { + case 7002: + switch serviceErrorName { + case "empty": + resp.Body = io.NopCloser(bytes.NewReader(serviceErrorBody)) + if !bytes.Equal(bytes.TrimSpace(serviceErrorBody), []byte("null")) { + return nil, jresp.Error + } + return nil, NewRunEmpty() + default: + return nil, jresp.Error + } + case 7001: + switch serviceErrorName { + case "first": + resp.Body = io.NopCloser(bytes.NewReader(serviceErrorBody)) + var ( + body ErrorServicerunResponseBody + err error + ) + err = decoder(resp).Decode(&body) + if err != nil { + return nil, goahttp.ErrDecodingError("Error Service", "run", err) + } + return nil, NewRunFirst(&body) + case "second": + resp.Body = io.NopCloser(bytes.NewReader(serviceErrorBody)) + var ( + body RunSecondResponseBody + err error + ) + err = decoder(resp).Decode(&body) + if err != nil { + return nil, goahttp.ErrDecodingError("Error Service", "run", err) + } + err = ValidateRunSecondResponseBody(&body) + if err != nil { + return nil, goahttp.ErrValidationError("Error Service", "run", err) + } + return nil, NewRunSecond(&body) + default: + return nil, jresp.Error + } + case -32603: + switch serviceErrorName { + case "internal": + resp.Body = io.NopCloser(bytes.NewReader(serviceErrorBody)) + var ( + body RunInternalResponseBody + err error + ) + err = decoder(resp).Decode(&body) + if err != nil { + return nil, goahttp.ErrDecodingError("Error Service", "run", err) + } + err = ValidateRunInternalResponseBody(&body) + if err != nil { + return nil, goahttp.ErrValidationError("Error Service", "run", err) + } + return nil, NewRunInternal(&body) + default: + return nil, jresp.Error + } + case -32600: + switch serviceErrorName { + case "invalid": + if jresp.ID == nil { + return nil, goahttp.ErrInvalidResponse("Error Service", "run", resp.StatusCode, "response id is null") + } + resp.Body = io.NopCloser(bytes.NewReader(serviceErrorBody)) + var ( + body RunInvalidResponseBody + err error + ) + err = decoder(resp).Decode(&body) + if err != nil { + return nil, goahttp.ErrDecodingError("Error Service", "run", err) + } + err = ValidateRunInvalidResponseBody(&body) + if err != nil { + return nil, goahttp.ErrValidationError("Error Service", "run", err) + } + return nil, NewRunInvalid(&body) + default: + return nil, jresp.Error + } + case -32700: + switch serviceErrorName { + case "parse": + if jresp.ID == nil { + return nil, goahttp.ErrInvalidResponse("Error Service", "run", resp.StatusCode, "response id is null") + } + resp.Body = io.NopCloser(bytes.NewReader(serviceErrorBody)) + var ( + body RunParseResponseBody + err error + ) + err = decoder(resp).Decode(&body) + if err != nil { + return nil, goahttp.ErrDecodingError("Error Service", "run", err) + } + err = ValidateRunParseResponseBody(&body) + if err != nil { + return nil, goahttp.ErrValidationError("Error Service", "run", err) + } + return nil, NewRunParse(&body) + default: + return nil, jresp.Error + } + case 7003: + switch serviceErrorName { + case "selected": + resp.Body = io.NopCloser(bytes.NewReader(serviceErrorBody)) + var ( + body string + err error + ) + err = decoder(resp).Decode(&body) + if err != nil { + return nil, goahttp.ErrDecodingError("Error Service", "run", err) + } + return nil, NewRunSelected(body) + default: + return nil, jresp.Error + } + default: + return nil, jresp.Error + } + } + resp.Body = io.NopCloser(bytes.NewBuffer(jresp.Result)) + return nil, nil + } +} diff --git a/jsonrpc/codegen/testdata/golden/designed_error_unary_server.go.golden b/jsonrpc/codegen/testdata/golden/designed_error_unary_server.go.golden new file mode 100644 index 0000000000..2b507277ba --- /dev/null +++ b/jsonrpc/codegen/testdata/golden/designed_error_unary_server.go.golden @@ -0,0 +1,135 @@ +// NewRunHandler creates a JSON-RPC handler which calls the "Error Service" +// service "run" endpoint. +func NewRunHandler( + endpoint goa.Endpoint, + mux goahttp.Muxer, + decoder func(*http.Request) goahttp.Decoder, + encoder func(context.Context, http.ResponseWriter) goahttp.Encoder, + errhandler func(context.Context, http.ResponseWriter, error), +) func(context.Context, *http.Request, *jsonrpc.RawRequest, http.ResponseWriter) error { + return func(ctx context.Context, r *http.Request, req *jsonrpc.RawRequest, w http.ResponseWriter) error { + ctx = context.WithValue(ctx, goa.MethodKey, "run") + ctx = context.WithValue(ctx, goa.ServiceKey, "Error Service") + _, err := endpoint(ctx, nil) + if err != nil { + var en goa.GoaErrorNamer + if errors.As(err, &en) { + switch en.GoaErrorName() { + case "empty": + var res *errorservice.EmptyProblem + if !errors.As(err, &res) { + panic("JSON-RPC error name does not match its generated service error type") + } + var body *struct{} + data := struct { + Name string `json:"name"` + Body *struct{} `json:"body"` + }{ + Name: "empty", + Body: body, + } + encodeJSONRPCError(ctx, w, req, 7002, err.Error(), data, encoder, errhandler) + return nil + case "first": + var res *errorservice.FirstProblem + if !errors.As(err, &res) { + panic("JSON-RPC error name does not match its generated service error type") + } + body := NewErrorServicerunResponseBody(res) + data := struct { + Name string `json:"name"` + Body *ErrorServicerunResponseBody `json:"body"` + }{ + Name: "first", + Body: body, + } + encodeJSONRPCError(ctx, w, req, 7001, err.Error(), data, encoder, errhandler) + return nil + case "second": + var res *errorservice.SecondProblem + if !errors.As(err, &res) { + panic("JSON-RPC error name does not match its generated service error type") + } + body := NewRunSecondResponseBody(res) + data := struct { + Name string `json:"name"` + Body *RunSecondResponseBody `json:"body"` + }{ + Name: "second", + Body: body, + } + encodeJSONRPCError(ctx, w, req, 7001, err.Error(), data, encoder, errhandler) + return nil + case "internal": + var res *errorservice.InternalProblem + if !errors.As(err, &res) { + panic("JSON-RPC error name does not match its generated service error type") + } + body := NewRunInternalResponseBody(res) + data := struct { + Name string `json:"name"` + Body *RunInternalResponseBody `json:"body"` + }{ + Name: "internal", + Body: body, + } + encodeJSONRPCError(ctx, w, req, -32603, err.Error(), data, encoder, errhandler) + return nil + case "invalid": + var res *errorservice.InvalidProblem + if !errors.As(err, &res) { + panic("JSON-RPC error name does not match its generated service error type") + } + body := NewRunInvalidResponseBody(res) + data := struct { + Name string `json:"name"` + Body *RunInvalidResponseBody `json:"body"` + }{ + Name: "invalid", + Body: body, + } + encodeJSONRPCError(ctx, w, req, -32600, err.Error(), data, encoder, errhandler) + return nil + case "parse": + var res *errorservice.ParseProblem + if !errors.As(err, &res) { + panic("JSON-RPC error name does not match its generated service error type") + } + body := NewRunParseResponseBody(res) + data := struct { + Name string `json:"name"` + Body *RunParseResponseBody `json:"body"` + }{ + Name: "parse", + Body: body, + } + encodeJSONRPCError(ctx, w, req, -32700, err.Error(), data, encoder, errhandler) + return nil + case "selected": + var res *errorservice.SelectedProblem + if !errors.As(err, &res) { + panic("JSON-RPC error name does not match its generated service error type") + } + body := res.Detail + data := struct { + Name string `json:"name"` + Body string `json:"body"` + }{ + Name: "selected", + Body: body, + } + encodeJSONRPCError(ctx, w, req, 7003, err.Error(), data, encoder, errhandler) + return nil + } + } + encodeJSONRPCError(ctx, w, req, jsonrpc.InternalError, err.Error(), nil, encoder, errhandler) + return nil + } + // A method with no result returns a JSON null result. + response := jsonrpc.MakeSuccessResponse(req.ID, nil) + if err := encoder(ctx, w).Encode(response); err != nil { + errhandler(ctx, w, fmt.Errorf("failed to encode JSON-RPC response: %w", err)) + } + return nil + } +} diff --git a/jsonrpc/codegen/testdata/golden/jsonrpc-sse-object.golden b/jsonrpc/codegen/testdata/golden/jsonrpc-sse-object.golden index a7face3f7f..1c655da173 100644 --- a/jsonrpc/codegen/testdata/golden/jsonrpc-sse-object.golden +++ b/jsonrpc/codegen/testdata/golden/jsonrpc-sse-object.golden @@ -1,94 +1,34 @@ // StreamServerStream implements the jsonrpcsseobjectservice.StreamServerStream // interface using Server-Sent Events. type StreamServerStream struct { - // sseServerStream provides the shared SSE event encoding machinery + // sseServerStream writes JSON-RPC messages as server-sent events. sseServerStream - // requestID is the JSON-RPC request ID for sending final response - requestID any - // closed indicates if the stream has been closed via SendAndClose - closed bool - // mu protects the closed flag - mu sync.Mutex } -// Send sends a JSON-RPC notification to the client. -// Notifications do not expect a response from the client. -func (s *StreamServerStream) Send(ctx context.Context, event jsonrpcsseobjectservice.StreamEvent) error { - // Check if stream is closed - s.mu.Lock() - if s.closed { - s.mu.Unlock() - return fmt.Errorf("stream closed") - } - s.mu.Unlock() - - // Type assert to the specific result type - result, ok := event.(*jsonrpcsseobjectservice.StreamResult) - if !ok { - return fmt.Errorf("unexpected event type: %T", event) - } - // Convert to response body type for proper JSON encoding - body := NewStreamResponseBody(result) - - // Send as notification (no ID) - message := map[string]any{ - "jsonrpc": "2.0", - "method": "Stream", - "params": body, - } - - return s.sendSSEEvent("notification", message) +// Send streams instances of "StreamResult". +func (s *StreamServerStream) Send(event *jsonrpcsseobjectservice.StreamResult) error { + return s.SendWithContext(context.Background(), event) } -// SendAndClose sends a final JSON-RPC response to the client and closes the -// stream. -// The response will include the original request ID unless the result has an -// ID field populated. -// After calling this method, no more events can be sent on this stream. -func (s *StreamServerStream) SendAndClose(ctx context.Context, event jsonrpcsseobjectservice.StreamEvent) error { - // Check if stream is already closed - s.mu.Lock() - if s.closed { - s.mu.Unlock() - return fmt.Errorf("stream already closed") - } - s.closed = true - s.mu.Unlock() - - // Type assert to the specific result type - result, ok := event.(*jsonrpcsseobjectservice.StreamResult) - if !ok { - return fmt.Errorf("unexpected event type: %T", event) - } - - // Determine the ID to use for the response - var id any = s.requestID - if result.ID != nil && *result.ID != "" { - // Use the ID from the result if provided - id = *result.ID - // Clear the ID field so it's not duplicated in the result - result.ID = nil - } - // Convert to response body type for proper JSON encoding +// SendWithContext streams instances of "StreamResult" with context. +func (s *StreamServerStream) SendWithContext(ctx context.Context, event *jsonrpcsseobjectservice.StreamResult) error { + result := event body := NewStreamResponseBody(result) - - // Send as response with ID - message := map[string]any{ - "jsonrpc": "2.0", - "id": id, - "result": body, + var eventID *string + if result.ID != nil { + value := string(*result.ID) + eventID = &value } - return s.sendSSEEvent("response", message) + message := jsonrpc.MakeNotification( + "Stream", + body, + ) + return s.sendSSEEvent(ctx, message, eventID, nil, nil) } -// SendError sends a JSON-RPC error response. -func (s *StreamServerStream) SendError(ctx context.Context, id string, err error) error { - // No custom errors defined - check if it's a validation error, otherwise use - // internal error - code := jsonrpc.InternalError - if _, ok := err.(*goa.ServiceError); ok { - code = jsonrpc.InvalidParams - } - return s.sendError(ctx, id, code, err.Error(), nil) +// Close does nothing because the HTTP response closes when the service method +// returns. +func (s *StreamServerStream) Close() error { + return nil } diff --git a/jsonrpc/codegen/testdata/golden/jsonrpc-sse-string.golden b/jsonrpc/codegen/testdata/golden/jsonrpc-sse-string.golden index b788f135fe..0f11dec238 100644 --- a/jsonrpc/codegen/testdata/golden/jsonrpc-sse-string.golden +++ b/jsonrpc/codegen/testdata/golden/jsonrpc-sse-string.golden @@ -1,86 +1,29 @@ // StreamServerStream implements the jsonrpcssestringservice.StreamServerStream // interface using Server-Sent Events. type StreamServerStream struct { - // sseServerStream provides the shared SSE event encoding machinery + // sseServerStream writes JSON-RPC messages as server-sent events. sseServerStream - // requestID is the JSON-RPC request ID for sending final response - requestID any - // closed indicates if the stream has been closed via SendAndClose - closed bool - // mu protects the closed flag - mu sync.Mutex } -// Send sends a JSON-RPC notification to the client. -// Notifications do not expect a response from the client. -func (s *StreamServerStream) Send(ctx context.Context, event jsonrpcssestringservice.StreamEvent) error { - // Check if stream is closed - s.mu.Lock() - if s.closed { - s.mu.Unlock() - return fmt.Errorf("stream closed") - } - s.mu.Unlock() - - // Type assert to the specific result type - result, ok := event.(string) - if !ok { - return fmt.Errorf("unexpected event type: %T", event) - } - body := result - - // Send as notification (no ID) - message := map[string]any{ - "jsonrpc": "2.0", - "method": "Stream", - "params": body, - } - - return s.sendSSEEvent("notification", message) +// Send streams instances of "string". +func (s *StreamServerStream) Send(event string) error { + return s.SendWithContext(context.Background(), event) } -// SendAndClose sends a final JSON-RPC response to the client and closes the -// stream. -// The response will include the original request ID unless the result has an -// ID field populated. -// After calling this method, no more events can be sent on this stream. -func (s *StreamServerStream) SendAndClose(ctx context.Context, event jsonrpcssestringservice.StreamEvent) error { - // Check if stream is already closed - s.mu.Lock() - if s.closed { - s.mu.Unlock() - return fmt.Errorf("stream already closed") - } - s.closed = true - s.mu.Unlock() - - // Type assert to the specific result type - result, ok := event.(string) - if !ok { - return fmt.Errorf("unexpected event type: %T", event) - } - - // Determine the ID to use for the response - var id any = s.requestID +// SendWithContext streams instances of "string" with context. +func (s *StreamServerStream) SendWithContext(ctx context.Context, event string) error { + result := event body := result - // Send as response with ID - message := map[string]any{ - "jsonrpc": "2.0", - "id": id, - "result": body, - } - - return s.sendSSEEvent("response", message) + message := jsonrpc.MakeNotification( + "Stream", + []string{body}, + ) + return s.sendSSEEvent(ctx, message, nil, nil, nil) } -// SendError sends a JSON-RPC error response. -func (s *StreamServerStream) SendError(ctx context.Context, id string, err error) error { - // No custom errors defined - check if it's a validation error, otherwise use - // internal error - code := jsonrpc.InternalError - if _, ok := err.(*goa.ServiceError); ok { - code = jsonrpc.InvalidParams - } - return s.sendError(ctx, id, code, err.Error(), nil) +// Close does nothing because the HTTP response closes when the service method +// returns. +func (s *StreamServerStream) Close() error { + return nil } diff --git a/jsonrpc/codegen/testdata/golden/kitchen_sink/calc.go.golden b/jsonrpc/codegen/testdata/golden/kitchen_sink/calc.go.golden index eb2d4af3e6..0f531f073c 100644 --- a/jsonrpc/codegen/testdata/golden/kitchen_sink/calc.go.golden +++ b/jsonrpc/codegen/testdata/golden/kitchen_sink/calc.go.golden @@ -2,8 +2,8 @@ package kitchensink import ( "context" - calc "kitchensink/calc" + calc "generated.local/gen/calc" "goa.design/clue/log" ) diff --git a/jsonrpc/codegen/testdata/golden/kitchen_sink/chat.go.golden b/jsonrpc/codegen/testdata/golden/kitchen_sink/chat.go.golden deleted file mode 100644 index f569ea3367..0000000000 --- a/jsonrpc/codegen/testdata/golden/kitchen_sink/chat.go.golden +++ /dev/null @@ -1,52 +0,0 @@ -package kitchensink - -import ( - "context" - chat "kitchensink/chat" - - "goa.design/clue/log" -) - -// Chat service example implementation. -// The example methods log the requests and return zero values. -type chatsrvc struct{} - -// NewChat returns the Chat service implementation. -func NewChat() chat.Service { - return &chatsrvc{} -} - -// Echo implements echo. -func (s *chatsrvc) Echo(ctx context.Context, p *chat.EchoPayload, stream chat.EchoServerStream) (err error) { - log.Printf(ctx, "chat.echo") - // Minimal example: emit one progress notification and one final response - { - // Progress notification (no ID) - notif := &chat.EchoResult{} - if err := stream.Send(ctx, notif); err != nil { - return err - } - // Final response - final := &chat.EchoResult{} - return stream.SendAndClose(ctx, final) - } - return -} - -// HandleStream manages a JSON-RPC WebSocket connection, enabling bidirectional -// communication between the server and client. It receives requests from the -// client, dispatches them to the appropriate service methods, and can send -// server-initiated messages back to the client as needed. -func (s *chatsrvc) HandleStream(ctx context.Context, stream chat.Stream) error { - log.Printf(ctx, "chat.HandleStream") - - // Example: In a real implementation you might read from an event source - // and send notifications via stream.Send(ctx, event). This stub returns - // when the context is canceled. - select { - case <-ctx.Done(): - return ctx.Err() - default: - return nil - } -} diff --git a/jsonrpc/codegen/testdata/golden/kitchen_sink/cmd/kitchen_sink-cli/http.go.golden b/jsonrpc/codegen/testdata/golden/kitchen_sink/cmd/kitchen_sink-cli/http.go.golden index e87b0383d2..f0b99b05eb 100644 --- a/jsonrpc/codegen/testdata/golden/kitchen_sink/cmd/kitchen_sink-cli/http.go.golden +++ b/jsonrpc/codegen/testdata/golden/kitchen_sink/cmd/kitchen_sink-cli/http.go.golden @@ -1,16 +1,18 @@ package main import ( + "context" + "flag" "fmt" - cli "kitchensink/http/cli/kitchen_sink" + "io" "net/http" "time" + cli "generated.local/gen/http/cli/kitchen_sink" goahttp "goa.design/goa/v3/http" - goa "goa.design/goa/v3/pkg" ) -func doHTTP(scheme, host string, timeout int, debug bool) (goa.Endpoint, any, error) { +func doHTTP(ctx context.Context, scheme, host string, timeout int, debug bool, stdout io.Writer) error { var ( doer goahttp.Doer ) @@ -30,13 +32,22 @@ func doHTTP(scheme, host string, timeout int, debug bool) (goa.Endpoint, any, er debug, ) if err != nil { - return nil, nil, fmt.Errorf("parse endpoint: %w", err) + return fmt.Errorf("parse endpoint: %w", err) } - return endpoint, payload, nil -} -func httpUsageCommands() []string { - return cli.UsageCommands() + switch flag.Arg(0) { + case "mixed": + switch flag.Arg(1) { + case "lookup": + return writeEndpointResult(ctx, stdout, endpoint, payload) + } + case "health": + switch flag.Arg(1) { + case "check": + return writeEndpointResult(ctx, stdout, endpoint, payload) + } + } + panic("parsed HTTP command has no generated result writer") } func httpUsageExamples() string { diff --git a/jsonrpc/codegen/testdata/golden/kitchen_sink/cmd/kitchen_sink-cli/jsonrpc.go.golden b/jsonrpc/codegen/testdata/golden/kitchen_sink/cmd/kitchen_sink-cli/jsonrpc.go.golden index 00e4e3bdde..ce81913622 100644 --- a/jsonrpc/codegen/testdata/golden/kitchen_sink/cmd/kitchen_sink-cli/jsonrpc.go.golden +++ b/jsonrpc/codegen/testdata/golden/kitchen_sink/cmd/kitchen_sink-cli/jsonrpc.go.golden @@ -1,17 +1,19 @@ package main import ( + "context" + "flag" "fmt" - cli "kitchensink/jsonrpc/cli/kitchen_sink" + "io" "net/http" "time" - "github.com/gorilla/websocket" + feed "generated.local/gen/feed" + cli2 "generated.local/gen/jsonrpc/cli/kitchen_sink" goahttp "goa.design/goa/v3/http" - goa "goa.design/goa/v3/pkg" ) -func doJSONRPC(scheme, host string, timeout int, debug bool) (goa.Endpoint, any, error) { +func doJSONRPC(ctx context.Context, scheme, host string, timeout int, debug bool, stdout io.Writer) error { var ( doer goahttp.Doer ) @@ -22,33 +24,49 @@ func doJSONRPC(scheme, host string, timeout int, debug bool) (goa.Endpoint, any, } } - var ( - dialer *websocket.Dialer - ) - { - dialer = websocket.DefaultDialer - } - - endpoint, payload, err := cli.ParseEndpoint( + endpoint, payload, err := cli2.ParseEndpoint( scheme, host, doer, goahttp.RequestEncoder, goahttp.ResponseDecoder, debug, - dialer, - nil, ) if err != nil { - return nil, nil, fmt.Errorf("parse endpoint: %w", err) + return fmt.Errorf("parse endpoint: %w", err) } - return endpoint, payload, nil -} -func jsonrpcUsageCommands() []string { - return cli.UsageCommands() + switch flag.Arg(0) { + case "calc": + switch flag.Arg(1) { + case "add": + return writeEndpointResult(ctx, stdout, endpoint, payload) + case "ping": + return writeEndpointResult(ctx, stdout, endpoint, payload) + case "log": + return writeEndpointResult(ctx, stdout, endpoint, payload) + } + case "feed": + switch flag.Arg(1) { + case "watch": + data, err := endpoint(ctx, payload) + if err != nil { + return err + } + stream := data.(feed.WatchClientStream) + return writeStreamResults(ctx, stdout, stream.RecvWithContext) + case "snapshot": + return writeEndpointResult(ctx, stdout, endpoint, payload) + } + case "mixed": + switch flag.Arg(1) { + case "lookup": + return writeEndpointResult(ctx, stdout, endpoint, payload) + } + } + panic("parsed JSON-RPC command has no generated result writer") } func jsonrpcUsageExamples() string { - return cli.UsageExamples() + return cli2.UsageExamples() } diff --git a/jsonrpc/codegen/testdata/golden/kitchen_sink/cmd/kitchen_sink-cli/main.go.golden b/jsonrpc/codegen/testdata/golden/kitchen_sink/cmd/kitchen_sink-cli/main.go.golden index c450c8697b..5c4e46ee31 100644 --- a/jsonrpc/codegen/testdata/golden/kitchen_sink/cmd/kitchen_sink-cli/main.go.golden +++ b/jsonrpc/codegen/testdata/golden/kitchen_sink/cmd/kitchen_sink-cli/main.go.golden @@ -6,10 +6,9 @@ import ( "errors" "flag" "fmt" + "io" "net/url" "os" - "slices" - "sort" "strings" goa "goa.design/goa/v3/pkg" @@ -64,23 +63,41 @@ func main() { } var ( - endpoint goa.Endpoint - payload any - err error + err error ) { switch scheme { case "http", "https": if *jsonrpcF || *jF { - endpoint, payload, err = doJSONRPC(scheme, host, timeout, debug) + err = doJSONRPC(context.Background(), scheme, host, timeout, debug, os.Stdout) } else { - endpoint, payload, err = doHTTP(scheme, host, timeout, debug) - if err != nil && strings.HasPrefix(err.Error(), "unknown") { - endpoint, payload, err = doJSONRPC(scheme, host, timeout, debug) + switch flag.Arg(0) { + case "calc": + switch flag.Arg(1) { + case "add": + err = doJSONRPC(context.Background(), scheme, host, timeout, debug, os.Stdout) + case "ping": + err = doJSONRPC(context.Background(), scheme, host, timeout, debug, os.Stdout) + case "log": + err = doJSONRPC(context.Background(), scheme, host, timeout, debug, os.Stdout) + default: + err = doHTTP(context.Background(), scheme, host, timeout, debug, os.Stdout) + } + case "feed": + switch flag.Arg(1) { + case "watch": + err = doJSONRPC(context.Background(), scheme, host, timeout, debug, os.Stdout) + case "snapshot": + err = doJSONRPC(context.Background(), scheme, host, timeout, debug, os.Stdout) + default: + err = doHTTP(context.Background(), scheme, host, timeout, debug, os.Stdout) + } + default: + err = doHTTP(context.Background(), scheme, host, timeout, debug, os.Stdout) } } default: - fmt.Fprintf(os.Stderr, "invalid scheme: %q (valid schemes: grpc|http)\n", scheme) + fmt.Fprintf(os.Stderr, "invalid scheme: %q (valid schemes: http)\n", scheme) os.Exit(1) } } @@ -93,31 +110,62 @@ func main() { os.Exit(1) } - data, err := endpoint(context.Background(), payload) +} + +// writeEndpointResult calls one normal endpoint and writes its result as JSON. +func writeEndpointResult(ctx context.Context, stdout io.Writer, endpoint goa.Endpoint, payload any) error { + data, err := endpoint(ctx, payload) if err != nil { - fmt.Fprintln(os.Stderr, err.Error()) - os.Exit(1) + return err + } + return writeJSON(stdout, data) +} + +// writeStreamResults writes each server result until the server ends the stream. +func writeStreamResults[T any](ctx context.Context, stdout io.Writer, recv func(context.Context) (T, error)) error { + for { + data, err := recv(ctx) + if err == io.EOF { + return nil + } + if err != nil { + return fmt.Errorf("receive result: %w", err) + } + if err := writeJSON(stdout, data); err != nil { + return err + } } +} - if data != nil { - m, _ := json.MarshalIndent(data, "", " ") - fmt.Println(string(m)) +// writeJSON writes one indented JSON value followed by a newline. +func writeJSON(stdout io.Writer, data any) error { + if data == nil { + return nil } + encoded, err := json.MarshalIndent(data, "", " ") + if err != nil { + return fmt.Errorf("encode result: %w", err) + } + if _, err := fmt.Fprintln(stdout, string(encoded)); err != nil { + return fmt.Errorf("write result: %w", err) + } + return nil } func usage() { - var usageCommands []string - usageCommands = append(usageCommands, httpUsageCommands()...) - usageCommands = append(usageCommands, jsonrpcUsageCommands()...) - sort.Strings(usageCommands) - usageCommands = slices.Compact(usageCommands) + usageCommands := []string{ + "calc (add|ping|log)", + "feed (watch|snapshot)", + "health check", + "mixed lookup", + } fmt.Fprintf(os.Stderr, `%s is a command line client for the kitchen-sink API. Usage: %s [-host HOST][-url URL][-timeout SECONDS][-verbose|-v] SERVICE ENDPOINT [flags] -host HOST: server host (localhost). valid values: localhost - -url URL: specify service URL overriding host URL (http://localhost:8080) + -url URL: specify service URL overriding host URL (http://localhost:80) -jsonrpc|-j: force JSON-RPC (false) -timeout: maximum number of seconds to wait for response (30) -verbose|-v: print request and response details (false) diff --git a/jsonrpc/codegen/testdata/golden/kitchen_sink/cmd/kitchen_sink/http.go.golden b/jsonrpc/codegen/testdata/golden/kitchen_sink/cmd/kitchen_sink/http.go.golden index da5f5da0fc..7dc70c327f 100644 --- a/jsonrpc/codegen/testdata/golden/kitchen_sink/cmd/kitchen_sink/http.go.golden +++ b/jsonrpc/codegen/testdata/golden/kitchen_sink/cmd/kitchen_sink/http.go.golden @@ -2,23 +2,20 @@ package main import ( "context" - calc "kitchensink/calc" - chat "kitchensink/chat" - feed "kitchensink/feed" - health "kitchensink/health" - healthsvr "kitchensink/http/health/server" - mixedsvr "kitchensink/http/mixed/server" - calcjssvr "kitchensink/jsonrpc/calc/server" - chatjssvr "kitchensink/jsonrpc/chat/server" - feedjssvr "kitchensink/jsonrpc/feed/server" - mixedjssvr "kitchensink/jsonrpc/mixed/server" - mixed "kitchensink/mixed" "net/http" "net/url" "sync" "time" - "github.com/gorilla/websocket" + calc "generated.local/gen/calc" + feed "generated.local/gen/feed" + health "generated.local/gen/health" + healthsvr "generated.local/gen/http/health/server" + mixedsvr "generated.local/gen/http/mixed/server" + calcjssvr "generated.local/gen/jsonrpc/calc/server" + feedjssvr "generated.local/gen/jsonrpc/feed/server" + mixedjssvr "generated.local/gen/jsonrpc/mixed/server" + mixed "generated.local/gen/mixed" "goa.design/clue/debug" "goa.design/clue/log" goahttp "goa.design/goa/v3/http" @@ -26,7 +23,7 @@ import ( // handleHTTPServer starts configures and starts a HTTP server on the given // URL. It shuts down the server if any error is received in the error channel. -func handleHTTPServer(ctx context.Context, u *url.URL, mixedEndpoints *mixed.Endpoints, healthEndpoints *health.Endpoints, calcSvc calc.Service, calcEndpoints *calc.Endpoints, chatSvc chat.Service, chatEndpoints *chat.Endpoints, feedSvc feed.Service, feedEndpoints *feed.Endpoints, mixedSvc mixed.Service, wg *sync.WaitGroup, errc chan error, dbg bool) { +func handleHTTPServer(ctx context.Context, u *url.URL, mixedEndpoints *mixed.Endpoints, healthEndpoints *health.Endpoints, calcSvc calc.Service, calcEndpoints *calc.Endpoints, feedSvc feed.Service, feedEndpoints *feed.Endpoints, mixedSvc mixed.Service, wg *sync.WaitGroup, errc chan error, dbg bool) { // Provide the transport specific request decoder and response encoder. // The goa http package has built-in support for JSON, XML and gob. // Other encodings can be used by providing the corresponding functions, @@ -57,17 +54,14 @@ func handleHTTPServer(ctx context.Context, u *url.URL, mixedEndpoints *mixed.End mixedServer *mixedsvr.Server healthServer *healthsvr.Server calcJSONRPCServer *calcjssvr.Server - chatJSONRPCServer *chatjssvr.Server feedJSONRPCServer *feedjssvr.Server mixedJSONRPCServer *mixedjssvr.Server ) { eh := errorHandler(ctx) - upgrader := &websocket.Upgrader{} mixedServer = mixedsvr.New(mixedEndpoints, mux, dec, enc, eh, nil) healthServer = healthsvr.New(healthEndpoints, mux, dec, enc, eh, nil) calcJSONRPCServer = calcjssvr.New(calcEndpoints, mux, dec, enc, eh) - chatJSONRPCServer = chatjssvr.New(chatSvc.HandleStream, chatEndpoints, mux, dec, enc, eh, upgrader, nil) feedJSONRPCServer = feedjssvr.New(feedEndpoints, mux, dec, enc, eh) mixedJSONRPCServer = mixedjssvr.New(mixedEndpoints, mux, dec, enc, eh) } @@ -76,7 +70,6 @@ func handleHTTPServer(ctx context.Context, u *url.URL, mixedEndpoints *mixed.End mixedsvr.Mount(mux, mixedServer) healthsvr.Mount(mux, healthServer) calcjssvr.Mount(mux, calcJSONRPCServer) - chatjssvr.Mount(mux, chatJSONRPCServer) feedjssvr.Mount(mux, feedJSONRPCServer) mixedjssvr.Mount(mux, mixedJSONRPCServer) @@ -99,9 +92,6 @@ func handleHTTPServer(ctx context.Context, u *url.URL, mixedEndpoints *mixed.End for _, m := range calcJSONRPCServer.Methods { log.Printf(ctx, "JSON-RPC method %q mounted on POST /rpc", m) } - for _, m := range chatJSONRPCServer.Methods { - log.Printf(ctx, "JSON-RPC method %q mounted on GET /ws/ws", m) - } for _, m := range feedJSONRPCServer.Methods { log.Printf(ctx, "JSON-RPC method %q mounted on POST /feed", m) } diff --git a/jsonrpc/codegen/testdata/golden/kitchen_sink/cmd/kitchen_sink/main.go.golden b/jsonrpc/codegen/testdata/golden/kitchen_sink/cmd/kitchen_sink/main.go.golden index 01d6a7cb4a..49d67debe1 100644 --- a/jsonrpc/codegen/testdata/golden/kitchen_sink/cmd/kitchen_sink/main.go.golden +++ b/jsonrpc/codegen/testdata/golden/kitchen_sink/cmd/kitchen_sink/main.go.golden @@ -4,11 +4,6 @@ import ( "context" "flag" "fmt" - calc "kitchensink/calc" - chat "kitchensink/chat" - feed "kitchensink/feed" - health "kitchensink/health" - mixed "kitchensink/mixed" "net" "net/url" "os" @@ -16,7 +11,11 @@ import ( "sync" "syscall" - kitchensink "." + kitchensink "generated.local" + calc "generated.local/gen/calc" + feed "generated.local/gen/feed" + health "generated.local/gen/health" + mixed "generated.local/gen/mixed" "goa.design/clue/debug" "goa.design/clue/log" ) @@ -48,14 +47,12 @@ func main() { // Initialize the services. var ( calcSvc calc.Service - chatSvc chat.Service feedSvc feed.Service mixedSvc mixed.Service healthSvc health.Service ) { calcSvc = kitchensink.NewCalc() - chatSvc = kitchensink.NewChat() feedSvc = kitchensink.NewFeed() mixedSvc = kitchensink.NewMixed() healthSvc = kitchensink.NewHealth() @@ -65,7 +62,6 @@ func main() { // potentially running in different processes. var ( calcEndpoints *calc.Endpoints - chatEndpoints *chat.Endpoints feedEndpoints *feed.Endpoints mixedEndpoints *mixed.Endpoints healthEndpoints *health.Endpoints @@ -74,9 +70,6 @@ func main() { calcEndpoints = calc.NewEndpoints(calcSvc) calcEndpoints.Use(debug.LogPayloads()) calcEndpoints.Use(log.Endpoint) - chatEndpoints = chat.NewEndpoints(chatSvc) - chatEndpoints.Use(debug.LogPayloads()) - chatEndpoints.Use(log.Endpoint) feedEndpoints = feed.NewEndpoints(feedSvc) feedEndpoints.Use(debug.LogPayloads()) feedEndpoints.Use(log.Endpoint) @@ -127,7 +120,7 @@ func main() { } else if u.Port() == "" { u.Host = net.JoinHostPort(u.Host, "80") } - handleHTTPServer(ctx, u, mixedEndpoints, healthEndpoints, calcSvc, calcEndpoints, chatSvc, chatEndpoints, feedSvc, feedEndpoints, mixedSvc, &wg, errc, *dbgF) + handleHTTPServer(ctx, u, mixedEndpoints, healthEndpoints, calcSvc, calcEndpoints, feedSvc, feedEndpoints, mixedSvc, &wg, errc, *dbgF) } default: diff --git a/jsonrpc/codegen/testdata/golden/kitchen_sink/feed.go.golden b/jsonrpc/codegen/testdata/golden/kitchen_sink/feed.go.golden index 8fdbe4aca2..6b7f16c5b9 100644 --- a/jsonrpc/codegen/testdata/golden/kitchen_sink/feed.go.golden +++ b/jsonrpc/codegen/testdata/golden/kitchen_sink/feed.go.golden @@ -2,8 +2,8 @@ package kitchensink import ( "context" - feed "kitchensink/feed" + feed "generated.local/gen/feed" "goa.design/clue/log" ) @@ -19,16 +19,11 @@ func NewFeed() feed.Service { // Watch implements watch. func (s *feedsrvc) Watch(ctx context.Context, p *feed.WatchPayload, stream feed.WatchServerStream) (err error) { log.Printf(ctx, "feed.watch") - // Minimal example: emit one progress notification and one final response - { - // Progress notification (no ID) - notif := &feed.WatchResult{} - if err := stream.Send(ctx, notif); err != nil { - return err - } - // Final response - final := &feed.WatchResult{} - return stream.SendAndClose(ctx, final) - } + return +} + +// Snapshot implements snapshot. +func (s *feedsrvc) Snapshot(ctx context.Context, p *feed.SnapshotPayload) (res string, err error) { + log.Printf(ctx, "feed.snapshot") return } diff --git a/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/http/cli/kitchen_sink/cli.go.golden b/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/http/cli/kitchen_sink/cli.go.golden index 960a032aba..33b757ae81 100644 --- a/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/http/cli/kitchen_sink/cli.go.golden +++ b/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/http/cli/kitchen_sink/cli.go.golden @@ -10,11 +10,11 @@ package cli import ( "flag" "fmt" - healthc "kitchensink/http/health/client" - mixedc "kitchensink/http/mixed/client" "net/http" "os" + healthc "generated.local/gen/http/health/client" + mixedc "generated.local/gen/http/mixed/client" goahttp "goa.design/goa/v3/http" goa "goa.design/goa/v3/pkg" ) @@ -31,11 +31,30 @@ func UsageCommands() []string { // UsageExamples produces an example of a valid invocation of the CLI tool. func UsageExamples() string { - return os.Args[0] + " " + "mixed lookup --body '{\n \"id\": \"Mollitia voluptatum expedita velit assumenda.\",\n \"key\": \"Blanditiis sed voluptatum odit dolores impedit.\"\n }'" + "\n" + + return os.Args[0] + " " + "mixed lookup --body '{\n \"id\": \"Cupiditate cupiditate minus veniam sed officia qui.\",\n \"key\": \"Numquam iusto molestias nulla quod nobis molestias.\"\n }'" + "\n" + os.Args[0] + " " + "health check" + "\n" + "" } +// cliStringFlag keeps an omitted command-line flag distinct from an explicitly empty flag. +type cliStringFlag struct { + value *string +} + +// String returns the flag text shown by the standard flag package. +func (f *cliStringFlag) String() string { + if f.value == nil { + return "" + } + return *f.value +} + +// Set records that the user supplied the flag, even when value is empty. +func (f *cliStringFlag) Set(value string) error { + f.value = &value + return nil +} + // ParseEndpoint returns the endpoint and payload as specified on the command // line. func ParseEndpoint( @@ -49,12 +68,14 @@ func ParseEndpoint( mixedFlags = flag.NewFlagSet("mixed", flag.ContinueOnError) mixedLookupFlags = flag.NewFlagSet("lookup", flag.ExitOnError) - mixedLookupBodyFlag = mixedLookupFlags.String("body", "REQUIRED", "") + mixedLookupBodyFlag = new(cliStringFlag) healthFlags = flag.NewFlagSet("health", flag.ContinueOnError) healthCheckFlags = flag.NewFlagSet("check", flag.ExitOnError) ) + mixedLookupFlags.Var(mixedLookupBodyFlag, "body", "") + mixedFlags.Usage = mixedUsage mixedLookupFlags.Usage = mixedLookupUsage @@ -134,7 +155,7 @@ func ParseEndpoint( switch epn { case "lookup": endpoint = c.Lookup() - data, err = mixedc.BuildLookupPayload(*mixedLookupBodyFlag) + data, err = mixedc.BuildLookupPayload(mixedLookupBodyFlag.value) } case "health": c := healthc.NewClient(scheme, host, doer, enc, dec, restore) @@ -176,7 +197,7 @@ func mixedLookupUsage() { fmt.Fprintln(os.Stderr) fmt.Fprintln(os.Stderr, "Example:") - fmt.Fprintf(os.Stderr, " %s %s\n", os.Args[0], "mixed lookup --body '{\n \"id\": \"Mollitia voluptatum expedita velit assumenda.\",\n \"key\": \"Blanditiis sed voluptatum odit dolores impedit.\"\n }'") + fmt.Fprintf(os.Stderr, " %s %s\n", os.Args[0], "mixed lookup --body '{\n \"id\": \"Cupiditate cupiditate minus veniam sed officia qui.\",\n \"key\": \"Numquam iusto molestias nulla quod nobis molestias.\"\n }'") } // healthUsage displays the usage of the health command and its subcommands. diff --git a/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/http/health/client/encode_decode.go.golden b/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/http/health/client/encode_decode.go.golden index 1ad12c6838..a4f1eebf6a 100644 --- a/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/http/health/client/encode_decode.go.golden +++ b/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/http/health/client/encode_decode.go.golden @@ -10,6 +10,7 @@ package client import ( "bytes" "context" + "errors" "io" "net/http" "net/url" @@ -36,18 +37,24 @@ func (c *Client) BuildCheckRequest(ctx context.Context, v any) (*http.Request, e // check endpoint. restoreBody controls whether the response body should be // restored after having been read. func DecodeCheckResponse(decoder func(*http.Response) goahttp.Decoder, restoreBody bool) func(*http.Response) (any, error) { - return func(resp *http.Response) (any, error) { + return func(resp *http.Response) (result any, decodeErr error) { + responseBody := resp.Body if restoreBody { - b, err := io.ReadAll(resp.Body) - if err != nil { - return nil, err + b, readErr := io.ReadAll(responseBody) + closeErr := responseBody.Close() + if err := errors.Join(readErr, closeErr); err != nil { + return nil, goahttp.ErrDecodingError("Health", "check", err) } resp.Body = io.NopCloser(bytes.NewBuffer(b)) defer func() { resp.Body = io.NopCloser(bytes.NewBuffer(b)) }() } else { - defer resp.Body.Close() + defer func() { + if err := responseBody.Close(); err != nil { + decodeErr = errors.Join(decodeErr, goahttp.ErrDecodingError("Health", "check", err)) + } + }() } switch resp.StatusCode { case http.StatusOK: @@ -61,7 +68,10 @@ func DecodeCheckResponse(decoder func(*http.Response) goahttp.Decoder, restoreBo } return body, nil default: - body, _ := io.ReadAll(resp.Body) + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, goahttp.ErrDecodingError("Health", "check", err) + } return nil, goahttp.ErrInvalidResponse("Health", "check", resp.StatusCode, string(body)) } } diff --git a/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/http/health/server/server.go.golden b/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/http/health/server/server.go.golden index 21812454bd..75797ae4a0 100644 --- a/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/http/health/server/server.go.golden +++ b/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/http/health/server/server.go.golden @@ -9,9 +9,9 @@ package server import ( "context" - health "kitchensink/health" "net/http" + health "generated.local/gen/health" goahttp "goa.design/goa/v3/http" goa "goa.design/goa/v3/pkg" ) diff --git a/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/http/mixed/client/cli.go.golden b/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/http/mixed/client/cli.go.golden index 9c72b09db6..1e538668d1 100644 --- a/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/http/mixed/client/cli.go.golden +++ b/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/http/mixed/client/cli.go.golden @@ -10,18 +10,22 @@ package client import ( "encoding/json" "fmt" - mixed "kitchensink/mixed" + + mixed "generated.local/gen/mixed" ) // BuildLookupPayload builds the payload for the Mixed lookup endpoint from CLI // flags. -func BuildLookupPayload(mixedLookupBody string) (*mixed.LookupPayload, error) { +func BuildLookupPayload(mixedLookupBody *string) (*mixed.LookupPayload, error) { var err error var body LookupRequestBody { - err = json.Unmarshal([]byte(mixedLookupBody), &body) + if mixedLookupBody == nil { + return nil, fmt.Errorf("missing required flag --body") + } + err = json.Unmarshal([]byte(*mixedLookupBody), &body) if err != nil { - return nil, fmt.Errorf("invalid JSON for body, \nerror: %s, \nexample of valid JSON:\n%s", err, "'{\n \"id\": \"Mollitia voluptatum expedita velit assumenda.\",\n \"key\": \"Blanditiis sed voluptatum odit dolores impedit.\"\n }'") + return nil, fmt.Errorf("invalid JSON for body, \nerror: %s, \nexample of valid JSON:\n%s", err, "'{\n \"id\": \"Cupiditate cupiditate minus veniam sed officia qui.\",\n \"key\": \"Numquam iusto molestias nulla quod nobis molestias.\"\n }'") } } v := &mixed.LookupPayload{ diff --git a/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/http/mixed/client/encode_decode.go.golden b/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/http/mixed/client/encode_decode.go.golden index af9df4cd94..578e8ea24f 100644 --- a/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/http/mixed/client/encode_decode.go.golden +++ b/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/http/mixed/client/encode_decode.go.golden @@ -10,11 +10,12 @@ package client import ( "bytes" "context" + "errors" "io" - mixed "kitchensink/mixed" "net/http" "net/url" + mixed "generated.local/gen/mixed" goahttp "goa.design/goa/v3/http" ) @@ -53,18 +54,24 @@ func EncodeLookupRequest(encoder func(*http.Request) goahttp.Encoder) func(*http // lookup endpoint. restoreBody controls whether the response body should be // restored after having been read. func DecodeLookupResponse(decoder func(*http.Response) goahttp.Decoder, restoreBody bool) func(*http.Response) (any, error) { - return func(resp *http.Response) (any, error) { + return func(resp *http.Response) (result any, decodeErr error) { + responseBody := resp.Body if restoreBody { - b, err := io.ReadAll(resp.Body) - if err != nil { - return nil, err + b, readErr := io.ReadAll(responseBody) + closeErr := responseBody.Close() + if err := errors.Join(readErr, closeErr); err != nil { + return nil, goahttp.ErrDecodingError("Mixed", "lookup", err) } resp.Body = io.NopCloser(bytes.NewBuffer(b)) defer func() { resp.Body = io.NopCloser(bytes.NewBuffer(b)) }() } else { - defer resp.Body.Close() + defer func() { + if err := responseBody.Close(); err != nil { + decodeErr = errors.Join(decodeErr, goahttp.ErrDecodingError("Mixed", "lookup", err)) + } + }() } switch resp.StatusCode { case http.StatusOK: @@ -76,14 +83,13 @@ func DecodeLookupResponse(decoder func(*http.Response) goahttp.Decoder, restoreB if err != nil { return nil, goahttp.ErrDecodingError("Mixed", "lookup", err) } - err = ValidateLookupResponseBody(&body) - if err != nil { - return nil, goahttp.ErrValidationError("Mixed", "lookup", err) - } res := NewLookupResultOK(&body) return res, nil default: - body, _ := io.ReadAll(resp.Body) + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, goahttp.ErrDecodingError("Mixed", "lookup", err) + } return nil, goahttp.ErrInvalidResponse("Mixed", "lookup", resp.StatusCode, string(body)) } } diff --git a/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/http/mixed/client/types.go.golden b/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/http/mixed/client/types.go.golden index 5f55e34495..57ff6caf09 100644 --- a/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/http/mixed/client/types.go.golden +++ b/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/http/mixed/client/types.go.golden @@ -7,25 +7,19 @@ package client -import ( - mixed "kitchensink/mixed" - - goa "goa.design/goa/v3/pkg" -) +import mixed "generated.local/gen/mixed" // LookupRequestBody is the type of the "Mixed" service "lookup" endpoint HTTP // request body. type LookupRequestBody struct { // Request ID - ID string `form:"id,omitempty" json:"id,omitempty" xml:"id,omitempty"` + ID string `form:"id" json:"id" xml:"id"` Key string `form:"key" json:"key" xml:"key"` } // LookupResponseBody is the type of the "Mixed" service "lookup" endpoint HTTP // response body. type LookupResponseBody struct { - // Request ID - ID *string `form:"id,omitempty" json:"id,omitempty" xml:"id,omitempty"` Value *string `form:"value,omitempty" json:"value,omitempty" xml:"value,omitempty"` } @@ -43,17 +37,8 @@ func NewLookupRequestBody(p *mixed.LookupPayload) *LookupRequestBody { // HTTP "OK" response. func NewLookupResultOK(body *LookupResponseBody) *mixed.LookupResult { v := &mixed.LookupResult{ - ID: *body.ID, Value: body.Value, } return v } - -// ValidateLookupResponseBody runs the validations defined on LookupResponseBody -func ValidateLookupResponseBody(body *LookupResponseBody) (err error) { - if body.ID == nil { - err = goa.MergeErrors(err, goa.MissingFieldError("id", "body")) - } - return -} diff --git a/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/http/mixed/server/encode_decode.go.golden b/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/http/mixed/server/encode_decode.go.golden index 857b538a9a..35df2187ef 100644 --- a/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/http/mixed/server/encode_decode.go.golden +++ b/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/http/mixed/server/encode_decode.go.golden @@ -11,9 +11,9 @@ import ( "context" "errors" "io" - mixed "kitchensink/mixed" "net/http" + mixed "generated.local/gen/mixed" goahttp "goa.design/goa/v3/http" goa "goa.design/goa/v3/pkg" ) diff --git a/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/http/mixed/server/server.go.golden b/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/http/mixed/server/server.go.golden index 0d3139d9c7..0a9fa434ca 100644 --- a/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/http/mixed/server/server.go.golden +++ b/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/http/mixed/server/server.go.golden @@ -9,9 +9,9 @@ package server import ( "context" - mixed "kitchensink/mixed" "net/http" + mixed "generated.local/gen/mixed" goahttp "goa.design/goa/v3/http" goa "goa.design/goa/v3/pkg" ) diff --git a/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/http/mixed/server/types.go.golden b/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/http/mixed/server/types.go.golden index cdd6f15233..eee434bdbf 100644 --- a/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/http/mixed/server/types.go.golden +++ b/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/http/mixed/server/types.go.golden @@ -8,8 +8,7 @@ package server import ( - mixed "kitchensink/mixed" - + mixed "generated.local/gen/mixed" goa "goa.design/goa/v3/pkg" ) @@ -24,8 +23,6 @@ type LookupRequestBody struct { // LookupResponseBody is the type of the "Mixed" service "lookup" endpoint HTTP // response body. type LookupResponseBody struct { - // Request ID - ID string `form:"id,omitempty" json:"id,omitempty" xml:"id,omitempty"` Value *string `form:"value,omitempty" json:"value,omitempty" xml:"value,omitempty"` } @@ -33,7 +30,6 @@ type LookupResponseBody struct { // "lookup" endpoint of the "Mixed" service. func NewLookupResponseBody(res *mixed.LookupResult) *LookupResponseBody { body := &LookupResponseBody{ - ID: res.ID, Value: res.Value, } return body diff --git a/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/calc/client/cli.go.golden b/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/calc/client/cli.go.golden index e6eabf22b7..e450559f05 100644 --- a/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/calc/client/cli.go.golden +++ b/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/calc/client/cli.go.golden @@ -10,42 +10,62 @@ package client import ( "encoding/json" "fmt" - calc "kitchensink/calc" + + calc "generated.local/gen/calc" ) // BuildAddPayload builds the payload for the Calc add endpoint from CLI flags. -func BuildAddPayload(calcAddBody string) (*calc.AddPayload, error) { +func BuildAddPayload(calcAddBody *string, calcAddRequestID *string) (*calc.AddPayload, error) { var err error var body AddRequestBody { - err = json.Unmarshal([]byte(calcAddBody), &body) + if calcAddBody == nil { + return nil, fmt.Errorf("missing required flag --body") + } + err = json.Unmarshal([]byte(*calcAddBody), &body) if err != nil { - return nil, fmt.Errorf("invalid JSON for body, \nerror: %s, \nexample of valid JSON:\n%s", err, "'{\n \"a\": 281524111196350841,\n \"b\": 4865202500627059760,\n \"id\": \"Perspiciatis sed soluta distinctio facere voluptas et.\"\n }'") + return nil, fmt.Errorf("invalid JSON for body, \nerror: %s, \nexample of valid JSON:\n%s", err, "'{\n \"a\": 5718665059814127631,\n \"b\": 5455021967244844938\n }'") + } + } + var requestID string + { + if calcAddRequestID == nil { + return nil, fmt.Errorf("missing required flag --request-id") } + requestID = *calcAddRequestID } v := &calc.AddPayload{ - ID: body.ID, - A: body.A, - B: body.B, + A: body.A, + B: body.B, } + v.ID = requestID return v, nil } // BuildLogPayload builds the payload for the Calc log endpoint from CLI flags. -func BuildLogPayload(calcLogBody string) (*calc.LogPayload, error) { +func BuildLogPayload(calcLogBody *string, calcLogRequestID *string) (*calc.LogPayload, error) { var err error var body LogRequestBody { - err = json.Unmarshal([]byte(calcLogBody), &body) + if calcLogBody == nil { + return nil, fmt.Errorf("missing required flag --body") + } + err = json.Unmarshal([]byte(*calcLogBody), &body) if err != nil { - return nil, fmt.Errorf("invalid JSON for body, \nerror: %s, \nexample of valid JSON:\n%s", err, "'{\n \"id\": \"Suscipit saepe tempore fuga recusandae amet blanditiis.\",\n \"message\": \"Est quisquam molestiae.\"\n }'") + return nil, fmt.Errorf("invalid JSON for body, \nerror: %s, \nexample of valid JSON:\n%s", err, "'{\n \"message\": \"Quo excepturi.\"\n }'") + } + } + var requestID *string + { + if calcLogRequestID != nil { + requestID = calcLogRequestID } } v := &calc.LogPayload{ - ID: body.ID, Message: body.Message, } + v.ID = requestID return v, nil } diff --git a/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/calc/client/client.go.golden b/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/calc/client/client.go.golden index 854d337caa..0828d8d8a0 100644 --- a/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/calc/client/client.go.golden +++ b/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/calc/client/client.go.golden @@ -31,12 +31,12 @@ type Client struct { decoder func(*http.Response) goahttp.Decoder } -// bufferPool is a pool of bytes.Buffers for encoding requests. +// bufferPool reuses byte buffers while requests are encoded. var bufferPool = sync.Pool{ New: func() any { return new(bytes.Buffer) }, } -// NewClient instantiates HTTP clients for all the Calc service servers. +// NewClient creates HTTP clients for all the Calc service servers. func NewClient( scheme string, host string, @@ -45,7 +45,6 @@ func NewClient( dec func(*http.Response) goahttp.Decoder, restoreBody bool, ) *Client { - return &Client{ Doer: doer, RestoreResponseBody: restoreBody, @@ -68,14 +67,15 @@ func (c *Client) Add() goa.Endpoint { if err != nil { return nil, err } - if err := encodeRequest(req, v); err != nil { + requestID, err := encodeRequest(req, v) + if err != nil { return nil, err } resp, err := c.Doer.Do(req) if err != nil { return nil, goahttp.ErrRequestError("Calc", "add", err) } - return decodeResponse(resp) + return decodeResponse(resp, requestID) } } @@ -91,14 +91,15 @@ func (c *Client) Ping() goa.Endpoint { if err != nil { return nil, err } - if err := encodeRequest(req, v); err != nil { + requestID, err := encodeRequest(req, v) + if err != nil { return nil, err } resp, err := c.Doer.Do(req) if err != nil { return nil, goahttp.ErrRequestError("Calc", "ping", err) } - return decodeResponse(resp) + return decodeResponse(resp, requestID) } } @@ -114,13 +115,14 @@ func (c *Client) Log() goa.Endpoint { if err != nil { return nil, err } - if err := encodeRequest(req, v); err != nil { + requestID, err := encodeRequest(req, v) + if err != nil { return nil, err } resp, err := c.Doer.Do(req) if err != nil { return nil, goahttp.ErrRequestError("Calc", "log", err) } - return decodeResponse(resp) + return decodeResponse(resp, requestID) } } diff --git a/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/calc/client/encode_decode.go.golden b/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/calc/client/encode_decode.go.golden index 769f589798..327c343cc6 100644 --- a/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/calc/client/encode_decode.go.golden +++ b/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/calc/client/encode_decode.go.golden @@ -10,11 +10,12 @@ package client import ( "bytes" "context" + "errors" "io" - calc "kitchensink/calc" "net/http" "net/url" + calc "generated.local/gen/calc" "github.com/google/uuid" goahttp "goa.design/goa/v3/http" "goa.design/goa/v3/jsonrpc" @@ -35,12 +36,14 @@ func (c *Client) BuildAddRequest(ctx context.Context, v any) (*http.Request, err return req, nil } -// EncodeAddRequest returns an encoder for requests sent to the Calc add server. -func EncodeAddRequest(encoder func(*http.Request) goahttp.Encoder) func(*http.Request, any) error { - return func(req *http.Request, v any) error { +// EncodeAddRequest returns an encoder for requests sent to the Calc service +// add JSON-RPC method. The encoder returns the request ID written into the +// JSON-RPC message. +func EncodeAddRequest(encoder func(*http.Request) goahttp.Encoder) func(*http.Request, any) (string, error) { + return func(req *http.Request, v any) (string, error) { p, ok := v.(*calc.AddPayload) if !ok { - return goahttp.ErrInvalidType("Calc", "add", "*calc.AddPayload", v) + return "", goahttp.ErrInvalidType("Calc", "add", "*calc.AddPayload", v) } b := NewAddRequestBody(p) body := &jsonrpc.Request{ @@ -48,36 +51,45 @@ func EncodeAddRequest(encoder func(*http.Request) goahttp.Encoder) func(*http.Re Method: "add", Params: b, } - if p.ID != "" { - body.ID = p.ID - } - // If ID is empty, this is a notification - no ID field + requestID := p.ID + body.ID = requestID if err := encoder(req).Encode(&body); err != nil { - return goahttp.ErrEncodingError("Calc", "add", err) + return "", goahttp.ErrEncodingError("Calc", "add", err) } - return nil + return requestID, nil } } // DecodeAddResponse returns a decoder for responses returned by the Calc -// service add JSON-RPC method. restoreBody controls whether the response body -// should be restored after having been read. -func DecodeAddResponse(decoder func(*http.Response) goahttp.Decoder, restoreBody bool) func(*http.Response) (any, error) { - return func(resp *http.Response) (any, error) { +// service add JSON-RPC method. The decoder rejects responses that do not +// repeat requestID. restoreBody controls whether the response body should be +// restored after having been read. +func DecodeAddResponse(decoder func(*http.Response) goahttp.Decoder, restoreBody bool) func(*http.Response, string) (any, error) { + return func(resp *http.Response, requestID string) (result any, decodeErr error) { + responseBody := resp.Body if restoreBody { - b, err := io.ReadAll(resp.Body) - if err != nil { - return nil, err + b, readErr := io.ReadAll(responseBody) + closeErr := responseBody.Close() + if err := errors.Join(readErr, closeErr); err != nil { + return nil, goahttp.ErrDecodingError("Calc", "add", err) } resp.Body = io.NopCloser(bytes.NewBuffer(b)) defer func() { resp.Body = io.NopCloser(bytes.NewBuffer(b)) }() + } else { + defer func() { + if err := responseBody.Close(); err != nil { + decodeErr = errors.Join(decodeErr, goahttp.ErrDecodingError("Calc", "add", err)) + } + }() } - defer resp.Body.Close() if resp.StatusCode != http.StatusOK { - body, _ := io.ReadAll(resp.Body) + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, goahttp.ErrDecodingError("Calc", "add", err) + } return nil, goahttp.ErrInvalidResponse("Calc", "add", resp.StatusCode, string(body)) } @@ -85,27 +97,38 @@ func DecodeAddResponse(decoder func(*http.Response) goahttp.Decoder, restoreBody if err := decoder(resp).Decode(&jresp); err != nil { return nil, goahttp.ErrDecodingError("Calc", "add", err) } + if err := jresp.Validate(requestID); err != nil { + return nil, goahttp.ErrInvalidResponse("Calc", "add", resp.StatusCode, err.Error()) + } if jresp.Error != nil { + serviceErrorName, serviceErrorBody, ok := jsonrpc.DecodeServiceErrorData(jresp.Error.Data) + if !ok { + return nil, jresp.Error + } switch jresp.Error.Code { case -32602: - resp.Body = io.NopCloser(bytes.NewBuffer(jresp.Error.Data)) - var ( - body AddOverflowResponseBody - err error - ) - err = decoder(resp).Decode(&body) - if err != nil { - return nil, goahttp.ErrDecodingError("Calc", "add", err) - } - err = ValidateAddOverflowResponseBody(&body) - if err != nil { - return nil, goahttp.ErrValidationError("Calc", "add", err) + switch serviceErrorName { + case "overflow": + resp.Body = io.NopCloser(bytes.NewReader(serviceErrorBody)) + var ( + body AddOverflowResponseBody + err error + ) + err = decoder(resp).Decode(&body) + if err != nil { + return nil, goahttp.ErrDecodingError("Calc", "add", err) + } + err = ValidateAddOverflowResponseBody(&body) + if err != nil { + return nil, goahttp.ErrValidationError("Calc", "add", err) + } + return nil, NewAddOverflow(&body) + default: + return nil, jresp.Error } - return nil, NewAddOverflow(&body) default: - body, _ := io.ReadAll(resp.Body) - return nil, goahttp.ErrInvalidResponse("Calc", "add", resp.StatusCode, string(body)) + return nil, jresp.Error } } resp.Body = io.NopCloser(bytes.NewBuffer(jresp.Result)) @@ -142,43 +165,53 @@ func (c *Client) BuildPingRequest(ctx context.Context, v any) (*http.Request, er } // EncodePingRequest returns an encoder for requests sent to the Calc service -// ping JSON-RPC method. -func EncodePingRequest(encoder func(*http.Request) goahttp.Encoder) func(*http.Request, any) error { - return func(req *http.Request, v any) error { - // For JSON-RPC methods without payloads, we still need to send the method envelope - // Generate a unique ID for the request - id := uuid.New().String() +// ping JSON-RPC method. The encoder returns the request ID written into the +// JSON-RPC message. +func EncodePingRequest(encoder func(*http.Request) goahttp.Encoder) func(*http.Request, any) (string, error) { + return func(req *http.Request, v any) (string, error) { + requestID := uuid.New().String() body := &jsonrpc.Request{ JSONRPC: "2.0", Method: "ping", - ID: id, + ID: requestID, } if err := encoder(req).Encode(body); err != nil { - return goahttp.ErrEncodingError("Calc", "ping", err) + return "", goahttp.ErrEncodingError("Calc", "ping", err) } - return nil + return requestID, nil } } // DecodePingResponse returns a decoder for responses returned by the Calc -// service ping JSON-RPC method. restoreBody controls whether the response body -// should be restored after having been read. -func DecodePingResponse(decoder func(*http.Response) goahttp.Decoder, restoreBody bool) func(*http.Response) (any, error) { - return func(resp *http.Response) (any, error) { +// service ping JSON-RPC method. The decoder rejects responses that do not +// repeat requestID. restoreBody controls whether the response body should be +// restored after having been read. +func DecodePingResponse(decoder func(*http.Response) goahttp.Decoder, restoreBody bool) func(*http.Response, string) (any, error) { + return func(resp *http.Response, requestID string) (result any, decodeErr error) { + responseBody := resp.Body if restoreBody { - b, err := io.ReadAll(resp.Body) - if err != nil { - return nil, err + b, readErr := io.ReadAll(responseBody) + closeErr := responseBody.Close() + if err := errors.Join(readErr, closeErr); err != nil { + return nil, goahttp.ErrDecodingError("Calc", "ping", err) } resp.Body = io.NopCloser(bytes.NewBuffer(b)) defer func() { resp.Body = io.NopCloser(bytes.NewBuffer(b)) }() + } else { + defer func() { + if err := responseBody.Close(); err != nil { + decodeErr = errors.Join(decodeErr, goahttp.ErrDecodingError("Calc", "ping", err)) + } + }() } - defer resp.Body.Close() if resp.StatusCode != http.StatusOK { - body, _ := io.ReadAll(resp.Body) + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, goahttp.ErrDecodingError("Calc", "ping", err) + } return nil, goahttp.ErrInvalidResponse("Calc", "ping", resp.StatusCode, string(body)) } @@ -186,13 +219,12 @@ func DecodePingResponse(decoder func(*http.Response) goahttp.Decoder, restoreBod if err := decoder(resp).Decode(&jresp); err != nil { return nil, goahttp.ErrDecodingError("Calc", "ping", err) } + if err := jresp.Validate(requestID); err != nil { + return nil, goahttp.ErrInvalidResponse("Calc", "ping", resp.StatusCode, err.Error()) + } if jresp.Error != nil { - switch jresp.Error.Code { - default: - body, _ := io.ReadAll(resp.Body) - return nil, goahttp.ErrInvalidResponse("Calc", "ping", resp.StatusCode, string(body)) - } + return nil, jresp.Error } resp.Body = io.NopCloser(bytes.NewBuffer(jresp.Result)) var ( @@ -223,12 +255,14 @@ func (c *Client) BuildLogRequest(ctx context.Context, v any) (*http.Request, err return req, nil } -// EncodeLogRequest returns an encoder for requests sent to the Calc log server. -func EncodeLogRequest(encoder func(*http.Request) goahttp.Encoder) func(*http.Request, any) error { - return func(req *http.Request, v any) error { +// EncodeLogRequest returns an encoder for requests sent to the Calc service +// log JSON-RPC method. The encoder returns the request ID written into the +// JSON-RPC message. +func EncodeLogRequest(encoder func(*http.Request) goahttp.Encoder) func(*http.Request, any) (string, error) { + return func(req *http.Request, v any) (string, error) { p, ok := v.(*calc.LogPayload) if !ok { - return goahttp.ErrInvalidType("Calc", "log", "*calc.LogPayload", v) + return "", goahttp.ErrInvalidType("Calc", "log", "*calc.LogPayload", v) } b := NewLogRequestBody(p) body := &jsonrpc.Request{ @@ -236,36 +270,50 @@ func EncodeLogRequest(encoder func(*http.Request) goahttp.Encoder) func(*http.Re Method: "log", Params: b, } - if p.ID != nil && *p.ID != "" { - body.ID = p.ID + var requestID string + if p.ID == nil { + requestID = uuid.New().String() + } else { + requestID = *p.ID } - // If ID is nil or empty, this is a notification - no ID field + body.ID = requestID if err := encoder(req).Encode(&body); err != nil { - return goahttp.ErrEncodingError("Calc", "log", err) + return "", goahttp.ErrEncodingError("Calc", "log", err) } - return nil + return requestID, nil } } // DecodeLogResponse returns a decoder for responses returned by the Calc -// service log JSON-RPC method. restoreBody controls whether the response body -// should be restored after having been read. -func DecodeLogResponse(decoder func(*http.Response) goahttp.Decoder, restoreBody bool) func(*http.Response) (any, error) { - return func(resp *http.Response) (any, error) { +// service log JSON-RPC method. The decoder rejects responses that do not +// repeat requestID. restoreBody controls whether the response body should be +// restored after having been read. +func DecodeLogResponse(decoder func(*http.Response) goahttp.Decoder, restoreBody bool) func(*http.Response, string) (any, error) { + return func(resp *http.Response, requestID string) (result any, decodeErr error) { + responseBody := resp.Body if restoreBody { - b, err := io.ReadAll(resp.Body) - if err != nil { - return nil, err + b, readErr := io.ReadAll(responseBody) + closeErr := responseBody.Close() + if err := errors.Join(readErr, closeErr); err != nil { + return nil, goahttp.ErrDecodingError("Calc", "log", err) } resp.Body = io.NopCloser(bytes.NewBuffer(b)) defer func() { resp.Body = io.NopCloser(bytes.NewBuffer(b)) }() + } else { + defer func() { + if err := responseBody.Close(); err != nil { + decodeErr = errors.Join(decodeErr, goahttp.ErrDecodingError("Calc", "log", err)) + } + }() } - defer resp.Body.Close() if resp.StatusCode != http.StatusOK { - body, _ := io.ReadAll(resp.Body) + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, goahttp.ErrDecodingError("Calc", "log", err) + } return nil, goahttp.ErrInvalidResponse("Calc", "log", resp.StatusCode, string(body)) } @@ -273,13 +321,12 @@ func DecodeLogResponse(decoder func(*http.Response) goahttp.Decoder, restoreBody if err := decoder(resp).Decode(&jresp); err != nil { return nil, goahttp.ErrDecodingError("Calc", "log", err) } + if err := jresp.Validate(requestID); err != nil { + return nil, goahttp.ErrInvalidResponse("Calc", "log", resp.StatusCode, err.Error()) + } if jresp.Error != nil { - switch jresp.Error.Code { - default: - body, _ := io.ReadAll(resp.Body) - return nil, goahttp.ErrInvalidResponse("Calc", "log", resp.StatusCode, string(body)) - } + return nil, jresp.Error } resp.Body = io.NopCloser(bytes.NewBuffer(jresp.Result)) return nil, nil diff --git a/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/calc/client/types.go.golden b/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/calc/client/types.go.golden index 3bdfe020a8..91288ee6d7 100644 --- a/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/calc/client/types.go.golden +++ b/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/calc/client/types.go.golden @@ -8,34 +8,27 @@ package client import ( - calc "kitchensink/calc" - + calc "generated.local/gen/calc" goa "goa.design/goa/v3/pkg" ) // AddRequestBody is the type of the "Calc" service "add" endpoint HTTP request // body. type AddRequestBody struct { - // Request ID - ID string `form:"id,omitempty" json:"id,omitempty" xml:"id,omitempty"` - A int `form:"a" json:"a" xml:"a"` - B int `form:"b" json:"b" xml:"b"` + A int `form:"a" json:"a" xml:"a"` + B int `form:"b" json:"b" xml:"b"` } // LogRequestBody is the type of the "Calc" service "log" endpoint HTTP request // body. type LogRequestBody struct { - // Optional request ID - ID *string `form:"id,omitempty" json:"id,omitempty" xml:"id,omitempty"` - Message string `form:"message" json:"message" xml:"message"` + Message string `form:"message" json:"message" xml:"message"` } // AddResponseBody is the type of the "Calc" service "add" endpoint HTTP // response body. type AddResponseBody struct { - // Request ID - ID *string `form:"id,omitempty" json:"id,omitempty" xml:"id,omitempty"` - Sum *int `form:"sum,omitempty" json:"sum,omitempty" xml:"sum,omitempty"` + Sum *int `form:"sum,omitempty" json:"sum,omitempty" xml:"sum,omitempty"` } // PingResponseBody is the type of the "Calc" service "ping" endpoint HTTP @@ -66,9 +59,8 @@ type AddOverflowResponseBody struct { // endpoint of the "Calc" service. func NewAddRequestBody(p *calc.AddPayload) *AddRequestBody { body := &AddRequestBody{ - ID: p.ID, - A: p.A, - B: p.B, + A: p.A, + B: p.B, } return body } @@ -77,7 +69,6 @@ func NewAddRequestBody(p *calc.AddPayload) *AddRequestBody { // endpoint of the "Calc" service. func NewLogRequestBody(p *calc.LogPayload) *LogRequestBody { body := &LogRequestBody{ - ID: p.ID, Message: p.Message, } return body @@ -87,7 +78,6 @@ func NewLogRequestBody(p *calc.LogPayload) *LogRequestBody { // "OK" response. func NewAddResultOK(body *AddResponseBody) *calc.AddResult { v := &calc.AddResult{ - ID: *body.ID, Sum: *body.Sum, } @@ -120,9 +110,6 @@ func NewPingResultOK(body *PingResponseBody) *calc.PingResult { // ValidateAddResponseBody runs the validations defined on AddResponseBody func ValidateAddResponseBody(body *AddResponseBody) (err error) { - if body.ID == nil { - err = goa.MergeErrors(err, goa.MissingFieldError("id", "body")) - } if body.Sum == nil { err = goa.MergeErrors(err, goa.MissingFieldError("sum", "body")) } @@ -130,7 +117,7 @@ func ValidateAddResponseBody(body *AddResponseBody) (err error) { } // ValidateAddOverflowResponseBody runs the validations defined on -// add_overflow_response_body +// AddOverflowResponseBody func ValidateAddOverflowResponseBody(body *AddOverflowResponseBody) (err error) { if body.Name == nil { err = goa.MergeErrors(err, goa.MissingFieldError("name", "body")) diff --git a/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/calc/server/encode_decode.go.golden b/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/calc/server/encode_decode.go.golden index 11c6870d95..918a89a449 100644 --- a/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/calc/server/encode_decode.go.golden +++ b/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/calc/server/encode_decode.go.golden @@ -11,9 +11,9 @@ import ( "bytes" "errors" "io" - calc "kitchensink/calc" "net/http" + calc "generated.local/gen/calc" goahttp "goa.design/goa/v3/http" "goa.design/goa/v3/jsonrpc" goa "goa.design/goa/v3/pkg" @@ -25,6 +25,15 @@ func DecodeAddRequest(mux goahttp.Muxer, decoder func(*http.Request) goahttp.Dec return func(r *http.Request, req *jsonrpc.RawRequest) (*calc.AddPayload, error) { r.Body = io.NopCloser(bytes.NewReader(req.Params)) var payload *calc.AddPayload + if !req.HasID || req.ID == nil { + return payload, goa.MissingFieldError("id", "JSON-RPC request") + } + var requestID string + decodedID, decodeErr := jsonrpc.IDToString(req.ID) + if decodeErr != nil { + return payload, goa.DecodePayloadError(decodeErr.Error()) + } + requestID = decodedID var ( body AddRequestBody err error @@ -44,7 +53,7 @@ func DecodeAddRequest(mux goahttp.Muxer, decoder func(*http.Request) goahttp.Dec if err != nil { return payload, err } - payload = NewAddPayload(&body) + payload = NewAddPayload(&body, requestID) return payload, nil } @@ -56,6 +65,15 @@ func DecodeLogRequest(mux goahttp.Muxer, decoder func(*http.Request) goahttp.Dec return func(r *http.Request, req *jsonrpc.RawRequest) (*calc.LogPayload, error) { r.Body = io.NopCloser(bytes.NewReader(req.Params)) var payload *calc.LogPayload + var requestID *string + if req.ID != nil { + decodedID, decodeErr := jsonrpc.IDToString(req.ID) + if decodeErr != nil { + return payload, goa.DecodePayloadError(decodeErr.Error()) + } + value := decodedID + requestID = &value + } var ( body LogRequestBody err error @@ -75,7 +93,7 @@ func DecodeLogRequest(mux goahttp.Muxer, decoder func(*http.Request) goahttp.Dec if err != nil { return payload, err } - payload = NewLogPayload(&body) + payload = NewLogPayload(&body, requestID) return payload, nil } diff --git a/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/calc/server/server.go.golden b/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/calc/server/server.go.golden index 72f785418f..e004f68844 100644 --- a/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/calc/server/server.go.golden +++ b/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/calc/server/server.go.golden @@ -13,14 +13,40 @@ import ( "errors" "fmt" "io" - calc "kitchensink/calc" "net/http" + calc "generated.local/gen/calc" goahttp "goa.design/goa/v3/http" "goa.design/goa/v3/jsonrpc" goa "goa.design/goa/v3/pkg" ) +type ( + // noOutputResponseWriter accepts notification output without storing or + // sending it. + noOutputResponseWriter struct { + header http.Header + } +) + +// Header returns the private headers written while a notification is encoded. +func (w *noOutputResponseWriter) Header() http.Header { + return w.header +} + +// Write accepts notification bytes without storing or sending them. +func (w *noOutputResponseWriter) Write(data []byte) (int, error) { + return len(data), nil +} + +// WriteHeader accepts a notification status without sending it. +func (w *noOutputResponseWriter) WriteHeader(int) { +} + +// Flush completes successfully because notification bytes are not sent. +func (w *noOutputResponseWriter) Flush() { +} + // Server handles JSON-RPC requests for the Calc service. type Server struct { http.Handler @@ -61,8 +87,8 @@ func New( encoder: encoder, errhandler: errhandler, } - // Default HTTP handler per transport kind - // Plain HTTP JSON-RPC + // Install the request handler required by this service's methods. + // ServeHTTP handles ordinary JSON-RPC request bodies. s.Handler = http.HandlerFunc(s.ServeHTTP) return s } @@ -70,6 +96,13 @@ func New( // Service returns the name of the service served. func (s *Server) Service() string { return "Calc" } +// reportRejectedNotification reports a notification sent to a request-only +// method without allowing the error handler to write a response. +func (s *Server) reportRejectedNotification(ctx context.Context, req *jsonrpc.RawRequest) { + outputWriter := &noOutputResponseWriter{header: make(http.Header)} + s.errhandler(ctx, outputWriter, fmt.Errorf("JSON-RPC notification cannot call request method %q", req.Method)) +} + // Use wraps the server handlers with the given middleware. func (s *Server) Use(m func(http.Handler) http.Handler) { s.Handler = m(s.Handler) @@ -81,32 +114,44 @@ func (s *Server) MethodNames() []string { return calc.MethodNames[:] } // ServeHTTP handles JSON-RPC requests. func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { s.handleHTTP(w, r) -} // handleHTTP handles JSON-RPC requests. +} + +// handleHTTP reads one JSON-RPC request object or one array of requests. func (s *Server) handleHTTP(w http.ResponseWriter, r *http.Request) { - // Peek at the first byte to determine request type - bufReader := bufio.NewReader(r.Body) - peek, err := bufReader.Peek(1) - if err != nil && err != io.EOF { - r.Body.Close() - s.errhandler(r.Context(), w, fmt.Errorf("failed to read request body: %w", err)) - return + originalBody := r.Body + + // Find the first JSON byte so leading whitespace does not change whether the + // body is decoded as one request or an array. + bufReader := bufio.NewReader(originalBody) + var peek []byte + for { + var err error + peek, err = bufReader.Peek(1) + if err != nil && err != io.EOF { + closeErr := originalBody.Close() + s.errhandler(r.Context(), w, fmt.Errorf("failed to read request body: %w", errors.Join(err, closeErr))) + return + } + if len(peek) == 0 || (peek[0] != ' ' && peek[0] != '\t' && peek[0] != '\n' && peek[0] != '\r') { + break + } + if _, err := bufReader.Discard(1); err != nil { + closeErr := originalBody.Close() + s.errhandler(r.Context(), w, fmt.Errorf("failed to read request body: %w", errors.Join(err, closeErr))) + return + } } - // Wrap the buffered reader with the original closer - r.Body = struct { - io.Reader - io.Closer - }{ - Reader: bufReader, - Closer: r.Body, - } - defer func(r *http.Request) { - if err := r.Body.Close(); err != nil { + // The generated handler owns the original body. Decoders receive a wrapper + // whose Close method cannot close it a second time. + r.Body = io.NopCloser(bufReader) + defer func() { + if err := originalBody.Close(); err != nil { s.errhandler(r.Context(), w, fmt.Errorf("failed to close request body: %w", err)) } - }(r) + }() - // Route to appropriate handler + // A leading '[' starts an array of requests. if len(peek) > 0 && peek[0] == '[' { s.handleBatch(w, r) return @@ -114,11 +159,11 @@ func (s *Server) handleHTTP(w http.ResponseWriter, r *http.Request) { s.handleSingle(w, r) } -// handleSingle handles a single JSON-RPC request. +// handleSingle decodes and runs one JSON-RPC request. func (s *Server) handleSingle(w http.ResponseWriter, r *http.Request) { var req jsonrpc.RawRequest if err := s.decoder(r).Decode(&req); err != nil { - // JSON-RPC parse error with null id and generic message + // A request that cannot be decoded receives the JSON-RPC parse error. response := jsonrpc.MakeErrorResponse(nil, jsonrpc.ParseError, "Parse error", nil) if encErr := s.encoder(r.Context(), w).Encode(response); encErr != nil { s.errhandler(r.Context(), w, fmt.Errorf("failed to encode parse error response: %w", encErr)) @@ -128,64 +173,101 @@ func (s *Server) handleSingle(w http.ResponseWriter, r *http.Request) { s.processRequest(r.Context(), r, &req, w) } -// handleBatch handles a batch of JSON-RPC requests. +// handleBatch handles an array of JSON-RPC values and writes the required responses. func (s *Server) handleBatch(w http.ResponseWriter, r *http.Request) { var reqs []jsonrpc.RawRequest if err := s.decoder(r).Decode(&reqs); err != nil { - // JSON-RPC parse error for batch with null id and generic message + // An array that cannot be decoded receives the JSON-RPC parse error. response := jsonrpc.MakeErrorResponse(nil, jsonrpc.ParseError, "Parse error", nil) if encErr := s.encoder(r.Context(), w).Encode(response); encErr != nil { s.errhandler(r.Context(), w, fmt.Errorf("failed to encode parse error response: %w", encErr)) } return } + if len(reqs) == 0 { + // JSON-RPC defines an empty request array as one invalid request. + response := jsonrpc.MakeErrorResponse(nil, jsonrpc.InvalidRequest, "Invalid request", nil) + if err := s.encoder(r.Context(), w).Encode(response); err != nil { + s.errhandler(r.Context(), w, fmt.Errorf("failed to encode invalid request response: %w", err)) + } + return + } - // Write responses + // Write every response into one JSON array. w.Header().Set("Content-Type", "application/json") writer := &batchWriter{Writer: w} for _, req := range reqs { - // Process the request with batch writer + // The writer inserts the array separators around each response. s.processRequest(r.Context(), r, &req, writer) } - // Close the batch array + // Write the closing bracket only when at least one request produced a response. if writer.written { - writer.Writer.Write([]byte{']'}) + if _, err := writer.Writer.Write([]byte{']'}); err != nil { + s.errhandler(r.Context(), w, fmt.Errorf("failed to close JSON-RPC batch response: %w", err)) + } } } -// ProcessRequest processes a single JSON-RPC request. +// processRequest validates the JSON-RPC version and method, then calls the matching handler. func (s *Server) processRequest(ctx context.Context, r *http.Request, req *jsonrpc.RawRequest, w http.ResponseWriter) { - if req.JSONRPC != "2.0" { + if req.Invalid || req.JSONRPC != "2.0" { s.encodeJSONRPCError(ctx, w, req, jsonrpc.InvalidRequest, "Invalid request", nil) return } - if req.Method == "" { + if !req.HasMethod { s.encodeJSONRPCError(ctx, w, req, jsonrpc.InvalidRequest, "Missing method field", nil) return } switch req.Method { case "add": + if !req.HasID { + s.reportRejectedNotification(ctx, req) + return + } + if req.ID == nil { + s.encodeJSONRPCError(ctx, w, req, jsonrpc.InvalidRequest, "Invalid request", nil) + return + } if err := s.Add(ctx, r, req, w); err != nil { s.errhandler(ctx, w, fmt.Errorf("handler error for %s: %w", "add", err)) } case "ping": + if !req.HasID { + s.reportRejectedNotification(ctx, req) + return + } + if req.ID == nil { + s.encodeJSONRPCError(ctx, w, req, jsonrpc.InvalidRequest, "Invalid request", nil) + return + } if err := s.Ping(ctx, r, req, w); err != nil { s.errhandler(ctx, w, fmt.Errorf("handler error for %s: %w", "ping", err)) } case "log": + if !req.HasID { + s.reportRejectedNotification(ctx, req) + return + } + if req.ID == nil { + s.encodeJSONRPCError(ctx, w, req, jsonrpc.InvalidRequest, "Invalid request", nil) + return + } if err := s.Log(ctx, r, req, w); err != nil { s.errhandler(ctx, w, fmt.Errorf("handler error for %s: %w", "log", err)) } default: - s.encodeJSONRPCError(ctx, w, req, jsonrpc.MethodNotFound, "Method not found", nil) + if req.HasID { + s.encodeJSONRPCError(ctx, w, req, jsonrpc.MethodNotFound, "Method not found", nil) + } } } -// batchWriter is a helper type that implements http.ResponseWriter for writing multiple JSON-RPC responses +// batchWriter inserts JSON array separators around responses from one request +// array. type batchWriter struct { io.Writer header http.Header @@ -208,18 +290,20 @@ func (rb *batchWriter) WriteHeader(statusCode int) { } func (rb *batchWriter) Write(data []byte) (int, error) { + separator := byte(',') if !rb.written { - rb.written = true - rb.Writer.Write([]byte{'['}) - } else { - rb.Writer.Write([]byte{','}) + separator = '[' + } + if _, err := rb.Writer.Write([]byte{separator}); err != nil { + return 0, err } + rb.written = true return rb.Writer.Write(data) } // Mount configures the mux to serve the JSON-RPC Calc service methods. func Mount(mux goahttp.Muxer, h *Server) { - // HTTP only + // This server handles ordinary JSON-RPC request bodies. mux.Handle("POST", "/rpc", h.ServeHTTP) } @@ -243,73 +327,39 @@ func NewAddHandler( ctx = context.WithValue(ctx, goa.ServiceKey, "Calc") params, err := decodeParams(r, req) if err != nil { - // Only send error response if request has ID (not nil or empty string) - if req.ID != nil && req.ID != "" { - code := jsonrpc.InternalError - if _, ok := err.(*goa.ServiceError); ok { - code = jsonrpc.InvalidParams - } - encodeJSONRPCError(ctx, w, req, code, err.Error(), nil, encoder, errhandler) - } else { - // No ID means notification - just log error - errhandler(ctx, w, fmt.Errorf("failed to decode parameters: %w", err)) - } + encodeJSONRPCError(ctx, w, req, jsonrpc.InvalidParams, err.Error(), nil, encoder, errhandler) return nil } - if req.ID != nil { - params.ID = jsonrpc.IDToString(req.ID) - } res, err := endpoint(ctx, params) if err != nil { - // Only send error response if request has ID (not nil or empty string) - if req.ID != nil && req.ID != "" { - var en goa.GoaErrorNamer - if !errors.As(err, &en) { - encodeJSONRPCError(ctx, w, req, jsonrpc.InternalError, err.Error(), nil, encoder, errhandler) - return nil - } + var en goa.GoaErrorNamer + if errors.As(err, &en) { switch en.GoaErrorName() { case "overflow": - encodeJSONRPCError(ctx, w, req, -32602, err.Error(), err, encoder, errhandler) - case "invalid_params": - encodeJSONRPCError(ctx, w, req, jsonrpc.InvalidParams, err.Error(), nil, encoder, errhandler) - case "method_not_found": - encodeJSONRPCError(ctx, w, req, jsonrpc.MethodNotFound, err.Error(), nil, encoder, errhandler) - default: - code := jsonrpc.InternalError - if _, ok := err.(*goa.ServiceError); ok { - code = jsonrpc.InvalidParams + var res *goa.ServiceError + if !errors.As(err, &res) { + panic("JSON-RPC error name does not match its generated service error type") + } + body := NewAddOverflowResponseBody(res) + data := struct { + Name string `json:"name"` + Body *AddOverflowResponseBody `json:"body"` + }{ + Name: "overflow", + Body: body, } - encodeJSONRPCError(ctx, w, req, code, err.Error(), nil, encoder, errhandler) + encodeJSONRPCError(ctx, w, req, -32602, err.Error(), data, encoder, errhandler) + return nil } - } else { - // No ID means notification - just log error - errhandler(ctx, w, fmt.Errorf("endpoint error: %w", err)) } + encodeJSONRPCError(ctx, w, req, jsonrpc.InternalError, err.Error(), nil, encoder, errhandler) return nil } - // For methods with no result, check if this is a notification - - // For methods with results, determine the ID to use for the response - var id any - // Result has an ID field - use it if set, otherwise fall back to request ID - actual := res.(*calc.AddResult) - if actual.ID != "" { - id = actual.ID - } else { - id = req.ID - } - - if id == nil || id == "" { - // Notification - no response - return nil - } - - // Send response with the result - // Convert result to response body with proper JSON tags + // The response repeats the exact request ID. + // Build the response body with the fields and JSON names declared by the service. body := NewAddResponseBody(res.(*calc.AddResult)) - response := jsonrpc.MakeSuccessResponse(id, body) + response := jsonrpc.MakeSuccessResponse(req.ID, body) if err := encoder(ctx, w).Encode(response); err != nil { errhandler(ctx, w, fmt.Errorf("failed to encode JSON-RPC response: %w", err)) } @@ -331,48 +381,14 @@ func NewPingHandler( ctx = context.WithValue(ctx, goa.ServiceKey, "Calc") res, err := endpoint(ctx, nil) if err != nil { - // Only send error response if request has ID (not nil or empty string) - if req.ID != nil && req.ID != "" { - var en goa.GoaErrorNamer - if !errors.As(err, &en) { - encodeJSONRPCError(ctx, w, req, jsonrpc.InternalError, err.Error(), nil, encoder, errhandler) - return nil - } - switch en.GoaErrorName() { - case "invalid_params": - encodeJSONRPCError(ctx, w, req, jsonrpc.InvalidParams, err.Error(), nil, encoder, errhandler) - case "method_not_found": - encodeJSONRPCError(ctx, w, req, jsonrpc.MethodNotFound, err.Error(), nil, encoder, errhandler) - default: - code := jsonrpc.InternalError - if _, ok := err.(*goa.ServiceError); ok { - code = jsonrpc.InvalidParams - } - encodeJSONRPCError(ctx, w, req, code, err.Error(), nil, encoder, errhandler) - } - } else { - // No ID means notification - just log error - errhandler(ctx, w, fmt.Errorf("endpoint error: %w", err)) - } + encodeJSONRPCError(ctx, w, req, jsonrpc.InternalError, err.Error(), nil, encoder, errhandler) return nil } - // For methods with no result, check if this is a notification - - // For methods with results, determine the ID to use for the response - var id any - // No ID field in result - use request ID - id = req.ID - - if id == nil || id == "" { - // Notification - no response - return nil - } - - // Send response with the result - // Convert result to response body with proper JSON tags + // The response repeats the exact request ID. + // Build the response body with the fields and JSON names declared by the service. body := NewPingResponseBody(res.(*calc.PingResult)) - response := jsonrpc.MakeSuccessResponse(id, body) + response := jsonrpc.MakeSuccessResponse(req.ID, body) if err := encoder(ctx, w).Encode(response); err != nil { errhandler(ctx, w, fmt.Errorf("failed to encode JSON-RPC response: %w", err)) } @@ -395,57 +411,15 @@ func NewLogHandler( ctx = context.WithValue(ctx, goa.ServiceKey, "Calc") params, err := decodeParams(r, req) if err != nil { - // Only send error response if request has ID (not nil or empty string) - if req.ID != nil && req.ID != "" { - code := jsonrpc.InternalError - if _, ok := err.(*goa.ServiceError); ok { - code = jsonrpc.InvalidParams - } - encodeJSONRPCError(ctx, w, req, code, err.Error(), nil, encoder, errhandler) - } else { - // No ID means notification - just log error - errhandler(ctx, w, fmt.Errorf("failed to decode parameters: %w", err)) - } + encodeJSONRPCError(ctx, w, req, jsonrpc.InvalidParams, err.Error(), nil, encoder, errhandler) return nil } - if req.ID != nil { - idStr := jsonrpc.IDToString(req.ID) - params.ID = &idStr - } _, err = endpoint(ctx, params) if err != nil { - // Only send error response if request has ID (not nil or empty string) - if req.ID != nil && req.ID != "" { - var en goa.GoaErrorNamer - if !errors.As(err, &en) { - encodeJSONRPCError(ctx, w, req, jsonrpc.InternalError, err.Error(), nil, encoder, errhandler) - return nil - } - switch en.GoaErrorName() { - case "invalid_params": - encodeJSONRPCError(ctx, w, req, jsonrpc.InvalidParams, err.Error(), nil, encoder, errhandler) - case "method_not_found": - encodeJSONRPCError(ctx, w, req, jsonrpc.MethodNotFound, err.Error(), nil, encoder, errhandler) - default: - code := jsonrpc.InternalError - if _, ok := err.(*goa.ServiceError); ok { - code = jsonrpc.InvalidParams - } - encodeJSONRPCError(ctx, w, req, code, err.Error(), nil, encoder, errhandler) - } - } else { - // No ID means notification - just log error - errhandler(ctx, w, fmt.Errorf("endpoint error: %w", err)) - } - return nil - } - - // For methods with no result, check if this is a notification - if req.ID == nil || req.ID == "" { - // Notification - no response + encodeJSONRPCError(ctx, w, req, jsonrpc.InternalError, err.Error(), nil, encoder, errhandler) return nil } - // Request with no result - send empty success response + // A method with no result returns a JSON null result. response := jsonrpc.MakeSuccessResponse(req.ID, nil) if err := encoder(ctx, w).Encode(response); err != nil { errhandler(ctx, w, fmt.Errorf("failed to encode JSON-RPC response: %w", err)) @@ -454,14 +428,14 @@ func NewLogHandler( } } -// encodeJSONRPCError creates and sends a JSON-RPC error response (handles nil -// ID gracefully) +// encodeJSONRPCError writes one error, copying the request ID or using null +// when none is available. func (s *Server) encodeJSONRPCError(ctx context.Context, w http.ResponseWriter, req *jsonrpc.RawRequest, code jsonrpc.Code, message string, data any) { encodeJSONRPCError(ctx, w, req, code, message, data, s.encoder, s.errhandler) } -// encodeJSONRPCError creates and sends a JSON-RPC error response (handles nil -// ID gracefully) +// encodeJSONRPCError writes one error, copying the request ID or using null +// when none is available. func encodeJSONRPCError( ctx context.Context, w http.ResponseWriter, @@ -472,10 +446,8 @@ func encodeJSONRPCError( encoder func(context.Context, http.ResponseWriter) goahttp.Encoder, errhandler func(context.Context, http.ResponseWriter, error), ) { - if req.ID != nil { - response := jsonrpc.MakeErrorResponse(req.ID, code, message, data) - if err := encoder(ctx, w).Encode(response); err != nil { - errhandler(ctx, w, fmt.Errorf("failed to encode JSON-RPC response: %w", err)) - } + response := jsonrpc.MakeErrorResponse(req.ID, code, message, data) + if err := encoder(ctx, w).Encode(response); err != nil { + errhandler(ctx, w, fmt.Errorf("failed to encode JSON-RPC response: %w", err)) } } diff --git a/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/calc/server/types.go.golden b/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/calc/server/types.go.golden index 2373a233dc..896a737609 100644 --- a/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/calc/server/types.go.golden +++ b/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/calc/server/types.go.golden @@ -8,34 +8,27 @@ package server import ( - calc "kitchensink/calc" - + calc "generated.local/gen/calc" goa "goa.design/goa/v3/pkg" ) // AddRequestBody is the type of the "Calc" service "add" endpoint HTTP request // body. type AddRequestBody struct { - // Request ID - ID *string `form:"id,omitempty" json:"id,omitempty" xml:"id,omitempty"` - A *int `form:"a,omitempty" json:"a,omitempty" xml:"a,omitempty"` - B *int `form:"b,omitempty" json:"b,omitempty" xml:"b,omitempty"` + A *int `form:"a,omitempty" json:"a,omitempty" xml:"a,omitempty"` + B *int `form:"b,omitempty" json:"b,omitempty" xml:"b,omitempty"` } // LogRequestBody is the type of the "Calc" service "log" endpoint HTTP request // body. type LogRequestBody struct { - // Optional request ID - ID *string `form:"id,omitempty" json:"id,omitempty" xml:"id,omitempty"` Message *string `form:"message,omitempty" json:"message,omitempty" xml:"message,omitempty"` } // AddResponseBody is the type of the "Calc" service "add" endpoint HTTP // response body. type AddResponseBody struct { - // Request ID - ID string `form:"id,omitempty" json:"id,omitempty" xml:"id,omitempty"` - Sum int `form:"sum" json:"sum" xml:"sum"` + Sum int `form:"sum" json:"sum" xml:"sum"` } // PingResponseBody is the type of the "Calc" service "ping" endpoint HTTP @@ -66,7 +59,6 @@ type AddOverflowResponseBody struct { // "add" endpoint of the "Calc" service. func NewAddResponseBody(res *calc.AddResult) *AddResponseBody { body := &AddResponseBody{ - ID: res.ID, Sum: res.Sum, } return body @@ -96,31 +88,28 @@ func NewAddOverflowResponseBody(res *goa.ServiceError) *AddOverflowResponseBody } // NewAddPayload builds a Calc service add endpoint payload. -func NewAddPayload(body *AddRequestBody) *calc.AddPayload { +func NewAddPayload(body *AddRequestBody, requestID string) *calc.AddPayload { v := &calc.AddPayload{ - ID: *body.ID, - A: *body.A, - B: *body.B, + A: *body.A, + B: *body.B, } + v.ID = requestID return v } // NewLogPayload builds a Calc service log endpoint payload. -func NewLogPayload(body *LogRequestBody) *calc.LogPayload { +func NewLogPayload(body *LogRequestBody, requestID *string) *calc.LogPayload { v := &calc.LogPayload{ - ID: body.ID, Message: *body.Message, } + v.ID = requestID return v } // ValidateAddRequestBody runs the validations defined on AddRequestBody func ValidateAddRequestBody(body *AddRequestBody) (err error) { - if body.ID == nil { - err = goa.MergeErrors(err, goa.MissingFieldError("id", "body")) - } if body.A == nil { err = goa.MergeErrors(err, goa.MissingFieldError("a", "body")) } diff --git a/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/chat/client/cli.go.golden b/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/chat/client/cli.go.golden deleted file mode 100644 index f2dcb9b86c..0000000000 --- a/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/chat/client/cli.go.golden +++ /dev/null @@ -1,33 +0,0 @@ -// Code generated by goa, DO NOT EDIT. -// -// Chat JSON-RPC client CLI support package -// -// Command: -// goa - -package client - -import ( - "encoding/json" - "fmt" - chat "kitchensink/chat" -) - -// BuildEchoPayload builds the payload for the Chat echo endpoint from CLI -// flags. -func BuildEchoPayload(chatEchoBody string) (*chat.EchoPayload, error) { - var err error - var body EchoStreamingBody - { - err = json.Unmarshal([]byte(chatEchoBody), &body) - if err != nil { - return nil, fmt.Errorf("invalid JSON for body, \nerror: %s, \nexample of valid JSON:\n%s", err, "'{\n \"id\": \"Nemo consequuntur est odio.\",\n \"msg\": \"Accusantium mollitia id sapiente ratione.\"\n }'") - } - } - v := &chat.EchoPayload{ - ID: body.ID, - Msg: body.Msg, - } - - return v, nil -} diff --git a/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/chat/client/client.go.golden b/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/chat/client/client.go.golden deleted file mode 100644 index b0c1f21da4..0000000000 --- a/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/chat/client/client.go.golden +++ /dev/null @@ -1,178 +0,0 @@ -// Code generated by goa, DO NOT EDIT. -// -// Chat client JSON-RPC transport -// -// Command: -// goa - -package client - -import ( - "context" - "net/http" - "sync" - "sync/atomic" - "time" - - "github.com/gorilla/websocket" - goahttp "goa.design/goa/v3/http" - "goa.design/goa/v3/jsonrpc" - goa "goa.design/goa/v3/pkg" -) - -// Client lists the Chat service endpoint HTTP clients. -type Client struct { - // Doer is the HTTP client used to make requests to the Chat service. - Doer goahttp.Doer - // RestoreResponseBody controls whether the response bodies are reset after - // decoding so they can be read again. - RestoreResponseBody bool - - scheme string - host string - encoder func(*http.Request) goahttp.Encoder - decoder func(*http.Response) goahttp.Decoder - dialer goahttp.Dialer - configfn goahttp.ConnConfigureFunc - - connMu sync.RWMutex - conn *websocket.Conn - closed atomic.Bool - - // Stream configuration (shared by all WebSocket streams) - streamConfig *jsonrpc.StreamConfig -} - -// NewClient instantiates HTTP clients for all the Chat service servers. -func NewClient( - scheme string, - host string, - doer goahttp.Doer, - enc func(*http.Request) goahttp.Encoder, - dec func(*http.Response) goahttp.Decoder, - restoreBody bool, - dialer goahttp.Dialer, - cfn goahttp.ConnConfigureFunc, - streamOpts ...jsonrpc.StreamConfigOption, -) *Client { - // Create stream configuration from options - streamConfig := jsonrpc.NewStreamConfig(streamOpts...) - - return &Client{ - Doer: doer, - RestoreResponseBody: restoreBody, - scheme: scheme, - host: host, - decoder: dec, - encoder: enc, - dialer: dialer, - configfn: cfn, - streamConfig: streamConfig, - } -} - -// Echo returns an endpoint that makes JSON-RPC requests to the Chat service -// echo method. -func (c *Client) Echo() goa.Endpoint { - return func(ctx context.Context, v any) (any, error) { - // For WebSocket, pass the base decoder to the stream and decode inner results - decodeResponse := c.decoder - - // Get direct WebSocket connection - ws, err := c.getConn(ctx) - if err != nil { - return nil, err - } - - // Create context with cancellation for the stream - streamCtx, cancel := context.WithCancel(ctx) - - // Create the stream with direct WebSocket handling - stream := &EchoClientStream{ - ws: ws, - ctx: streamCtx, - cancel: cancel, - done: make(chan struct{}), - config: c.streamConfig, - decoder: decodeResponse, - } - - // Start background response handler - go stream.responseHandler() - - return stream, nil - } -} - -// getConn returns the current WebSocket connection or creates a new one -func (c *Client) getConn(ctx context.Context) (*websocket.Conn, error) { - c.connMu.RLock() - conn := c.conn - if conn != nil { - // Check if connection is still alive - if err := conn.WriteControl(websocket.PingMessage, []byte{}, time.Now().Add(5*time.Second)); err == nil { - c.connMu.RUnlock() - return conn, nil - } - // Connection is dead, need new one - } - c.connMu.RUnlock() - - // Create new connection - c.connMu.Lock() - defer c.connMu.Unlock() - - // Double-check after acquiring write lock - if c.conn != nil { - if err := c.conn.WriteControl(websocket.PingMessage, []byte{}, time.Now().Add(5*time.Second)); err == nil { - return c.conn, nil - } - // Close the dead connection - c.conn.Close() - } - - // Convert scheme for WebSocket - wsScheme := "ws" - if c.scheme == "https" { - wsScheme = "wss" - } - - // Find the WebSocket path from the service endpoints - url := wsScheme + "://" + c.host + "/ws/ws" - - ws, _, err := c.dialer.DialContext(ctx, url, nil) - if err != nil { - return nil, goahttp.ErrRequestError("Chat", "connect", err) - } - - if c.configfn != nil { - ws = c.configfn(ws, nil) - } - - // Store the direct WebSocket connection - c.conn = ws - - return c.conn, nil -} - -// Close closes the WebSocket connection and marks the client as closed -func (c *Client) Close() error { - if c.closed.Swap(true) { - return nil // Already closed - } - - c.connMu.Lock() - defer c.connMu.Unlock() - - if c.conn != nil { - err := c.conn.Close() - c.conn = nil - return err - } - return nil -} - -// IsClosed returns true if the client connection has been closed -func (c *Client) IsClosed() bool { - return c.closed.Load() -} diff --git a/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/chat/client/encode_decode.go.golden b/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/chat/client/encode_decode.go.golden deleted file mode 100644 index 7df8db7d95..0000000000 --- a/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/chat/client/encode_decode.go.golden +++ /dev/null @@ -1,115 +0,0 @@ -// Code generated by goa, DO NOT EDIT. -// -// Chat JSON-RPC client encoders and decoders -// -// Command: -// goa - -package client - -import ( - "bytes" - "context" - "io" - chat "kitchensink/chat" - "net/http" - "net/url" - - goahttp "goa.design/goa/v3/http" - "goa.design/goa/v3/jsonrpc" -) - -// BuildEchoRequest instantiates a HTTP request object with method and path set -// to call the "Chat" service "echo" endpoint -func (c *Client) BuildEchoRequest(ctx context.Context, v any) (*http.Request, error) { - scheme := c.scheme - switch c.scheme { - case "http": - scheme = "ws" - case "https": - scheme = "wss" - } - u := &url.URL{Scheme: scheme, Host: c.host, Path: EchoChatPath()} - req, err := http.NewRequest("GET", u.String(), nil) - if err != nil { - return nil, goahttp.ErrInvalidURL("Chat", "echo", u.String(), err) - } - if ctx != nil { - req = req.WithContext(ctx) - } - - return req, nil -} - -// EncodeEchoRequest returns an encoder for requests sent to the Chat echo -// server. -func EncodeEchoRequest(encoder func(*http.Request) goahttp.Encoder) func(*http.Request, any) error { - return func(req *http.Request, v any) error { - p, ok := v.(*chat.EchoPayload) - if !ok { - return goahttp.ErrInvalidType("Chat", "echo", "*chat.EchoPayload", v) - } - b := NewEchoStreamingBody(p) - body := &jsonrpc.Request{ - JSONRPC: "2.0", - Method: "echo", - Params: b, - } - if p.ID != nil && *p.ID != "" { - body.ID = p.ID - } - // If ID is nil or empty, this is a notification - no ID field - if err := encoder(req).Encode(&body); err != nil { - return goahttp.ErrEncodingError("Chat", "echo", err) - } - return nil - } -} - -// DecodeEchoResponse returns a decoder for responses returned by the Chat -// service echo JSON-RPC method. restoreBody controls whether the response body -// should be restored after having been read. -func DecodeEchoResponse(decoder func(*http.Response) goahttp.Decoder, restoreBody bool) func(*http.Response) (any, error) { - return func(resp *http.Response) (any, error) { - if restoreBody { - b, err := io.ReadAll(resp.Body) - if err != nil { - return nil, err - } - resp.Body = io.NopCloser(bytes.NewBuffer(b)) - defer func() { - resp.Body = io.NopCloser(bytes.NewBuffer(b)) - }() - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - body, _ := io.ReadAll(resp.Body) - return nil, goahttp.ErrInvalidResponse("Chat", "echo", resp.StatusCode, string(body)) - } - - var jresp jsonrpc.RawResponse - if err := decoder(resp).Decode(&jresp); err != nil { - return nil, goahttp.ErrDecodingError("Chat", "echo", err) - } - - if jresp.Error != nil { - switch jresp.Error.Code { - default: - body, _ := io.ReadAll(resp.Body) - return nil, goahttp.ErrInvalidResponse("Chat", "echo", resp.StatusCode, string(body)) - } - } - resp.Body = io.NopCloser(bytes.NewBuffer(jresp.Result)) - var ( - body EchoResponseBody - err error - ) - err = decoder(resp).Decode(&body) - if err != nil { - return nil, goahttp.ErrDecodingError("Chat", "echo", err) - } - res := NewEchoResultOK(&body) - return res, nil - } -} diff --git a/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/chat/client/paths.go.golden b/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/chat/client/paths.go.golden deleted file mode 100644 index 8122b4ec17..0000000000 --- a/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/chat/client/paths.go.golden +++ /dev/null @@ -1,13 +0,0 @@ -// Code generated by goa, DO NOT EDIT. -// -// JSON-RPC request path constructors for the Chat service. -// -// Command: -// goa - -package client - -// EchoChatPath returns the URL path to the Chat service echo HTTP endpoint. -func EchoChatPath() string { - return "/ws/ws" -} diff --git a/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/chat/client/types.go.golden b/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/chat/client/types.go.golden deleted file mode 100644 index 73b4ab7475..0000000000 --- a/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/chat/client/types.go.golden +++ /dev/null @@ -1,49 +0,0 @@ -// Code generated by goa, DO NOT EDIT. -// -// Chat JSON-RPC client types -// -// Command: -// goa - -package client - -import ( - chat "kitchensink/chat" -) - -// EchoStreamingBody is the type of the "Chat" service "echo" endpoint HTTP -// request body. -type EchoStreamingBody struct { - // Request ID - ID *string `form:"id,omitempty" json:"id,omitempty" xml:"id,omitempty"` - Msg *string `form:"msg,omitempty" json:"msg,omitempty" xml:"msg,omitempty"` -} - -// EchoResponseBody is the type of the "Chat" service "echo" endpoint HTTP -// response body. -type EchoResponseBody struct { - // Request ID - ID *string `form:"id,omitempty" json:"id,omitempty" xml:"id,omitempty"` - Echo *string `form:"echo,omitempty" json:"echo,omitempty" xml:"echo,omitempty"` -} - -// NewEchoStreamingBody builds the HTTP request body from the payload of the -// "echo" endpoint of the "Chat" service. -func NewEchoStreamingBody(p *chat.EchoPayload) *EchoStreamingBody { - body := &EchoStreamingBody{ - ID: p.ID, - Msg: p.Msg, - } - return body -} - -// NewEchoResultOK builds a "Chat" service "echo" endpoint result from a HTTP -// "OK" response. -func NewEchoResultOK(body *EchoResponseBody) *chat.EchoResult { - v := &chat.EchoResult{ - ID: body.ID, - Echo: body.Echo, - } - - return v -} diff --git a/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/chat/client/websocket.go.golden b/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/chat/client/websocket.go.golden deleted file mode 100644 index 474ec665dc..0000000000 --- a/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/chat/client/websocket.go.golden +++ /dev/null @@ -1,346 +0,0 @@ -// Code generated by goa, DO NOT EDIT. -// -// Chat WebSocket JSON-RPC client -// -// Command: -// goa - -package client - -import ( - "bytes" - "context" - "encoding/json" - "fmt" - "io" - chat "kitchensink/chat" - "net/http" - "strconv" - "sync" - "sync/atomic" - "time" - - "github.com/gorilla/websocket" - goahttp "goa.design/goa/v3/http" - "goa.design/goa/v3/jsonrpc" -) - -// Stream error types for comprehensive error reporting -type StreamErrorType int - -const ( - StreamErrorConnection StreamErrorType = iota // WebSocket connection errors - StreamErrorProtocol // Invalid JSON-RPC protocol - StreamErrorParsing // Failed to parse/decode response - StreamErrorOrphaned // Response with no matching request - StreamErrorTimeout // Request timeout -) - -// StreamErrorHandler allows users to handle stream errors -type StreamErrorHandler func(ctx context.Context, errorType StreamErrorType, err error, response *jsonrpc.RawResponse) - -// EchoClientStream implements the echo client stream with direct WebSocket -// handling. -type EchoClientStream struct { - // Direct WebSocket transport - ws *websocket.Conn - writeMu sync.Mutex // Serialize WebSocket writes - - // JSON-RPC correlation - pending sync.Map // map[jsonrpcID]*EchoClientStreamPendingRequest - idGenerator atomic.Uint64 // JSON-RPC request ID generator - - // Lifecycle management - ctx context.Context - cancel context.CancelFunc - done chan struct{} // Signals stream closure - closeOnce sync.Once - - // Error handling - errorOnce sync.Once - lastError atomic.Value // Last error encountered - - // Stream configuration - config *jsonrpc.StreamConfig // Stream configuration options - decoder func(*http.Response) goahttp.Decoder // User-provided decoder for result bodies -} - -// Stream-specific types for EchoClientStream -type EchoClientStreamPendingRequest struct { - userID string // User-provided payload ID - resultChan chan EchoClientStreamStreamResult // Buffered result delivery - timeout *time.Timer // Request timeout handling -} - -type EchoClientStreamStreamResult struct { - result *chat.EchoResult - err error -} - -// Send sends streaming data to the echo endpoint with dual ID correlation. -func (s *EchoClientStream) Send(v *chat.EchoPayload) error { - return s.SendWithContext(s.ctx, v) -} - -// SendWithContext sends streaming data to the echo endpoint with context. -func (s *EchoClientStream) SendWithContext(ctx context.Context, v *chat.EchoPayload) error { - // Check for stream-level errors first - if err := s.getError(); err != nil { - return err - } - // Honor user-provided ID or generate one - userID := "" - // Honor user-provided ID if it exists in the payload - userID = s.generateUserID() - - // Generate JSON-RPC protocol ID - jsonrpcID := strconv.FormatUint(s.idGenerator.Add(1), 10) - // Create pending request tracking for bidirectional streaming - pending := &EchoClientStreamPendingRequest{ - userID: userID, - resultChan: make(chan EchoClientStreamStreamResult, s.config.ResultChannelBuffer), - timeout: time.NewTimer(s.config.RequestTimeout), - } - - s.pending.Store(jsonrpcID, pending) - - // Construct JSON-RPC request - request := &jsonrpc.Request{ - JSONRPC: "2.0", - Method: "echo", - Params: v, - ID: &jsonrpcID, - } - - // Send with write protection - s.writeMu.Lock() - err := s.ws.WriteJSON(request) - s.writeMu.Unlock() - - if err != nil { - s.pending.Delete(jsonrpcID) - pending.timeout.Stop() - s.setError(err) - // Report connection errors - s.handleError(jsonrpc.StreamErrorConnection, err, nil) - return fmt.Errorf("failed to send request: %w", err) - } - - return nil -} - -// Recv receives streaming data from the echo endpoint. -func (s *EchoClientStream) Recv() (*chat.EchoResult, error) { - return s.RecvWithContext(s.ctx) -} - -// RecvWithContext receives streaming data from the echo endpoint with context. -func (s *EchoClientStream) RecvWithContext(ctx context.Context) (*chat.EchoResult, error) { - // Check for stream-level errors first - if err := s.getError(); err != nil { - return nil, err - } - // Find the oldest pending request (FIFO ordering) - var oldestPending *EchoClientStreamPendingRequest - var oldestKey string - - s.pending.Range(func(key, value any) bool { - pending := value.(*EchoClientStreamPendingRequest) - if oldestPending == nil { - oldestPending = pending - oldestKey = key.(string) - } - return false // Take first one for FIFO - }) - - if oldestPending == nil { - return nil, fmt.Errorf("no pending requests - call Send() first") - } - - // Wait for result with context cancellation - select { - case result := <-oldestPending.resultChan: - s.pending.Delete(oldestKey) - oldestPending.timeout.Stop() - return result.result, result.err - - case <-oldestPending.timeout.C: - s.pending.Delete(oldestKey) - timeoutErr := fmt.Errorf("request timeout after %v", s.config.RequestTimeout) - // Report timeout errors - s.handleError(jsonrpc.StreamErrorTimeout, timeoutErr, nil) - return nil, timeoutErr - - case <-ctx.Done(): - return nil, ctx.Err() - - case <-s.done: - if err := s.getError(); err != nil { - return nil, err - } - return nil, fmt.Errorf("stream closed") - } -} - -// responseHandler processes incoming WebSocket messages in a background goroutine -func (s *EchoClientStream) responseHandler() { - defer close(s.done) - - for { - select { - case <-s.ctx.Done(): - s.cleanupPendingRequests(s.ctx.Err()) - return - default: - var response jsonrpc.RawResponse - if err := s.ws.ReadJSON(&response); err != nil { - connectionErr := fmt.Errorf("failed to read response: %w", err) - s.setError(connectionErr) - - // Report connection errors - s.handleError(jsonrpc.StreamErrorConnection, connectionErr, nil) - - s.cleanupPendingRequests(connectionErr) - return - } - - s.handleResponse(&response) - } - } -} - -func (s *EchoClientStream) handleResponse(response *jsonrpc.RawResponse) { - if response.ID == nil { - // This is a server-initiated notification - // For now, just report it as an event via the error handler - // In the future, we could add a dedicated notification handler - if s.config.ErrorHandler != nil { - s.config.ErrorHandler(s.ctx, jsonrpc.StreamErrorNotification, - fmt.Errorf("received server notification"), response) - } - return - } - - jsonrpcID := response.ID - pendingInterface, exists := s.pending.LoadAndDelete(jsonrpcID) - if !exists { - // Orphaned response - report to error handler - s.handleError(jsonrpc.StreamErrorOrphaned, fmt.Errorf("received response for unknown ID: %s", jsonrpcID), response) - return - } - - pending := pendingInterface.(*EchoClientStreamPendingRequest) - pending.timeout.Stop() - - var result EchoClientStreamStreamResult - - if response.Error != nil { - result.err = response.Error - // Report protocol-level JSON-RPC errors - s.handleError(jsonrpc.StreamErrorProtocol, response.Error, response) - } else { - // Use generated decoder for consistent response parsing - parsedResult, err := s.decodeResponse(response.Result) - if err != nil { - result.err = fmt.Errorf("failed to decode response: %w", err) - // Report parsing errors - s.handleError(jsonrpc.StreamErrorParsing, err, response) - } else { - // Backfill the result ID from the envelope when missing - if parsedResult.ID == nil || *parsedResult.ID == "" { - idCopy := jsonrpc.IDToString(response.ID) - parsedResult.ID = &idCopy - } - result.result = parsedResult - } - } - - // Non-blocking send to result channel - select { - case pending.resultChan <- result: - default: - // Channel full - should not happen with buffer size 1 - } -} - -// Helper methods -func (s *EchoClientStream) generateUserID() string { - return fmt.Sprintf("user-%d-%d", time.Now().UnixNano(), s.idGenerator.Load()) -} - -// handleError calls the user-provided error handler if available -func (s *EchoClientStream) handleError(errorType jsonrpc.StreamErrorType, err error, response *jsonrpc.RawResponse) { - if s.config.ErrorHandler != nil { - s.config.ErrorHandler(s.ctx, errorType, err, response) - } -} - -// decodeResponse decodes JSON-RPC response data using the user-provided decoder -func (s *EchoClientStream) decodeResponse(data json.RawMessage) (*chat.EchoResult, error) { - // Create minimal HTTP response with raw JSON data for user's decoder - resp := &http.Response{ - StatusCode: http.StatusOK, - Body: io.NopCloser(bytes.NewReader(data)), - } - - // Use user-provided decoder to decode the result (expects inner result JSON) - dec := s.decoder(resp) - var out *chat.EchoResult - if err := dec.Decode(&out); err != nil { - return nil, err - } - return out, nil -} - -func (s *EchoClientStream) setError(err error) { - s.errorOnce.Do(func() { - s.lastError.Store(err) - s.cancel() // Cancel context to signal error state - }) -} - -func (s *EchoClientStream) getError() error { - if err, ok := s.lastError.Load().(error); ok { - return err - } - return nil -} - -func (s *EchoClientStream) cleanupPendingRequests(err error) { - s.pending.Range(func(key, value any) bool { - pending := value.(*EchoClientStreamPendingRequest) - pending.timeout.Stop() - - select { - case pending.resultChan <- EchoClientStreamStreamResult{err: err}: - default: - } - - s.pending.Delete(key) - return true - }) -} - -// Close closes the stream and cleans up resources. -func (s *EchoClientStream) Close() error { - var err error - s.closeOnce.Do(func() { - s.cancel() - - // Wait for response handler to finish - select { - case <-s.done: - case <-time.After(s.config.CloseTimeout): - // Force close if handler doesn't respond - } - - // Clean up any remaining pending requests - s.cleanupPendingRequests(fmt.Errorf("stream closed")) - - // Close the WebSocket connection - if s.ws != nil { - err = s.ws.Close() - } - }) - return err -} diff --git a/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/chat/server/encode_decode.go.golden b/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/chat/server/encode_decode.go.golden deleted file mode 100644 index 2678df7e92..0000000000 --- a/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/chat/server/encode_decode.go.golden +++ /dev/null @@ -1,47 +0,0 @@ -// Code generated by goa, DO NOT EDIT. -// -// Chat JSON-RPC server encoders and decoders -// -// Command: -// goa - -package server - -import ( - "bytes" - "errors" - "io" - chat "kitchensink/chat" - "net/http" - - goahttp "goa.design/goa/v3/http" - "goa.design/goa/v3/jsonrpc" - goa "goa.design/goa/v3/pkg" -) - -// DecodeEchoRequest returns a decoder for requests sent to the Chat echo -// endpoint. -func DecodeEchoRequest(mux goahttp.Muxer, decoder func(*http.Request) goahttp.Decoder) func(*http.Request, *jsonrpc.RawRequest) (*chat.EchoPayload, error) { - return func(r *http.Request, req *jsonrpc.RawRequest) (*chat.EchoPayload, error) { - r.Body = io.NopCloser(bytes.NewReader(req.Params)) - var payload *chat.EchoPayload - var ( - body EchoStreamingBody - err error - ) - err = decoder(r).Decode(&body) - if err != nil { - if errors.Is(err, io.EOF) { - return payload, goa.MissingPayloadError() - } - var gerr *goa.ServiceError - if errors.As(err, &gerr) { - return payload, gerr - } - return payload, goa.DecodePayloadError(err.Error()) - } - payload = NewEchoPayload(&body) - - return payload, nil - } -} diff --git a/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/chat/server/paths.go.golden b/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/chat/server/paths.go.golden deleted file mode 100644 index b2ebfec66b..0000000000 --- a/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/chat/server/paths.go.golden +++ /dev/null @@ -1,13 +0,0 @@ -// Code generated by goa, DO NOT EDIT. -// -// JSON-RPC request path constructors for the Chat service. -// -// Command: -// goa - -package server - -// EchoChatPath returns the URL path to the Chat service echo HTTP endpoint. -func EchoChatPath() string { - return "/ws/ws" -} diff --git a/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/chat/server/server.go.golden b/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/chat/server/server.go.golden deleted file mode 100644 index 6d511d6e1b..0000000000 --- a/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/chat/server/server.go.golden +++ /dev/null @@ -1,140 +0,0 @@ -// Code generated by goa, DO NOT EDIT. -// -// Chat JSON-RPC server -// -// Command: -// goa - -package server - -import ( - "context" - "fmt" - chat "kitchensink/chat" - "net/http" - - goahttp "goa.design/goa/v3/http" - "goa.design/goa/v3/jsonrpc" - goa "goa.design/goa/v3/pkg" -) - -// Server handles JSON-RPC requests for the Chat service. -type Server struct { - http.Handler - // Methods is the list of methods served by this server. - Methods []string - // StreamHandler is the handler for the streaming service. - StreamHandler func(context.Context, chat.Stream) error - - echo func(context.Context, *http.Request, *jsonrpc.RawRequest) (any, error) - echoEndpoint goa.Endpoint - - decoder func(*http.Request) goahttp.Decoder - encoder func(context.Context, http.ResponseWriter) goahttp.Encoder - errhandler func(context.Context, http.ResponseWriter, error) - upgrader goahttp.Upgrader - configfn goahttp.ConnConfigureFunc -} - -// New creates a JSON-RPC server which loads HTTP requests and calls the "Chat" -// service methods. -func New( - streamHandler func(context.Context, chat.Stream) error, - endpoints *chat.Endpoints, - mux goahttp.Muxer, - decoder func(*http.Request) goahttp.Decoder, - encoder func(context.Context, http.ResponseWriter) goahttp.Encoder, - errhandler func(context.Context, http.ResponseWriter, error), - upgrader goahttp.Upgrader, - configfn goahttp.ConnConfigureFunc, -) *Server { - s := &Server{ - Methods: []string{ - "echo", - }, - StreamHandler: streamHandler, - echo: NewEchoHandler(endpoints.Echo, mux, decoder), - echoEndpoint: endpoints.Echo, - decoder: decoder, - encoder: encoder, - errhandler: errhandler, - upgrader: upgrader, - configfn: configfn, - } - // Default HTTP handler per transport kind - // WebSocket services implement ServeHTTP for upgrade - s.Handler = http.HandlerFunc(s.ServeHTTP) - return s -} - -// Service returns the name of the service served. -func (s *Server) Service() string { return "Chat" } - -// Use wraps the server handlers with the given middleware. -func (s *Server) Use(m func(http.Handler) http.Handler) { - s.Handler = m(s.Handler) -} - -// MethodNames returns the methods served. -func (s *Server) MethodNames() []string { return chat.MethodNames[:] } - -// ServeHTTP handles WebSocket JSON-RPC requests. -func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { - ctx, cancel := context.WithCancel(r.Context()) - conn, err := s.upgrader.Upgrade(w, r, nil) - if err != nil { - s.errhandler(r.Context(), w, fmt.Errorf("failed to upgrade to WebSocket: %w", err)) - cancel() - return - } - if s.configfn != nil { - conn = s.configfn(conn, cancel) - } - defer conn.Close() - - stream := &chatStream{ - echo: s.echo, - echoEndpoint: s.echoEndpoint, - r: r, - w: w, - conn: conn, - cancel: cancel, - } - s.StreamHandler(ctx, stream) -} - -// Mount configures the mux to serve the JSON-RPC Chat service methods. -func Mount(mux goahttp.Muxer, h *Server) { - // HTTP only - mux.Handle("GET", "/ws/ws", h.ServeHTTP) -} - -// Mount configures the mux to serve the JSON-RPC Chat service methods. -func (s *Server) Mount(mux goahttp.Muxer) { - Mount(mux, s) -} - -// NewEchoHandler creates a JSON-RPC handler which calls the "Chat" service -// "echo" endpoint. -func NewEchoHandler( - endpoint goa.Endpoint, - mux goahttp.Muxer, - decoder func(*http.Request) goahttp.Decoder, -) func(context.Context, *http.Request, *jsonrpc.RawRequest) (any, error) { - return func(ctx context.Context, r *http.Request, req *jsonrpc.RawRequest) (any, error) { - ctx = context.WithValue(ctx, goa.MethodKey, "echo") - ctx = context.WithValue(ctx, goa.ServiceKey, "Chat") - decodeParams := DecodeEchoRequest(mux, decoder) - params, err := decodeParams(r, req) - if err != nil { - return nil, err - } - if req.ID != nil { - idStr := jsonrpc.IDToString(req.ID) - params.ID = &idStr - } - // For bidirectional streaming, we need to return the payload - // The actual streaming will be handled when the stream is passed to the endpoint - return params, nil - } -} diff --git a/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/chat/server/types.go.golden b/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/chat/server/types.go.golden deleted file mode 100644 index 7ec0943aed..0000000000 --- a/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/chat/server/types.go.golden +++ /dev/null @@ -1,58 +0,0 @@ -// Code generated by goa, DO NOT EDIT. -// -// Chat JSON-RPC server types -// -// Command: -// goa - -package server - -import ( - chat "kitchensink/chat" -) - -// EchoStreamingBody is the type of the "Chat" service "echo" endpoint HTTP -// request body. -type EchoStreamingBody struct { - // Request ID - ID *string `form:"id,omitempty" json:"id,omitempty" xml:"id,omitempty"` - Msg *string `form:"msg,omitempty" json:"msg,omitempty" xml:"msg,omitempty"` -} - -// EchoResponseBody is the type of the "Chat" service "echo" endpoint HTTP -// response body. -type EchoResponseBody struct { - // Request ID - ID *string `form:"id,omitempty" json:"id,omitempty" xml:"id,omitempty"` - Echo *string `form:"echo,omitempty" json:"echo,omitempty" xml:"echo,omitempty"` -} - -// NewEchoResponseBody builds the HTTP response body from the result of the -// "echo" endpoint of the "Chat" service. -func NewEchoResponseBody(res *chat.EchoResult) *EchoResponseBody { - body := &EchoResponseBody{ - ID: res.ID, - Echo: res.Echo, - } - return body -} - -// NewEchoPayload builds a Chat service echo endpoint payload. -func NewEchoPayload(body *EchoStreamingBody) *chat.EchoPayload { - v := &chat.EchoPayload{ - ID: body.ID, - Msg: body.Msg, - } - - return v -} - -// NewEchoStreamingBody builds a Chat service echo endpoint payload. -func NewEchoStreamingBody(body *EchoStreamingBody) *chat.EchoPayload { - v := &chat.EchoPayload{ - ID: body.ID, - Msg: body.Msg, - } - - return v -} diff --git a/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/chat/server/websocket.go.golden b/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/chat/server/websocket.go.golden deleted file mode 100644 index 6391bc1329..0000000000 --- a/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/chat/server/websocket.go.golden +++ /dev/null @@ -1,192 +0,0 @@ -// Code generated by goa, DO NOT EDIT. -// -// Chat WebSocket server streaming -// -// Command: -// goa - -package server - -import ( - "context" - "fmt" - chat "kitchensink/chat" - "net/http" - "time" - - "github.com/gorilla/websocket" - "goa.design/goa/v3/jsonrpc" - goa "goa.design/goa/v3/pkg" -) - -// chatStream implements the Stream interface. -type chatStream struct { - // echo decodes requests for the echo method - echo func(context.Context, *http.Request, *jsonrpc.RawRequest) (any, error) - // echoEndpoint is the endpoint for the echo method - echoEndpoint goa.Endpoint - // cancel is the context cancellation function which cancels the request - // context when invoked. - cancel context.CancelFunc - // w is the HTTP response writer used in upgrading the connection. - w http.ResponseWriter - // r is the HTTP request. - r *http.Request - // conn is the underlying websocket connection. - conn *websocket.Conn -} - -// echoStreamWrapper wraps the JSON-RPC stream to provide a method-specific interface. -type echoStreamWrapper struct { - stream *chatStream - requestID any // Store the JSON-RPC request ID for responses -} - -// SendNotification sends a notification to the client (no response expected). -func (w *echoStreamWrapper) SendNotification(ctx context.Context, res *chat.EchoResult) error { - return w.stream.SendEchoNotification(ctx, res) -} - -// SendResponse sends a response to the client for the original request. -func (w *echoStreamWrapper) SendResponse(ctx context.Context, res *chat.EchoResult) error { - return w.stream.SendEchoResponse(ctx, w.requestID, res) -} - -// SendError sends an error response to the client. -func (w *echoStreamWrapper) SendError(ctx context.Context, err error) error { - return w.stream.SendError(ctx, w.requestID, err) -} - -// Close closes the underlying JSON-RPC stream. -func (w *echoStreamWrapper) Close() error { - return w.stream.Close() -} - -// SendEchoNotification sends a JSON-RPC notification for the echo method. -func (s *chatStream) SendEchoNotification(ctx context.Context, result *chat.EchoResult) error { - body := NewEchoResponseBody(result) - return s.conn.WriteJSON(jsonrpc.MakeNotification("echo", body)) -} - -// SendEchoResponse sends a JSON-RPC response for the echo method. -func (s *chatStream) SendEchoResponse(ctx context.Context, id any, result *chat.EchoResult) error { - body := NewEchoResponseBody(result) - return s.conn.WriteJSON(jsonrpc.MakeSuccessResponse(id, body)) -} - -// SendError streams JSON-RPC errors. -func (s *chatStream) SendError(ctx context.Context, id any, err error) error { - // No custom errors defined - check if it's a validation error, otherwise use internal error - code := jsonrpc.InternalError - if _, ok := err.(*goa.ServiceError); ok { - code = jsonrpc.InvalidParams - } - return s.sendError(ctx, id, code, err.Error(), nil) -} - -// send writes a JSON-RPC response to the websocket connection. -func (s *chatStream) send(id any, method string, result any) error { - // If there's no ID, send as a notification instead of a response - // A JSON-RPC result with no ID is invalid per the spec - if id == nil || id == "" { - return s.conn.WriteJSON(jsonrpc.MakeNotification(method, result)) - } - return s.conn.WriteJSON(jsonrpc.MakeSuccessResponse(id, result)) -} - -// sendError sends a JSON-RPC error response to the websocket connection. -func (s *chatStream) sendError(ctx context.Context, id any, code jsonrpc.Code, message string, data any) error { - response := jsonrpc.MakeErrorResponse(id, code, message, data) - return s.conn.WriteJSON(response) -} - -// Recv reads JSON-RPC requests from the Chat service stream. -func (s *chatStream) Recv(ctx context.Context) error { - var req jsonrpc.RawRequest - if err := s.conn.ReadJSON(&req); err != nil { - // Handle different types of errors gracefully - if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway, websocket.CloseAbnormalClosure) { - // Network/connection errors - terminate connection - return err - } - - // JSON parse errors - send Parse Error response and continue - if err := s.sendError(ctx, nil, jsonrpc.ParseError, "Parse error", nil); err != nil { - // If we can't send error response, connection is broken - return fmt.Errorf("failed to send parse error: %w", err) - } - // Continue processing after sending parse error - return nil - } - return s.processRequest(ctx, &req) -} - -func (s *chatStream) processRequest(ctx context.Context, req *jsonrpc.RawRequest) error { - if req.JSONRPC != "2.0" { - if req.HasID { - return s.sendError(ctx, req.ID, jsonrpc.InvalidRequest, "Invalid request", nil) - } - return nil - } - - if req.Method == "" { - if req.HasID { - return s.sendError(ctx, req.ID, jsonrpc.InvalidRequest, "Invalid request", nil) - } - return nil - } - - switch req.Method { - case "echo": - // Bidirectional streaming: decode payload and create stream wrapper - payload, err := s.echo(ctx, s.r, req) - if err != nil { - return fmt.Errorf("handler error for %s: %w", "echo", err) - } - // Create wrapper that implements the method-specific stream interface - streamWrapper := &echoStreamWrapper{ - stream: s, - requestID: req.ID, - } - // Call the endpoint with payload and stream wrapper - endpointInput := &chat.EchoEndpointInput{ - Payload: payload.(*chat.EchoPayload), - Stream: streamWrapper, - } - if _, err := s.echoEndpoint(ctx, endpointInput); err != nil { - // For streaming endpoints, send error as JSON-RPC error response - if req.HasID { - // Send error response to client - if sendErr := streamWrapper.SendError(ctx, err); sendErr != nil { - return fmt.Errorf("failed to send error response: %w", sendErr) - } - // Continue processing other requests - return nil - } - // For notifications (no ID), just log and continue - return nil - } - return nil - default: - if req.HasID { - return s.sendError(ctx, req.ID, jsonrpc.MethodNotFound, "Method not found", nil) - } - return nil - } -} - -// Close closes the Chat service websocket connection. -func (s *chatStream) Close() error { - var err error - if s.conn == nil { - return nil - } - if err = s.conn.WriteControl( - websocket.CloseMessage, - websocket.FormatCloseMessage(websocket.CloseNormalClosure, "server closing connection"), - time.Now().Add(time.Second), - ); err != nil { - return err - } - return s.conn.Close() -} diff --git a/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/cli/kitchen_sink/cli.go.golden b/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/cli/kitchen_sink/cli.go.golden index 79aaaa6d12..9f9eba8139 100644 --- a/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/cli/kitchen_sink/cli.go.golden +++ b/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/cli/kitchen_sink/cli.go.golden @@ -10,13 +10,12 @@ package cli import ( "flag" "fmt" - calcc "kitchensink/jsonrpc/calc/client" - chatc "kitchensink/jsonrpc/chat/client" - feedc "kitchensink/jsonrpc/feed/client" - mixedc "kitchensink/jsonrpc/mixed/client" "net/http" "os" + calcc "generated.local/gen/jsonrpc/calc/client" + feedc "generated.local/gen/jsonrpc/feed/client" + mixedc "generated.local/gen/jsonrpc/mixed/client" goahttp "goa.design/goa/v3/http" goa "goa.design/goa/v3/pkg" ) @@ -27,21 +26,38 @@ import ( func UsageCommands() []string { return []string{ "calc (add|ping|log)", - "chat echo", - "feed watch", + "feed (watch|snapshot)", "mixed lookup", } } // UsageExamples produces an example of a valid invocation of the CLI tool. func UsageExamples() string { - return os.Args[0] + " " + "calc add --body '{\n \"a\": 281524111196350841,\n \"b\": 4865202500627059760,\n \"id\": \"Perspiciatis sed soluta distinctio facere voluptas et.\"\n }'" + "\n" + - os.Args[0] + " " + "chat echo --body '{\n \"id\": \"Nemo consequuntur est odio.\",\n \"msg\": \"Accusantium mollitia id sapiente ratione.\"\n }'" + "\n" + - os.Args[0] + " " + "feed watch --body '{\n \"last_event_id\": \"Iure dolor.\",\n \"request_id\": \"Aut voluptas.\"\n }'" + "\n" + - os.Args[0] + " " + "mixed lookup --body '{\n \"id\": \"Mollitia voluptatum expedita velit assumenda.\",\n \"key\": \"Blanditiis sed voluptatum odit dolores impedit.\"\n }'" + "\n" + + return os.Args[0] + " " + "calc add --body '{\n \"a\": 5718665059814127631,\n \"b\": 5455021967244844938\n }' --request-id \"Necessitatibus perferendis autem dolor.\"" + "\n" + + os.Args[0] + " " + "feed watch --last-event-id \"Eum in.\" --request-id \"Dolor consequatur dolor cum commodi saepe.\"" + "\n" + + os.Args[0] + " " + "mixed lookup --body '{\n \"key\": \"Earum amet voluptatum ad soluta.\"\n }' --request-id \"Deserunt non maxime tempora alias qui.\"" + "\n" + "" } +// cliStringFlag keeps an omitted command-line flag distinct from an explicitly empty flag. +type cliStringFlag struct { + value *string +} + +// String returns the flag text shown by the standard flag package. +func (f *cliStringFlag) String() string { + if f.value == nil { + return "" + } + return *f.value +} + +// Set records that the user supplied the flag, even when value is empty. +func (f *cliStringFlag) Set(value string) error { + f.value = &value + return nil +} + // ParseEndpoint returns the endpoint and payload as specified on the command // line. func ParseEndpoint( @@ -50,45 +66,53 @@ func ParseEndpoint( enc func(*http.Request) goahttp.Encoder, dec func(*http.Response) goahttp.Decoder, restore bool, - dialer goahttp.Dialer, - chatConfigFn goahttp.ConnConfigureFunc, ) (goa.Endpoint, any, error) { var ( calcFlags = flag.NewFlagSet("calc", flag.ContinueOnError) - calcAddFlags = flag.NewFlagSet("add", flag.ExitOnError) - calcAddBodyFlag = calcAddFlags.String("body", "REQUIRED", "") + calcAddFlags = flag.NewFlagSet("add", flag.ExitOnError) + calcAddBodyFlag = new(cliStringFlag) + calcAddRequestIDFlag = new(cliStringFlag) calcPingFlags = flag.NewFlagSet("ping", flag.ExitOnError) - calcLogFlags = flag.NewFlagSet("log", flag.ExitOnError) - calcLogBodyFlag = calcLogFlags.String("body", "REQUIRED", "") - - chatFlags = flag.NewFlagSet("chat", flag.ContinueOnError) - - chatEchoFlags = flag.NewFlagSet("echo", flag.ExitOnError) - chatEchoBodyFlag = chatEchoFlags.String("body", "REQUIRED", "") + calcLogFlags = flag.NewFlagSet("log", flag.ExitOnError) + calcLogBodyFlag = new(cliStringFlag) + calcLogRequestIDFlag = new(cliStringFlag) feedFlags = flag.NewFlagSet("feed", flag.ContinueOnError) - feedWatchFlags = flag.NewFlagSet("watch", flag.ExitOnError) - feedWatchBodyFlag = feedWatchFlags.String("body", "REQUIRED", "") + feedWatchFlags = flag.NewFlagSet("watch", flag.ExitOnError) + feedWatchLastEventIDFlag = new(cliStringFlag) + feedWatchRequestIDFlag = new(cliStringFlag) + + feedSnapshotFlags = flag.NewFlagSet("snapshot", flag.ExitOnError) + feedSnapshotRequestIDFlag = new(cliStringFlag) mixedFlags = flag.NewFlagSet("mixed", flag.ContinueOnError) - mixedLookupFlags = flag.NewFlagSet("lookup", flag.ExitOnError) - mixedLookupBodyFlag = mixedLookupFlags.String("body", "REQUIRED", "") + mixedLookupFlags = flag.NewFlagSet("lookup", flag.ExitOnError) + mixedLookupBodyFlag = new(cliStringFlag) + mixedLookupRequestIDFlag = new(cliStringFlag) ) + calcAddFlags.Var(calcAddBodyFlag, "body", "") + calcAddFlags.Var(calcAddRequestIDFlag, "request-id", "Request ID") + calcLogFlags.Var(calcLogBodyFlag, "body", "") + calcLogFlags.Var(calcLogRequestIDFlag, "request-id", "Optional caller-supplied request ID") + feedWatchFlags.Var(feedWatchLastEventIDFlag, "last-event-id", "") + feedWatchFlags.Var(feedWatchRequestIDFlag, "request-id", "Request ID") + feedSnapshotFlags.Var(feedSnapshotRequestIDFlag, "request-id", "Request ID") + mixedLookupFlags.Var(mixedLookupBodyFlag, "body", "") + mixedLookupFlags.Var(mixedLookupRequestIDFlag, "request-id", "Request ID") + calcFlags.Usage = calcUsage calcAddFlags.Usage = calcAddUsage calcPingFlags.Usage = calcPingUsage calcLogFlags.Usage = calcLogUsage - chatFlags.Usage = chatUsage - chatEchoFlags.Usage = chatEchoUsage - feedFlags.Usage = feedUsage feedWatchFlags.Usage = feedWatchUsage + feedSnapshotFlags.Usage = feedSnapshotUsage mixedFlags.Usage = mixedUsage mixedLookupFlags.Usage = mixedLookupUsage @@ -110,8 +134,6 @@ func ParseEndpoint( switch svcn { case "calc": svcf = calcFlags - case "chat": - svcf = chatFlags case "feed": svcf = feedFlags case "mixed": @@ -144,18 +166,14 @@ func ParseEndpoint( } - case "chat": - switch epn { - case "echo": - epf = chatEchoFlags - - } - case "feed": switch epn { case "watch": epf = feedWatchFlags + case "snapshot": + epf = feedSnapshotFlags + } case "mixed": @@ -190,33 +208,29 @@ func ParseEndpoint( switch epn { case "add": endpoint = c.Add() - data, err = calcc.BuildAddPayload(*calcAddBodyFlag) + data, err = calcc.BuildAddPayload(calcAddBodyFlag.value, calcAddRequestIDFlag.value) case "ping": endpoint = c.Ping() case "log": endpoint = c.Log() - data, err = calcc.BuildLogPayload(*calcLogBodyFlag) - } - case "chat": - c := chatc.NewClient(scheme, host, doer, enc, dec, restore, dialer, chatConfigFn) - switch epn { - case "echo": - endpoint = c.Echo() - data, err = chatc.BuildEchoPayload(*chatEchoBodyFlag) + data, err = calcc.BuildLogPayload(calcLogBodyFlag.value, calcLogRequestIDFlag.value) } case "feed": c := feedc.NewClient(scheme, host, doer, enc, dec, restore) switch epn { case "watch": endpoint = c.Watch() - data, err = feedc.BuildWatchPayload(*feedWatchBodyFlag) + data, err = feedc.BuildWatchPayload(feedWatchLastEventIDFlag.value, feedWatchRequestIDFlag.value) + case "snapshot": + endpoint = c.Snapshot() + data, err = feedc.BuildSnapshotPayload(feedSnapshotRequestIDFlag.value) } case "mixed": c := mixedc.NewClient(scheme, host, doer, enc, dec, restore) switch epn { case "lookup": endpoint = c.Lookup() - data, err = mixedc.BuildLookupPayload(*mixedLookupBodyFlag) + data, err = mixedc.BuildLookupPayload(mixedLookupBodyFlag.value, mixedLookupRequestIDFlag.value) } } } @@ -243,6 +257,7 @@ func calcAddUsage() { // Header with flags fmt.Fprintf(os.Stderr, "%s [flags] calc add", os.Args[0]) fmt.Fprint(os.Stderr, " -body JSON") + fmt.Fprint(os.Stderr, " -request-id STRING") fmt.Fprintln(os.Stderr) // Description @@ -251,10 +266,11 @@ func calcAddUsage() { // Flags list fmt.Fprintln(os.Stderr, ` -body JSON: `) + fmt.Fprintln(os.Stderr, ` -request-id STRING: Request ID`) fmt.Fprintln(os.Stderr) fmt.Fprintln(os.Stderr, "Example:") - fmt.Fprintf(os.Stderr, " %s %s\n", os.Args[0], "calc add --body '{\n \"a\": 281524111196350841,\n \"b\": 4865202500627059760,\n \"id\": \"Perspiciatis sed soluta distinctio facere voluptas et.\"\n }'") + fmt.Fprintf(os.Stderr, " %s %s\n", os.Args[0], "calc add --body '{\n \"a\": 5718665059814127631,\n \"b\": 5455021967244844938\n }' --request-id \"Necessitatibus perferendis autem dolor.\"") } func calcPingUsage() { @@ -277,6 +293,7 @@ func calcLogUsage() { // Header with flags fmt.Fprintf(os.Stderr, "%s [flags] calc log", os.Args[0]) fmt.Fprint(os.Stderr, " -body JSON") + fmt.Fprint(os.Stderr, " -request-id STRING") fmt.Fprintln(os.Stderr) // Description @@ -285,66 +302,60 @@ func calcLogUsage() { // Flags list fmt.Fprintln(os.Stderr, ` -body JSON: `) + fmt.Fprintln(os.Stderr, ` -request-id STRING: Optional caller-supplied request ID`) fmt.Fprintln(os.Stderr) fmt.Fprintln(os.Stderr, "Example:") - fmt.Fprintf(os.Stderr, " %s %s\n", os.Args[0], "calc log --body '{\n \"id\": \"Suscipit saepe tempore fuga recusandae amet blanditiis.\",\n \"message\": \"Est quisquam molestiae.\"\n }'") + fmt.Fprintf(os.Stderr, " %s %s\n", os.Args[0], "calc log --body '{\n \"message\": \"Quo excepturi.\"\n }' --request-id \"Rerum aut.\"") } -// chatUsage displays the usage of the chat command and its subcommands. -func chatUsage() { - fmt.Fprintln(os.Stderr, `Service is the Chat service interface.`) - fmt.Fprintf(os.Stderr, "Usage:\n %s [globalflags] chat COMMAND [flags]\n\n", os.Args[0]) +// feedUsage displays the usage of the feed command and its subcommands. +func feedUsage() { + fmt.Fprintln(os.Stderr, `Service is the Feed service interface.`) + fmt.Fprintf(os.Stderr, "Usage:\n %s [globalflags] feed COMMAND [flags]\n\n", os.Args[0]) fmt.Fprintln(os.Stderr, "COMMAND:") - fmt.Fprintln(os.Stderr, ` echo: Echo implements echo.`) + fmt.Fprintln(os.Stderr, ` watch: Watch implements watch.`) + fmt.Fprintln(os.Stderr, ` snapshot: Snapshot implements snapshot.`) fmt.Fprintln(os.Stderr) fmt.Fprintln(os.Stderr, "Additional help:") - fmt.Fprintf(os.Stderr, " %s chat COMMAND --help\n", os.Args[0]) + fmt.Fprintf(os.Stderr, " %s feed COMMAND --help\n", os.Args[0]) } -func chatEchoUsage() { +func feedWatchUsage() { // Header with flags - fmt.Fprintf(os.Stderr, "%s [flags] chat echo", os.Args[0]) - fmt.Fprint(os.Stderr, " -body JSON") + fmt.Fprintf(os.Stderr, "%s [flags] feed watch", os.Args[0]) + fmt.Fprint(os.Stderr, " -last-event-id STRING") + fmt.Fprint(os.Stderr, " -request-id STRING") fmt.Fprintln(os.Stderr) // Description fmt.Fprintln(os.Stderr) - fmt.Fprintln(os.Stderr, `Echo implements echo.`) + fmt.Fprintln(os.Stderr, `Watch implements watch.`) // Flags list - fmt.Fprintln(os.Stderr, ` -body JSON: `) + fmt.Fprintln(os.Stderr, ` -last-event-id STRING: `) + fmt.Fprintln(os.Stderr, ` -request-id STRING: Request ID`) fmt.Fprintln(os.Stderr) fmt.Fprintln(os.Stderr, "Example:") - fmt.Fprintf(os.Stderr, " %s %s\n", os.Args[0], "chat echo --body '{\n \"id\": \"Nemo consequuntur est odio.\",\n \"msg\": \"Accusantium mollitia id sapiente ratione.\"\n }'") + fmt.Fprintf(os.Stderr, " %s %s\n", os.Args[0], "feed watch --last-event-id \"Eum in.\" --request-id \"Dolor consequatur dolor cum commodi saepe.\"") } -// feedUsage displays the usage of the feed command and its subcommands. -func feedUsage() { - fmt.Fprintln(os.Stderr, `Service is the Feed service interface.`) - fmt.Fprintf(os.Stderr, "Usage:\n %s [globalflags] feed COMMAND [flags]\n\n", os.Args[0]) - fmt.Fprintln(os.Stderr, "COMMAND:") - fmt.Fprintln(os.Stderr, ` watch: Watch implements watch.`) - fmt.Fprintln(os.Stderr) - fmt.Fprintln(os.Stderr, "Additional help:") - fmt.Fprintf(os.Stderr, " %s feed COMMAND --help\n", os.Args[0]) -} -func feedWatchUsage() { +func feedSnapshotUsage() { // Header with flags - fmt.Fprintf(os.Stderr, "%s [flags] feed watch", os.Args[0]) - fmt.Fprint(os.Stderr, " -body JSON") + fmt.Fprintf(os.Stderr, "%s [flags] feed snapshot", os.Args[0]) + fmt.Fprint(os.Stderr, " -request-id STRING") fmt.Fprintln(os.Stderr) // Description fmt.Fprintln(os.Stderr) - fmt.Fprintln(os.Stderr, `Watch implements watch.`) + fmt.Fprintln(os.Stderr, `Snapshot implements snapshot.`) // Flags list - fmt.Fprintln(os.Stderr, ` -body JSON: `) + fmt.Fprintln(os.Stderr, ` -request-id STRING: Request ID`) fmt.Fprintln(os.Stderr) fmt.Fprintln(os.Stderr, "Example:") - fmt.Fprintf(os.Stderr, " %s %s\n", os.Args[0], "feed watch --body '{\n \"last_event_id\": \"Iure dolor.\",\n \"request_id\": \"Aut voluptas.\"\n }'") + fmt.Fprintf(os.Stderr, " %s %s\n", os.Args[0], "feed snapshot --request-id \"Et nulla modi necessitatibus ipsam quia.\"") } // mixedUsage displays the usage of the mixed command and its subcommands. @@ -361,6 +372,7 @@ func mixedLookupUsage() { // Header with flags fmt.Fprintf(os.Stderr, "%s [flags] mixed lookup", os.Args[0]) fmt.Fprint(os.Stderr, " -body JSON") + fmt.Fprint(os.Stderr, " -request-id STRING") fmt.Fprintln(os.Stderr) // Description @@ -369,8 +381,9 @@ func mixedLookupUsage() { // Flags list fmt.Fprintln(os.Stderr, ` -body JSON: `) + fmt.Fprintln(os.Stderr, ` -request-id STRING: Request ID`) fmt.Fprintln(os.Stderr) fmt.Fprintln(os.Stderr, "Example:") - fmt.Fprintf(os.Stderr, " %s %s\n", os.Args[0], "mixed lookup --body '{\n \"id\": \"Mollitia voluptatum expedita velit assumenda.\",\n \"key\": \"Blanditiis sed voluptatum odit dolores impedit.\"\n }'") + fmt.Fprintf(os.Stderr, " %s %s\n", os.Args[0], "mixed lookup --body '{\n \"key\": \"Earum amet voluptatum ad soluta.\"\n }' --request-id \"Deserunt non maxime tempora alias qui.\"") } diff --git a/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/feed/client/cli.go.golden b/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/feed/client/cli.go.golden index 084bb0c210..d3bb4740b6 100644 --- a/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/feed/client/cli.go.golden +++ b/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/feed/client/cli.go.golden @@ -8,26 +8,46 @@ package client import ( - "encoding/json" "fmt" - feed "kitchensink/feed" + + feed "generated.local/gen/feed" ) // BuildWatchPayload builds the payload for the Feed watch endpoint from CLI // flags. -func BuildWatchPayload(feedWatchBody string) (*feed.WatchPayload, error) { - var err error - var body WatchRequestBody +func BuildWatchPayload(feedWatchLastEventID *string, feedWatchRequestID *string) (*feed.WatchPayload, error) { + var lastEventID *string { - err = json.Unmarshal([]byte(feedWatchBody), &body) - if err != nil { - return nil, fmt.Errorf("invalid JSON for body, \nerror: %s, \nexample of valid JSON:\n%s", err, "'{\n \"last_event_id\": \"Iure dolor.\",\n \"request_id\": \"Aut voluptas.\"\n }'") + if feedWatchLastEventID != nil { + lastEventID = feedWatchLastEventID } } - v := &feed.WatchPayload{ - RequestID: body.RequestID, - LastEventID: body.LastEventID, + var requestID string + { + if feedWatchRequestID == nil { + return nil, fmt.Errorf("missing required flag --request-id") + } + requestID = *feedWatchRequestID + } + v := &feed.WatchPayload{} + v.LastEventID = lastEventID + v.RequestID = requestID + + return v, nil +} + +// BuildSnapshotPayload builds the payload for the Feed snapshot endpoint from +// CLI flags. +func BuildSnapshotPayload(feedSnapshotRequestID *string) (*feed.SnapshotPayload, error) { + var requestID string + { + if feedSnapshotRequestID == nil { + return nil, fmt.Errorf("missing required flag --request-id") + } + requestID = *feedSnapshotRequestID } + v := &feed.SnapshotPayload{} + v.RequestID = requestID return v, nil } diff --git a/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/feed/client/client.go.golden b/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/feed/client/client.go.golden index 9f9bfb0ee3..9b86d9b6b9 100644 --- a/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/feed/client/client.go.golden +++ b/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/feed/client/client.go.golden @@ -8,13 +8,13 @@ package client import ( - "bufio" "bytes" "context" + "errors" "fmt" "io" + "mime" "net/http" - "strings" "sync" goahttp "goa.design/goa/v3/http" @@ -37,12 +37,12 @@ type Client struct { decoder func(*http.Response) goahttp.Decoder } -// bufferPool is a pool of bytes.Buffers for encoding requests. +// bufferPool reuses byte buffers while requests are encoded. var bufferPool = sync.Pool{ New: func() any { return new(bytes.Buffer) }, } -// NewClient instantiates HTTP clients for all the Feed service servers. +// NewClient creates HTTP clients for all the Feed service servers. func NewClient( scheme string, host string, @@ -51,7 +51,6 @@ func NewClient( dec func(*http.Response) goahttp.Decoder, restoreBody bool, ) *Client { - return &Client{ Doer: doer, WatchDoer: doer, @@ -74,7 +73,8 @@ func (c *Client) Watch() goa.Endpoint { if err != nil { return nil, err } - if err := encodeRequest(req, v); err != nil { + requestID, err := encodeRequest(req, v) + if err != nil { return nil, err } // For SSE endpoints, send JSON-RPC request and establish stream @@ -84,24 +84,49 @@ func (c *Client) Watch() goa.Endpoint { } if resp.StatusCode != http.StatusOK { - body, _ := io.ReadAll(resp.Body) - resp.Body.Close() + body, readErr := io.ReadAll(resp.Body) + closeErr := resp.Body.Close() + if err := errors.Join(readErr, closeErr); err != nil { + return nil, goahttp.ErrDecodingError("Feed", "watch", err) + } return nil, goahttp.ErrInvalidResponse("Feed", "watch", resp.StatusCode, string(body)) } contentType := resp.Header.Get("Content-Type") - if contentType != "" && !strings.HasPrefix(contentType, "text/event-stream") { - resp.Body.Close() - return nil, fmt.Errorf("unexpected content type: %s (expected text/event-stream)", contentType) + mediaType, _, parseErr := mime.ParseMediaType(contentType) + if parseErr != nil || mediaType != "text/event-stream" { + contentTypeErr := fmt.Errorf("unexpected content type: %s (expected text/event-stream)", contentType) + if err := resp.Body.Close(); err != nil { + return nil, errors.Join(contentTypeErr, goahttp.ErrDecodingError("Feed", "watch", err)) + } + return nil, contentTypeErr } // Create the SSE client stream - stream := &WatchClientStream{ - resp: resp, - reader: bufio.NewReader(resp.Body), - decoder: c.decoder, - } + return NewWatchStream(resp, c.decoder, requestID), nil + } +} - return stream, nil +// Snapshot returns an endpoint that makes JSON-RPC requests to the Feed +// service snapshot method. +func (c *Client) Snapshot() goa.Endpoint { + var ( + encodeRequest = EncodeSnapshotRequest(c.encoder) + decodeResponse = DecodeSnapshotResponse(c.decoder, c.RestoreResponseBody) + ) + return func(ctx context.Context, v any) (any, error) { + req, err := c.BuildSnapshotRequest(ctx, v) + if err != nil { + return nil, err + } + requestID, err := encodeRequest(req, v) + if err != nil { + return nil, err + } + resp, err := c.Doer.Do(req) + if err != nil { + return nil, goahttp.ErrRequestError("Feed", "snapshot", err) + } + return decodeResponse(resp, requestID) } } diff --git a/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/feed/client/encode_decode.go.golden b/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/feed/client/encode_decode.go.golden index 8c82460695..9c7ecdb794 100644 --- a/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/feed/client/encode_decode.go.golden +++ b/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/feed/client/encode_decode.go.golden @@ -10,13 +10,15 @@ package client import ( "bytes" "context" + "errors" "io" - feed "kitchensink/feed" "net/http" "net/url" + feed "generated.local/gen/feed" goahttp "goa.design/goa/v3/http" "goa.design/goa/v3/jsonrpc" + goa "goa.design/goa/v3/pkg" ) // BuildWatchRequest instantiates a HTTP request object with method and path @@ -34,75 +36,127 @@ func (c *Client) BuildWatchRequest(ctx context.Context, v any) (*http.Request, e return req, nil } -// EncodeWatchRequest returns an encoder for requests sent to the Feed watch -// server. -func EncodeWatchRequest(encoder func(*http.Request) goahttp.Encoder) func(*http.Request, any) error { - return func(req *http.Request, v any) error { +// EncodeWatchRequest returns an encoder for requests sent to the Feed service +// watch JSON-RPC method. The encoder returns the request ID written into the +// JSON-RPC message. +func EncodeWatchRequest(encoder func(*http.Request) goahttp.Encoder) func(*http.Request, any) (string, error) { + return func(req *http.Request, v any) (string, error) { p, ok := v.(*feed.WatchPayload) if !ok { - return goahttp.ErrInvalidType("Feed", "watch", "*feed.WatchPayload", v) + return "", goahttp.ErrInvalidType("Feed", "watch", "*feed.WatchPayload", v) + } + if p.LastEventID != nil { + head := *p.LastEventID + for _, character := range head { + if character == 0 || character == '\r' || character == '\n' { + return "", goahttp.ErrValidationError("Feed", "watch", goa.InvalidPatternError("last_event_id", string(head), "[^\\x00\\r\\n]*")) + } + } + req.Header.Set("Last-Event-ID", head) } - b := NewWatchRequestBody(p) body := &jsonrpc.Request{ JSONRPC: "2.0", Method: "watch", - Params: b, } - if p.RequestID != "" { - body.ID = p.RequestID + requestID := p.RequestID + body.ID = requestID + if err := encoder(req).Encode(&body); err != nil { + return "", goahttp.ErrEncodingError("Feed", "watch", err) + } + return requestID, nil + } +} + +// BuildSnapshotRequest instantiates a HTTP request object with method and path +// set to call the "Feed" service "snapshot" endpoint +func (c *Client) BuildSnapshotRequest(ctx context.Context, v any) (*http.Request, error) { + u := &url.URL{Scheme: c.scheme, Host: c.host, Path: SnapshotFeedPath()} + req, err := http.NewRequest("POST", u.String(), nil) + if err != nil { + return nil, goahttp.ErrInvalidURL("Feed", "snapshot", u.String(), err) + } + if ctx != nil { + req = req.WithContext(ctx) + } + + return req, nil +} + +// EncodeSnapshotRequest returns an encoder for requests sent to the Feed +// service snapshot JSON-RPC method. The encoder returns the request ID written +// into the JSON-RPC message. +func EncodeSnapshotRequest(encoder func(*http.Request) goahttp.Encoder) func(*http.Request, any) (string, error) { + return func(req *http.Request, v any) (string, error) { + p, ok := v.(*feed.SnapshotPayload) + if !ok { + return "", goahttp.ErrInvalidType("Feed", "snapshot", "*feed.SnapshotPayload", v) + } + body := &jsonrpc.Request{ + JSONRPC: "2.0", + Method: "snapshot", } - // If ID is empty, this is a notification - no ID field + requestID := p.RequestID + body.ID = requestID if err := encoder(req).Encode(&body); err != nil { - return goahttp.ErrEncodingError("Feed", "watch", err) + return "", goahttp.ErrEncodingError("Feed", "snapshot", err) } - return nil + return requestID, nil } } -// DecodeWatchResponse returns a decoder for responses returned by the Feed -// service watch JSON-RPC method. restoreBody controls whether the response -// body should be restored after having been read. -func DecodeWatchResponse(decoder func(*http.Response) goahttp.Decoder, restoreBody bool) func(*http.Response) (any, error) { - return func(resp *http.Response) (any, error) { +// DecodeSnapshotResponse returns a decoder for responses returned by the Feed +// service snapshot JSON-RPC method. The decoder rejects responses that do not +// repeat requestID. restoreBody controls whether the response body should be +// restored after having been read. +func DecodeSnapshotResponse(decoder func(*http.Response) goahttp.Decoder, restoreBody bool) func(*http.Response, string) (any, error) { + return func(resp *http.Response, requestID string) (result any, decodeErr error) { + responseBody := resp.Body if restoreBody { - b, err := io.ReadAll(resp.Body) - if err != nil { - return nil, err + b, readErr := io.ReadAll(responseBody) + closeErr := responseBody.Close() + if err := errors.Join(readErr, closeErr); err != nil { + return nil, goahttp.ErrDecodingError("Feed", "snapshot", err) } resp.Body = io.NopCloser(bytes.NewBuffer(b)) defer func() { resp.Body = io.NopCloser(bytes.NewBuffer(b)) }() + } else { + defer func() { + if err := responseBody.Close(); err != nil { + decodeErr = errors.Join(decodeErr, goahttp.ErrDecodingError("Feed", "snapshot", err)) + } + }() } - defer resp.Body.Close() if resp.StatusCode != http.StatusOK { - body, _ := io.ReadAll(resp.Body) - return nil, goahttp.ErrInvalidResponse("Feed", "watch", resp.StatusCode, string(body)) + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, goahttp.ErrDecodingError("Feed", "snapshot", err) + } + return nil, goahttp.ErrInvalidResponse("Feed", "snapshot", resp.StatusCode, string(body)) } var jresp jsonrpc.RawResponse if err := decoder(resp).Decode(&jresp); err != nil { - return nil, goahttp.ErrDecodingError("Feed", "watch", err) + return nil, goahttp.ErrDecodingError("Feed", "snapshot", err) + } + if err := jresp.Validate(requestID); err != nil { + return nil, goahttp.ErrInvalidResponse("Feed", "snapshot", resp.StatusCode, err.Error()) } if jresp.Error != nil { - switch jresp.Error.Code { - default: - body, _ := io.ReadAll(resp.Body) - return nil, goahttp.ErrInvalidResponse("Feed", "watch", resp.StatusCode, string(body)) - } + return nil, jresp.Error } resp.Body = io.NopCloser(bytes.NewBuffer(jresp.Result)) var ( - body WatchResponseBody + body string err error ) err = decoder(resp).Decode(&body) if err != nil { - return nil, goahttp.ErrDecodingError("Feed", "watch", err) + return nil, goahttp.ErrDecodingError("Feed", "snapshot", err) } - res := NewWatchResultOK(&body) - return res, nil + return body, nil } } diff --git a/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/feed/client/paths.go.golden b/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/feed/client/paths.go.golden index 1330b366e8..6bb9493df4 100644 --- a/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/feed/client/paths.go.golden +++ b/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/feed/client/paths.go.golden @@ -11,3 +11,8 @@ package client func WatchFeedPath() string { return "/feed" } + +// SnapshotFeedPath returns the URL path to the Feed service snapshot HTTP endpoint. +func SnapshotFeedPath() string { + return "/feed" +} diff --git a/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/feed/client/stream.go.golden b/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/feed/client/stream.go.golden index 8bdbe76ea3..f50d8b1a01 100644 --- a/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/feed/client/stream.go.golden +++ b/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/feed/client/stream.go.golden @@ -12,70 +12,196 @@ import ( "bytes" "context" "encoding/json" + "errors" "fmt" "io" - feed "kitchensink/feed" "net/http" "strings" "sync" + feed "generated.local/gen/feed" goahttp "goa.design/goa/v3/http" "goa.design/goa/v3/jsonrpc" ) -// WatchClientStream implements the feed.WatchClientStream interface using -// Server-Sent Events. -type WatchClientStream struct { - resp *http.Response // HTTP response object - reader *bufio.Reader // Buffered reader for SSE parsing - decoder func(*http.Response) goahttp.Decoder // User-provided decoder - closed bool // Whether the stream has been closed - lock sync.Mutex // Mutex to protect state +type ( + // WatchClientStream reads results sent as server-sent events. + WatchClientStream interface { + Recv() (*feed.WatchResult, error) + RecvWithContext(context.Context) (*feed.WatchResult, error) + Close() error + } + + // WatchStreamImpl reads and decodes events for watch. + WatchStreamImpl struct { + // resp is the open server response. + resp *http.Response + // reader reads one line at a time from resp. + reader *bufio.Reader + // decoder converts each result into its service type. + decoder func(*http.Response) goahttp.Decoder + // requestID is the ID every final response must repeat. + requestID string + // lastEventID is the last valid id field read from the stream. + lastEventID string + // hasLastEventID distinguishes no id from an explicitly empty id. + hasLastEventID bool + // firstLine is true until the optional stream byte-order marker is read. + firstLine bool + // closed records whether Close was called or the response ended. + closed bool + // closeOnce ensures the response body is closed only once. + closeOnce sync.Once + // closeErr stores the response body close error. + closeErr error + // lock prevents two calls from reading or closing the response at once. + lock sync.Mutex + } +) + +// NewWatchStream creates a stream that reads server-sent events from resp. +// Final responses must repeat requestID. +func NewWatchStream(resp *http.Response, decoder func(*http.Response) goahttp.Decoder, requestID string) WatchClientStream { + return &WatchStreamImpl{ + resp: resp, + reader: bufio.NewReader(resp.Body), + decoder: decoder, + requestID: requestID, + firstLine: true, + } } -// parseSSEEvent parses a single SSE event from the stream -func (s *WatchClientStream) parseSSEEvent() (eventType string, data []byte, err error) { - var event strings.Builder +// parseSSEEvent reads one complete event from the response. Ending ctx closes +// the response body so a blocked read returns. +func (s *WatchStreamImpl) parseSSEEvent(ctx context.Context) (id string, hasID bool, eventType string, hasEventType bool, retry string, hasRetry bool, data []byte, err error) { + closeResult := make(chan struct{}, 1) + stopClose := context.AfterFunc(ctx, func() { + s.closeBody() + closeResult <- struct{}{} + }) + defer func() { + if stopClose() { + return + } + <-closeResult + if contextErr := ctx.Err(); contextErr != nil { + id = "" + hasID = false + eventType = "" + hasEventType = false + retry = "" + hasRetry = false + data = nil + err = contextErr + } + }() var dataLines []string for { - line, err := s.reader.ReadString('\n') + line, err := s.readSSELine() if err != nil { - if err == io.EOF && len(dataLines) > 0 { - // Process final event - break - } - return "", nil, err + return "", false, "", false, "", false, nil, err } - line = strings.TrimSuffix(line, "\n") - line = strings.TrimSuffix(line, "\r") - if line == "" { - // Empty line marks end of event + // A blank line ends the current event. if len(dataLines) > 0 { break } + // An event without data is not delivered. Its event and retry fields + // must not appear on the next delivered event. + eventType = "" + hasEventType = false + retry = "" + hasRetry = false continue } - if strings.HasPrefix(line, "event:") { - event.WriteString(strings.TrimSpace(line[6:])) - } else if strings.HasPrefix(line, "data:") { - dataLines = append(dataLines, strings.TrimSpace(line[5:])) + if strings.HasPrefix(line, ":") { + continue + } + field, value, found := strings.Cut(line, ":") + if !found { + value = "" + } else { + value = strings.TrimPrefix(value, " ") + } + switch field { + case "id": + if !strings.ContainsRune(value, '\x00') { + s.lastEventID = value + s.hasLastEventID = true + } + case "event": + eventType = value + hasEventType = true + case "retry": + // An invalid retry field does not replace an earlier valid field. + validRetry := len(value) > 0 + for index := 0; validRetry && index < len(value); index++ { + validRetry = value[index] >= '0' && value[index] <= '9' + } + if !validRetry { + continue + } + retry = value + hasRetry = true + case "data": + dataLines = append(dataLines, value) } - // Ignore other fields like id:, retry: } if len(dataLines) > 0 { data = []byte(strings.Join(dataLines, "\n")) } - return event.String(), data, nil + return s.lastEventID, s.hasLastEventID, eventType, hasEventType, retry, hasRetry, data, nil +} + +// readSSELine reads one line ending in CR, LF, or CRLF. It returns io.EOF for +// a final line that is not terminated, so the caller cannot dispatch an +// incomplete event. +func (s *WatchStreamImpl) readSSELine() (string, error) { + var line strings.Builder + for { + character, err := s.reader.ReadByte() + if err != nil { + return "", err + } + switch character { + case '\n': + return s.finishSSELine(line.String()), nil + case '\r': + if next, err := s.reader.Peek(1); err == nil && next[0] == '\n' { + if _, err := s.reader.Discard(1); err != nil { + return "", err + } + } + return s.finishSSELine(line.String()), nil + default: + line.WriteByte(character) + } + } +} + +// finishSSELine removes the optional byte-order marker only at the start of +// the stream. +func (s *WatchStreamImpl) finishSSELine(line string) string { + if !s.firstLine { + return line + } + s.firstLine = false + return strings.TrimPrefix(line, "\uFEFF") } // Recv reads instances of "WatchResult" from the stream. -func (s *WatchClientStream) Recv(ctx context.Context) (*feed.WatchResult, error) { +func (s *WatchStreamImpl) Recv() (*feed.WatchResult, error) { + return s.RecvWithContext(context.Background()) +} + +// RecvWithContext reads instances of "WatchResult" from the stream with +// context. +func (s *WatchStreamImpl) RecvWithContext(ctx context.Context) (*feed.WatchResult, error) { s.lock.Lock() defer s.lock.Unlock() @@ -86,117 +212,137 @@ func (s *WatchClientStream) Recv(ctx context.Context) (*feed.WatchResult, error) } for { - eventType, data, err := s.parseSSEEvent() + eventID, hasEventID, _, _, _, _, data, err := s.parseSSEEvent(ctx) if err != nil { - s.closed = true - return zero, err + return zero, s.endStream(err) } - switch eventType { - case "notification": - // Parse JSON-RPC notification - var notification struct { - JSONRPC string `json:"jsonrpc"` - Method string `json:"method"` - Params json.RawMessage `json:"params"` + var message struct { + Method json.RawMessage `json:"method"` + Params json.RawMessage `json:"params"` + Result json.RawMessage `json:"result"` + Error json.RawMessage `json:"error"` + ID json.RawMessage `json:"id"` + } + if err := json.Unmarshal(data, &message); err != nil { + return zero, s.endStream(fmt.Errorf("failed to parse JSON-RPC message: %w", err)) + } + if len(message.Method) > 0 { + // Read the streamed service result from the notification parameters. + if len(message.Result) > 0 || len(message.Error) > 0 || len(message.ID) > 0 { + return zero, s.endStream(fmt.Errorf("invalid JSON-RPC notification")) } + var notification jsonrpc.RawRequest if err := json.Unmarshal(data, ¬ification); err != nil { - return zero, fmt.Errorf("failed to parse notification: %w", err) + return zero, s.endStream(fmt.Errorf("failed to parse notification: %w", err)) + } + if notification.Invalid { + return zero, s.endStream(fmt.Errorf("invalid JSON-RPC notification")) } - - // Validate notification if notification.JSONRPC != "2.0" { - return zero, fmt.Errorf("invalid JSON-RPC version: %s", notification.JSONRPC) + return zero, s.endStream(fmt.Errorf("invalid JSON-RPC version: %s", notification.JSONRPC)) + } + if notification.HasID { + return zero, s.endStream(fmt.Errorf("notification contains an id")) + } + if !notification.HasMethod { + return zero, s.endStream(fmt.Errorf("notification has no method")) } - if notification.Method != "watch" { - // Skip notifications for other methods - continue + return zero, s.endStream(fmt.Errorf("received notification for JSON-RPC method %q", notification.Method)) } - - // Decode the result from params - result, err := s.decodeResult(notification.Params) + if len(notification.Params) == 0 { + return zero, s.endStream(fmt.Errorf("notification has no params")) + } + result, err := s.decodeResult(notification.Params, eventID, hasEventID) if err != nil { - return zero, fmt.Errorf("failed to decode result: %w", err) + return zero, s.endStream(err) } return result, nil + } - case "response": - // Final response - parse and return - var response jsonrpc.Response + if len(message.Params) > 0 { + return zero, s.endStream(fmt.Errorf("JSON-RPC response contains params")) + } + { + // A response completes the stream. Stream values arrive in the + // notifications handled above. + var response jsonrpc.RawResponse if err := json.Unmarshal(data, &response); err != nil { - return zero, fmt.Errorf("failed to parse response: %w", err) - } - - if response.Error != nil { - return zero, response.Error + return zero, s.endStream(fmt.Errorf("failed to parse response: %w", err)) } - // Decode the final result - if response.Result == nil { - return zero, fmt.Errorf("missing result in response") + if err := response.Validate(s.requestID); err != nil { + return zero, s.endStream(err) } - // Convert response.Result to json.RawMessage - resultBytes, err := json.Marshal(response.Result) - if err != nil { - return zero, fmt.Errorf("failed to marshal result: %w", err) + if response.Error == nil && !bytes.Equal(bytes.TrimSpace(response.Result), []byte("null")) { + return zero, s.endStream(fmt.Errorf("JSON-RPC stream completion result must be null")) } - result, err := s.decodeResult(json.RawMessage(resultBytes)) - if err != nil { - return zero, fmt.Errorf("failed to decode final result: %w", err) + if response.Error != nil { + serviceErr, decodeErr := s.decodeError(response.Error) + if decodeErr != nil { + return zero, s.endStream(decodeErr) + } + return zero, s.endStream(serviceErr) } + return zero, s.endStream(io.EOF) + } + } +} - // Mark stream as closed after final response - s.closed = true - return result, nil - - case "error": - // Error response - var response jsonrpc.Response - if err := json.Unmarshal(data, &response); err != nil { - return zero, fmt.Errorf("failed to parse error response: %w", err) - } +// decodeError rebuilds one designed service error from its JSON-RPC code and data. +func (s *WatchStreamImpl) decodeError(response *jsonrpc.RawErrorResponse) (error, error) { + return response, nil +} - s.closed = true - if response.Error != nil { - return zero, response.Error - } - return zero, fmt.Errorf("unexpected error response") +// closeBody closes the HTTP response body once and returns its close error. +func (s *WatchStreamImpl) closeBody() error { + s.closeOnce.Do(func() { + s.closeErr = s.resp.Body.Close() + }) + return s.closeErr +} - default: - // Ignore unknown event types - continue - } +// endStream marks the stream closed and preserves both the receive error and +// any error returned while closing the HTTP response body. +func (s *WatchStreamImpl) endStream(err error) error { + s.closed = true + if closeErr := s.closeBody(); closeErr != nil { + return errors.Join(err, closeErr) } + return err } -// decodeResult decodes JSON-RPC result data using the user-provided decoder -func (s *WatchClientStream) decodeResult(data json.RawMessage) (*feed.WatchResult, error) { - // Create minimal HTTP response with raw JSON data for user's decoder +// decodeResult passes one successful stream item to the decoder configured by NewClient. +func (s *WatchStreamImpl) decodeResult(data json.RawMessage, eventID string, hasEventID bool) (*feed.WatchResult, error) { resp := &http.Response{ StatusCode: http.StatusOK, Body: io.NopCloser(bytes.NewReader(data)), } - - // Use the user-provided decoder to decode the result decoder := s.decoder(resp) - var result *feed.WatchResult - if err := decoder.Decode(&result); err != nil { - return result, err + var ( + body WatchResponseBody + err error + ) + if err = decoder.Decode(&body); err != nil { + var zero *feed.WatchResult + return zero, goahttp.ErrDecodingError("Feed", "watch", err) } - + if hasEventID { + value := string(eventID) + body.EventID = &value + } + result := NewWatchResultOK(&body) return result, nil } // Close closes the stream. -func (s *WatchClientStream) Close() error { +func (s *WatchStreamImpl) Close() error { s.lock.Lock() defer s.lock.Unlock() if !s.closed { s.closed = true - if s.resp != nil && s.resp.Body != nil { - return s.resp.Body.Close() - } + return s.closeBody() } return nil } diff --git a/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/feed/client/types.go.golden b/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/feed/client/types.go.golden index 56e7c1ac65..446758a9f5 100644 --- a/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/feed/client/types.go.golden +++ b/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/feed/client/types.go.golden @@ -7,18 +7,7 @@ package client -import ( - feed "kitchensink/feed" -) - -// WatchRequestBody is the type of the "Feed" service "watch" endpoint HTTP -// request body. -type WatchRequestBody struct { - // Request ID - RequestID string `form:"request_id,omitempty" json:"request_id,omitempty" xml:"request_id,omitempty"` - // Last received event ID - LastEventID *string `form:"last_event_id,omitempty" json:"last_event_id,omitempty" xml:"last_event_id,omitempty"` -} +import feed "generated.local/gen/feed" // WatchResponseBody is the type of the "Feed" service "watch" endpoint HTTP // response body. @@ -29,16 +18,6 @@ type WatchResponseBody struct { Data *string `form:"data,omitempty" json:"data,omitempty" xml:"data,omitempty"` } -// NewWatchRequestBody builds the HTTP request body from the payload of the -// "watch" endpoint of the "Feed" service. -func NewWatchRequestBody(p *feed.WatchPayload) *WatchRequestBody { - body := &WatchRequestBody{ - RequestID: p.RequestID, - LastEventID: p.LastEventID, - } - return body -} - // NewWatchResultOK builds a "Feed" service "watch" endpoint result from a HTTP // "OK" response. func NewWatchResultOK(body *WatchResponseBody) *feed.WatchResult { diff --git a/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/feed/server/encode_decode.go.golden b/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/feed/server/encode_decode.go.golden index 460413bd0a..7dc395efe2 100644 --- a/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/feed/server/encode_decode.go.golden +++ b/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/feed/server/encode_decode.go.golden @@ -9,11 +9,10 @@ package server import ( "bytes" - "errors" "io" - feed "kitchensink/feed" "net/http" + feed "generated.local/gen/feed" goahttp "goa.design/goa/v3/http" "goa.design/goa/v3/jsonrpc" goa "goa.design/goa/v3/pkg" @@ -25,26 +24,55 @@ func DecodeWatchRequest(mux goahttp.Muxer, decoder func(*http.Request) goahttp.D return func(r *http.Request, req *jsonrpc.RawRequest) (*feed.WatchPayload, error) { r.Body = io.NopCloser(bytes.NewReader(req.Params)) var payload *feed.WatchPayload + if !req.HasID || req.ID == nil { + return payload, goa.MissingFieldError("id", "JSON-RPC request") + } + var requestID string + decodedID, decodeErr := jsonrpc.IDToString(req.ID) + if decodeErr != nil { + return payload, goa.DecodePayloadError(decodeErr.Error()) + } + requestID = decodedID var ( - body WatchRequestBody - err error + lastEventID *string + err error ) - err = decoder(r).Decode(&body) - if err != nil { - if errors.Is(err, io.EOF) { - return payload, goa.MissingPayloadError() + lastEventIDValues, lastEventIDPresent := r.Header["Last-Event-Id"] + if lastEventIDPresent && len(lastEventIDValues) > 0 { + for _, character := range lastEventIDValues[0] { + if character == 0 || character == '\r' || character == '\n' { + err = goa.MergeErrors(err, goa.InvalidPatternError("last_event_id", lastEventIDValues[0], "[^\\x00\\r\\n]*")) + break + } } - var gerr *goa.ServiceError - if errors.As(err, &gerr) { - return payload, gerr - } - return payload, goa.DecodePayloadError(err.Error()) + value := lastEventIDValues[0] + lastEventID = &value } - err = ValidateWatchRequestBody(&body) if err != nil { return payload, err } - payload = NewWatchPayload(&body) + payload = NewWatchPayload(lastEventID, requestID) + + return payload, nil + } +} + +// DecodeSnapshotRequest returns a decoder for requests sent to the Feed +// snapshot endpoint. +func DecodeSnapshotRequest(mux goahttp.Muxer, decoder func(*http.Request) goahttp.Decoder) func(*http.Request, *jsonrpc.RawRequest) (*feed.SnapshotPayload, error) { + return func(r *http.Request, req *jsonrpc.RawRequest) (*feed.SnapshotPayload, error) { + r.Body = io.NopCloser(bytes.NewReader(req.Params)) + var payload *feed.SnapshotPayload + if !req.HasID || req.ID == nil { + return payload, goa.MissingFieldError("id", "JSON-RPC request") + } + var requestID string + decodedID, decodeErr := jsonrpc.IDToString(req.ID) + if decodeErr != nil { + return payload, goa.DecodePayloadError(decodeErr.Error()) + } + requestID = decodedID + payload = NewSnapshotPayload(requestID) return payload, nil } diff --git a/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/feed/server/paths.go.golden b/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/feed/server/paths.go.golden index 8df7009ac8..e9ccf29038 100644 --- a/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/feed/server/paths.go.golden +++ b/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/feed/server/paths.go.golden @@ -11,3 +11,8 @@ package server func WatchFeedPath() string { return "/feed" } + +// SnapshotFeedPath returns the URL path to the Feed service snapshot HTTP endpoint. +func SnapshotFeedPath() string { + return "/feed" +} diff --git a/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/feed/server/server.go.golden b/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/feed/server/server.go.golden index fa9536278a..a2f189b138 100644 --- a/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/feed/server/server.go.golden +++ b/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/feed/server/server.go.golden @@ -8,16 +8,164 @@ package server import ( + "bufio" + "bytes" "context" + "errors" "fmt" - feed "kitchensink/feed" + "io" + "mime" "net/http" + "strconv" + "strings" + "sync" + feed "generated.local/gen/feed" goahttp "goa.design/goa/v3/http" "goa.design/goa/v3/jsonrpc" goa "goa.design/goa/v3/pkg" ) +type ( + // sseServerStream writes JSON-RPC messages as server-sent events. + sseServerStream struct { + // once writes the HTTP headers only for the first event. + once sync.Once + // w receives the HTTP headers and event bytes. + w http.ResponseWriter + // encoder turns one JSON-RPC message into bytes. + encoder func(context.Context, http.ResponseWriter) goahttp.Encoder + } + + // sseEventBuffer stores an encoded event before any HTTP output is written. + sseEventBuffer struct { + bytes.Buffer + header http.Header + } + // noOutputResponseWriter accepts notification output without storing or + // sending it. + noOutputResponseWriter struct { + header http.Header + } +) + +// Header returns the headers written while the event is being encoded. +func (b *sseEventBuffer) Header() http.Header { + return b.header +} + +// WriteHeader leaves the response status for the real HTTP response writer. +func (b *sseEventBuffer) WriteHeader(int) { +} + +// Header returns the private headers written while a notification is encoded. +func (w *noOutputResponseWriter) Header() http.Header { + return w.header +} + +// Write accepts notification bytes without storing or sending them. +func (w *noOutputResponseWriter) Write(data []byte) (int, error) { + return len(data), nil +} + +// WriteHeader accepts a notification status without sending it. +func (w *noOutputResponseWriter) WriteHeader(int) { +} + +// Flush completes successfully because notification bytes are not sent. +func (w *noOutputResponseWriter) Flush() { +} + +// initSSEHeaders writes the response headers before the first event. +func (s *sseServerStream) initSSEHeaders() { + s.once.Do(func() { + header := s.w.Header() + header.Set("Content-Type", "text/event-stream") + header.Set("Cache-Control", "no-cache") + header.Set("Connection", "keep-alive") + header.Set("X-Accel-Buffering", "no") + s.w.WriteHeader(http.StatusOK) + }) +} + +// sendSSEEvent encodes one complete JSON-RPC message before starting the +// response, then writes its mapped SSE fields and data. +func (s *sseServerStream) sendSSEEvent(ctx context.Context, value any, id, eventType, retry *string) error { + if id != nil { + for _, character := range *id { + if character == 0 || character == '\r' || character == '\n' { + return fmt.Errorf("server-sent event id contains a forbidden character") + } + } + } + if eventType != nil { + for _, character := range *eventType { + if character == '\r' || character == '\n' { + return fmt.Errorf("server-sent event name contains a line break") + } + } + } + if retry != nil { + if *retry == "" { + return fmt.Errorf("server-sent event retry must contain only decimal digits") + } + for index := 0; index < len(*retry); index++ { + if (*retry)[index] < '0' || (*retry)[index] > '9' { + return fmt.Errorf("server-sent event retry must contain only decimal digits") + } + } + } + + event := &sseEventBuffer{header: make(http.Header)} + if err := s.encoder(ctx, event).Encode(value); err != nil { + return err + } + + s.initSSEHeaders() + if id != nil { + if _, err := fmt.Fprintf(s.w, "id: %s\n", *id); err != nil { + return fmt.Errorf("write server-sent event id: %w", err) + } + } + if eventType != nil { + if _, err := fmt.Fprintf(s.w, "event: %s\n", *eventType); err != nil { + return fmt.Errorf("write server-sent event name: %w", err) + } + } + if retry != nil { + if _, err := fmt.Fprintf(s.w, "retry: %s\n", *retry); err != nil { + return fmt.Errorf("write server-sent event retry: %w", err) + } + } + data := bytes.ReplaceAll(event.Bytes(), []byte("\r\n"), []byte("\n")) + data = bytes.ReplaceAll(data, []byte("\r"), []byte("\n")) + data = bytes.TrimSuffix(data, []byte("\n")) + for _, line := range bytes.Split(data, []byte("\n")) { + if _, err := s.w.Write([]byte("data: ")); err != nil { + return fmt.Errorf("write server-sent event data label: %w", err) + } + if _, err := s.w.Write(line); err != nil { + return fmt.Errorf("write server-sent event data: %w", err) + } + if _, err := s.w.Write([]byte("\n")); err != nil { + return fmt.Errorf("finish server-sent event data line: %w", err) + } + } + if _, err := s.w.Write([]byte("\n")); err != nil { + return fmt.Errorf("finish server-sent event: %w", err) + } + if err := http.NewResponseController(s.w).Flush(); err != nil { + return fmt.Errorf("flush server-sent event: %w", err) + } + return nil +} + +// sendError writes one JSON-RPC error as a server-sent event. +func (s *sseServerStream) sendError(ctx context.Context, id any, code jsonrpc.Code, message string, data any) error { + response := jsonrpc.MakeErrorResponse(id, code, message, data) + return s.sendSSEEvent(ctx, response, nil, nil, nil) +} + // Server handles JSON-RPC requests for the Feed service. type Server struct { http.Handler @@ -26,6 +174,8 @@ type Server struct { // Watch is the handler for the watch method. Watch func(context.Context, *http.Request, *jsonrpc.RawRequest, http.ResponseWriter) error + // Snapshot is the handler for the snapshot method. + Snapshot func(context.Context, *http.Request, *jsonrpc.RawRequest, http.ResponseWriter) error decoder func(*http.Request) goahttp.Decoder encoder func(context.Context, http.ResponseWriter) goahttp.Encoder @@ -44,21 +194,29 @@ func New( s := &Server{ Methods: []string{ "watch", + "snapshot", }, Watch: NewWatchHandler(endpoints.Watch, mux, decoder, encoder, errhandler), + Snapshot: NewSnapshotHandler(endpoints.Snapshot, mux, decoder, encoder, errhandler), decoder: decoder, encoder: encoder, errhandler: errhandler, } - // Default HTTP handler per transport kind - // SSE-only services route via handleSSE - s.Handler = http.HandlerFunc(s.handleSSE) + // Install the request handler required by this service's methods. + s.Handler = http.HandlerFunc(s.ServeHTTP) return s } // Service returns the name of the service served. func (s *Server) Service() string { return "Feed" } +// reportRejectedNotification reports a notification sent to a request-only +// method without allowing the error handler to write a response. +func (s *Server) reportRejectedNotification(ctx context.Context, req *jsonrpc.RawRequest) { + outputWriter := &noOutputResponseWriter{header: make(http.Header)} + s.errhandler(ctx, outputWriter, fmt.Errorf("JSON-RPC notification cannot call request method %q", req.Method)) +} + // Use wraps the server handlers with the given middleware. func (s *Server) Use(m func(http.Handler) http.Handler) { s.Handler = m(s.Handler) @@ -67,56 +225,320 @@ func (s *Server) Use(m func(http.Handler) http.Handler) { // MethodNames returns the methods served. func (s *Server) MethodNames() []string { return feed.MethodNames[:] } -// handleSSE handles JSON-RPC SSE requests by dispatching to the appropriate method. -func (s *Server) handleSSE(w http.ResponseWriter, r *http.Request) { - ctx := r.Context() +// ServeHTTP decodes one request and uses the response type designed for its method. +func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { + acceptJSON := false + acceptSSE := false + acceptValues := r.Header.Values("Accept") + if len(acceptValues) == 0 || len(acceptValues) == 1 && strings.TrimSpace(acceptValues[0]) == "" { + acceptJSON = true + acceptSSE = true + } else { + for _, header := range acceptValues { + for _, value := range strings.Split(header, ",") { + mediaType, params, err := mime.ParseMediaType(value) + if err != nil { + continue + } + quality := 1.0 + if value, ok := params["q"]; ok { + quality, err = strconv.ParseFloat(value, 64) + if err != nil { + continue + } + } + if quality <= 0 { + continue + } + switch mediaType { + case "*/*": + acceptJSON = true + acceptSSE = true + case "application/json", "application/*": + acceptJSON = true + case "text/event-stream", "text/*": + acceptSSE = true + } + } + } + } + + originalBody := r.Body + bufReader := bufio.NewReader(originalBody) + var peek []byte + for { + var err error + peek, err = bufReader.Peek(1) + if err != nil && err != io.EOF { + closeErr := originalBody.Close() + s.errhandler(r.Context(), w, fmt.Errorf("failed to read request body: %w", errors.Join(err, closeErr))) + return + } + if len(peek) == 0 || (peek[0] != ' ' && peek[0] != '\t' && peek[0] != '\n' && peek[0] != '\r') { + break + } + if _, err := bufReader.Discard(1); err != nil { + closeErr := originalBody.Close() + s.errhandler(r.Context(), w, fmt.Errorf("failed to read request body: %w", errors.Join(err, closeErr))) + return + } + } + r.Body = io.NopCloser(bufReader) + + // Request arrays always use ordinary JSON-RPC responses. Streaming methods + // in an array receive one method error and are not called. + if len(peek) > 0 && peek[0] == '[' { + defer func() { + if err := originalBody.Close(); err != nil { + s.errhandler(r.Context(), w, fmt.Errorf("failed to close request body: %w", err)) + } + }() + if !acceptJSON { + w.WriteHeader(http.StatusNotAcceptable) + return + } + s.handleBatch(w, r) + return + } - // Read the JSON-RPC request + // Decode the request once so the generated method switch below can choose + // both the handler and its response type. var req jsonrpc.RawRequest if err := s.decoder(r).Decode(&req); err != nil { - // Emit JSON-RPC parse error as SSE event - stream := &sseServerStream{w: w, r: r, encoder: s.encoder} - _ = stream.sendError(ctx, nil, jsonrpc.ParseError, "Parse error", nil) + closeErr := originalBody.Close() + s.errhandler(r.Context(), w, fmt.Errorf("failed to read request body: %w", errors.Join(err, closeErr))) + switch { + case acceptJSON: + response := jsonrpc.MakeErrorResponse(nil, jsonrpc.ParseError, "Parse error", nil) + if encErr := s.encoder(r.Context(), w).Encode(response); encErr != nil { + s.errhandler(r.Context(), w, fmt.Errorf("failed to encode parse error response: %w", encErr)) + } + case acceptSSE: + stream := &sseServerStream{w: w, encoder: s.encoder} + if sendErr := stream.sendError(r.Context(), nil, jsonrpc.ParseError, "Parse error", nil); sendErr != nil { + s.errhandler(r.Context(), w, fmt.Errorf("write parse error event: %w", sendErr)) + } + default: + w.WriteHeader(http.StatusNotAcceptable) + } return } + defer func() { + if err := originalBody.Close(); err != nil { + s.errhandler(r.Context(), w, fmt.Errorf("failed to close request body: %w", err)) + } + }() - // Validate JSON-RPC request - if req.JSONRPC != "2.0" { - stream := &sseServerStream{w: w, r: r, encoder: s.encoder} - _ = stream.sendError(ctx, req.ID, jsonrpc.InvalidRequest, "Invalid request", nil) + // Invalid and unknown requests do not have a designed response type. Use + // JSON when the client accepts it, then events, or reject the response. + if req.Invalid || req.JSONRPC != "2.0" || !req.HasMethod { + switch { + case acceptJSON: + s.processRequest(r.Context(), r, &req, w) + case acceptSSE: + s.processSSERequest(r.Context(), r, &req, w) + default: + w.WriteHeader(http.StatusNotAcceptable) + } return } - if req.Method == "" { - stream := &sseServerStream{w: w, r: r, encoder: s.encoder} - _ = stream.sendError(ctx, req.ID, jsonrpc.InvalidRequest, "Invalid request", nil) + switch req.Method { + case "watch": + if !acceptSSE { + w.WriteHeader(http.StatusNotAcceptable) + return + } + s.processSSERequest(r.Context(), r, &req, w) + case "snapshot": + if !acceptJSON { + w.WriteHeader(http.StatusNotAcceptable) + return + } + s.processRequest(r.Context(), r, &req, w) + default: + switch { + case acceptJSON: + s.processRequest(r.Context(), r, &req, w) + case acceptSSE: + s.processSSERequest(r.Context(), r, &req, w) + default: + w.WriteHeader(http.StatusNotAcceptable) + } + } +} + +// handleBatch handles an array of JSON-RPC values and writes the required responses. +func (s *Server) handleBatch(w http.ResponseWriter, r *http.Request) { + var reqs []jsonrpc.RawRequest + if err := s.decoder(r).Decode(&reqs); err != nil { + // An array that cannot be decoded receives the JSON-RPC parse error. + response := jsonrpc.MakeErrorResponse(nil, jsonrpc.ParseError, "Parse error", nil) + if encErr := s.encoder(r.Context(), w).Encode(response); encErr != nil { + s.errhandler(r.Context(), w, fmt.Errorf("failed to encode parse error response: %w", encErr)) + } + return + } + if len(reqs) == 0 { + // JSON-RPC defines an empty request array as one invalid request. + response := jsonrpc.MakeErrorResponse(nil, jsonrpc.InvalidRequest, "Invalid request", nil) + if err := s.encoder(r.Context(), w).Encode(response); err != nil { + s.errhandler(r.Context(), w, fmt.Errorf("failed to encode invalid request response: %w", err)) + } + return + } + + // Write every response into one JSON array. + w.Header().Set("Content-Type", "application/json") + writer := &batchWriter{Writer: w} + + for _, req := range reqs { + // The writer inserts the array separators around each response. + s.processRequest(r.Context(), r, &req, writer) + } + + // Write the closing bracket only when at least one request produced a response. + if writer.written { + if _, err := writer.Writer.Write([]byte{']'}); err != nil { + s.errhandler(r.Context(), w, fmt.Errorf("failed to close JSON-RPC batch response: %w", err)) + } + } +} + +// processRequest validates the JSON-RPC version and method, then calls the matching handler. +func (s *Server) processRequest(ctx context.Context, r *http.Request, req *jsonrpc.RawRequest, w http.ResponseWriter) { + if req.Invalid || req.JSONRPC != "2.0" { + s.encodeJSONRPCError(ctx, w, req, jsonrpc.InvalidRequest, "Invalid request", nil) + return + } + + if !req.HasMethod { + s.encodeJSONRPCError(ctx, w, req, jsonrpc.InvalidRequest, "Missing method field", nil) return } - // Find the appropriate handler based on method name - var handler func(context.Context, *http.Request, *jsonrpc.RawRequest, http.ResponseWriter) error switch req.Method { case "watch": - handler = s.Watch + if !req.HasID { + s.reportRejectedNotification(ctx, req) + return + } + if req.ID == nil { + s.encodeJSONRPCError(ctx, w, req, jsonrpc.InvalidRequest, "Invalid request", nil) + return + } + s.encodeJSONRPCError(ctx, w, req, jsonrpc.MethodNotFound, "Method is not available in a batch request", nil) + case "snapshot": + if !req.HasID { + s.reportRejectedNotification(ctx, req) + return + } + if req.ID == nil { + s.encodeJSONRPCError(ctx, w, req, jsonrpc.InvalidRequest, "Invalid request", nil) + return + } + if err := s.Snapshot(ctx, r, req, w); err != nil { + s.errhandler(ctx, w, fmt.Errorf("handler error for %s: %w", "snapshot", err)) + } default: - stream := &sseServerStream{w: w, r: r, encoder: s.encoder} - _ = stream.sendError(ctx, req.ID, jsonrpc.MethodNotFound, "Method not found", nil) + if req.HasID { + s.encodeJSONRPCError(ctx, w, req, jsonrpc.MethodNotFound, "Method not found", nil) + } + } +} + +// batchWriter inserts JSON array separators around responses from one request +// array. +type batchWriter struct { + io.Writer + header http.Header + statusCode int + written bool +} + +func (rb *batchWriter) Header() http.Header { + if rb.header == nil { + rb.header = make(http.Header) + } + return rb.header +} + +func (rb *batchWriter) WriteHeader(statusCode int) { + if rb.written { return } + rb.statusCode = statusCode +} - // Call the handler for the specific method - if err := handler(ctx, r, &req, w); err != nil { - s.errhandler(ctx, w, fmt.Errorf("handler error for %s: %w", req.Method, err)) +func (rb *batchWriter) Write(data []byte) (int, error) { + separator := byte(',') + if !rb.written { + separator = '[' + } + if _, err := rb.Writer.Write([]byte{separator}); err != nil { + return 0, err + } + rb.written = true + return rb.Writer.Write(data) +} + +// processSSERequest validates and runs one server-sent-event request. +func (s *Server) processSSERequest(ctx context.Context, r *http.Request, req *jsonrpc.RawRequest, w http.ResponseWriter) { + + // Reject requests that do not use JSON-RPC 2.0. + if req.Invalid || req.JSONRPC != "2.0" { + stream := &sseServerStream{w: w, encoder: s.encoder} + if err := stream.sendError(ctx, req.ID, jsonrpc.InvalidRequest, "Invalid request", nil); err != nil { + s.errhandler(ctx, w, fmt.Errorf("write invalid request event: %w", err)) + } + return + } + + if !req.HasMethod { + stream := &sseServerStream{w: w, encoder: s.encoder} + if err := stream.sendError(ctx, req.ID, jsonrpc.InvalidRequest, "Invalid request", nil); err != nil { + s.errhandler(ctx, w, fmt.Errorf("write invalid request event: %w", err)) + } return } - // For notifications (requests without ID) that don't stream, return 204 No Content + // Find the function for the requested method. + var handler func(context.Context, *http.Request, *jsonrpc.RawRequest, http.ResponseWriter) error switch req.Method { + case "watch": + if !req.HasID { + s.reportRejectedNotification(ctx, req) + return + } + if req.ID == nil { + stream := &sseServerStream{w: w, encoder: s.encoder} + if err := stream.sendError(ctx, req.ID, jsonrpc.InvalidRequest, "Invalid request", nil); err != nil { + s.errhandler(ctx, w, fmt.Errorf("write invalid request event: %w", err)) + } + return + } + handler = s.Watch + default: + if !req.HasID { + return + } + stream := &sseServerStream{w: w, encoder: s.encoder} + if err := stream.sendError(ctx, req.ID, jsonrpc.MethodNotFound, "Method not found", nil); err != nil { + s.errhandler(ctx, w, fmt.Errorf("write method not found event: %w", err)) + } + return } -} // Mount configures the mux to serve the JSON-RPC Feed service methods. + + // Call the requested method. + if err := handler(ctx, r, req, w); err != nil { + s.errhandler(ctx, w, fmt.Errorf("handler error for %s: %w", req.Method, err)) + } +} + +// Mount configures the mux to serve the JSON-RPC Feed service methods. func Mount(mux goahttp.Muxer, h *Server) { - // SSE only: mount SSE handler - mux.Handle("POST", "/feed", h.handleSSE) + // ServeHTTP chooses ordinary JSON-RPC handling or server-sent events. + mux.Handle("POST", "/feed", h.ServeHTTP) } // Mount configures the mux to serve the JSON-RPC Feed service methods. @@ -136,55 +558,73 @@ func NewWatchHandler( return func(ctx context.Context, r *http.Request, req *jsonrpc.RawRequest, w http.ResponseWriter) error { ctx = context.WithValue(ctx, goa.MethodKey, "watch") ctx = context.WithValue(ctx, goa.ServiceKey, "Feed") - // Initialize SSE stream early so decode errors can be sent as SSE error events + // Create the stream before decoding so request failures can be sent on the + // same HTTP response. strm := &WatchServerStream{ sseServerStream: sseServerStream{ w: w, - r: r, encoder: encoder, }, - requestID: req.ID, } decodeParams := DecodeWatchRequest(mux, decoder) params, err := decodeParams(r, req) if err != nil { - // Send error via SSE (JSON-RPC error event) to match SSE transport semantics - if req.ID != nil && req.ID != "" { - strm.SendError(ctx, jsonrpc.IDToString(req.ID), err) - } - return nil - } - if req.ID != nil { - params.RequestID = jsonrpc.IDToString(req.ID) - } - // Set Last-Event-ID header if present - if lastEventID := r.Header.Get("Last-Event-ID"); lastEventID != "" { - ctx = context.WithValue(ctx, "last-event-id", lastEventID) + return strm.sendError(ctx, req.ID, jsonrpc.InvalidParams, err.Error(), nil) } v := &feed.WatchEndpointInput{ Stream: strm, Payload: params, } - if _, err := endpoint(ctx, v); err != nil { - // Send the error as a JSON-RPC error event; SendError applies the - // design-driven error code mapping. - if req.ID != nil && req.ID != "" { - return strm.SendError(ctx, jsonrpc.IDToString(req.ID), err) - } + _, err = endpoint(ctx, v) + if err != nil { + return strm.sendError(ctx, req.ID, jsonrpc.InternalError, err.Error(), nil) + } + response := jsonrpc.MakeSuccessResponse(req.ID, nil) + return strm.sendSSEEvent(ctx, response, nil, nil, nil) + } +} + +// NewSnapshotHandler creates a JSON-RPC handler which calls the "Feed" service +// "snapshot" endpoint. +func NewSnapshotHandler( + endpoint goa.Endpoint, + mux goahttp.Muxer, + decoder func(*http.Request) goahttp.Decoder, + encoder func(context.Context, http.ResponseWriter) goahttp.Encoder, + errhandler func(context.Context, http.ResponseWriter, error), +) func(context.Context, *http.Request, *jsonrpc.RawRequest, http.ResponseWriter) error { + decodeParams := DecodeSnapshotRequest(mux, decoder) + return func(ctx context.Context, r *http.Request, req *jsonrpc.RawRequest, w http.ResponseWriter) error { + ctx = context.WithValue(ctx, goa.MethodKey, "snapshot") + ctx = context.WithValue(ctx, goa.ServiceKey, "Feed") + params, err := decodeParams(r, req) + if err != nil { + encodeJSONRPCError(ctx, w, req, jsonrpc.InvalidParams, err.Error(), nil, encoder, errhandler) return nil } + res, err := endpoint(ctx, params) + if err != nil { + encodeJSONRPCError(ctx, w, req, jsonrpc.InternalError, err.Error(), nil, encoder, errhandler) + return nil + } + + // The response repeats the exact request ID. + response := jsonrpc.MakeSuccessResponse(req.ID, res) + if err := encoder(ctx, w).Encode(response); err != nil { + errhandler(ctx, w, fmt.Errorf("failed to encode JSON-RPC response: %w", err)) + } return nil } } -// encodeJSONRPCError creates and sends a JSON-RPC error response (handles nil -// ID gracefully) +// encodeJSONRPCError writes one error, copying the request ID or using null +// when none is available. func (s *Server) encodeJSONRPCError(ctx context.Context, w http.ResponseWriter, req *jsonrpc.RawRequest, code jsonrpc.Code, message string, data any) { encodeJSONRPCError(ctx, w, req, code, message, data, s.encoder, s.errhandler) } -// encodeJSONRPCError creates and sends a JSON-RPC error response (handles nil -// ID gracefully) +// encodeJSONRPCError writes one error, copying the request ID or using null +// when none is available. func encodeJSONRPCError( ctx context.Context, w http.ResponseWriter, @@ -195,10 +635,8 @@ func encodeJSONRPCError( encoder func(context.Context, http.ResponseWriter) goahttp.Encoder, errhandler func(context.Context, http.ResponseWriter, error), ) { - if req.ID != nil { - response := jsonrpc.MakeErrorResponse(req.ID, code, message, data) - if err := encoder(ctx, w).Encode(response); err != nil { - errhandler(ctx, w, fmt.Errorf("failed to encode JSON-RPC response: %w", err)) - } + response := jsonrpc.MakeErrorResponse(req.ID, code, message, data) + if err := encoder(ctx, w).Encode(response); err != nil { + errhandler(ctx, w, fmt.Errorf("failed to encode JSON-RPC response: %w", err)) } } diff --git a/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/feed/server/sse.go.golden b/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/feed/server/sse.go.golden index 14f21e3c8e..44df0015e5 100644 --- a/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/feed/server/sse.go.golden +++ b/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/feed/server/sse.go.golden @@ -9,175 +9,42 @@ package server import ( "context" - "fmt" - feed "kitchensink/feed" - "net/http" - "sync" - goahttp "goa.design/goa/v3/http" + feed "generated.local/gen/feed" "goa.design/goa/v3/jsonrpc" - goa "goa.design/goa/v3/pkg" ) -// sseServerStream provides the SSE event encoding machinery shared by all -// JSON-RPC SSE server streams of the service. -type sseServerStream struct { - // once ensures the headers are written once. - once sync.Once - // w is the HTTP response writer used to send the SSE events. - w http.ResponseWriter - // r is the HTTP request. - r *http.Request - // encoder is the response encoder. - encoder func(context.Context, http.ResponseWriter) goahttp.Encoder -} - -// sseEventWriter wraps http.ResponseWriter to format output as SSE events. -type sseEventWriter struct { - w http.ResponseWriter - eventType string - started bool -} - -func (s *sseEventWriter) Header() http.Header { return s.w.Header() } -func (s *sseEventWriter) WriteHeader(statusCode int) { s.w.WriteHeader(statusCode) } -func (s *sseEventWriter) Write(data []byte) (int, error) { - if !s.started { - s.started = true - if s.eventType != "" { - fmt.Fprintf(s.w, "event: %s\n", s.eventType) - } - s.w.Write([]byte("data: ")) - } - return s.w.Write(data) -} - -func (s *sseEventWriter) finish() { - if s.started { - s.w.Write([]byte("\n\n")) - http.NewResponseController(s.w).Flush() - } -} - -// initSSEHeaders initializes the SSE response headers -func (s *sseServerStream) initSSEHeaders() { - s.once.Do(func() { - header := s.w.Header() - header.Set("Content-Type", "text/event-stream") - header.Set("Cache-Control", "no-cache") - header.Set("Connection", "keep-alive") - header.Set("X-Accel-Buffering", "no") - s.w.WriteHeader(http.StatusOK) - }) -} - -// sendSSEEvent sends a single SSE event by creating an encoder that writes to the event writer -func (s *sseServerStream) sendSSEEvent(eventType string, v any) error { - s.initSSEHeaders() - - // Create SSE event writer that wraps the response writer - ew := &sseEventWriter{w: s.w, eventType: eventType} - - // Create encoder with the event writer and encode the value - err := s.encoder(context.Background(), ew).Encode(v) - - // Finish the SSE event (adds newlines and flushes) - ew.finish() - - return err -} - -// sendError sends a JSON-RPC error response to the SSE stream -func (s *sseServerStream) sendError(ctx context.Context, id any, code jsonrpc.Code, message string, data any) error { - response := jsonrpc.MakeErrorResponse(id, code, message, data) - return s.sendSSEEvent("error", response) -} - // WatchServerStream implements the feed.WatchServerStream interface using // Server-Sent Events. type WatchServerStream struct { - // sseServerStream provides the shared SSE event encoding machinery + // sseServerStream writes JSON-RPC messages as server-sent events. sseServerStream - // requestID is the JSON-RPC request ID for sending final response - requestID any - // closed indicates if the stream has been closed via SendAndClose - closed bool - // mu protects the closed flag - mu sync.Mutex } -// Send sends a JSON-RPC notification to the client. -// Notifications do not expect a response from the client. -func (s *WatchServerStream) Send(ctx context.Context, event feed.WatchEvent) error { - // Check if stream is closed - s.mu.Lock() - if s.closed { - s.mu.Unlock() - return fmt.Errorf("stream closed") - } - s.mu.Unlock() - - // Type assert to the specific result type - result, ok := event.(*feed.WatchResult) - if !ok { - return fmt.Errorf("unexpected event type: %T", event) - } - // Convert to response body type for proper JSON encoding - body := NewWatchResponseBody(result) - - // Send as notification (no ID) - message := map[string]any{ - "jsonrpc": "2.0", - "method": "watch", - "params": body, - } - - return s.sendSSEEvent("notification", message) +// Send streams instances of "WatchResult". +func (s *WatchServerStream) Send(event *feed.WatchResult) error { + return s.SendWithContext(context.Background(), event) } -// SendAndClose sends a final JSON-RPC response to the client and closes the -// stream. -// The response will include the original request ID unless the result has an -// ID field populated. -// After calling this method, no more events can be sent on this stream. -func (s *WatchServerStream) SendAndClose(ctx context.Context, event feed.WatchEvent) error { - // Check if stream is already closed - s.mu.Lock() - if s.closed { - s.mu.Unlock() - return fmt.Errorf("stream already closed") - } - s.closed = true - s.mu.Unlock() - - // Type assert to the specific result type - result, ok := event.(*feed.WatchResult) - if !ok { - return fmt.Errorf("unexpected event type: %T", event) - } - - // Determine the ID to use for the response - var id any = s.requestID - // Convert to response body type for proper JSON encoding +// SendWithContext streams instances of "WatchResult" with context. +func (s *WatchServerStream) SendWithContext(ctx context.Context, event *feed.WatchResult) error { + result := event body := NewWatchResponseBody(result) - - // Send as response with ID - message := map[string]any{ - "jsonrpc": "2.0", - "id": id, - "result": body, + var eventID *string + if result.EventID != nil { + value := string(*result.EventID) + eventID = &value } - return s.sendSSEEvent("response", message) + message := jsonrpc.MakeNotification( + "watch", + body, + ) + return s.sendSSEEvent(ctx, message, eventID, nil, nil) } -// SendError sends a JSON-RPC error response. -func (s *WatchServerStream) SendError(ctx context.Context, id string, err error) error { - // No custom errors defined - check if it's a validation error, otherwise use - // internal error - code := jsonrpc.InternalError - if _, ok := err.(*goa.ServiceError); ok { - code = jsonrpc.InvalidParams - } - return s.sendError(ctx, id, code, err.Error(), nil) +// Close does nothing because the HTTP response closes when the service method +// returns. +func (s *WatchServerStream) Close() error { + return nil } diff --git a/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/feed/server/types.go.golden b/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/feed/server/types.go.golden index 79512461e3..425dd11806 100644 --- a/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/feed/server/types.go.golden +++ b/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/feed/server/types.go.golden @@ -7,20 +7,7 @@ package server -import ( - feed "kitchensink/feed" - - goa "goa.design/goa/v3/pkg" -) - -// WatchRequestBody is the type of the "Feed" service "watch" endpoint HTTP -// request body. -type WatchRequestBody struct { - // Request ID - RequestID *string `form:"request_id,omitempty" json:"request_id,omitempty" xml:"request_id,omitempty"` - // Last received event ID - LastEventID *string `form:"last_event_id,omitempty" json:"last_event_id,omitempty" xml:"last_event_id,omitempty"` -} +import feed "generated.local/gen/feed" // WatchResponseBody is the type of the "Feed" service "watch" endpoint HTTP // response body. @@ -42,19 +29,18 @@ func NewWatchResponseBody(res *feed.WatchResult) *WatchResponseBody { } // NewWatchPayload builds a Feed service watch endpoint payload. -func NewWatchPayload(body *WatchRequestBody) *feed.WatchPayload { - v := &feed.WatchPayload{ - RequestID: *body.RequestID, - LastEventID: body.LastEventID, - } +func NewWatchPayload(lastEventID *string, requestID string) *feed.WatchPayload { + v := &feed.WatchPayload{} + v.LastEventID = lastEventID + v.RequestID = requestID return v } -// ValidateWatchRequestBody runs the validations defined on WatchRequestBody -func ValidateWatchRequestBody(body *WatchRequestBody) (err error) { - if body.RequestID == nil { - err = goa.MergeErrors(err, goa.MissingFieldError("request_id", "body")) - } - return +// NewSnapshotPayload builds a Feed service snapshot endpoint payload. +func NewSnapshotPayload(requestID string) *feed.SnapshotPayload { + v := &feed.SnapshotPayload{} + v.RequestID = requestID + + return v } diff --git a/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/mixed/client/cli.go.golden b/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/mixed/client/cli.go.golden index cc9f3238d7..2f062288c0 100644 --- a/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/mixed/client/cli.go.golden +++ b/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/mixed/client/cli.go.golden @@ -10,24 +10,35 @@ package client import ( "encoding/json" "fmt" - mixed "kitchensink/mixed" + + mixed "generated.local/gen/mixed" ) // BuildLookupPayload builds the payload for the Mixed lookup endpoint from CLI // flags. -func BuildLookupPayload(mixedLookupBody string) (*mixed.LookupPayload, error) { +func BuildLookupPayload(mixedLookupBody *string, mixedLookupRequestID *string) (*mixed.LookupPayload, error) { var err error var body LookupRequestBody { - err = json.Unmarshal([]byte(mixedLookupBody), &body) + if mixedLookupBody == nil { + return nil, fmt.Errorf("missing required flag --body") + } + err = json.Unmarshal([]byte(*mixedLookupBody), &body) if err != nil { - return nil, fmt.Errorf("invalid JSON for body, \nerror: %s, \nexample of valid JSON:\n%s", err, "'{\n \"id\": \"Mollitia voluptatum expedita velit assumenda.\",\n \"key\": \"Blanditiis sed voluptatum odit dolores impedit.\"\n }'") + return nil, fmt.Errorf("invalid JSON for body, \nerror: %s, \nexample of valid JSON:\n%s", err, "'{\n \"key\": \"Earum amet voluptatum ad soluta.\"\n }'") + } + } + var requestID string + { + if mixedLookupRequestID == nil { + return nil, fmt.Errorf("missing required flag --request-id") } + requestID = *mixedLookupRequestID } v := &mixed.LookupPayload{ - ID: body.ID, Key: body.Key, } + v.ID = requestID return v, nil } diff --git a/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/mixed/client/client.go.golden b/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/mixed/client/client.go.golden index 9f65d3eea8..84f32392a8 100644 --- a/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/mixed/client/client.go.golden +++ b/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/mixed/client/client.go.golden @@ -31,12 +31,12 @@ type Client struct { decoder func(*http.Response) goahttp.Decoder } -// bufferPool is a pool of bytes.Buffers for encoding requests. +// bufferPool reuses byte buffers while requests are encoded. var bufferPool = sync.Pool{ New: func() any { return new(bytes.Buffer) }, } -// NewClient instantiates HTTP clients for all the Mixed service servers. +// NewClient creates HTTP clients for all the Mixed service servers. func NewClient( scheme string, host string, @@ -45,7 +45,6 @@ func NewClient( dec func(*http.Response) goahttp.Decoder, restoreBody bool, ) *Client { - return &Client{ Doer: doer, RestoreResponseBody: restoreBody, @@ -68,13 +67,14 @@ func (c *Client) Lookup() goa.Endpoint { if err != nil { return nil, err } - if err := encodeRequest(req, v); err != nil { + requestID, err := encodeRequest(req, v) + if err != nil { return nil, err } resp, err := c.Doer.Do(req) if err != nil { return nil, goahttp.ErrRequestError("Mixed", "lookup", err) } - return decodeResponse(resp) + return decodeResponse(resp, requestID) } } diff --git a/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/mixed/client/encode_decode.go.golden b/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/mixed/client/encode_decode.go.golden index 70091bef0b..3d391ef32c 100644 --- a/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/mixed/client/encode_decode.go.golden +++ b/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/mixed/client/encode_decode.go.golden @@ -10,11 +10,12 @@ package client import ( "bytes" "context" + "errors" "io" - mixed "kitchensink/mixed" "net/http" "net/url" + mixed "generated.local/gen/mixed" goahttp "goa.design/goa/v3/http" "goa.design/goa/v3/jsonrpc" ) @@ -34,13 +35,14 @@ func (c *Client) BuildLookupRequest(ctx context.Context, v any) (*http.Request, return req, nil } -// EncodeLookupRequest returns an encoder for requests sent to the Mixed lookup -// server. -func EncodeLookupRequest(encoder func(*http.Request) goahttp.Encoder) func(*http.Request, any) error { - return func(req *http.Request, v any) error { +// EncodeLookupRequest returns an encoder for requests sent to the Mixed +// service lookup JSON-RPC method. The encoder returns the request ID written +// into the JSON-RPC message. +func EncodeLookupRequest(encoder func(*http.Request) goahttp.Encoder) func(*http.Request, any) (string, error) { + return func(req *http.Request, v any) (string, error) { p, ok := v.(*mixed.LookupPayload) if !ok { - return goahttp.ErrInvalidType("Mixed", "lookup", "*mixed.LookupPayload", v) + return "", goahttp.ErrInvalidType("Mixed", "lookup", "*mixed.LookupPayload", v) } b := NewLookupRequestBody(p) body := &jsonrpc.Request{ @@ -48,36 +50,45 @@ func EncodeLookupRequest(encoder func(*http.Request) goahttp.Encoder) func(*http Method: "lookup", Params: b, } - if p.ID != "" { - body.ID = p.ID - } - // If ID is empty, this is a notification - no ID field + requestID := p.ID + body.ID = requestID if err := encoder(req).Encode(&body); err != nil { - return goahttp.ErrEncodingError("Mixed", "lookup", err) + return "", goahttp.ErrEncodingError("Mixed", "lookup", err) } - return nil + return requestID, nil } } // DecodeLookupResponse returns a decoder for responses returned by the Mixed -// service lookup JSON-RPC method. restoreBody controls whether the response -// body should be restored after having been read. -func DecodeLookupResponse(decoder func(*http.Response) goahttp.Decoder, restoreBody bool) func(*http.Response) (any, error) { - return func(resp *http.Response) (any, error) { +// service lookup JSON-RPC method. The decoder rejects responses that do not +// repeat requestID. restoreBody controls whether the response body should be +// restored after having been read. +func DecodeLookupResponse(decoder func(*http.Response) goahttp.Decoder, restoreBody bool) func(*http.Response, string) (any, error) { + return func(resp *http.Response, requestID string) (result any, decodeErr error) { + responseBody := resp.Body if restoreBody { - b, err := io.ReadAll(resp.Body) - if err != nil { - return nil, err + b, readErr := io.ReadAll(responseBody) + closeErr := responseBody.Close() + if err := errors.Join(readErr, closeErr); err != nil { + return nil, goahttp.ErrDecodingError("Mixed", "lookup", err) } resp.Body = io.NopCloser(bytes.NewBuffer(b)) defer func() { resp.Body = io.NopCloser(bytes.NewBuffer(b)) }() + } else { + defer func() { + if err := responseBody.Close(); err != nil { + decodeErr = errors.Join(decodeErr, goahttp.ErrDecodingError("Mixed", "lookup", err)) + } + }() } - defer resp.Body.Close() if resp.StatusCode != http.StatusOK { - body, _ := io.ReadAll(resp.Body) + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, goahttp.ErrDecodingError("Mixed", "lookup", err) + } return nil, goahttp.ErrInvalidResponse("Mixed", "lookup", resp.StatusCode, string(body)) } @@ -85,13 +96,12 @@ func DecodeLookupResponse(decoder func(*http.Response) goahttp.Decoder, restoreB if err := decoder(resp).Decode(&jresp); err != nil { return nil, goahttp.ErrDecodingError("Mixed", "lookup", err) } + if err := jresp.Validate(requestID); err != nil { + return nil, goahttp.ErrInvalidResponse("Mixed", "lookup", resp.StatusCode, err.Error()) + } if jresp.Error != nil { - switch jresp.Error.Code { - default: - body, _ := io.ReadAll(resp.Body) - return nil, goahttp.ErrInvalidResponse("Mixed", "lookup", resp.StatusCode, string(body)) - } + return nil, jresp.Error } resp.Body = io.NopCloser(bytes.NewBuffer(jresp.Result)) var ( @@ -102,10 +112,6 @@ func DecodeLookupResponse(decoder func(*http.Response) goahttp.Decoder, restoreB if err != nil { return nil, goahttp.ErrDecodingError("Mixed", "lookup", err) } - err = ValidateLookupResponseBody(&body) - if err != nil { - return nil, goahttp.ErrValidationError("Mixed", "lookup", err) - } res := NewLookupResultOK(&body) return res, nil } diff --git a/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/mixed/client/types.go.golden b/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/mixed/client/types.go.golden index c179a3ff8d..6741ce779f 100644 --- a/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/mixed/client/types.go.golden +++ b/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/mixed/client/types.go.golden @@ -7,25 +7,17 @@ package client -import ( - mixed "kitchensink/mixed" - - goa "goa.design/goa/v3/pkg" -) +import mixed "generated.local/gen/mixed" // LookupRequestBody is the type of the "Mixed" service "lookup" endpoint HTTP // request body. type LookupRequestBody struct { - // Request ID - ID string `form:"id,omitempty" json:"id,omitempty" xml:"id,omitempty"` Key string `form:"key" json:"key" xml:"key"` } // LookupResponseBody is the type of the "Mixed" service "lookup" endpoint HTTP // response body. type LookupResponseBody struct { - // Request ID - ID *string `form:"id,omitempty" json:"id,omitempty" xml:"id,omitempty"` Value *string `form:"value,omitempty" json:"value,omitempty" xml:"value,omitempty"` } @@ -33,7 +25,6 @@ type LookupResponseBody struct { // "lookup" endpoint of the "Mixed" service. func NewLookupRequestBody(p *mixed.LookupPayload) *LookupRequestBody { body := &LookupRequestBody{ - ID: p.ID, Key: p.Key, } return body @@ -43,17 +34,8 @@ func NewLookupRequestBody(p *mixed.LookupPayload) *LookupRequestBody { // HTTP "OK" response. func NewLookupResultOK(body *LookupResponseBody) *mixed.LookupResult { v := &mixed.LookupResult{ - ID: *body.ID, Value: body.Value, } return v } - -// ValidateLookupResponseBody runs the validations defined on LookupResponseBody -func ValidateLookupResponseBody(body *LookupResponseBody) (err error) { - if body.ID == nil { - err = goa.MergeErrors(err, goa.MissingFieldError("id", "body")) - } - return -} diff --git a/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/mixed/server/encode_decode.go.golden b/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/mixed/server/encode_decode.go.golden index 993f585576..fc14e1e123 100644 --- a/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/mixed/server/encode_decode.go.golden +++ b/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/mixed/server/encode_decode.go.golden @@ -11,9 +11,9 @@ import ( "bytes" "errors" "io" - mixed "kitchensink/mixed" "net/http" + mixed "generated.local/gen/mixed" goahttp "goa.design/goa/v3/http" "goa.design/goa/v3/jsonrpc" goa "goa.design/goa/v3/pkg" @@ -25,6 +25,15 @@ func DecodeLookupRequest(mux goahttp.Muxer, decoder func(*http.Request) goahttp. return func(r *http.Request, req *jsonrpc.RawRequest) (*mixed.LookupPayload, error) { r.Body = io.NopCloser(bytes.NewReader(req.Params)) var payload *mixed.LookupPayload + if !req.HasID || req.ID == nil { + return payload, goa.MissingFieldError("id", "JSON-RPC request") + } + var requestID string + decodedID, decodeErr := jsonrpc.IDToString(req.ID) + if decodeErr != nil { + return payload, goa.DecodePayloadError(decodeErr.Error()) + } + requestID = decodedID var ( body LookupRequestBody err error @@ -44,7 +53,7 @@ func DecodeLookupRequest(mux goahttp.Muxer, decoder func(*http.Request) goahttp. if err != nil { return payload, err } - payload = NewLookupPayload(&body) + payload = NewLookupPayload(&body, requestID) return payload, nil } diff --git a/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/mixed/server/server.go.golden b/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/mixed/server/server.go.golden index 71c205ed09..4f3302586f 100644 --- a/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/mixed/server/server.go.golden +++ b/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/mixed/server/server.go.golden @@ -13,14 +13,40 @@ import ( "errors" "fmt" "io" - mixed "kitchensink/mixed" "net/http" + mixed "generated.local/gen/mixed" goahttp "goa.design/goa/v3/http" "goa.design/goa/v3/jsonrpc" goa "goa.design/goa/v3/pkg" ) +type ( + // noOutputResponseWriter accepts notification output without storing or + // sending it. + noOutputResponseWriter struct { + header http.Header + } +) + +// Header returns the private headers written while a notification is encoded. +func (w *noOutputResponseWriter) Header() http.Header { + return w.header +} + +// Write accepts notification bytes without storing or sending them. +func (w *noOutputResponseWriter) Write(data []byte) (int, error) { + return len(data), nil +} + +// WriteHeader accepts a notification status without sending it. +func (w *noOutputResponseWriter) WriteHeader(int) { +} + +// Flush completes successfully because notification bytes are not sent. +func (w *noOutputResponseWriter) Flush() { +} + // Server handles JSON-RPC requests for the Mixed service. type Server struct { http.Handler @@ -53,8 +79,8 @@ func New( encoder: encoder, errhandler: errhandler, } - // Default HTTP handler per transport kind - // Plain HTTP JSON-RPC + // Install the request handler required by this service's methods. + // ServeHTTP handles ordinary JSON-RPC request bodies. s.Handler = http.HandlerFunc(s.ServeHTTP) return s } @@ -62,6 +88,13 @@ func New( // Service returns the name of the service served. func (s *Server) Service() string { return "Mixed" } +// reportRejectedNotification reports a notification sent to a request-only +// method without allowing the error handler to write a response. +func (s *Server) reportRejectedNotification(ctx context.Context, req *jsonrpc.RawRequest) { + outputWriter := &noOutputResponseWriter{header: make(http.Header)} + s.errhandler(ctx, outputWriter, fmt.Errorf("JSON-RPC notification cannot call request method %q", req.Method)) +} + // Use wraps the server handlers with the given middleware. func (s *Server) Use(m func(http.Handler) http.Handler) { s.Handler = m(s.Handler) @@ -73,32 +106,44 @@ func (s *Server) MethodNames() []string { return mixed.MethodNames[:] } // ServeHTTP handles JSON-RPC requests. func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { s.handleHTTP(w, r) -} // handleHTTP handles JSON-RPC requests. +} + +// handleHTTP reads one JSON-RPC request object or one array of requests. func (s *Server) handleHTTP(w http.ResponseWriter, r *http.Request) { - // Peek at the first byte to determine request type - bufReader := bufio.NewReader(r.Body) - peek, err := bufReader.Peek(1) - if err != nil && err != io.EOF { - r.Body.Close() - s.errhandler(r.Context(), w, fmt.Errorf("failed to read request body: %w", err)) - return + originalBody := r.Body + + // Find the first JSON byte so leading whitespace does not change whether the + // body is decoded as one request or an array. + bufReader := bufio.NewReader(originalBody) + var peek []byte + for { + var err error + peek, err = bufReader.Peek(1) + if err != nil && err != io.EOF { + closeErr := originalBody.Close() + s.errhandler(r.Context(), w, fmt.Errorf("failed to read request body: %w", errors.Join(err, closeErr))) + return + } + if len(peek) == 0 || (peek[0] != ' ' && peek[0] != '\t' && peek[0] != '\n' && peek[0] != '\r') { + break + } + if _, err := bufReader.Discard(1); err != nil { + closeErr := originalBody.Close() + s.errhandler(r.Context(), w, fmt.Errorf("failed to read request body: %w", errors.Join(err, closeErr))) + return + } } - // Wrap the buffered reader with the original closer - r.Body = struct { - io.Reader - io.Closer - }{ - Reader: bufReader, - Closer: r.Body, - } - defer func(r *http.Request) { - if err := r.Body.Close(); err != nil { + // The generated handler owns the original body. Decoders receive a wrapper + // whose Close method cannot close it a second time. + r.Body = io.NopCloser(bufReader) + defer func() { + if err := originalBody.Close(); err != nil { s.errhandler(r.Context(), w, fmt.Errorf("failed to close request body: %w", err)) } - }(r) + }() - // Route to appropriate handler + // A leading '[' starts an array of requests. if len(peek) > 0 && peek[0] == '[' { s.handleBatch(w, r) return @@ -106,11 +151,11 @@ func (s *Server) handleHTTP(w http.ResponseWriter, r *http.Request) { s.handleSingle(w, r) } -// handleSingle handles a single JSON-RPC request. +// handleSingle decodes and runs one JSON-RPC request. func (s *Server) handleSingle(w http.ResponseWriter, r *http.Request) { var req jsonrpc.RawRequest if err := s.decoder(r).Decode(&req); err != nil { - // JSON-RPC parse error with null id and generic message + // A request that cannot be decoded receives the JSON-RPC parse error. response := jsonrpc.MakeErrorResponse(nil, jsonrpc.ParseError, "Parse error", nil) if encErr := s.encoder(r.Context(), w).Encode(response); encErr != nil { s.errhandler(r.Context(), w, fmt.Errorf("failed to encode parse error response: %w", encErr)) @@ -120,56 +165,77 @@ func (s *Server) handleSingle(w http.ResponseWriter, r *http.Request) { s.processRequest(r.Context(), r, &req, w) } -// handleBatch handles a batch of JSON-RPC requests. +// handleBatch handles an array of JSON-RPC values and writes the required responses. func (s *Server) handleBatch(w http.ResponseWriter, r *http.Request) { var reqs []jsonrpc.RawRequest if err := s.decoder(r).Decode(&reqs); err != nil { - // JSON-RPC parse error for batch with null id and generic message + // An array that cannot be decoded receives the JSON-RPC parse error. response := jsonrpc.MakeErrorResponse(nil, jsonrpc.ParseError, "Parse error", nil) if encErr := s.encoder(r.Context(), w).Encode(response); encErr != nil { s.errhandler(r.Context(), w, fmt.Errorf("failed to encode parse error response: %w", encErr)) } return } + if len(reqs) == 0 { + // JSON-RPC defines an empty request array as one invalid request. + response := jsonrpc.MakeErrorResponse(nil, jsonrpc.InvalidRequest, "Invalid request", nil) + if err := s.encoder(r.Context(), w).Encode(response); err != nil { + s.errhandler(r.Context(), w, fmt.Errorf("failed to encode invalid request response: %w", err)) + } + return + } - // Write responses + // Write every response into one JSON array. w.Header().Set("Content-Type", "application/json") writer := &batchWriter{Writer: w} for _, req := range reqs { - // Process the request with batch writer + // The writer inserts the array separators around each response. s.processRequest(r.Context(), r, &req, writer) } - // Close the batch array + // Write the closing bracket only when at least one request produced a response. if writer.written { - writer.Writer.Write([]byte{']'}) + if _, err := writer.Writer.Write([]byte{']'}); err != nil { + s.errhandler(r.Context(), w, fmt.Errorf("failed to close JSON-RPC batch response: %w", err)) + } } } -// ProcessRequest processes a single JSON-RPC request. +// processRequest validates the JSON-RPC version and method, then calls the matching handler. func (s *Server) processRequest(ctx context.Context, r *http.Request, req *jsonrpc.RawRequest, w http.ResponseWriter) { - if req.JSONRPC != "2.0" { + if req.Invalid || req.JSONRPC != "2.0" { s.encodeJSONRPCError(ctx, w, req, jsonrpc.InvalidRequest, "Invalid request", nil) return } - if req.Method == "" { + if !req.HasMethod { s.encodeJSONRPCError(ctx, w, req, jsonrpc.InvalidRequest, "Missing method field", nil) return } switch req.Method { case "lookup": + if !req.HasID { + s.reportRejectedNotification(ctx, req) + return + } + if req.ID == nil { + s.encodeJSONRPCError(ctx, w, req, jsonrpc.InvalidRequest, "Invalid request", nil) + return + } if err := s.Lookup(ctx, r, req, w); err != nil { s.errhandler(ctx, w, fmt.Errorf("handler error for %s: %w", "lookup", err)) } default: - s.encodeJSONRPCError(ctx, w, req, jsonrpc.MethodNotFound, "Method not found", nil) + if req.HasID { + s.encodeJSONRPCError(ctx, w, req, jsonrpc.MethodNotFound, "Method not found", nil) + } } } -// batchWriter is a helper type that implements http.ResponseWriter for writing multiple JSON-RPC responses +// batchWriter inserts JSON array separators around responses from one request +// array. type batchWriter struct { io.Writer header http.Header @@ -192,18 +258,20 @@ func (rb *batchWriter) WriteHeader(statusCode int) { } func (rb *batchWriter) Write(data []byte) (int, error) { + separator := byte(',') if !rb.written { - rb.written = true - rb.Writer.Write([]byte{'['}) - } else { - rb.Writer.Write([]byte{','}) + separator = '[' } + if _, err := rb.Writer.Write([]byte{separator}); err != nil { + return 0, err + } + rb.written = true return rb.Writer.Write(data) } // Mount configures the mux to serve the JSON-RPC Mixed service methods. func Mount(mux goahttp.Muxer, h *Server) { - // HTTP only + // This server handles ordinary JSON-RPC request bodies. mux.Handle("POST", "/mixed/rpc/mixed/rpc", h.ServeHTTP) } @@ -227,71 +295,19 @@ func NewLookupHandler( ctx = context.WithValue(ctx, goa.ServiceKey, "Mixed") params, err := decodeParams(r, req) if err != nil { - // Only send error response if request has ID (not nil or empty string) - if req.ID != nil && req.ID != "" { - code := jsonrpc.InternalError - if _, ok := err.(*goa.ServiceError); ok { - code = jsonrpc.InvalidParams - } - encodeJSONRPCError(ctx, w, req, code, err.Error(), nil, encoder, errhandler) - } else { - // No ID means notification - just log error - errhandler(ctx, w, fmt.Errorf("failed to decode parameters: %w", err)) - } + encodeJSONRPCError(ctx, w, req, jsonrpc.InvalidParams, err.Error(), nil, encoder, errhandler) return nil } - if req.ID != nil { - params.ID = jsonrpc.IDToString(req.ID) - } res, err := endpoint(ctx, params) if err != nil { - // Only send error response if request has ID (not nil or empty string) - if req.ID != nil && req.ID != "" { - var en goa.GoaErrorNamer - if !errors.As(err, &en) { - encodeJSONRPCError(ctx, w, req, jsonrpc.InternalError, err.Error(), nil, encoder, errhandler) - return nil - } - switch en.GoaErrorName() { - case "invalid_params": - encodeJSONRPCError(ctx, w, req, jsonrpc.InvalidParams, err.Error(), nil, encoder, errhandler) - case "method_not_found": - encodeJSONRPCError(ctx, w, req, jsonrpc.MethodNotFound, err.Error(), nil, encoder, errhandler) - default: - code := jsonrpc.InternalError - if _, ok := err.(*goa.ServiceError); ok { - code = jsonrpc.InvalidParams - } - encodeJSONRPCError(ctx, w, req, code, err.Error(), nil, encoder, errhandler) - } - } else { - // No ID means notification - just log error - errhandler(ctx, w, fmt.Errorf("endpoint error: %w", err)) - } + encodeJSONRPCError(ctx, w, req, jsonrpc.InternalError, err.Error(), nil, encoder, errhandler) return nil } - // For methods with no result, check if this is a notification - - // For methods with results, determine the ID to use for the response - var id any - // Result has an ID field - use it if set, otherwise fall back to request ID - actual := res.(*mixed.LookupResult) - if actual.ID != "" { - id = actual.ID - } else { - id = req.ID - } - - if id == nil || id == "" { - // Notification - no response - return nil - } - - // Send response with the result - // Convert result to response body with proper JSON tags + // The response repeats the exact request ID. + // Build the response body with the fields and JSON names declared by the service. body := NewLookupResponseBody(res.(*mixed.LookupResult)) - response := jsonrpc.MakeSuccessResponse(id, body) + response := jsonrpc.MakeSuccessResponse(req.ID, body) if err := encoder(ctx, w).Encode(response); err != nil { errhandler(ctx, w, fmt.Errorf("failed to encode JSON-RPC response: %w", err)) } @@ -299,14 +315,14 @@ func NewLookupHandler( } } -// encodeJSONRPCError creates and sends a JSON-RPC error response (handles nil -// ID gracefully) +// encodeJSONRPCError writes one error, copying the request ID or using null +// when none is available. func (s *Server) encodeJSONRPCError(ctx context.Context, w http.ResponseWriter, req *jsonrpc.RawRequest, code jsonrpc.Code, message string, data any) { encodeJSONRPCError(ctx, w, req, code, message, data, s.encoder, s.errhandler) } -// encodeJSONRPCError creates and sends a JSON-RPC error response (handles nil -// ID gracefully) +// encodeJSONRPCError writes one error, copying the request ID or using null +// when none is available. func encodeJSONRPCError( ctx context.Context, w http.ResponseWriter, @@ -317,10 +333,8 @@ func encodeJSONRPCError( encoder func(context.Context, http.ResponseWriter) goahttp.Encoder, errhandler func(context.Context, http.ResponseWriter, error), ) { - if req.ID != nil { - response := jsonrpc.MakeErrorResponse(req.ID, code, message, data) - if err := encoder(ctx, w).Encode(response); err != nil { - errhandler(ctx, w, fmt.Errorf("failed to encode JSON-RPC response: %w", err)) - } + response := jsonrpc.MakeErrorResponse(req.ID, code, message, data) + if err := encoder(ctx, w).Encode(response); err != nil { + errhandler(ctx, w, fmt.Errorf("failed to encode JSON-RPC response: %w", err)) } } diff --git a/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/mixed/server/types.go.golden b/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/mixed/server/types.go.golden index 33d0f1b2fa..5bbfc4b962 100644 --- a/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/mixed/server/types.go.golden +++ b/jsonrpc/codegen/testdata/golden/kitchen_sink/gen/jsonrpc/mixed/server/types.go.golden @@ -8,24 +8,19 @@ package server import ( - mixed "kitchensink/mixed" - + mixed "generated.local/gen/mixed" goa "goa.design/goa/v3/pkg" ) // LookupRequestBody is the type of the "Mixed" service "lookup" endpoint HTTP // request body. type LookupRequestBody struct { - // Request ID - ID *string `form:"id,omitempty" json:"id,omitempty" xml:"id,omitempty"` Key *string `form:"key,omitempty" json:"key,omitempty" xml:"key,omitempty"` } // LookupResponseBody is the type of the "Mixed" service "lookup" endpoint HTTP // response body. type LookupResponseBody struct { - // Request ID - ID string `form:"id,omitempty" json:"id,omitempty" xml:"id,omitempty"` Value *string `form:"value,omitempty" json:"value,omitempty" xml:"value,omitempty"` } @@ -33,27 +28,23 @@ type LookupResponseBody struct { // "lookup" endpoint of the "Mixed" service. func NewLookupResponseBody(res *mixed.LookupResult) *LookupResponseBody { body := &LookupResponseBody{ - ID: res.ID, Value: res.Value, } return body } // NewLookupPayload builds a Mixed service lookup endpoint payload. -func NewLookupPayload(body *LookupRequestBody) *mixed.LookupPayload { +func NewLookupPayload(body *LookupRequestBody, requestID string) *mixed.LookupPayload { v := &mixed.LookupPayload{ - ID: *body.ID, Key: *body.Key, } + v.ID = requestID return v } // ValidateLookupRequestBody runs the validations defined on LookupRequestBody func ValidateLookupRequestBody(body *LookupRequestBody) (err error) { - if body.ID == nil { - err = goa.MergeErrors(err, goa.MissingFieldError("id", "body")) - } if body.Key == nil { err = goa.MergeErrors(err, goa.MissingFieldError("key", "body")) } diff --git a/jsonrpc/codegen/testdata/golden/kitchen_sink/health.go.golden b/jsonrpc/codegen/testdata/golden/kitchen_sink/health.go.golden index 0dc4247c85..789ab0a811 100644 --- a/jsonrpc/codegen/testdata/golden/kitchen_sink/health.go.golden +++ b/jsonrpc/codegen/testdata/golden/kitchen_sink/health.go.golden @@ -2,8 +2,8 @@ package kitchensink import ( "context" - health "kitchensink/health" + health "generated.local/gen/health" "goa.design/clue/log" ) diff --git a/jsonrpc/codegen/testdata/golden/kitchen_sink/manifest.golden b/jsonrpc/codegen/testdata/golden/kitchen_sink/manifest.golden index df69561d71..2b5dca25b8 100644 --- a/jsonrpc/codegen/testdata/golden/kitchen_sink/manifest.golden +++ b/jsonrpc/codegen/testdata/golden/kitchen_sink/manifest.golden @@ -1,5 +1,4 @@ calc.go -chat.go cmd/kitchen_sink-cli/http.go cmd/kitchen_sink-cli/jsonrpc.go cmd/kitchen_sink-cli/main.go @@ -34,17 +33,6 @@ gen/jsonrpc/calc/server/encode_decode.go gen/jsonrpc/calc/server/paths.go gen/jsonrpc/calc/server/server.go gen/jsonrpc/calc/server/types.go -gen/jsonrpc/chat/client/cli.go -gen/jsonrpc/chat/client/client.go -gen/jsonrpc/chat/client/encode_decode.go -gen/jsonrpc/chat/client/paths.go -gen/jsonrpc/chat/client/types.go -gen/jsonrpc/chat/client/websocket.go -gen/jsonrpc/chat/server/encode_decode.go -gen/jsonrpc/chat/server/paths.go -gen/jsonrpc/chat/server/server.go -gen/jsonrpc/chat/server/types.go -gen/jsonrpc/chat/server/websocket.go gen/jsonrpc/cli/kitchen_sink/cli.go gen/jsonrpc/feed/client/cli.go gen/jsonrpc/feed/client/client.go diff --git a/jsonrpc/codegen/testdata/golden/kitchen_sink/mixed.go.golden b/jsonrpc/codegen/testdata/golden/kitchen_sink/mixed.go.golden index a375511629..985bb59267 100644 --- a/jsonrpc/codegen/testdata/golden/kitchen_sink/mixed.go.golden +++ b/jsonrpc/codegen/testdata/golden/kitchen_sink/mixed.go.golden @@ -2,8 +2,8 @@ package kitchensink import ( "context" - mixed "kitchensink/mixed" + mixed "generated.local/gen/mixed" "goa.design/clue/log" ) diff --git a/jsonrpc/codegen/testdata/golden/notification_client.go.golden b/jsonrpc/codegen/testdata/golden/notification_client.go.golden new file mode 100644 index 0000000000..85b12f251d --- /dev/null +++ b/jsonrpc/codegen/testdata/golden/notification_client.go.golden @@ -0,0 +1,151 @@ +package client + +// EncodeWithPayloadRequest returns an encoder for requests sent to the +// notifications with_payload server. +func EncodeWithPayloadRequest(encoder func(*http.Request) goahttp.Encoder) func(*http.Request, any) error { + return func(req *http.Request, v any) error { + p, ok := v.(*notifications.WithPayloadPayload) + if !ok { + return goahttp.ErrInvalidType("notifications", "with_payload", "*notifications.WithPayloadPayload", v) + } + b := NewWithPayloadRequestBody(p) + body := &jsonrpc.Request{ + JSONRPC: "2.0", + Method: "with_payload", + Params: b, + } + // This method is a notification, so the envelope has no ID. + if err := encoder(req).Encode(&body); err != nil { + return goahttp.ErrEncodingError("notifications", "with_payload", err) + } + return nil + } +} + +// EncodeWithoutPayloadRequest returns an encoder for requests sent to the +// notifications service without_payload JSON-RPC method. +func EncodeWithoutPayloadRequest(encoder func(*http.Request) goahttp.Encoder) func(*http.Request, any) error { + return func(req *http.Request, v any) error { + body := &jsonrpc.Request{ + JSONRPC: "2.0", + Method: "without_payload", + } + if err := encoder(req).Encode(body); err != nil { + return goahttp.ErrEncodingError("notifications", "without_payload", err) + } + return nil + } +} + +// EncodeOrdinaryVoidRequest returns an encoder for requests sent to the +// notifications service ordinary_void JSON-RPC method. The encoder returns the +// request ID written into the JSON-RPC message. +func EncodeOrdinaryVoidRequest(encoder func(*http.Request) goahttp.Encoder) func(*http.Request, any) (string, error) { + return func(req *http.Request, v any) (string, error) { + requestID := uuid.New().String() + body := &jsonrpc.Request{ + JSONRPC: "2.0", + Method: "ordinary_void", + ID: requestID, + } + if err := encoder(req).Encode(body); err != nil { + return "", goahttp.ErrEncodingError("notifications", "ordinary_void", err) + } + return requestID, nil + } +} + +// EncodeRequiredIDRequest returns an encoder for requests sent to the +// notifications service required_id JSON-RPC method. The encoder returns the +// request ID written into the JSON-RPC message. +func EncodeRequiredIDRequest(encoder func(*http.Request) goahttp.Encoder) func(*http.Request, any) (string, error) { + return func(req *http.Request, v any) (string, error) { + p, ok := v.(*notifications.RequiredIDPayload) + if !ok { + return "", goahttp.ErrInvalidType("notifications", "required_id", "*notifications.RequiredIDPayload", v) + } + b := NewRequiredIDRequestBody(p) + body := &jsonrpc.Request{ + JSONRPC: "2.0", + Method: "required_id", + Params: b, + } + requestID := string(p.ID) + body.ID = requestID + if err := encoder(req).Encode(&body); err != nil { + return "", goahttp.ErrEncodingError("notifications", "required_id", err) + } + return requestID, nil + } +} + +// EncodeOptionalIDRequest returns an encoder for requests sent to the +// notifications service optional_id JSON-RPC method. The encoder returns the +// request ID written into the JSON-RPC message. +func EncodeOptionalIDRequest(encoder func(*http.Request) goahttp.Encoder) func(*http.Request, any) (string, error) { + return func(req *http.Request, v any) (string, error) { + p, ok := v.(*notifications.OptionalIDPayload) + if !ok { + return "", goahttp.ErrInvalidType("notifications", "optional_id", "*notifications.OptionalIDPayload", v) + } + body := &jsonrpc.Request{ + JSONRPC: "2.0", + Method: "optional_id", + } + var requestID string + if p.ID == nil { + requestID = uuid.New().String() + } else { + requestID = string(*p.ID) + } + body.ID = requestID + if err := encoder(req).Encode(&body); err != nil { + return "", goahttp.ErrEncodingError("notifications", "optional_id", err) + } + return requestID, nil + } +} + +// EncodeDefaultedIDRequest returns an encoder for requests sent to the +// notifications service defaulted_id JSON-RPC method. The encoder returns the +// request ID written into the JSON-RPC message. +func EncodeDefaultedIDRequest(encoder func(*http.Request) goahttp.Encoder) func(*http.Request, any) (string, error) { + return func(req *http.Request, v any) (string, error) { + p, ok := v.(*notifications.DefaultedIDPayload) + if !ok { + return "", goahttp.ErrInvalidType("notifications", "defaulted_id", "*notifications.DefaultedIDPayload", v) + } + body := &jsonrpc.Request{ + JSONRPC: "2.0", + Method: "defaulted_id", + } + requestID := string(p.ID) + body.ID = requestID + if err := encoder(req).Encode(&body); err != nil { + return "", goahttp.ErrEncodingError("notifications", "defaulted_id", err) + } + return requestID, nil + } +} + +// EncodeValidatedIDRequest returns an encoder for requests sent to the +// notifications service validated_id JSON-RPC method. The encoder returns the +// request ID written into the JSON-RPC message. +func EncodeValidatedIDRequest(encoder func(*http.Request) goahttp.Encoder) func(*http.Request, any) (string, error) { + return func(req *http.Request, v any) (string, error) { + p, ok := v.(*notifications.ValidatedIDPayload) + if !ok { + return "", goahttp.ErrInvalidType("notifications", "validated_id", "*notifications.ValidatedIDPayload", v) + } + body := &jsonrpc.Request{ + JSONRPC: "2.0", + Method: "validated_id", + } + requestID := string(p.ID) + body.ID = requestID + if err := encoder(req).Encode(&body); err != nil { + return "", goahttp.ErrEncodingError("notifications", "validated_id", err) + } + return requestID, nil + } +} diff --git a/jsonrpc/codegen/testdata/golden/notification_decoder.go.golden b/jsonrpc/codegen/testdata/golden/notification_decoder.go.golden new file mode 100644 index 0000000000..caa7c63a2f --- /dev/null +++ b/jsonrpc/codegen/testdata/golden/notification_decoder.go.golden @@ -0,0 +1,142 @@ +package server + +// DecodeWithPayloadRequest returns a decoder for requests sent to the +// notifications with_payload endpoint. +func DecodeWithPayloadRequest(mux goahttp.Muxer, decoder func(*http.Request) goahttp.Decoder) func(*http.Request, *jsonrpc.RawRequest) (*notifications.WithPayloadPayload, error) { + return func(r *http.Request, req *jsonrpc.RawRequest) (*notifications.WithPayloadPayload, error) { + r.Body = io.NopCloser(bytes.NewReader(req.Params)) + var payload *notifications.WithPayloadPayload + var ( + body WithPayloadRequestBody + err error + ) + err = decoder(r).Decode(&body) + if err != nil { + if errors.Is(err, io.EOF) { + return payload, goa.MissingPayloadError() + } + var gerr *goa.ServiceError + if errors.As(err, &gerr) { + return payload, gerr + } + return payload, goa.DecodePayloadError(err.Error()) + } + err = ValidateWithPayloadRequestBody(&body) + if err != nil { + return payload, err + } + payload = NewWithPayloadPayload(&body) + + return payload, nil + } +} + +// DecodeRequiredIDRequest returns a decoder for requests sent to the +// notifications required_id endpoint. +func DecodeRequiredIDRequest(mux goahttp.Muxer, decoder func(*http.Request) goahttp.Decoder) func(*http.Request, *jsonrpc.RawRequest) (*notifications.RequiredIDPayload, error) { + return func(r *http.Request, req *jsonrpc.RawRequest) (*notifications.RequiredIDPayload, error) { + r.Body = io.NopCloser(bytes.NewReader(req.Params)) + var payload *notifications.RequiredIDPayload + if !req.HasID || req.ID == nil { + return payload, goa.MissingFieldError("id", "JSON-RPC request") + } + var requestID notifications.RequestID + decodedID, decodeErr := jsonrpc.IDToString(req.ID) + if decodeErr != nil { + return payload, goa.DecodePayloadError(decodeErr.Error()) + } + requestID = notifications.RequestID(decodedID) + var ( + body RequiredIDRequestBody + err error + ) + err = decoder(r).Decode(&body) + if err != nil { + if errors.Is(err, io.EOF) { + return payload, goa.MissingPayloadError() + } + var gerr *goa.ServiceError + if errors.As(err, &gerr) { + return payload, gerr + } + return payload, goa.DecodePayloadError(err.Error()) + } + err = ValidateRequiredIDRequestBody(&body) + if err != nil { + return payload, err + } + payload = NewRequiredIDPayload(&body, requestID) + + return payload, nil + } +} + +// DecodeOptionalIDRequest returns a decoder for requests sent to the +// notifications optional_id endpoint. +func DecodeOptionalIDRequest(mux goahttp.Muxer, decoder func(*http.Request) goahttp.Decoder) func(*http.Request, *jsonrpc.RawRequest) (*notifications.OptionalIDPayload, error) { + return func(r *http.Request, req *jsonrpc.RawRequest) (*notifications.OptionalIDPayload, error) { + r.Body = io.NopCloser(bytes.NewReader(req.Params)) + var payload *notifications.OptionalIDPayload + var requestID *notifications.RequestID + if req.ID != nil { + decodedID, decodeErr := jsonrpc.IDToString(req.ID) + if decodeErr != nil { + return payload, goa.DecodePayloadError(decodeErr.Error()) + } + value := notifications.RequestID(decodedID) + requestID = &value + } + payload = NewOptionalIDPayload(requestID) + + return payload, nil + } +} + +// DecodeDefaultedIDRequest returns a decoder for requests sent to the +// notifications defaulted_id endpoint. +func DecodeDefaultedIDRequest(mux goahttp.Muxer, decoder func(*http.Request) goahttp.Decoder) func(*http.Request, *jsonrpc.RawRequest) (*notifications.DefaultedIDPayload, error) { + return func(r *http.Request, req *jsonrpc.RawRequest) (*notifications.DefaultedIDPayload, error) { + r.Body = io.NopCloser(bytes.NewReader(req.Params)) + var payload *notifications.DefaultedIDPayload + var requestID notifications.RequestID + requestID = notifications.RequestID("default-id") + if req.ID != nil { + decodedID, decodeErr := jsonrpc.IDToString(req.ID) + if decodeErr != nil { + return payload, goa.DecodePayloadError(decodeErr.Error()) + } + requestID = notifications.RequestID(decodedID) + } + payload = NewDefaultedIDPayload(requestID) + + return payload, nil + } +} + +// DecodeValidatedIDRequest returns a decoder for requests sent to the +// notifications validated_id endpoint. +func DecodeValidatedIDRequest(mux goahttp.Muxer, decoder func(*http.Request) goahttp.Decoder) func(*http.Request, *jsonrpc.RawRequest) (*notifications.ValidatedIDPayload, error) { + return func(r *http.Request, req *jsonrpc.RawRequest) (*notifications.ValidatedIDPayload, error) { + r.Body = io.NopCloser(bytes.NewReader(req.Params)) + var payload *notifications.ValidatedIDPayload + if !req.HasID || req.ID == nil { + return payload, goa.MissingFieldError("id", "JSON-RPC request") + } + var requestID notifications.ValidatedRequestID + decodedID, decodeErr := jsonrpc.IDToString(req.ID) + if decodeErr != nil { + return payload, goa.DecodePayloadError(decodeErr.Error()) + } + requestID = notifications.ValidatedRequestID(decodedID) + { + var err error + err = goa.MergeErrors(err, goa.ValidatePattern("id", string(requestID), "^req-[0-9]+$")) + if err != nil { + return payload, err + } + } + payload = NewValidatedIDPayload(requestID) + + return payload, nil + } +} diff --git a/jsonrpc/codegen/testdata/golden/notification_dispatch.go.golden b/jsonrpc/codegen/testdata/golden/notification_dispatch.go.golden new file mode 100644 index 0000000000..30e5bbccac --- /dev/null +++ b/jsonrpc/codegen/testdata/golden/notification_dispatch.go.golden @@ -0,0 +1,231 @@ +package server + +// ServeHTTP handles JSON-RPC requests. +func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { + s.handleHTTP(w, r) +} + +// handleHTTP reads one JSON-RPC request object or one array of requests. +func (s *Server) handleHTTP(w http.ResponseWriter, r *http.Request) { + originalBody := r.Body + + // Find the first JSON byte so leading whitespace does not change whether the + // body is decoded as one request or an array. + bufReader := bufio.NewReader(originalBody) + var peek []byte + for { + var err error + peek, err = bufReader.Peek(1) + if err != nil && err != io.EOF { + closeErr := originalBody.Close() + s.errhandler(r.Context(), w, fmt.Errorf("failed to read request body: %w", errors.Join(err, closeErr))) + return + } + if len(peek) == 0 || (peek[0] != ' ' && peek[0] != '\t' && peek[0] != '\n' && peek[0] != '\r') { + break + } + if _, err := bufReader.Discard(1); err != nil { + closeErr := originalBody.Close() + s.errhandler(r.Context(), w, fmt.Errorf("failed to read request body: %w", errors.Join(err, closeErr))) + return + } + } + + // The generated handler owns the original body. Decoders receive a wrapper + // whose Close method cannot close it a second time. + r.Body = io.NopCloser(bufReader) + defer func() { + if err := originalBody.Close(); err != nil { + s.errhandler(r.Context(), w, fmt.Errorf("failed to close request body: %w", err)) + } + }() + + // A leading '[' starts an array of requests. + if len(peek) > 0 && peek[0] == '[' { + s.handleBatch(w, r) + return + } + s.handleSingle(w, r) +} + +// handleSingle decodes and runs one JSON-RPC request. +func (s *Server) handleSingle(w http.ResponseWriter, r *http.Request) { + var req jsonrpc.RawRequest + if err := s.decoder(r).Decode(&req); err != nil { + // A request that cannot be decoded receives the JSON-RPC parse error. + response := jsonrpc.MakeErrorResponse(nil, jsonrpc.ParseError, "Parse error", nil) + if encErr := s.encoder(r.Context(), w).Encode(response); encErr != nil { + s.errhandler(r.Context(), w, fmt.Errorf("failed to encode parse error response: %w", encErr)) + } + return + } + s.processRequest(r.Context(), r, &req, w) +} + +// handleBatch handles an array of JSON-RPC values and writes the required responses. +func (s *Server) handleBatch(w http.ResponseWriter, r *http.Request) { + var reqs []jsonrpc.RawRequest + if err := s.decoder(r).Decode(&reqs); err != nil { + // An array that cannot be decoded receives the JSON-RPC parse error. + response := jsonrpc.MakeErrorResponse(nil, jsonrpc.ParseError, "Parse error", nil) + if encErr := s.encoder(r.Context(), w).Encode(response); encErr != nil { + s.errhandler(r.Context(), w, fmt.Errorf("failed to encode parse error response: %w", encErr)) + } + return + } + if len(reqs) == 0 { + // JSON-RPC defines an empty request array as one invalid request. + response := jsonrpc.MakeErrorResponse(nil, jsonrpc.InvalidRequest, "Invalid request", nil) + if err := s.encoder(r.Context(), w).Encode(response); err != nil { + s.errhandler(r.Context(), w, fmt.Errorf("failed to encode invalid request response: %w", err)) + } + return + } + + // Write every response into one JSON array. + w.Header().Set("Content-Type", "application/json") + writer := &batchWriter{Writer: w} + + for _, req := range reqs { + // The writer inserts the array separators around each response. + s.processRequest(r.Context(), r, &req, writer) + } + + // Write the closing bracket only when at least one request produced a response. + if writer.written { + if _, err := writer.Writer.Write([]byte{']'}); err != nil { + s.errhandler(r.Context(), w, fmt.Errorf("failed to close JSON-RPC batch response: %w", err)) + } + } +} + +// processRequest validates the JSON-RPC version and method, then calls the matching handler. +func (s *Server) processRequest(ctx context.Context, r *http.Request, req *jsonrpc.RawRequest, w http.ResponseWriter) { + if req.Invalid || req.JSONRPC != "2.0" { + s.encodeJSONRPCError(ctx, w, req, jsonrpc.InvalidRequest, "Invalid request", nil) + return + } + + if !req.HasMethod { + s.encodeJSONRPCError(ctx, w, req, jsonrpc.InvalidRequest, "Missing method field", nil) + return + } + + switch req.Method { + case "with_payload": + if req.HasID { + s.encodeJSONRPCError(ctx, w, req, jsonrpc.InvalidRequest, "Invalid request", nil) + return + } + if err := s.WithPayload(ctx, r, req, w); err != nil { + s.errhandler(ctx, w, fmt.Errorf("handler error for %s: %w", "with_payload", err)) + } + case "without_payload": + if req.HasID { + s.encodeJSONRPCError(ctx, w, req, jsonrpc.InvalidRequest, "Invalid request", nil) + return + } + if err := s.WithoutPayload(ctx, r, req, w); err != nil { + s.errhandler(ctx, w, fmt.Errorf("handler error for %s: %w", "without_payload", err)) + } + case "ordinary_void": + if !req.HasID { + s.reportRejectedNotification(ctx, req) + return + } + if req.ID == nil { + s.encodeJSONRPCError(ctx, w, req, jsonrpc.InvalidRequest, "Invalid request", nil) + return + } + if err := s.OrdinaryVoid(ctx, r, req, w); err != nil { + s.errhandler(ctx, w, fmt.Errorf("handler error for %s: %w", "ordinary_void", err)) + } + case "required_id": + if !req.HasID { + s.reportRejectedNotification(ctx, req) + return + } + if req.ID == nil { + s.encodeJSONRPCError(ctx, w, req, jsonrpc.InvalidRequest, "Invalid request", nil) + return + } + if err := s.RequiredID(ctx, r, req, w); err != nil { + s.errhandler(ctx, w, fmt.Errorf("handler error for %s: %w", "required_id", err)) + } + case "optional_id": + if !req.HasID { + s.reportRejectedNotification(ctx, req) + return + } + if req.ID == nil { + s.encodeJSONRPCError(ctx, w, req, jsonrpc.InvalidRequest, "Invalid request", nil) + return + } + if err := s.OptionalID(ctx, r, req, w); err != nil { + s.errhandler(ctx, w, fmt.Errorf("handler error for %s: %w", "optional_id", err)) + } + case "defaulted_id": + if !req.HasID { + s.reportRejectedNotification(ctx, req) + return + } + if req.ID == nil { + s.encodeJSONRPCError(ctx, w, req, jsonrpc.InvalidRequest, "Invalid request", nil) + return + } + if err := s.DefaultedID(ctx, r, req, w); err != nil { + s.errhandler(ctx, w, fmt.Errorf("handler error for %s: %w", "defaulted_id", err)) + } + case "validated_id": + if !req.HasID { + s.reportRejectedNotification(ctx, req) + return + } + if req.ID == nil { + s.encodeJSONRPCError(ctx, w, req, jsonrpc.InvalidRequest, "Invalid request", nil) + return + } + if err := s.ValidatedID(ctx, r, req, w); err != nil { + s.errhandler(ctx, w, fmt.Errorf("handler error for %s: %w", "validated_id", err)) + } + default: + if req.HasID { + s.encodeJSONRPCError(ctx, w, req, jsonrpc.MethodNotFound, "Method not found", nil) + } + } +} + +// batchWriter inserts JSON array separators around responses from one request +// array. +type batchWriter struct { + io.Writer + header http.Header + statusCode int + written bool +} + +func (rb *batchWriter) Header() http.Header { + if rb.header == nil { + rb.header = make(http.Header) + } + return rb.header +} + +func (rb *batchWriter) WriteHeader(statusCode int) { + if rb.written { + return + } + rb.statusCode = statusCode +} + +func (rb *batchWriter) Write(data []byte) (int, error) { + separator := byte(',') + if !rb.written { + separator = '[' + } + if _, err := rb.Writer.Write([]byte{separator}); err != nil { + return 0, err + } + rb.written = true + return rb.Writer.Write(data) +} diff --git a/jsonrpc/codegen/testdata/golden/notification_endpoint.go.golden b/jsonrpc/codegen/testdata/golden/notification_endpoint.go.golden new file mode 100644 index 0000000000..42b862233a --- /dev/null +++ b/jsonrpc/codegen/testdata/golden/notification_endpoint.go.golden @@ -0,0 +1,187 @@ +package client + +// WithPayload returns an endpoint that sends JSON-RPC notifications to the +// notifications service with_payload method. +func (c *Client) WithPayload() goa.Endpoint { + var ( + encodeRequest = EncodeWithPayloadRequest(c.encoder) + ) + return func(ctx context.Context, v any) (any, error) { + req, err := c.BuildWithPayloadRequest(ctx, v) + if err != nil { + return nil, err + } + if err := encodeRequest(req, v); err != nil { + return nil, err + } + resp, err := c.Doer.Do(req) + if err != nil { + return nil, goahttp.ErrRequestError("notifications", "with_payload", err) + } + if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices { + body, readErr := io.ReadAll(resp.Body) + closeErr := resp.Body.Close() + if err := errors.Join(readErr, closeErr); err != nil { + return nil, goahttp.ErrDecodingError("notifications", "with_payload", err) + } + return nil, goahttp.ErrInvalidResponse("notifications", "with_payload", resp.StatusCode, string(body)) + } + if err := resp.Body.Close(); err != nil { + return nil, goahttp.ErrDecodingError("notifications", "with_payload", err) + } + return nil, nil + } +} + +// WithoutPayload returns an endpoint that sends JSON-RPC notifications to the +// notifications service without_payload method. +func (c *Client) WithoutPayload() goa.Endpoint { + var ( + encodeRequest = EncodeWithoutPayloadRequest(c.encoder) + ) + return func(ctx context.Context, v any) (any, error) { + req, err := c.BuildWithoutPayloadRequest(ctx, v) + if err != nil { + return nil, err + } + if err := encodeRequest(req, v); err != nil { + return nil, err + } + resp, err := c.Doer.Do(req) + if err != nil { + return nil, goahttp.ErrRequestError("notifications", "without_payload", err) + } + if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices { + body, readErr := io.ReadAll(resp.Body) + closeErr := resp.Body.Close() + if err := errors.Join(readErr, closeErr); err != nil { + return nil, goahttp.ErrDecodingError("notifications", "without_payload", err) + } + return nil, goahttp.ErrInvalidResponse("notifications", "without_payload", resp.StatusCode, string(body)) + } + if err := resp.Body.Close(); err != nil { + return nil, goahttp.ErrDecodingError("notifications", "without_payload", err) + } + return nil, nil + } +} + +// OrdinaryVoid returns an endpoint that makes JSON-RPC requests to the +// notifications service ordinary_void method. +func (c *Client) OrdinaryVoid() goa.Endpoint { + var ( + encodeRequest = EncodeOrdinaryVoidRequest(c.encoder) + decodeResponse = DecodeOrdinaryVoidResponse(c.decoder, c.RestoreResponseBody) + ) + return func(ctx context.Context, v any) (any, error) { + req, err := c.BuildOrdinaryVoidRequest(ctx, v) + if err != nil { + return nil, err + } + requestID, err := encodeRequest(req, v) + if err != nil { + return nil, err + } + resp, err := c.Doer.Do(req) + if err != nil { + return nil, goahttp.ErrRequestError("notifications", "ordinary_void", err) + } + return decodeResponse(resp, requestID) + } +} + +// RequiredID returns an endpoint that makes JSON-RPC requests to the +// notifications service required_id method. +func (c *Client) RequiredID() goa.Endpoint { + var ( + encodeRequest = EncodeRequiredIDRequest(c.encoder) + decodeResponse = DecodeRequiredIDResponse(c.decoder, c.RestoreResponseBody) + ) + return func(ctx context.Context, v any) (any, error) { + req, err := c.BuildRequiredIDRequest(ctx, v) + if err != nil { + return nil, err + } + requestID, err := encodeRequest(req, v) + if err != nil { + return nil, err + } + resp, err := c.Doer.Do(req) + if err != nil { + return nil, goahttp.ErrRequestError("notifications", "required_id", err) + } + return decodeResponse(resp, requestID) + } +} + +// OptionalID returns an endpoint that makes JSON-RPC requests to the +// notifications service optional_id method. +func (c *Client) OptionalID() goa.Endpoint { + var ( + encodeRequest = EncodeOptionalIDRequest(c.encoder) + decodeResponse = DecodeOptionalIDResponse(c.decoder, c.RestoreResponseBody) + ) + return func(ctx context.Context, v any) (any, error) { + req, err := c.BuildOptionalIDRequest(ctx, v) + if err != nil { + return nil, err + } + requestID, err := encodeRequest(req, v) + if err != nil { + return nil, err + } + resp, err := c.Doer.Do(req) + if err != nil { + return nil, goahttp.ErrRequestError("notifications", "optional_id", err) + } + return decodeResponse(resp, requestID) + } +} + +// DefaultedID returns an endpoint that makes JSON-RPC requests to the +// notifications service defaulted_id method. +func (c *Client) DefaultedID() goa.Endpoint { + var ( + encodeRequest = EncodeDefaultedIDRequest(c.encoder) + decodeResponse = DecodeDefaultedIDResponse(c.decoder, c.RestoreResponseBody) + ) + return func(ctx context.Context, v any) (any, error) { + req, err := c.BuildDefaultedIDRequest(ctx, v) + if err != nil { + return nil, err + } + requestID, err := encodeRequest(req, v) + if err != nil { + return nil, err + } + resp, err := c.Doer.Do(req) + if err != nil { + return nil, goahttp.ErrRequestError("notifications", "defaulted_id", err) + } + return decodeResponse(resp, requestID) + } +} + +// ValidatedID returns an endpoint that makes JSON-RPC requests to the +// notifications service validated_id method. +func (c *Client) ValidatedID() goa.Endpoint { + var ( + encodeRequest = EncodeValidatedIDRequest(c.encoder) + decodeResponse = DecodeValidatedIDResponse(c.decoder, c.RestoreResponseBody) + ) + return func(ctx context.Context, v any) (any, error) { + req, err := c.BuildValidatedIDRequest(ctx, v) + if err != nil { + return nil, err + } + requestID, err := encodeRequest(req, v) + if err != nil { + return nil, err + } + resp, err := c.Doer.Do(req) + if err != nil { + return nil, goahttp.ErrRequestError("notifications", "validated_id", err) + } + return decodeResponse(resp, requestID) + } +} diff --git a/jsonrpc/codegen/testdata/golden/notification_server.go.golden b/jsonrpc/codegen/testdata/golden/notification_server.go.golden new file mode 100644 index 0000000000..f3ba83b756 --- /dev/null +++ b/jsonrpc/codegen/testdata/golden/notification_server.go.golden @@ -0,0 +1,209 @@ +package server + +// NewWithPayloadHandler creates a JSON-RPC handler which calls the +// "notifications" service "with_payload" endpoint. +func NewWithPayloadHandler( + endpoint goa.Endpoint, + mux goahttp.Muxer, + decoder func(*http.Request) goahttp.Decoder, + encoder func(context.Context, http.ResponseWriter) goahttp.Encoder, + errhandler func(context.Context, http.ResponseWriter, error), +) func(context.Context, *http.Request, *jsonrpc.RawRequest, http.ResponseWriter) error { + decodeParams := DecodeWithPayloadRequest(mux, decoder) + return func(ctx context.Context, r *http.Request, req *jsonrpc.RawRequest, w http.ResponseWriter) error { + ctx = context.WithValue(ctx, goa.MethodKey, "with_payload") + ctx = context.WithValue(ctx, goa.ServiceKey, "notifications") + // A declared notification reports failures without writing an HTTP response. + outputWriter := &noOutputResponseWriter{header: make(http.Header)} + params, err := decodeParams(r, req) + if err != nil { + errhandler(ctx, outputWriter, fmt.Errorf("failed to decode parameters: %w", err)) + return nil + } + _, err = endpoint(ctx, params) + if err != nil { + errhandler(ctx, outputWriter, fmt.Errorf("endpoint error: %w", err)) + return nil + } + // A declared notification receives no response. + return nil + } +} + +// NewWithoutPayloadHandler creates a JSON-RPC handler which calls the +// "notifications" service "without_payload" endpoint. +func NewWithoutPayloadHandler( + endpoint goa.Endpoint, + mux goahttp.Muxer, + decoder func(*http.Request) goahttp.Decoder, + encoder func(context.Context, http.ResponseWriter) goahttp.Encoder, + errhandler func(context.Context, http.ResponseWriter, error), +) func(context.Context, *http.Request, *jsonrpc.RawRequest, http.ResponseWriter) error { + return func(ctx context.Context, r *http.Request, req *jsonrpc.RawRequest, w http.ResponseWriter) error { + ctx = context.WithValue(ctx, goa.MethodKey, "without_payload") + ctx = context.WithValue(ctx, goa.ServiceKey, "notifications") + // A declared notification reports failures without writing an HTTP response. + outputWriter := &noOutputResponseWriter{header: make(http.Header)} + _, err := endpoint(ctx, nil) + if err != nil { + errhandler(ctx, outputWriter, fmt.Errorf("endpoint error: %w", err)) + return nil + } + // A declared notification receives no response. + return nil + } +} + +// NewOrdinaryVoidHandler creates a JSON-RPC handler which calls the +// "notifications" service "ordinary_void" endpoint. +func NewOrdinaryVoidHandler( + endpoint goa.Endpoint, + mux goahttp.Muxer, + decoder func(*http.Request) goahttp.Decoder, + encoder func(context.Context, http.ResponseWriter) goahttp.Encoder, + errhandler func(context.Context, http.ResponseWriter, error), +) func(context.Context, *http.Request, *jsonrpc.RawRequest, http.ResponseWriter) error { + return func(ctx context.Context, r *http.Request, req *jsonrpc.RawRequest, w http.ResponseWriter) error { + ctx = context.WithValue(ctx, goa.MethodKey, "ordinary_void") + ctx = context.WithValue(ctx, goa.ServiceKey, "notifications") + _, err := endpoint(ctx, nil) + if err != nil { + encodeJSONRPCError(ctx, w, req, jsonrpc.InternalError, err.Error(), nil, encoder, errhandler) + return nil + } + // A method with no result returns a JSON null result. + response := jsonrpc.MakeSuccessResponse(req.ID, nil) + if err := encoder(ctx, w).Encode(response); err != nil { + errhandler(ctx, w, fmt.Errorf("failed to encode JSON-RPC response: %w", err)) + } + return nil + } +} + +// NewRequiredIDHandler creates a JSON-RPC handler which calls the +// "notifications" service "required_id" endpoint. +func NewRequiredIDHandler( + endpoint goa.Endpoint, + mux goahttp.Muxer, + decoder func(*http.Request) goahttp.Decoder, + encoder func(context.Context, http.ResponseWriter) goahttp.Encoder, + errhandler func(context.Context, http.ResponseWriter, error), +) func(context.Context, *http.Request, *jsonrpc.RawRequest, http.ResponseWriter) error { + decodeParams := DecodeRequiredIDRequest(mux, decoder) + return func(ctx context.Context, r *http.Request, req *jsonrpc.RawRequest, w http.ResponseWriter) error { + ctx = context.WithValue(ctx, goa.MethodKey, "required_id") + ctx = context.WithValue(ctx, goa.ServiceKey, "notifications") + params, err := decodeParams(r, req) + if err != nil { + encodeJSONRPCError(ctx, w, req, jsonrpc.InvalidParams, err.Error(), nil, encoder, errhandler) + return nil + } + _, err = endpoint(ctx, params) + if err != nil { + encodeJSONRPCError(ctx, w, req, jsonrpc.InternalError, err.Error(), nil, encoder, errhandler) + return nil + } + // A method with no result returns a JSON null result. + response := jsonrpc.MakeSuccessResponse(req.ID, nil) + if err := encoder(ctx, w).Encode(response); err != nil { + errhandler(ctx, w, fmt.Errorf("failed to encode JSON-RPC response: %w", err)) + } + return nil + } +} + +// NewOptionalIDHandler creates a JSON-RPC handler which calls the +// "notifications" service "optional_id" endpoint. +func NewOptionalIDHandler( + endpoint goa.Endpoint, + mux goahttp.Muxer, + decoder func(*http.Request) goahttp.Decoder, + encoder func(context.Context, http.ResponseWriter) goahttp.Encoder, + errhandler func(context.Context, http.ResponseWriter, error), +) func(context.Context, *http.Request, *jsonrpc.RawRequest, http.ResponseWriter) error { + decodeParams := DecodeOptionalIDRequest(mux, decoder) + return func(ctx context.Context, r *http.Request, req *jsonrpc.RawRequest, w http.ResponseWriter) error { + ctx = context.WithValue(ctx, goa.MethodKey, "optional_id") + ctx = context.WithValue(ctx, goa.ServiceKey, "notifications") + params, err := decodeParams(r, req) + if err != nil { + encodeJSONRPCError(ctx, w, req, jsonrpc.InvalidParams, err.Error(), nil, encoder, errhandler) + return nil + } + _, err = endpoint(ctx, params) + if err != nil { + encodeJSONRPCError(ctx, w, req, jsonrpc.InternalError, err.Error(), nil, encoder, errhandler) + return nil + } + // A method with no result returns a JSON null result. + response := jsonrpc.MakeSuccessResponse(req.ID, nil) + if err := encoder(ctx, w).Encode(response); err != nil { + errhandler(ctx, w, fmt.Errorf("failed to encode JSON-RPC response: %w", err)) + } + return nil + } +} + +// NewDefaultedIDHandler creates a JSON-RPC handler which calls the +// "notifications" service "defaulted_id" endpoint. +func NewDefaultedIDHandler( + endpoint goa.Endpoint, + mux goahttp.Muxer, + decoder func(*http.Request) goahttp.Decoder, + encoder func(context.Context, http.ResponseWriter) goahttp.Encoder, + errhandler func(context.Context, http.ResponseWriter, error), +) func(context.Context, *http.Request, *jsonrpc.RawRequest, http.ResponseWriter) error { + decodeParams := DecodeDefaultedIDRequest(mux, decoder) + return func(ctx context.Context, r *http.Request, req *jsonrpc.RawRequest, w http.ResponseWriter) error { + ctx = context.WithValue(ctx, goa.MethodKey, "defaulted_id") + ctx = context.WithValue(ctx, goa.ServiceKey, "notifications") + params, err := decodeParams(r, req) + if err != nil { + encodeJSONRPCError(ctx, w, req, jsonrpc.InvalidParams, err.Error(), nil, encoder, errhandler) + return nil + } + _, err = endpoint(ctx, params) + if err != nil { + encodeJSONRPCError(ctx, w, req, jsonrpc.InternalError, err.Error(), nil, encoder, errhandler) + return nil + } + // A method with no result returns a JSON null result. + response := jsonrpc.MakeSuccessResponse(req.ID, nil) + if err := encoder(ctx, w).Encode(response); err != nil { + errhandler(ctx, w, fmt.Errorf("failed to encode JSON-RPC response: %w", err)) + } + return nil + } +} + +// NewValidatedIDHandler creates a JSON-RPC handler which calls the +// "notifications" service "validated_id" endpoint. +func NewValidatedIDHandler( + endpoint goa.Endpoint, + mux goahttp.Muxer, + decoder func(*http.Request) goahttp.Decoder, + encoder func(context.Context, http.ResponseWriter) goahttp.Encoder, + errhandler func(context.Context, http.ResponseWriter, error), +) func(context.Context, *http.Request, *jsonrpc.RawRequest, http.ResponseWriter) error { + decodeParams := DecodeValidatedIDRequest(mux, decoder) + return func(ctx context.Context, r *http.Request, req *jsonrpc.RawRequest, w http.ResponseWriter) error { + ctx = context.WithValue(ctx, goa.MethodKey, "validated_id") + ctx = context.WithValue(ctx, goa.ServiceKey, "notifications") + params, err := decodeParams(r, req) + if err != nil { + encodeJSONRPCError(ctx, w, req, jsonrpc.InvalidParams, err.Error(), nil, encoder, errhandler) + return nil + } + _, err = endpoint(ctx, params) + if err != nil { + encodeJSONRPCError(ctx, w, req, jsonrpc.InternalError, err.Error(), nil, encoder, errhandler) + return nil + } + // A method with no result returns a JSON null result. + response := jsonrpc.MakeSuccessResponse(req.ID, nil) + if err := encoder(ctx, w).Encode(response); err != nil { + errhandler(ctx, w, fmt.Errorf("failed to encode JSON-RPC response: %w", err)) + } + return nil + } +} diff --git a/jsonrpc/codegen/testdata/golden/params_alias_request_encoder.go.golden b/jsonrpc/codegen/testdata/golden/params_alias_request_encoder.go.golden new file mode 100644 index 0000000000..68f5ef8e39 --- /dev/null +++ b/jsonrpc/codegen/testdata/golden/params_alias_request_encoder.go.golden @@ -0,0 +1,23 @@ +// EncodeAliasRequest returns an encoder for requests sent to the params +// service alias JSON-RPC method. The encoder returns the request ID written +// into the JSON-RPC message. +func EncodeAliasRequest(encoder func(*http.Request) goahttp.Encoder) func(*http.Request, any) (string, error) { + return func(req *http.Request, v any) (string, error) { + p, ok := v.(params.Alias) + if !ok { + return "", goahttp.ErrInvalidType("params", "alias", "params.Alias", v) + } + b := p + body := &jsonrpc.Request{ + JSONRPC: "2.0", + Method: "alias", + Params: []params.Alias{b}, + } + requestID := uuid.New().String() + body.ID = requestID + if err := encoder(req).Encode(&body); err != nil { + return "", goahttp.ErrEncodingError("params", "alias", err) + } + return requestID, nil + } +} diff --git a/jsonrpc/codegen/testdata/golden/params_array_request_encoder.go.golden b/jsonrpc/codegen/testdata/golden/params_array_request_encoder.go.golden new file mode 100644 index 0000000000..16864be838 --- /dev/null +++ b/jsonrpc/codegen/testdata/golden/params_array_request_encoder.go.golden @@ -0,0 +1,23 @@ +// EncodeArrayRequest returns an encoder for requests sent to the params +// service array JSON-RPC method. The encoder returns the request ID written +// into the JSON-RPC message. +func EncodeArrayRequest(encoder func(*http.Request) goahttp.Encoder) func(*http.Request, any) (string, error) { + return func(req *http.Request, v any) (string, error) { + p, ok := v.([]string) + if !ok { + return "", goahttp.ErrInvalidType("params", "array", "[]string", v) + } + b := p + body := &jsonrpc.Request{ + JSONRPC: "2.0", + Method: "array", + Params: b, + } + requestID := uuid.New().String() + body.ID = requestID + if err := encoder(req).Encode(&body); err != nil { + return "", goahttp.ErrEncodingError("params", "array", err) + } + return requestID, nil + } +} diff --git a/jsonrpc/codegen/testdata/golden/params_array_stream_server.go.golden b/jsonrpc/codegen/testdata/golden/params_array_stream_server.go.golden new file mode 100644 index 0000000000..9b29f2d457 --- /dev/null +++ b/jsonrpc/codegen/testdata/golden/params_array_stream_server.go.golden @@ -0,0 +1,29 @@ +// StreamArrayServerStream implements the params.StreamArrayServerStream +// interface using Server-Sent Events. +type StreamArrayServerStream struct { + // sseServerStream writes JSON-RPC messages as server-sent events. + sseServerStream +} + +// Send streams instances of "[]string". +func (s *StreamArrayServerStream) Send(event []string) error { + return s.SendWithContext(context.Background(), event) +} + +// SendWithContext streams instances of "[]string" with context. +func (s *StreamArrayServerStream) SendWithContext(ctx context.Context, event []string) error { + result := event + body := result + + message := jsonrpc.MakeNotification( + "stream_array", + body, + ) + return s.sendSSEEvent(ctx, message, nil, nil, nil) +} + +// Close does nothing because the HTTP response closes when the service method +// returns. +func (s *StreamArrayServerStream) Close() error { + return nil +} diff --git a/jsonrpc/codegen/testdata/golden/params_default_array_payload_constructor.go.golden b/jsonrpc/codegen/testdata/golden/params_default_array_payload_constructor.go.golden new file mode 100644 index 0000000000..13ea3e255f --- /dev/null +++ b/jsonrpc/codegen/testdata/golden/params_default_array_payload_constructor.go.golden @@ -0,0 +1,16 @@ +// NewDefaultArrayPayload builds a defaults service default_array endpoint +// payload. +func NewDefaultArrayPayload(body []string) *defaults.DefaultArrayPayload { + res := &defaults.DefaultArrayPayload{} + if body != nil { + v := make([]string, len(body)) + for i, val := range body { + v[i] = val + } + res.Value = v + } else { + res.Value = defaults.Values{"safe"} + } + + return res +} diff --git a/jsonrpc/codegen/testdata/golden/params_default_array_request_decoder.go.golden b/jsonrpc/codegen/testdata/golden/params_default_array_request_decoder.go.golden new file mode 100644 index 0000000000..faf54f7172 --- /dev/null +++ b/jsonrpc/codegen/testdata/golden/params_default_array_request_decoder.go.golden @@ -0,0 +1,27 @@ +// DecodeDefaultArrayRequest returns a decoder for requests sent to the +// defaults default_array endpoint. +func DecodeDefaultArrayRequest(mux goahttp.Muxer, decoder func(*http.Request) goahttp.Decoder) func(*http.Request, *jsonrpc.RawRequest) (*defaults.DefaultArrayPayload, error) { + return func(r *http.Request, req *jsonrpc.RawRequest) (*defaults.DefaultArrayPayload, error) { + r.Body = io.NopCloser(bytes.NewReader(req.Params)) + var payload *defaults.DefaultArrayPayload + var ( + body []string + err error + ) + err = decoder(r).Decode(&body) + if err != nil { + if errors.Is(err, io.EOF) { + err = nil + } else { + var gerr *goa.ServiceError + if errors.As(err, &gerr) { + return payload, gerr + } + return payload, goa.DecodePayloadError(err.Error()) + } + } + payload = NewDefaultArrayPayload(body) + + return payload, nil + } +} diff --git a/jsonrpc/codegen/testdata/golden/params_default_array_request_encoder.go.golden b/jsonrpc/codegen/testdata/golden/params_default_array_request_encoder.go.golden new file mode 100644 index 0000000000..1751d0607b --- /dev/null +++ b/jsonrpc/codegen/testdata/golden/params_default_array_request_encoder.go.golden @@ -0,0 +1,25 @@ +// EncodeDefaultArrayRequest returns an encoder for requests sent to the +// defaults service default_array JSON-RPC method. The encoder returns the +// request ID written into the JSON-RPC message. +func EncodeDefaultArrayRequest(encoder func(*http.Request) goahttp.Encoder) func(*http.Request, any) (string, error) { + return func(req *http.Request, v any) (string, error) { + p, ok := v.(*defaults.DefaultArrayPayload) + if !ok { + return "", goahttp.ErrInvalidType("defaults", "default_array", "*defaults.DefaultArrayPayload", v) + } + body := &jsonrpc.Request{ + JSONRPC: "2.0", + Method: "default_array", + } + requestID := uuid.New().String() + body.ID = requestID + if p.Value != nil { + b := p.Value + body.Params = b + } + if err := encoder(req).Encode(&body); err != nil { + return "", goahttp.ErrEncodingError("defaults", "default_array", err) + } + return requestID, nil + } +} diff --git a/jsonrpc/codegen/testdata/golden/params_default_text_payload_constructor.go.golden b/jsonrpc/codegen/testdata/golden/params_default_text_payload_constructor.go.golden new file mode 100644 index 0000000000..58b26a2b9b --- /dev/null +++ b/jsonrpc/codegen/testdata/golden/params_default_text_payload_constructor.go.golden @@ -0,0 +1,13 @@ +// NewDefaultTextPayload builds a defaults service default_text endpoint +// payload. +func NewDefaultTextPayload(body *string) *defaults.DefaultTextPayload { + res := &defaults.DefaultTextPayload{} + if body != nil { + v := defaults.Mode(*body) + res.Value = v + } else { + res.Value = "safe" + } + + return res +} diff --git a/jsonrpc/codegen/testdata/golden/params_default_text_request_decoder.go.golden b/jsonrpc/codegen/testdata/golden/params_default_text_request_decoder.go.golden new file mode 100644 index 0000000000..56ba46b6f2 --- /dev/null +++ b/jsonrpc/codegen/testdata/golden/params_default_text_request_decoder.go.golden @@ -0,0 +1,41 @@ +// DecodeDefaultTextRequest returns a decoder for requests sent to the defaults +// default_text endpoint. +func DecodeDefaultTextRequest(mux goahttp.Muxer, decoder func(*http.Request) goahttp.Decoder) func(*http.Request, *jsonrpc.RawRequest) (*defaults.DefaultTextPayload, error) { + return func(r *http.Request, req *jsonrpc.RawRequest) (*defaults.DefaultTextPayload, error) { + var payload *defaults.DefaultTextPayload + if len(req.Params) > 0 { + param, paramErr := jsonrpc.SinglePositionalParam(req.Params) + if paramErr != nil { + return payload, goa.DecodePayloadError(paramErr.Error()) + } + r.Body = io.NopCloser(bytes.NewReader(param)) + } else { + r.Body = http.NoBody + } + var ( + body *string + err error + ) + err = decoder(r).Decode(&body) + if err != nil { + if errors.Is(err, io.EOF) { + err = nil + } else { + var gerr *goa.ServiceError + if errors.As(err, &gerr) { + return payload, gerr + } + return payload, goa.DecodePayloadError(err.Error()) + } + } + if body != nil { + err = goa.MergeErrors(err, goa.ValidatePattern("body", *body, "^[a-z]*$")) + if err != nil { + return payload, err + } + } + payload = NewDefaultTextPayload(body) + + return payload, nil + } +} diff --git a/jsonrpc/codegen/testdata/golden/params_default_text_request_encoder.go.golden b/jsonrpc/codegen/testdata/golden/params_default_text_request_encoder.go.golden new file mode 100644 index 0000000000..c232b9fe5b --- /dev/null +++ b/jsonrpc/codegen/testdata/golden/params_default_text_request_encoder.go.golden @@ -0,0 +1,23 @@ +// EncodeDefaultTextRequest returns an encoder for requests sent to the +// defaults service default_text JSON-RPC method. The encoder returns the +// request ID written into the JSON-RPC message. +func EncodeDefaultTextRequest(encoder func(*http.Request) goahttp.Encoder) func(*http.Request, any) (string, error) { + return func(req *http.Request, v any) (string, error) { + p, ok := v.(*defaults.DefaultTextPayload) + if !ok { + return "", goahttp.ErrInvalidType("defaults", "default_text", "*defaults.DefaultTextPayload", v) + } + b := p.Value + body := &jsonrpc.Request{ + JSONRPC: "2.0", + Method: "default_text", + Params: []defaults.Mode{b}, + } + requestID := uuid.New().String() + body.ID = requestID + if err := encoder(req).Encode(&body); err != nil { + return "", goahttp.ErrEncodingError("defaults", "default_text", err) + } + return requestID, nil + } +} diff --git a/jsonrpc/codegen/testdata/golden/params_default_text_stream_client.go.golden b/jsonrpc/codegen/testdata/golden/params_default_text_stream_client.go.golden new file mode 100644 index 0000000000..108633caf0 --- /dev/null +++ b/jsonrpc/codegen/testdata/golden/params_default_text_stream_client.go.golden @@ -0,0 +1,329 @@ +type ( + // StreamDefaultTextClientStream reads results sent as server-sent events. + StreamDefaultTextClientStream interface { + Recv() (*params.DefaultTextEvent, error) + RecvWithContext(context.Context) (*params.DefaultTextEvent, error) + Close() error + } + + // StreamDefaultTextStreamImpl reads and decodes events for stream_default_text. + StreamDefaultTextStreamImpl struct { + // resp is the open server response. + resp *http.Response + // reader reads one line at a time from resp. + reader *bufio.Reader + // decoder converts each result into its service type. + decoder func(*http.Response) goahttp.Decoder + // requestID is the ID every final response must repeat. + requestID string + // lastEventID is the last valid id field read from the stream. + lastEventID string + // hasLastEventID distinguishes no id from an explicitly empty id. + hasLastEventID bool + // firstLine is true until the optional stream byte-order marker is read. + firstLine bool + // closed records whether Close was called or the response ended. + closed bool + // closeOnce ensures the response body is closed only once. + closeOnce sync.Once + // closeErr stores the response body close error. + closeErr error + // lock prevents two calls from reading or closing the response at once. + lock sync.Mutex + } +) + +// NewStreamDefaultTextStream creates a stream that reads server-sent events +// from resp. Final responses must repeat requestID. +func NewStreamDefaultTextStream(resp *http.Response, decoder func(*http.Response) goahttp.Decoder, requestID string) StreamDefaultTextClientStream { + return &StreamDefaultTextStreamImpl{ + resp: resp, + reader: bufio.NewReader(resp.Body), + decoder: decoder, + requestID: requestID, + firstLine: true, + } +} + +// parseSSEEvent reads one complete event from the response. Ending ctx closes +// the response body so a blocked read returns. +func (s *StreamDefaultTextStreamImpl) parseSSEEvent(ctx context.Context) (id string, hasID bool, eventType string, hasEventType bool, retry string, hasRetry bool, data []byte, err error) { + closeResult := make(chan struct{}, 1) + stopClose := context.AfterFunc(ctx, func() { + s.closeBody() + closeResult <- struct{}{} + }) + defer func() { + if stopClose() { + return + } + <-closeResult + if contextErr := ctx.Err(); contextErr != nil { + id = "" + hasID = false + eventType = "" + hasEventType = false + retry = "" + hasRetry = false + data = nil + err = contextErr + } + }() + var dataLines []string + + for { + line, err := s.readSSELine() + if err != nil { + return "", false, "", false, "", false, nil, err + } + + if line == "" { + // A blank line ends the current event. + if len(dataLines) > 0 { + break + } + // An event without data is not delivered. Its event and retry fields + // must not appear on the next delivered event. + eventType = "" + hasEventType = false + retry = "" + hasRetry = false + continue + } + + if strings.HasPrefix(line, ":") { + continue + } + field, value, found := strings.Cut(line, ":") + if !found { + value = "" + } else { + value = strings.TrimPrefix(value, " ") + } + switch field { + case "id": + if !strings.ContainsRune(value, '\x00') { + s.lastEventID = value + s.hasLastEventID = true + } + case "event": + eventType = value + hasEventType = true + case "retry": + // An invalid retry field does not replace an earlier valid field. + validRetry := len(value) > 0 + for index := 0; validRetry && index < len(value); index++ { + validRetry = value[index] >= '0' && value[index] <= '9' + } + if !validRetry { + continue + } + retry = value + hasRetry = true + case "data": + dataLines = append(dataLines, value) + } + } + + if len(dataLines) > 0 { + data = []byte(strings.Join(dataLines, "\n")) + } + + return s.lastEventID, s.hasLastEventID, eventType, hasEventType, retry, hasRetry, data, nil +} + +// readSSELine reads one line ending in CR, LF, or CRLF. It returns io.EOF for +// a final line that is not terminated, so the caller cannot dispatch an +// incomplete event. +func (s *StreamDefaultTextStreamImpl) readSSELine() (string, error) { + var line strings.Builder + for { + character, err := s.reader.ReadByte() + if err != nil { + return "", err + } + switch character { + case '\n': + return s.finishSSELine(line.String()), nil + case '\r': + if next, err := s.reader.Peek(1); err == nil && next[0] == '\n' { + if _, err := s.reader.Discard(1); err != nil { + return "", err + } + } + return s.finishSSELine(line.String()), nil + default: + line.WriteByte(character) + } + } +} + +// finishSSELine removes the optional byte-order marker only at the start of +// the stream. +func (s *StreamDefaultTextStreamImpl) finishSSELine(line string) string { + if !s.firstLine { + return line + } + s.firstLine = false + return strings.TrimPrefix(line, "\uFEFF") +} + +// Recv reads instances of "DefaultTextEvent" from the stream. +func (s *StreamDefaultTextStreamImpl) Recv() (*params.DefaultTextEvent, error) { + return s.RecvWithContext(context.Background()) +} + +// RecvWithContext reads instances of "DefaultTextEvent" from the stream with +// context. +func (s *StreamDefaultTextStreamImpl) RecvWithContext(ctx context.Context) (*params.DefaultTextEvent, error) { + s.lock.Lock() + defer s.lock.Unlock() + + var zero *params.DefaultTextEvent + + if s.closed { + return zero, io.EOF + } + + for { + _, _, _, _, _, _, data, err := s.parseSSEEvent(ctx) + if err != nil { + return zero, s.endStream(err) + } + + var message struct { + Method json.RawMessage `json:"method"` + Params json.RawMessage `json:"params"` + Result json.RawMessage `json:"result"` + Error json.RawMessage `json:"error"` + ID json.RawMessage `json:"id"` + } + if err := json.Unmarshal(data, &message); err != nil { + return zero, s.endStream(fmt.Errorf("failed to parse JSON-RPC message: %w", err)) + } + if len(message.Method) > 0 { + // Read the streamed service result from the notification parameters. + if len(message.Result) > 0 || len(message.Error) > 0 || len(message.ID) > 0 { + return zero, s.endStream(fmt.Errorf("invalid JSON-RPC notification")) + } + var notification jsonrpc.RawRequest + if err := json.Unmarshal(data, ¬ification); err != nil { + return zero, s.endStream(fmt.Errorf("failed to parse notification: %w", err)) + } + if notification.Invalid { + return zero, s.endStream(fmt.Errorf("invalid JSON-RPC notification")) + } + if notification.JSONRPC != "2.0" { + return zero, s.endStream(fmt.Errorf("invalid JSON-RPC version: %s", notification.JSONRPC)) + } + if notification.HasID { + return zero, s.endStream(fmt.Errorf("notification contains an id")) + } + if !notification.HasMethod { + return zero, s.endStream(fmt.Errorf("notification has no method")) + } + if notification.Method != "stream_default_text" { + return zero, s.endStream(fmt.Errorf("received notification for JSON-RPC method %q", notification.Method)) + } + var params json.RawMessage + if len(notification.Params) > 0 { + params, err = jsonrpc.SinglePositionalParam(notification.Params) + if err != nil { + return zero, s.endStream(err) + } + } + result, err := s.decodeResult(params) + if err != nil { + return zero, s.endStream(err) + } + return result, nil + } + + if len(message.Params) > 0 { + return zero, s.endStream(fmt.Errorf("JSON-RPC response contains params")) + } + { + // A response completes the stream. Stream values arrive in the + // notifications handled above. + var response jsonrpc.RawResponse + if err := json.Unmarshal(data, &response); err != nil { + return zero, s.endStream(fmt.Errorf("failed to parse response: %w", err)) + } + if err := response.Validate(s.requestID); err != nil { + return zero, s.endStream(err) + } + if response.Error == nil && !bytes.Equal(bytes.TrimSpace(response.Result), []byte("null")) { + return zero, s.endStream(fmt.Errorf("JSON-RPC stream completion result must be null")) + } + if response.Error != nil { + serviceErr, decodeErr := s.decodeError(response.Error) + if decodeErr != nil { + return zero, s.endStream(decodeErr) + } + return zero, s.endStream(serviceErr) + } + return zero, s.endStream(io.EOF) + } + } +} + +// decodeError rebuilds one designed service error from its JSON-RPC code and data. +func (s *StreamDefaultTextStreamImpl) decodeError(response *jsonrpc.RawErrorResponse) (error, error) { + return response, nil +} + +// closeBody closes the HTTP response body once and returns its close error. +func (s *StreamDefaultTextStreamImpl) closeBody() error { + s.closeOnce.Do(func() { + s.closeErr = s.resp.Body.Close() + }) + return s.closeErr +} + +// endStream marks the stream closed and preserves both the receive error and +// any error returned while closing the HTTP response body. +func (s *StreamDefaultTextStreamImpl) endStream(err error) error { + s.closed = true + if closeErr := s.closeBody(); closeErr != nil { + return errors.Join(err, closeErr) + } + return err +} + +// decodeResult passes one successful stream item to the decoder configured by NewClient. +func (s *StreamDefaultTextStreamImpl) decodeResult(data json.RawMessage) (*params.DefaultTextEvent, error) { + resp := &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(bytes.NewReader(data)), + } + decoder := s.decoder(resp) + var ( + body StreamDefaultTextResponseBody + err error + ) + if len(data) > 0 { + if err = decoder.Decode(&body.Data); err != nil { + var zero *params.DefaultTextEvent + return zero, goahttp.ErrDecodingError("params", "stream_default_text", err) + } + } + err = ValidateStreamDefaultTextResponseBody(&body) + if err != nil { + var zero *params.DefaultTextEvent + return zero, goahttp.ErrValidationError("params", "stream_default_text", err) + } + result := NewStreamDefaultTextDefaultTextEventOK(&body) + return result, nil +} + +// Close closes the stream. +func (s *StreamDefaultTextStreamImpl) Close() error { + s.lock.Lock() + defer s.lock.Unlock() + + if !s.closed { + s.closed = true + return s.closeBody() + } + return nil +} diff --git a/jsonrpc/codegen/testdata/golden/params_optional_array_request_encoder.go.golden b/jsonrpc/codegen/testdata/golden/params_optional_array_request_encoder.go.golden new file mode 100644 index 0000000000..a6b0d8bad1 --- /dev/null +++ b/jsonrpc/codegen/testdata/golden/params_optional_array_request_encoder.go.golden @@ -0,0 +1,25 @@ +// EncodeOptionalArrayRequest returns an encoder for requests sent to the +// params service optional_array JSON-RPC method. The encoder returns the +// request ID written into the JSON-RPC message. +func EncodeOptionalArrayRequest(encoder func(*http.Request) goahttp.Encoder) func(*http.Request, any) (string, error) { + return func(req *http.Request, v any) (string, error) { + p, ok := v.(*params.OptionalArrayPayload) + if !ok { + return "", goahttp.ErrInvalidType("params", "optional_array", "*params.OptionalArrayPayload", v) + } + body := &jsonrpc.Request{ + JSONRPC: "2.0", + Method: "optional_array", + } + requestID := uuid.New().String() + body.ID = requestID + if p.Value != nil { + b := NewObjectRequestBody(p) + body.Params = b + } + if err := encoder(req).Encode(&body); err != nil { + return "", goahttp.ErrEncodingError("params", "optional_array", err) + } + return requestID, nil + } +} diff --git a/jsonrpc/codegen/testdata/golden/params_optional_text_request_decoder.go.golden b/jsonrpc/codegen/testdata/golden/params_optional_text_request_decoder.go.golden new file mode 100644 index 0000000000..4ace391168 --- /dev/null +++ b/jsonrpc/codegen/testdata/golden/params_optional_text_request_decoder.go.golden @@ -0,0 +1,35 @@ +// DecodeOptionalTextRequest returns a decoder for requests sent to the params +// optional_text endpoint. +func DecodeOptionalTextRequest(mux goahttp.Muxer, decoder func(*http.Request) goahttp.Decoder) func(*http.Request, *jsonrpc.RawRequest) (*params.OptionalTextPayload, error) { + return func(r *http.Request, req *jsonrpc.RawRequest) (*params.OptionalTextPayload, error) { + var payload *params.OptionalTextPayload + if len(req.Params) > 0 { + param, paramErr := jsonrpc.SinglePositionalParam(req.Params) + if paramErr != nil { + return payload, goa.DecodePayloadError(paramErr.Error()) + } + r.Body = io.NopCloser(bytes.NewReader(param)) + } else { + r.Body = http.NoBody + } + var ( + body *string + err error + ) + err = decoder(r).Decode(&body) + if err != nil { + if errors.Is(err, io.EOF) { + err = nil + } else { + var gerr *goa.ServiceError + if errors.As(err, &gerr) { + return payload, gerr + } + return payload, goa.DecodePayloadError(err.Error()) + } + } + payload = NewOptionalTextPayload(body) + + return payload, nil + } +} diff --git a/jsonrpc/codegen/testdata/golden/params_optional_text_stream_client.go.golden b/jsonrpc/codegen/testdata/golden/params_optional_text_stream_client.go.golden new file mode 100644 index 0000000000..e77583b233 --- /dev/null +++ b/jsonrpc/codegen/testdata/golden/params_optional_text_stream_client.go.golden @@ -0,0 +1,330 @@ +type ( + // StreamOptionalTextClientStream reads results sent as server-sent events. + StreamOptionalTextClientStream interface { + Recv() (*params.OptionalTextEvent, error) + RecvWithContext(context.Context) (*params.OptionalTextEvent, error) + Close() error + } + + // StreamOptionalTextStreamImpl reads and decodes events for + // stream_optional_text. + StreamOptionalTextStreamImpl struct { + // resp is the open server response. + resp *http.Response + // reader reads one line at a time from resp. + reader *bufio.Reader + // decoder converts each result into its service type. + decoder func(*http.Response) goahttp.Decoder + // requestID is the ID every final response must repeat. + requestID string + // lastEventID is the last valid id field read from the stream. + lastEventID string + // hasLastEventID distinguishes no id from an explicitly empty id. + hasLastEventID bool + // firstLine is true until the optional stream byte-order marker is read. + firstLine bool + // closed records whether Close was called or the response ended. + closed bool + // closeOnce ensures the response body is closed only once. + closeOnce sync.Once + // closeErr stores the response body close error. + closeErr error + // lock prevents two calls from reading or closing the response at once. + lock sync.Mutex + } +) + +// NewStreamOptionalTextStream creates a stream that reads server-sent events +// from resp. Final responses must repeat requestID. +func NewStreamOptionalTextStream(resp *http.Response, decoder func(*http.Response) goahttp.Decoder, requestID string) StreamOptionalTextClientStream { + return &StreamOptionalTextStreamImpl{ + resp: resp, + reader: bufio.NewReader(resp.Body), + decoder: decoder, + requestID: requestID, + firstLine: true, + } +} + +// parseSSEEvent reads one complete event from the response. Ending ctx closes +// the response body so a blocked read returns. +func (s *StreamOptionalTextStreamImpl) parseSSEEvent(ctx context.Context) (id string, hasID bool, eventType string, hasEventType bool, retry string, hasRetry bool, data []byte, err error) { + closeResult := make(chan struct{}, 1) + stopClose := context.AfterFunc(ctx, func() { + s.closeBody() + closeResult <- struct{}{} + }) + defer func() { + if stopClose() { + return + } + <-closeResult + if contextErr := ctx.Err(); contextErr != nil { + id = "" + hasID = false + eventType = "" + hasEventType = false + retry = "" + hasRetry = false + data = nil + err = contextErr + } + }() + var dataLines []string + + for { + line, err := s.readSSELine() + if err != nil { + return "", false, "", false, "", false, nil, err + } + + if line == "" { + // A blank line ends the current event. + if len(dataLines) > 0 { + break + } + // An event without data is not delivered. Its event and retry fields + // must not appear on the next delivered event. + eventType = "" + hasEventType = false + retry = "" + hasRetry = false + continue + } + + if strings.HasPrefix(line, ":") { + continue + } + field, value, found := strings.Cut(line, ":") + if !found { + value = "" + } else { + value = strings.TrimPrefix(value, " ") + } + switch field { + case "id": + if !strings.ContainsRune(value, '\x00') { + s.lastEventID = value + s.hasLastEventID = true + } + case "event": + eventType = value + hasEventType = true + case "retry": + // An invalid retry field does not replace an earlier valid field. + validRetry := len(value) > 0 + for index := 0; validRetry && index < len(value); index++ { + validRetry = value[index] >= '0' && value[index] <= '9' + } + if !validRetry { + continue + } + retry = value + hasRetry = true + case "data": + dataLines = append(dataLines, value) + } + } + + if len(dataLines) > 0 { + data = []byte(strings.Join(dataLines, "\n")) + } + + return s.lastEventID, s.hasLastEventID, eventType, hasEventType, retry, hasRetry, data, nil +} + +// readSSELine reads one line ending in CR, LF, or CRLF. It returns io.EOF for +// a final line that is not terminated, so the caller cannot dispatch an +// incomplete event. +func (s *StreamOptionalTextStreamImpl) readSSELine() (string, error) { + var line strings.Builder + for { + character, err := s.reader.ReadByte() + if err != nil { + return "", err + } + switch character { + case '\n': + return s.finishSSELine(line.String()), nil + case '\r': + if next, err := s.reader.Peek(1); err == nil && next[0] == '\n' { + if _, err := s.reader.Discard(1); err != nil { + return "", err + } + } + return s.finishSSELine(line.String()), nil + default: + line.WriteByte(character) + } + } +} + +// finishSSELine removes the optional byte-order marker only at the start of +// the stream. +func (s *StreamOptionalTextStreamImpl) finishSSELine(line string) string { + if !s.firstLine { + return line + } + s.firstLine = false + return strings.TrimPrefix(line, "\uFEFF") +} + +// Recv reads instances of "OptionalTextEvent" from the stream. +func (s *StreamOptionalTextStreamImpl) Recv() (*params.OptionalTextEvent, error) { + return s.RecvWithContext(context.Background()) +} + +// RecvWithContext reads instances of "OptionalTextEvent" from the stream with +// context. +func (s *StreamOptionalTextStreamImpl) RecvWithContext(ctx context.Context) (*params.OptionalTextEvent, error) { + s.lock.Lock() + defer s.lock.Unlock() + + var zero *params.OptionalTextEvent + + if s.closed { + return zero, io.EOF + } + + for { + _, _, _, _, _, _, data, err := s.parseSSEEvent(ctx) + if err != nil { + return zero, s.endStream(err) + } + + var message struct { + Method json.RawMessage `json:"method"` + Params json.RawMessage `json:"params"` + Result json.RawMessage `json:"result"` + Error json.RawMessage `json:"error"` + ID json.RawMessage `json:"id"` + } + if err := json.Unmarshal(data, &message); err != nil { + return zero, s.endStream(fmt.Errorf("failed to parse JSON-RPC message: %w", err)) + } + if len(message.Method) > 0 { + // Read the streamed service result from the notification parameters. + if len(message.Result) > 0 || len(message.Error) > 0 || len(message.ID) > 0 { + return zero, s.endStream(fmt.Errorf("invalid JSON-RPC notification")) + } + var notification jsonrpc.RawRequest + if err := json.Unmarshal(data, ¬ification); err != nil { + return zero, s.endStream(fmt.Errorf("failed to parse notification: %w", err)) + } + if notification.Invalid { + return zero, s.endStream(fmt.Errorf("invalid JSON-RPC notification")) + } + if notification.JSONRPC != "2.0" { + return zero, s.endStream(fmt.Errorf("invalid JSON-RPC version: %s", notification.JSONRPC)) + } + if notification.HasID { + return zero, s.endStream(fmt.Errorf("notification contains an id")) + } + if !notification.HasMethod { + return zero, s.endStream(fmt.Errorf("notification has no method")) + } + if notification.Method != "stream_optional_text" { + return zero, s.endStream(fmt.Errorf("received notification for JSON-RPC method %q", notification.Method)) + } + var params json.RawMessage + if len(notification.Params) > 0 { + params, err = jsonrpc.SinglePositionalParam(notification.Params) + if err != nil { + return zero, s.endStream(err) + } + } + result, err := s.decodeResult(params) + if err != nil { + return zero, s.endStream(err) + } + return result, nil + } + + if len(message.Params) > 0 { + return zero, s.endStream(fmt.Errorf("JSON-RPC response contains params")) + } + { + // A response completes the stream. Stream values arrive in the + // notifications handled above. + var response jsonrpc.RawResponse + if err := json.Unmarshal(data, &response); err != nil { + return zero, s.endStream(fmt.Errorf("failed to parse response: %w", err)) + } + if err := response.Validate(s.requestID); err != nil { + return zero, s.endStream(err) + } + if response.Error == nil && !bytes.Equal(bytes.TrimSpace(response.Result), []byte("null")) { + return zero, s.endStream(fmt.Errorf("JSON-RPC stream completion result must be null")) + } + if response.Error != nil { + serviceErr, decodeErr := s.decodeError(response.Error) + if decodeErr != nil { + return zero, s.endStream(decodeErr) + } + return zero, s.endStream(serviceErr) + } + return zero, s.endStream(io.EOF) + } + } +} + +// decodeError rebuilds one designed service error from its JSON-RPC code and data. +func (s *StreamOptionalTextStreamImpl) decodeError(response *jsonrpc.RawErrorResponse) (error, error) { + return response, nil +} + +// closeBody closes the HTTP response body once and returns its close error. +func (s *StreamOptionalTextStreamImpl) closeBody() error { + s.closeOnce.Do(func() { + s.closeErr = s.resp.Body.Close() + }) + return s.closeErr +} + +// endStream marks the stream closed and preserves both the receive error and +// any error returned while closing the HTTP response body. +func (s *StreamOptionalTextStreamImpl) endStream(err error) error { + s.closed = true + if closeErr := s.closeBody(); closeErr != nil { + return errors.Join(err, closeErr) + } + return err +} + +// decodeResult passes one successful stream item to the decoder configured by NewClient. +func (s *StreamOptionalTextStreamImpl) decodeResult(data json.RawMessage) (*params.OptionalTextEvent, error) { + resp := &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(bytes.NewReader(data)), + } + decoder := s.decoder(resp) + var ( + body StreamOptionalTextResponseBody + err error + ) + if len(data) > 0 { + if err = decoder.Decode(&body.Data); err != nil { + var zero *params.OptionalTextEvent + return zero, goahttp.ErrDecodingError("params", "stream_optional_text", err) + } + } + err = ValidateStreamOptionalTextResponseBody(&body) + if err != nil { + var zero *params.OptionalTextEvent + return zero, goahttp.ErrValidationError("params", "stream_optional_text", err) + } + result := NewStreamOptionalTextOptionalTextEventOK(&body) + return result, nil +} + +// Close closes the stream. +func (s *StreamOptionalTextStreamImpl) Close() error { + s.lock.Lock() + defer s.lock.Unlock() + + if !s.closed { + s.closed = true + return s.closeBody() + } + return nil +} diff --git a/jsonrpc/codegen/testdata/golden/params_optional_text_stream_server.go.golden b/jsonrpc/codegen/testdata/golden/params_optional_text_stream_server.go.golden new file mode 100644 index 0000000000..45fd5020cf --- /dev/null +++ b/jsonrpc/codegen/testdata/golden/params_optional_text_stream_server.go.golden @@ -0,0 +1,28 @@ +// StreamOptionalTextServerStream implements the +// params.StreamOptionalTextServerStream interface using Server-Sent Events. +type StreamOptionalTextServerStream struct { + // sseServerStream writes JSON-RPC messages as server-sent events. + sseServerStream +} + +// Send streams instances of "OptionalTextEvent". +func (s *StreamOptionalTextServerStream) Send(event *params.OptionalTextEvent) error { + return s.SendWithContext(context.Background(), event) +} + +// SendWithContext streams instances of "OptionalTextEvent" with context. +func (s *StreamOptionalTextServerStream) SendWithContext(ctx context.Context, event *params.OptionalTextEvent) error { + result := event + + message := jsonrpc.MakeNotification( + "stream_optional_text", + []*string{result.Data}, + ) + return s.sendSSEEvent(ctx, message, nil, nil, nil) +} + +// Close does nothing because the HTTP response closes when the service method +// returns. +func (s *StreamOptionalTextServerStream) Close() error { + return nil +} diff --git a/jsonrpc/codegen/testdata/golden/params_optional_union_request_decoder.go.golden b/jsonrpc/codegen/testdata/golden/params_optional_union_request_decoder.go.golden new file mode 100644 index 0000000000..d30e57b65b --- /dev/null +++ b/jsonrpc/codegen/testdata/golden/params_optional_union_request_decoder.go.golden @@ -0,0 +1,27 @@ +// DecodeOptionalUnionRequest returns a decoder for requests sent to the params +// optional_union endpoint. +func DecodeOptionalUnionRequest(mux goahttp.Muxer, decoder func(*http.Request) goahttp.Decoder) func(*http.Request, *jsonrpc.RawRequest) (*params.OptionalUnionPayload, error) { + return func(r *http.Request, req *jsonrpc.RawRequest) (*params.OptionalUnionPayload, error) { + r.Body = io.NopCloser(bytes.NewReader(req.Params)) + var payload *params.OptionalUnionPayload + var ( + body *OptionalRequestValueRequestBody + err error + ) + err = decoder(r).Decode(&body) + if err != nil { + if errors.Is(err, io.EOF) { + err = nil + } else { + var gerr *goa.ServiceError + if errors.As(err, &gerr) { + return payload, gerr + } + return payload, goa.DecodePayloadError(err.Error()) + } + } + payload = NewOptionalUnionPayload(body) + + return payload, nil + } +} diff --git a/jsonrpc/codegen/testdata/golden/params_optional_union_stream_server.go.golden b/jsonrpc/codegen/testdata/golden/params_optional_union_stream_server.go.golden new file mode 100644 index 0000000000..7d321c61ee --- /dev/null +++ b/jsonrpc/codegen/testdata/golden/params_optional_union_stream_server.go.golden @@ -0,0 +1,32 @@ +// StreamUnionDataServerStream implements the +// params.StreamUnionDataServerStream interface using Server-Sent Events. +type StreamUnionDataServerStream struct { + // sseServerStream writes JSON-RPC messages as server-sent events. + sseServerStream +} + +// Send streams instances of "UnionEvent". +func (s *StreamUnionDataServerStream) Send(event *params.UnionEvent) error { + return s.SendWithContext(context.Background(), event) +} + +// SendWithContext streams instances of "UnionEvent" with context. +func (s *StreamUnionDataServerStream) SendWithContext(ctx context.Context, event *params.UnionEvent) error { + result := event + + message := &jsonrpc.Request{ + JSONRPC: "2.0", + Method: "stream_union_data", + } + if result.Data.Kind() != "" { + body := NewStreamUnionDataResponseBody2(result) + message.Params = body.Data + } + return s.sendSSEEvent(ctx, message, nil, nil, nil) +} + +// Close does nothing because the HTTP response closes when the service method +// returns. +func (s *StreamUnionDataServerStream) Close() error { + return nil +} diff --git a/jsonrpc/codegen/testdata/golden/params_text_request_decoder.go.golden b/jsonrpc/codegen/testdata/golden/params_text_request_decoder.go.golden new file mode 100644 index 0000000000..9a731b8d22 --- /dev/null +++ b/jsonrpc/codegen/testdata/golden/params_text_request_decoder.go.golden @@ -0,0 +1,33 @@ +// DecodeTextRequest returns a decoder for requests sent to the params text +// endpoint. +func DecodeTextRequest(mux goahttp.Muxer, decoder func(*http.Request) goahttp.Decoder) func(*http.Request, *jsonrpc.RawRequest) (string, error) { + return func(r *http.Request, req *jsonrpc.RawRequest) (string, error) { + var payload string + param, paramErr := jsonrpc.SinglePositionalParam(req.Params) + if paramErr != nil { + return payload, goa.DecodePayloadError(paramErr.Error()) + } + if bytes.Equal(bytes.TrimSpace(param), []byte("null")) { + return payload, goa.MissingPayloadError() + } + r.Body = io.NopCloser(bytes.NewReader(param)) + var ( + body string + err error + ) + err = decoder(r).Decode(&body) + if err != nil { + if errors.Is(err, io.EOF) { + return payload, goa.MissingPayloadError() + } + var gerr *goa.ServiceError + if errors.As(err, &gerr) { + return payload, gerr + } + return payload, goa.DecodePayloadError(err.Error()) + } + payload = body + + return payload, nil + } +} diff --git a/jsonrpc/codegen/testdata/golden/params_text_stream_server.go.golden b/jsonrpc/codegen/testdata/golden/params_text_stream_server.go.golden new file mode 100644 index 0000000000..f76bf23f51 --- /dev/null +++ b/jsonrpc/codegen/testdata/golden/params_text_stream_server.go.golden @@ -0,0 +1,29 @@ +// StreamTextServerStream implements the params.StreamTextServerStream +// interface using Server-Sent Events. +type StreamTextServerStream struct { + // sseServerStream writes JSON-RPC messages as server-sent events. + sseServerStream +} + +// Send streams instances of "string". +func (s *StreamTextServerStream) Send(event string) error { + return s.SendWithContext(context.Background(), event) +} + +// SendWithContext streams instances of "string" with context. +func (s *StreamTextServerStream) SendWithContext(ctx context.Context, event string) error { + result := event + body := result + + message := jsonrpc.MakeNotification( + "stream_text", + []string{body}, + ) + return s.sendSSEEvent(ctx, message, nil, nil, nil) +} + +// Close does nothing because the HTTP response closes when the service method +// returns. +func (s *StreamTextServerStream) Close() error { + return nil +} diff --git a/jsonrpc/codegen/testdata/golden/viewed_result_variable_decoder.go.golden b/jsonrpc/codegen/testdata/golden/viewed_result_variable_decoder.go.golden new file mode 100644 index 0000000000..4e7e76525b --- /dev/null +++ b/jsonrpc/codegen/testdata/golden/viewed_result_variable_decoder.go.golden @@ -0,0 +1,100 @@ +// decodeFetchViewedResult decodes the JSON body selected by the result view +// for the viewed service fetch method. +func decodeFetchViewedResult(decoder func(*http.Response) goahttp.Decoder, resp *http.Response, data json.RawMessage) (*viewed.ViewedGolden, error) { + var representation struct { + View *string `json:"view"` + Body *json.RawMessage `json:"body"` + } + if err := decodeJSONRPCResult(decoder, data, &representation); err != nil { + return nil, goahttp.ErrDecodingError("viewed", "fetch", err) + } + if representation.View == nil { + return nil, goahttp.ErrValidationError("viewed", "fetch", goa.MissingFieldError("view", "result")) + } + view := *representation.View + switch view { + case "summary": + if representation.Body == nil { + return nil, goahttp.ErrValidationError("viewed", "fetch", goa.MissingFieldError("body", "result")) + } + resp.Body = io.NopCloser(bytes.NewBuffer(*representation.Body)) + var ( + body FetchResponseBodySummary + err error + ) + err = decoder(resp).Decode(&body) + if err != nil { + return nil, goahttp.ErrDecodingError("viewed", "fetch", err) + } + err = ValidateFetchResponseBodySummary(&body) + if err != nil { + return nil, goahttp.ErrValidationError("viewed", "fetch", err) + } + projected := NewFetchResultSummaryOK(&body) + viewed2 := &viewedviews.ViewedGolden{ + Projected: projected, + View: view, + } + if err := viewedviews.ValidateViewedGolden(viewed2); err != nil { + return nil, goahttp.ErrValidationError("viewed", "fetch", err) + } + result := viewed.NewViewedGolden(viewed2) + return result, nil + case "detailed": + if representation.Body == nil { + return nil, goahttp.ErrValidationError("viewed", "fetch", goa.MissingFieldError("body", "result")) + } + resp.Body = io.NopCloser(bytes.NewBuffer(*representation.Body)) + var ( + body FetchResponseBodyDetailed + err error + ) + err = decoder(resp).Decode(&body) + if err != nil { + return nil, goahttp.ErrDecodingError("viewed", "fetch", err) + } + err = ValidateFetchResponseBodyDetailed(&body) + if err != nil { + return nil, goahttp.ErrValidationError("viewed", "fetch", err) + } + projected := NewFetchResultDetailedOK(&body) + viewed2 := &viewedviews.ViewedGolden{ + Projected: projected, + View: view, + } + if err := viewedviews.ValidateViewedGolden(viewed2); err != nil { + return nil, goahttp.ErrValidationError("viewed", "fetch", err) + } + result := viewed.NewViewedGolden(viewed2) + return result, nil + case "default": + if representation.Body == nil { + return nil, goahttp.ErrValidationError("viewed", "fetch", goa.MissingFieldError("body", "result")) + } + resp.Body = io.NopCloser(bytes.NewBuffer(*representation.Body)) + var ( + body FetchResponseBody + err error + ) + err = decoder(resp).Decode(&body) + if err != nil { + return nil, goahttp.ErrDecodingError("viewed", "fetch", err) + } + err = ValidateFetchResponseBody(&body) + if err != nil { + return nil, goahttp.ErrValidationError("viewed", "fetch", err) + } + projected := NewFetchResultDefaultOK(&body) + viewed2 := &viewedviews.ViewedGolden{ + Projected: projected, + View: view, + } + if err := viewedviews.ValidateViewedGolden(viewed2); err != nil { + return nil, goahttp.ErrValidationError("viewed", "fetch", err) + } + result := viewed.NewViewedGolden(viewed2) + return result, nil + default: + return nil, goahttp.ErrValidationError("viewed", "fetch", goa.InvalidEnumValueError("view", view, []any{"summary", "detailed", "default"})) + } +} diff --git a/jsonrpc/codegen/testdata/golden/viewed_result_variable_encoder.go.golden b/jsonrpc/codegen/testdata/golden/viewed_result_variable_encoder.go.golden new file mode 100644 index 0000000000..09676103c7 --- /dev/null +++ b/jsonrpc/codegen/testdata/golden/viewed_result_variable_encoder.go.golden @@ -0,0 +1,41 @@ +// encodeFetchViewedResult builds the JSON body selected by the result view for +// the viewed service fetch method. +func encodeFetchViewedResult(viewed *viewedviews.ViewedGolden) (any, error) { + if err := viewedviews.ValidateViewedGolden(viewed); err != nil { + return nil, err + } + switch viewed.View { + case "summary": + res := viewed + body := NewFetchResponseBodySummary(res.Projected) + return struct { + View string `json:"view"` + Body *FetchResponseBodySummary `json:"body"` + }{ + View: "summary", + Body: body, + }, nil + case "detailed": + res := viewed + body := NewFetchResponseBodyDetailed(res.Projected) + return struct { + View string `json:"view"` + Body *FetchResponseBodyDetailed `json:"body"` + }{ + View: "detailed", + Body: body, + }, nil + case "default": + res := viewed + body := NewFetchResponseBody(res.Projected) + return struct { + View string `json:"view"` + Body *FetchResponseBody `json:"body"` + }{ + View: "default", + Body: body, + }, nil + default: + panic("validated viewed result has no JSON-RPC representation") + } +} diff --git a/jsonrpc/codegen/testdata/golden/viewed_result_variable_sse_client.go.golden b/jsonrpc/codegen/testdata/golden/viewed_result_variable_sse_client.go.golden new file mode 100644 index 0000000000..3e2d19fd09 --- /dev/null +++ b/jsonrpc/codegen/testdata/golden/viewed_result_variable_sse_client.go.golden @@ -0,0 +1,307 @@ +type ( + // WatchClientStream reads results sent as server-sent events. + WatchClientStream interface { + Recv() (*viewed.ViewedGolden, error) + RecvWithContext(context.Context) (*viewed.ViewedGolden, error) + Close() error + } + + // WatchStreamImpl reads and decodes events for watch. + WatchStreamImpl struct { + // resp is the open server response. + resp *http.Response + // reader reads one line at a time from resp. + reader *bufio.Reader + // decoder converts each result into its service type. + decoder func(*http.Response) goahttp.Decoder + // requestID is the ID every final response must repeat. + requestID string + // lastEventID is the last valid id field read from the stream. + lastEventID string + // hasLastEventID distinguishes no id from an explicitly empty id. + hasLastEventID bool + // firstLine is true until the optional stream byte-order marker is read. + firstLine bool + // closed records whether Close was called or the response ended. + closed bool + // closeOnce ensures the response body is closed only once. + closeOnce sync.Once + // closeErr stores the response body close error. + closeErr error + // lock prevents two calls from reading or closing the response at once. + lock sync.Mutex + } +) + +// NewWatchStream creates a stream that reads server-sent events from resp. +// Final responses must repeat requestID. +func NewWatchStream(resp *http.Response, decoder func(*http.Response) goahttp.Decoder, requestID string) WatchClientStream { + return &WatchStreamImpl{ + resp: resp, + reader: bufio.NewReader(resp.Body), + decoder: decoder, + requestID: requestID, + firstLine: true, + } +} + +// parseSSEEvent reads one complete event from the response. Ending ctx closes +// the response body so a blocked read returns. +func (s *WatchStreamImpl) parseSSEEvent(ctx context.Context) (id string, hasID bool, eventType string, hasEventType bool, retry string, hasRetry bool, data []byte, err error) { + closeResult := make(chan struct{}, 1) + stopClose := context.AfterFunc(ctx, func() { + s.closeBody() + closeResult <- struct{}{} + }) + defer func() { + if stopClose() { + return + } + <-closeResult + if contextErr := ctx.Err(); contextErr != nil { + id = "" + hasID = false + eventType = "" + hasEventType = false + retry = "" + hasRetry = false + data = nil + err = contextErr + } + }() + var dataLines []string + + for { + line, err := s.readSSELine() + if err != nil { + return "", false, "", false, "", false, nil, err + } + + if line == "" { + // A blank line ends the current event. + if len(dataLines) > 0 { + break + } + // An event without data is not delivered. Its event and retry fields + // must not appear on the next delivered event. + eventType = "" + hasEventType = false + retry = "" + hasRetry = false + continue + } + + if strings.HasPrefix(line, ":") { + continue + } + field, value, found := strings.Cut(line, ":") + if !found { + value = "" + } else { + value = strings.TrimPrefix(value, " ") + } + switch field { + case "id": + if !strings.ContainsRune(value, '\x00') { + s.lastEventID = value + s.hasLastEventID = true + } + case "event": + eventType = value + hasEventType = true + case "retry": + // An invalid retry field does not replace an earlier valid field. + validRetry := len(value) > 0 + for index := 0; validRetry && index < len(value); index++ { + validRetry = value[index] >= '0' && value[index] <= '9' + } + if !validRetry { + continue + } + retry = value + hasRetry = true + case "data": + dataLines = append(dataLines, value) + } + } + + if len(dataLines) > 0 { + data = []byte(strings.Join(dataLines, "\n")) + } + + return s.lastEventID, s.hasLastEventID, eventType, hasEventType, retry, hasRetry, data, nil +} + +// readSSELine reads one line ending in CR, LF, or CRLF. It returns io.EOF for +// a final line that is not terminated, so the caller cannot dispatch an +// incomplete event. +func (s *WatchStreamImpl) readSSELine() (string, error) { + var line strings.Builder + for { + character, err := s.reader.ReadByte() + if err != nil { + return "", err + } + switch character { + case '\n': + return s.finishSSELine(line.String()), nil + case '\r': + if next, err := s.reader.Peek(1); err == nil && next[0] == '\n' { + if _, err := s.reader.Discard(1); err != nil { + return "", err + } + } + return s.finishSSELine(line.String()), nil + default: + line.WriteByte(character) + } + } +} + +// finishSSELine removes the optional byte-order marker only at the start of +// the stream. +func (s *WatchStreamImpl) finishSSELine(line string) string { + if !s.firstLine { + return line + } + s.firstLine = false + return strings.TrimPrefix(line, "\uFEFF") +} + +// Recv reads instances of "ViewedGolden" from the stream. +func (s *WatchStreamImpl) Recv() (*viewed.ViewedGolden, error) { + return s.RecvWithContext(context.Background()) +} + +// RecvWithContext reads instances of "ViewedGolden" from the stream with +// context. +func (s *WatchStreamImpl) RecvWithContext(ctx context.Context) (*viewed.ViewedGolden, error) { + s.lock.Lock() + defer s.lock.Unlock() + + var zero *viewed.ViewedGolden + + if s.closed { + return zero, io.EOF + } + + for { + eventID, hasEventID, _, _, _, _, data, err := s.parseSSEEvent(ctx) + if err != nil { + return zero, s.endStream(err) + } + + var message struct { + Method json.RawMessage `json:"method"` + Params json.RawMessage `json:"params"` + Result json.RawMessage `json:"result"` + Error json.RawMessage `json:"error"` + ID json.RawMessage `json:"id"` + } + if err := json.Unmarshal(data, &message); err != nil { + return zero, s.endStream(fmt.Errorf("failed to parse JSON-RPC message: %w", err)) + } + if len(message.Method) > 0 { + // Read the streamed service result from the notification parameters. + if len(message.Result) > 0 || len(message.Error) > 0 || len(message.ID) > 0 { + return zero, s.endStream(fmt.Errorf("invalid JSON-RPC notification")) + } + var notification jsonrpc.RawRequest + if err := json.Unmarshal(data, ¬ification); err != nil { + return zero, s.endStream(fmt.Errorf("failed to parse notification: %w", err)) + } + if notification.Invalid { + return zero, s.endStream(fmt.Errorf("invalid JSON-RPC notification")) + } + if notification.JSONRPC != "2.0" { + return zero, s.endStream(fmt.Errorf("invalid JSON-RPC version: %s", notification.JSONRPC)) + } + if notification.HasID { + return zero, s.endStream(fmt.Errorf("notification contains an id")) + } + if !notification.HasMethod { + return zero, s.endStream(fmt.Errorf("notification has no method")) + } + if notification.Method != "watch" { + return zero, s.endStream(fmt.Errorf("received notification for JSON-RPC method %q", notification.Method)) + } + if len(notification.Params) == 0 { + return zero, s.endStream(fmt.Errorf("notification has no params")) + } + result, err := s.decodeResult(notification.Params, eventID, hasEventID) + if err != nil { + return zero, s.endStream(err) + } + return result, nil + } + + if len(message.Params) > 0 { + return zero, s.endStream(fmt.Errorf("JSON-RPC response contains params")) + } + { + // A response completes the stream. Stream values arrive in the + // notifications handled above. + var response jsonrpc.RawResponse + if err := json.Unmarshal(data, &response); err != nil { + return zero, s.endStream(fmt.Errorf("failed to parse response: %w", err)) + } + if err := response.Validate(s.requestID); err != nil { + return zero, s.endStream(err) + } + if response.Error == nil && !bytes.Equal(bytes.TrimSpace(response.Result), []byte("null")) { + return zero, s.endStream(fmt.Errorf("JSON-RPC stream completion result must be null")) + } + if response.Error != nil { + serviceErr, decodeErr := s.decodeError(response.Error) + if decodeErr != nil { + return zero, s.endStream(decodeErr) + } + return zero, s.endStream(serviceErr) + } + return zero, s.endStream(io.EOF) + } + } +} + +// decodeError rebuilds one designed service error from its JSON-RPC code and data. +func (s *WatchStreamImpl) decodeError(response *jsonrpc.RawErrorResponse) (error, error) { + return response, nil +} + +// closeBody closes the HTTP response body once and returns its close error. +func (s *WatchStreamImpl) closeBody() error { + s.closeOnce.Do(func() { + s.closeErr = s.resp.Body.Close() + }) + return s.closeErr +} + +// endStream marks the stream closed and preserves both the receive error and +// any error returned while closing the HTTP response body. +func (s *WatchStreamImpl) endStream(err error) error { + s.closed = true + if closeErr := s.closeBody(); closeErr != nil { + return errors.Join(err, closeErr) + } + return err +} + +// decodeResult passes one successful stream item to the decoder configured by NewClient. +func (s *WatchStreamImpl) decodeResult(data json.RawMessage, eventID string, hasEventID bool) (*viewed.ViewedGolden, error) { + // The HTTP 200 status tells the configured decoder that this stream item is + // a successful JSON-RPC result. Streaming results cannot carry HTTP headers or cookies. + resp := &http.Response{StatusCode: http.StatusOK, Header: make(http.Header)} + return decodeWatchViewedResult(s.decoder, resp, data, eventID, hasEventID) +} + +// Close closes the stream. +func (s *WatchStreamImpl) Close() error { + s.lock.Lock() + defer s.lock.Unlock() + + if !s.closed { + s.closed = true + return s.closeBody() + } + return nil +} diff --git a/jsonrpc/codegen/testdata/golden/viewed_result_variable_sse_server.go.golden b/jsonrpc/codegen/testdata/golden/viewed_result_variable_sse_server.go.golden new file mode 100644 index 0000000000..1e8edcdb32 --- /dev/null +++ b/jsonrpc/codegen/testdata/golden/viewed_result_variable_sse_server.go.golden @@ -0,0 +1,53 @@ +// WatchServerStream implements the viewed.WatchServerStream interface using +// Server-Sent Events. +type WatchServerStream struct { + // sseServerStream writes JSON-RPC messages as server-sent events. + sseServerStream + // view is the result view used to encode later stream values. + view string + // sentView is the result view used by the first event. Later sends must use + // the same view. + sentView string +} + +// SetView selects the result view used by later stream values. +func (s *WatchServerStream) SetView(view string) { + s.view = view +} + +// Send streams instances of "ViewedGolden". +func (s *WatchServerStream) Send(event *viewed.ViewedGolden) error { + return s.SendWithContext(context.Background(), event) +} + +// SendWithContext streams instances of "ViewedGolden" with context. +func (s *WatchServerStream) SendWithContext(ctx context.Context, event *viewed.ViewedGolden) error { + result := event + view := s.view + if view == "" { + view = "default" + } + if s.sentView != "" && view != s.sentView { + return goa.InvalidEnumValueError("view", view, []any{s.sentView}) + } + body, err := encodeWatchResult(result, view) + if err != nil { + return err + } + s.sentView = view + var eventID *string + valueID := string(result.EventID) + eventID = &valueID + + message := jsonrpc.MakeNotification( + "watch", + body, + ) + return s.sendSSEEvent(ctx, message, eventID, nil, nil) +} + +// Close does nothing because the HTTP response closes when the service method +// returns. +func (s *WatchServerStream) Close() error { + return nil +} diff --git a/jsonrpc/codegen/testdata/golden/viewed_result_variable_unary_client.go.golden b/jsonrpc/codegen/testdata/golden/viewed_result_variable_unary_client.go.golden new file mode 100644 index 0000000000..c9107285da --- /dev/null +++ b/jsonrpc/codegen/testdata/golden/viewed_result_variable_unary_client.go.golden @@ -0,0 +1,47 @@ +// DecodeFetchResponse returns a decoder for responses returned by the viewed +// service fetch JSON-RPC method. The decoder rejects responses that do not +// repeat requestID. restoreBody controls whether the response body should be +// restored after having been read. +func DecodeFetchResponse(decoder func(*http.Response) goahttp.Decoder, restoreBody bool) func(*http.Response, string) (any, error) { + return func(resp *http.Response, requestID string) (result any, decodeErr error) { + responseBody := resp.Body + if restoreBody { + b, readErr := io.ReadAll(responseBody) + closeErr := responseBody.Close() + if err := errors.Join(readErr, closeErr); err != nil { + return nil, goahttp.ErrDecodingError("viewed", "fetch", err) + } + resp.Body = io.NopCloser(bytes.NewBuffer(b)) + defer func() { + resp.Body = io.NopCloser(bytes.NewBuffer(b)) + }() + } else { + defer func() { + if err := responseBody.Close(); err != nil { + decodeErr = errors.Join(decodeErr, goahttp.ErrDecodingError("viewed", "fetch", err)) + } + }() + } + + if resp.StatusCode != http.StatusOK { + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, goahttp.ErrDecodingError("viewed", "fetch", err) + } + return nil, goahttp.ErrInvalidResponse("viewed", "fetch", resp.StatusCode, string(body)) + } + + var jresp jsonrpc.RawResponse + if err := decoder(resp).Decode(&jresp); err != nil { + return nil, goahttp.ErrDecodingError("viewed", "fetch", err) + } + if err := jresp.Validate(requestID); err != nil { + return nil, goahttp.ErrInvalidResponse("viewed", "fetch", resp.StatusCode, err.Error()) + } + + if jresp.Error != nil { + return nil, jresp.Error + } + return decodeFetchViewedResult(decoder, resp, jresp.Result) + } +} diff --git a/jsonrpc/codegen/testdata/golden/viewed_result_variable_unary_server.go.golden b/jsonrpc/codegen/testdata/golden/viewed_result_variable_unary_server.go.golden new file mode 100644 index 0000000000..a0f177b34d --- /dev/null +++ b/jsonrpc/codegen/testdata/golden/viewed_result_variable_unary_server.go.golden @@ -0,0 +1,31 @@ +// NewFetchHandler creates a JSON-RPC handler which calls the "viewed" service +// "fetch" endpoint. +func NewFetchHandler( + endpoint goa.Endpoint, + mux goahttp.Muxer, + decoder func(*http.Request) goahttp.Decoder, + encoder func(context.Context, http.ResponseWriter) goahttp.Encoder, + errhandler func(context.Context, http.ResponseWriter, error), +) func(context.Context, *http.Request, *jsonrpc.RawRequest, http.ResponseWriter) error { + return func(ctx context.Context, r *http.Request, req *jsonrpc.RawRequest, w http.ResponseWriter) error { + ctx = context.WithValue(ctx, goa.MethodKey, "fetch") + ctx = context.WithValue(ctx, goa.ServiceKey, "viewed") + res, err := endpoint(ctx, nil) + if err != nil { + encodeJSONRPCError(ctx, w, req, jsonrpc.InternalError, err.Error(), nil, encoder, errhandler) + return nil + } + + // The response repeats the exact request ID. + viewedRes := res.(*viewedviews.ViewedGolden) + body, err := encodeFetchViewedResult(viewedRes) + if err != nil { + return err + } + response := jsonrpc.MakeSuccessResponse(req.ID, body) + if err := encoder(ctx, w).Encode(response); err != nil { + errhandler(ctx, w, fmt.Errorf("failed to encode JSON-RPC response: %w", err)) + } + return nil + } +} diff --git a/jsonrpc/codegen/testdata/jsonrpc_kitchen_sink_dsls.go b/jsonrpc/codegen/testdata/jsonrpc_kitchen_sink_dsls.go index cdc507fa0a..99d96c1140 100644 --- a/jsonrpc/codegen/testdata/jsonrpc_kitchen_sink_dsls.go +++ b/jsonrpc/codegen/testdata/jsonrpc_kitchen_sink_dsls.go @@ -6,11 +6,10 @@ import ( // JSONRPCKitchenSinkDSL exercises the full JSON-RPC generated surface in one // design so golden tests can pin every generator output: a plain JSON-RPC -// service (required and optional request IDs, a no-payload method, a method -// with no result, custom errors with JSON-RPC code mappings), a -// WebSocket-only streaming service, an SSE streaming service, a service -// mixing HTTP and JSON-RPC transports on the same methods, and a plain HTTP -// service sharing the design. +// service (required, optional, and generated request IDs, a no-payload method, a method +// with no result, custom errors with JSON-RPC code mappings), an SSE streaming +// service, a service mixing HTTP and JSON-RPC transports on the same methods, +// and a plain HTTP service sharing the design. var JSONRPCKitchenSinkDSL = func() { API("kitchen-sink", func() { JSONRPC(func() {}) @@ -28,9 +27,8 @@ var JSONRPCKitchenSinkDSL = func() { Required("id", "a", "b") }) Result(func() { - ID("id", String, "Request ID") Attribute("sum", Int) - Required("id", "sum") + Required("sum") }) Error("overflow") JSONRPC(func() { @@ -45,7 +43,7 @@ var JSONRPCKitchenSinkDSL = func() { }) Method("log", func() { Payload(func() { - ID("id", String, "Optional request ID") + ID("id", String, "Optional caller-supplied request ID") Attribute("message", String) Required("message") }) @@ -53,23 +51,6 @@ var JSONRPCKitchenSinkDSL = func() { }) }) - Service("Chat", func() { - JSONRPC(func() { - Path("/ws") - }) - Method("echo", func() { - StreamingPayload(func() { - ID("id", String, "Request ID") - Attribute("msg", String) - }) - StreamingResult(func() { - ID("id", String, "Request ID") - Attribute("echo", String) - }) - JSONRPC(func() {}) - }) - }) - Service("Feed", func() { JSONRPC(func() { POST("/feed") @@ -91,6 +72,14 @@ var JSONRPCKitchenSinkDSL = func() { }) }) }) + Method("snapshot", func() { + Payload(func() { + ID("request_id", String, "Request ID") + Required("request_id") + }) + Result(String) + JSONRPC(func() {}) + }) }) Service("Mixed", func() { @@ -104,9 +93,7 @@ var JSONRPCKitchenSinkDSL = func() { Required("id", "key") }) Result(func() { - ID("id", String, "Request ID") Attribute("value", String) - Required("id") }) HTTP(func() { POST("/lookup") diff --git a/jsonrpc/codegen/testdata/jsonrpc_sse_dsls.go b/jsonrpc/codegen/testdata/jsonrpc_sse_dsls.go index 38dbb13ee2..1d1018ab9c 100644 --- a/jsonrpc/codegen/testdata/jsonrpc_sse_dsls.go +++ b/jsonrpc/codegen/testdata/jsonrpc_sse_dsls.go @@ -38,7 +38,7 @@ var JSONRPCSSEObjectDSL = func() { Attribute("last_event_id", String, "Last event ID") }) StreamingResult(func() { - ID("id", String, "Event ID") + Attribute("id", String, "Event ID") Attribute("data", String, "Event data") }) JSONRPC(func() { @@ -49,4 +49,4 @@ var JSONRPCSSEObjectDSL = func() { }) }) }) -} \ No newline at end of file +} diff --git a/jsonrpc/codegen/testdata/jsonrpc_sse_duplicate_dsls.go b/jsonrpc/codegen/testdata/jsonrpc_sse_duplicate_dsls.go index 372c7bc842..6dbd6a2ef8 100644 --- a/jsonrpc/codegen/testdata/jsonrpc_sse_duplicate_dsls.go +++ b/jsonrpc/codegen/testdata/jsonrpc_sse_duplicate_dsls.go @@ -1,30 +1,29 @@ package testdata import ( - . "goa.design/goa/v3/dsl" + . "goa.design/goa/v3/dsl" ) // JSONRPCSSEDuplicateEventDSL defines two JSON-RPC SSE streaming methods that share the // same streaming result type to ensure generated server stream switch does not duplicate cases. var JSONRPCSSEDuplicateEventDSL = func() { - API("jsonrpc-sse-dedupe-test", func() { JSONRPC(func() {}) }) + API("jsonrpc-sse-dedupe-test", func() { JSONRPC(func() {}) }) - var SharedSSEEvent = Type("SharedSSEEvent", func() { - Attribute("data", String) - Required("data") - }) + var SharedSSEEvent = Type("SharedSSEEvent", func() { + Attribute("data", String) + Required("data") + }) - Service("JSONRPCSSEDupeService", func() { - JSONRPC(func() { POST("/stream") }) + Service("JSONRPCSSEDupeService", func() { + JSONRPC(func() { POST("/stream") }) - Method("StreamA", func() { - StreamingResult(SharedSSEEvent) - JSONRPC(func() { ServerSentEvents() }) - }) - Method("StreamB", func() { - StreamingResult(SharedSSEEvent) - JSONRPC(func() { ServerSentEvents() }) - }) - }) + Method("StreamA", func() { + StreamingResult(SharedSSEEvent) + JSONRPC(func() { ServerSentEvents() }) + }) + Method("StreamB", func() { + StreamingResult(SharedSSEEvent) + JSONRPC(func() { ServerSentEvents() }) + }) + }) } - diff --git a/jsonrpc/codegen/testing.go b/jsonrpc/codegen/testing.go index 1da00ba7d0..8cc0f86294 100644 --- a/jsonrpc/codegen/testing.go +++ b/jsonrpc/codegen/testing.go @@ -1,17 +1,69 @@ +// This file builds JSON-RPC code-generation analysis in tests using the same +// generation construction, planning, freezing, and rendering as production. package codegen import ( "goa.design/goa/v3/codegen" "goa.design/goa/v3/codegen/service" + "goa.design/goa/v3/eval" "goa.design/goa/v3/expr" httpcodegen "goa.design/goa/v3/http/codegen" ) -// CreateJSONRPCServices creates a new ServicesData instance for JSON-RPC -// testing. The root is normalized first like the production Generate flow -// does before the generators read the design. -func CreateJSONRPCServices(root *expr.RootExpr) *httpcodegen.ServicesData { - codegen.NormalizeRoot(root) - services := service.NewServicesData(root) - return httpcodegen.NewJSONRPCServicesData(services, &root.API.JSONRPC.HTTPExpr) +// CreateJSONRPCPlan builds and links the same service, HTTP, and JSON-RPC plans +// that production generation uses. +func CreateJSONRPCPlan(root *expr.RootExpr) *Plan { + generation, err := codegen.NewGeneration("generated.local/gen", []eval.Root{root}) + if err != nil { + panic(err) + } + servicePlan, err := service.NewPlan(root, generation, expr.NewExampleGenerator(root.API.RandomizerFactory)) + if err != nil { + panic(err) + } + var applicationHTTP *httpcodegen.Plan + if len(root.API.HTTP.Services) > 0 { + applicationPlans, err := httpcodegen.NewPlans(generation, httpcodegen.PlanInput{ + Root: root, + Service: servicePlan, + }) + if err != nil { + panic(err) + } + applicationHTTP = applicationPlans[0] + } + httpPlans, err := httpcodegen.NewJSONRPCPlans(generation, httpcodegen.PlanInput{ + Root: root, + Service: servicePlan, + }) + if err != nil { + panic(err) + } + plans, err := NewPlans(generation, PlanInput{ + Root: root, + Service: servicePlan, + HTTP: httpPlans[0], + ApplicationHTTP: applicationHTTP, + }) + if err != nil { + panic(err) + } + if err := generation.Freeze(); err != nil { + panic(err) + } + if err := servicePlan.Link(); err != nil { + panic(err) + } + if applicationHTTP != nil { + if err := applicationHTTP.Link(); err != nil { + panic(err) + } + } + if err := httpPlans[0].Link(); err != nil { + panic(err) + } + if err := plans[0].Link(); err != nil { + panic(err) + } + return plans[0] } diff --git a/jsonrpc/codegen/viewed_result.go b/jsonrpc/codegen/viewed_result.go new file mode 100644 index 0000000000..cea41210ef --- /dev/null +++ b/jsonrpc/codegen/viewed_result.go @@ -0,0 +1,177 @@ +// This file connects each JSON-RPC result view to the HTTP JSON body and +// service constructor chosen for that endpoint. Unary calls and SSE streams +// use the same generated functions, so clients decode the same JSON shape that +// servers encode. +package codegen + +import ( + "goa.design/goa/v3/codegen" + httpcodegen "goa.design/goa/v3/http/codegen" +) + +type ( + // viewedResultTemplateData contains one method's allowed views, body types, + // and generated function names. + viewedResultTemplateData struct { + ServiceName string + MethodName string + Decode *codegen.NameDeclaration + Encode *codegen.NameDeclaration + StreamEncode *codegen.NameDeclaration + BodyDecoder *codegen.NameDeclaration + Variable bool + FixedView string + Branches []*viewBranchTemplateData + ViewedTypeRef string + ViewedVarName string + ViewedPkg string + ViewedValidator string + ServiceResultConstructor string + ServiceViewedConstructor string + ServicePkg string + ViewedValue string + ResultRef string + IsCollection bool + SSE *httpcodegen.JSONRPCSSEData + } + + // viewBranchTemplateData contains one view's server body, client body, and + // function that rebuilds the result. + viewBranchTemplateData struct { + View string + ResultAttr string + ServerBody *httpcodegen.JSONRPCBodyData + ClientBody *httpcodegen.JSONRPCBodyData + ResultInit httpcodegen.InitData + } +) + +// clientViewedResultSections returns the result decoders written to one +// generated service client package. +func clientViewedResultSections(service *servicePlan) []*codegen.SectionTemplate { + var sections []*codegen.SectionTemplate + for _, endpoint := range service.endpoints { + if endpoint.viewed == nil { + continue + } + sections = append(sections, &codegen.SectionTemplate{ + Name: "jsonrpc-viewed-result-decoder", + Source: jsonrpcTemplates.Read(viewedResultDecodeT, singleResponseP, queryTypeConversionP, elementSliceConversionP, sliceItemConversionP), + Data: viewedResultData(service, endpoint), + FuncMap: map[string]any{ + "viewedResponseData": viewedResponseData, + "sseRetryBits": sseRetryBits, + "sseRetrySigned": sseRetrySigned, + }, + }) + } + return sections +} + +// serverViewedResultSections returns the result encoders written to one +// generated service server package. +func serverViewedResultSections(service *servicePlan) []*codegen.SectionTemplate { + var sections []*codegen.SectionTemplate + for _, endpoint := range service.endpoints { + if endpoint.viewed == nil { + continue + } + sections = append(sections, &codegen.SectionTemplate{ + Name: "jsonrpc-viewed-result-encoder", + Source: jsonrpcTemplates.Read(viewedResultEncodeT), + Data: viewedResultData(service, endpoint), + }) + } + return sections +} + +// viewedResultFuncs returns functions that read names already declared in the +// generated client and server packages. +func viewedResultFuncs(service *servicePlan) map[string]any { + return map[string]any{ + "viewedDecodeName": service.viewedDecodeName, + "viewedEncodeName": service.viewedEncodeName, + "viewedStreamEncodeName": service.viewedStreamEncodeName, + } +} + +// viewedDecodeName returns the generated client decoder name for method. +func (s *servicePlan) viewedDecodeName(method string) string { + return s.viewedHelpers(method).decode.Name() +} + +// viewedEncodeName returns the generated server encoder name for method. +func (s *servicePlan) viewedEncodeName(method string) string { + return s.viewedHelpers(method).encode.Name() +} + +// viewedStreamEncodeName returns the generated server stream encoder name for method. +func (s *servicePlan) viewedStreamEncodeName(method string) string { + return s.viewedHelpers(method).streamEncode.Name() +} + +// viewedHelpers returns the function names declared for a method that returns a +// result view. It panics when the generated file asks for an unknown method. +func (s *servicePlan) viewedHelpers(method string) *viewedHelperDeclarations { + declarations := s.helpers[method] + if declarations == nil { + panic("JSON-RPC viewed helper requested for unplanned method " + method) + } + return declarations +} + +// viewedResultData returns the method and function names used to write result +// conversion code. It does not read the design or create new names. +func viewedResultData(service *servicePlan, endpoint *endpointPlan) *viewedResultTemplateData { + representation := endpoint.viewed + viewed := representation.viewedResult + branches := make([]*viewBranchTemplateData, len(representation.branches)) + for index, branch := range representation.branches { + branches[index] = &viewBranchTemplateData{ + View: branch.view, + ResultAttr: branch.resultAttr, + ServerBody: branch.serverBody, + ClientBody: branch.clientBody, + ResultInit: branch.resultInit, + } + } + localScope := codegen.NewNameScope() + localScope.Unique(representation.servicePkg) + localScope.Unique(viewed.ViewsPkg) + + return &viewedResultTemplateData{ + ServiceName: endpoint.ServiceName, + MethodName: endpoint.Method.Name, + Decode: representation.decode, + Encode: representation.encode, + StreamEncode: representation.streamEncode, + BodyDecoder: service.bodyDecoder, + Variable: representation.variable, + FixedView: representation.fixedView, + Branches: branches, + ViewedTypeRef: viewed.FullRef, + ViewedVarName: viewed.VarName, + ViewedPkg: viewed.ViewsPkg, + ViewedValidator: viewed.Validate.Name(), + ServiceResultConstructor: viewed.ResultInit.Name(), + ServiceViewedConstructor: viewed.Init.Name(), + ServicePkg: representation.servicePkg, + ViewedValue: localScope.Unique("viewed"), + ResultRef: representation.resultRef, + IsCollection: viewed.IsCollection, + SSE: endpoint.SSE, + } +} + +// viewedResponseData gives the response reader one view's body together with +// the service and method names used in errors. +func viewedResponseData(branch *viewBranchTemplateData, serviceName, methodName string, sse *httpcodegen.JSONRPCSSEData) map[string]any { + return map[string]any{ + "Data": map[string]any{ + "ClientBody": branch.ClientBody, + }, + "ServiceName": serviceName, + "Method": map[string]any{"Name": methodName}, + "SSE": sse, + } +} diff --git a/jsonrpc/codegen/viewed_result_golden_test.go b/jsonrpc/codegen/viewed_result_golden_test.go new file mode 100644 index 0000000000..03263eada4 --- /dev/null +++ b/jsonrpc/codegen/viewed_result_golden_test.go @@ -0,0 +1,152 @@ +// This file checks the generated JSON-RPC code that carries a selected result +// view through unary responses and server-sent events. +package codegen + +import ( + "path/filepath" + "testing" + + "github.com/stretchr/testify/require" + + "goa.design/goa/v3/codegen" + "goa.design/goa/v3/codegen/testutil" + "goa.design/goa/v3/dsl" +) + +func TestVariableViewedResultGeneratedSource(t *testing.T) { + _, plan := linkedJSONRPCPlan(t, variableViewedResultGoldenDSL) + require.Len(t, plan.services, 1) + service := plan.services[0] + + clientConversions := clientViewedResultSections(service) + serverConversions := serverViewedResultSections(service) + require.Len(t, clientConversions, 2) + require.Len(t, serverConversions, 2) + clientData := clientConversions[0].Data.(*viewedResultTemplateData) + require.Equal(t, "fetch", clientData.MethodName) + require.Equal(t, "viewed2", clientData.ViewedValue) + require.Equal(t, "fetch", serverConversions[0].Data.(*viewedResultTemplateData).MethodName) + streamDecoder := codegen.SectionCode(t, clientConversions[1]) + require.Contains(t, streamDecoder, "result.EventID = string(*body.EventID)") + require.NotContains(t, streamDecoder, "if body.EventID != nil") + testutil.AssertGo( + t, + "testdata/golden/viewed_result_variable_decoder.go.golden", + codegen.SectionCode(t, clientConversions[0]), + ) + testutil.AssertGo( + t, + "testdata/golden/viewed_result_variable_encoder.go.golden", + codegen.SectionCode(t, serverConversions[0]), + ) + + tests := []struct { + name string + files []*codegen.File + packageName string + fileName string + sectionName string + sectionCount int + golden string + }{ + { + name: "unary client", + files: plan.ClientFiles(), + packageName: "client", + fileName: "encode_decode.go", + sectionName: "jsonrpc-response-decoder", + sectionCount: 1, + golden: "testdata/golden/viewed_result_variable_unary_client.go.golden", + }, + { + name: "unary server", + files: plan.ServerFiles(), + packageName: "server", + fileName: "server.go", + sectionName: "jsonrpc-server-handler-init", + sectionCount: 2, + golden: "testdata/golden/viewed_result_variable_unary_server.go.golden", + }, + { + name: "SSE client", + files: plan.ClientFiles(), + packageName: "client", + fileName: "stream.go", + sectionName: "jsonrpc-sse-client-stream", + sectionCount: 1, + golden: "testdata/golden/viewed_result_variable_sse_client.go.golden", + }, + { + name: "SSE server", + files: plan.ServerFiles(), + packageName: "server", + fileName: "sse.go", + sectionName: "jsonrpc-sse-server-stream", + sectionCount: 1, + golden: "testdata/golden/viewed_result_variable_sse_server.go.golden", + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + file := viewedResultGoldenFile(t, test.files, test.packageName, test.fileName) + sections := file.Section(test.sectionName) + require.Len(t, sections, test.sectionCount) + testutil.AssertGo(t, test.golden, codegen.SectionCode(t, sections[0])) + }) + } +} + +// viewedResultGoldenFile returns one generated client or server file for the +// viewed service used by the snapshots. +func viewedResultGoldenFile(t *testing.T, files []*codegen.File, packageName, fileName string) *codegen.File { + t.Helper() + for _, file := range files { + if filepath.Base(file.Path) != fileName { + continue + } + if filepath.Base(filepath.Dir(file.Path)) != packageName { + continue + } + if filepath.Base(filepath.Dir(filepath.Dir(file.Path))) == "viewed" { + return file + } + } + t.Fatalf("generated viewed/%s/%s file not found", packageName, fileName) + return nil +} + +// variableViewedResultGoldenDSL defines one unary method and one server stream +// whose callers choose between the two named views or the generated default. +func variableViewedResultGoldenDSL() { + result := dsl.ResultType("application/vnd.viewed-golden", func() { + dsl.TypeName("ViewedGolden") + dsl.Attribute("id", dsl.String) + dsl.Attribute("detail", dsl.String) + dsl.Attribute("event_id", dsl.String) + dsl.Required("id", "detail", "event_id") + dsl.View("summary", func() { + dsl.Attribute("id") + }) + dsl.View("detailed", func() { + dsl.Attribute("id") + dsl.Attribute("detail") + }) + }) + dsl.Service("viewed", func() { + dsl.JSONRPC(func() { + dsl.POST("/rpc") + }) + dsl.Method("fetch", func() { + dsl.Result(result) + dsl.JSONRPC(func() {}) + }) + dsl.Method("watch", func() { + dsl.StreamingResult(result) + dsl.JSONRPC(func() { + dsl.ServerSentEvents(func() { + dsl.SSEEventID("event_id") + }) + }) + }) + }) +} diff --git a/jsonrpc/codegen/viewed_result_runtime_regression_test.go b/jsonrpc/codegen/viewed_result_runtime_regression_test.go new file mode 100644 index 0000000000..21484de344 --- /dev/null +++ b/jsonrpc/codegen/viewed_result_runtime_regression_test.go @@ -0,0 +1,1514 @@ +// This file renders JSON-RPC clients and servers into a temporary Go module. +// The generated tests call each client with an application-supplied decoder +// and send an invalid request to an SSE server, then inspect the response data +// and errors that the generated code gives the application. +package codegen_test + +import ( + "context" + "fmt" + "os" + "os/exec" + "path/filepath" + "testing" + "time" + + "github.com/stretchr/testify/require" + + goacodegen "goa.design/goa/v3/codegen" + "goa.design/goa/v3/codegen/service" + "goa.design/goa/v3/dsl" + "goa.design/goa/v3/eval" + "goa.design/goa/v3/expr" + httpcodegen "goa.design/goa/v3/http/codegen" + jsonrpccodegen "goa.design/goa/v3/jsonrpc/codegen" +) + +// TestGeneratedViewedClientDecodersReceiveOKStatus renders a unary call and an +// SSE stream, then runs each generated client. +func TestGeneratedViewedClientDecodersReceiveOKStatus(t *testing.T) { + dir := renderViewedResultRuntimeModule(t) + writeViewedResultRuntimeTest(t, dir, "unary_status", unaryStatusRuntimeTest) + writeViewedResultRuntimeTest(t, dir, "sse_status", sseStatusRuntimeTest) + runViewedResultRuntimeTests(t, dir, + "./jsonrpc/unary_status/client", + "./jsonrpc/sse_status/client", + ) +} + +// TestGeneratedSSELifecycle renders a result stream and checks every JSON-RPC +// message written when the service sends values and returns. +func TestGeneratedSSELifecycle(t *testing.T) { + dir := renderViewedResultRuntimeModule(t) + writeViewedResultServerRuntimeTest(t, dir, "sse_decode", sseLifecycleRuntimeTest) + runViewedResultRuntimeTests(t, dir, "./jsonrpc/sse_decode/server") +} + +// TestGeneratedMappedObjectBodyValidatesRequiredFields renders an explicit +// object response body, decodes both selected views, and checks the required +// field when the generated client receives the selected body. +func TestGeneratedMappedObjectBodyValidatesRequiredFields(t *testing.T) { + dir := renderViewedResultRuntimeModule(t) + writeViewedResultRuntimeTest(t, dir, "mapped_body", mappedBodyRuntimeTest) + runViewedResultRuntimeTests(t, dir, "./jsonrpc/mapped_body/client") +} + +// TestGeneratedServerReturnsRequestBodyFailures makes reading and closing one +// request body fail and checks that the generated server reports both errors. +func TestGeneratedServerReturnsRequestBodyFailures(t *testing.T) { + dir := renderViewedResultRuntimeModule(t) + writeViewedResultServerRuntimeTest(t, dir, "unary_status", requestBodyFailureRuntimeTest) + runViewedResultRuntimeTests(t, dir, "./jsonrpc/unary_status/server") +} + +// TestGeneratedSSEDecodeErrorReturnsWriteFailure sends a request that omits a +// required parameter and makes writing the JSON-RPC error message fail. The +// generated server must report that failure once without starting a new +// response. +func TestGeneratedSSEDecodeErrorReturnsWriteFailure(t *testing.T) { + dir := renderViewedResultRuntimeModule(t) + writeViewedResultServerRuntimeTest(t, dir, "sse_decode", sseDecodeErrorRuntimeTest) + runViewedResultRuntimeTests(t, dir, "./jsonrpc/sse_decode/server") +} + +// TestGeneratedSSERecvWithContextStopsBlockedRead renders an SSE client and +// checks that canceling a receive closes its response body and ends the stream. +func TestGeneratedSSERecvWithContextStopsBlockedRead(t *testing.T) { + dir := renderViewedResultRuntimeModule(t) + writeViewedResultRuntimeTest(t, dir, "sse_status", sseCancellationRuntimeTest) + runViewedResultRuntimeTests(t, dir, "./jsonrpc/sse_status/client") +} + +// renderViewedResultRuntimeModule writes the generated service and JSON-RPC +// client files used by these tests. It uses this Goa checkout and leaves the +// repository's generated files unchanged. +func renderViewedResultRuntimeModule(t *testing.T) string { + t.Helper() + root := expr.RunDSL(t, viewedResultRuntimeDSL) + generation, err := goacodegen.NewGeneration("generated.local/gen", []eval.Root{root}) + require.NoError(t, err) + servicePlan, err := service.NewPlan(root, generation, expr.NewExampleGenerator(root.API.RandomizerFactory)) + require.NoError(t, err) + httpPlans, err := httpcodegen.NewJSONRPCPlans(generation, httpcodegen.PlanInput{ + Root: root, + Service: servicePlan, + }) + require.NoError(t, err) + jsonPlans, err := jsonrpccodegen.NewPlans(generation, jsonrpccodegen.PlanInput{ + Root: root, + Service: servicePlan, + HTTP: httpPlans[0], + }) + require.NoError(t, err) + require.NoError(t, generation.Freeze()) + require.NoError(t, servicePlan.Link()) + require.NoError(t, httpPlans[0].Link()) + require.NoError(t, jsonPlans[0].Link()) + + files, err := service.Files(servicePlan) + require.NoError(t, err) + files = append(files, jsonPlans[0].ClientFiles()...) + files = append(files, jsonPlans[0].ServerFiles()...) + files = append(files, jsonPlans[0].ClientTypeFiles()...) + files = append(files, jsonPlans[0].ServerTypeFiles()...) + files = append(files, jsonPlans[0].PathFiles()...) + base := t.TempDir() + for _, file := range files { + _, err := file.Render(base) + require.NoError(t, err) + } + + moduleDir := filepath.Join(base, goacodegen.Gendir) + workingDir, err := os.Getwd() + require.NoError(t, err) + repository := filepath.Clean(filepath.Join(workingDir, "..", "..")) + goMod := fmt.Sprintf("module generated.local/gen\n\ngo 1.25\n\nrequire goa.design/goa/v3 v3.0.0\n\nreplace goa.design/goa/v3 => %s\n", repository) + require.NoError(t, os.WriteFile(filepath.Join(moduleDir, "go.mod"), []byte(goMod), 0o600)) + return moduleDir +} + +// writeViewedResultRuntimeTest adds a client test to the temporary module. +func writeViewedResultRuntimeTest(t *testing.T, moduleDir, serviceName, source string) { + t.Helper() + dir := filepath.Join(moduleDir, "jsonrpc", serviceName, "client") + require.NoError(t, os.MkdirAll(dir, 0o750)) + require.NoError(t, os.WriteFile(filepath.Join(dir, "viewed_result_runtime_test.go"), []byte(source), 0o600)) +} + +// writeViewedResultServerRuntimeTest adds a server test to the temporary +// module without writing into this repository's generated directories. +func writeViewedResultServerRuntimeTest(t *testing.T, moduleDir, serviceName, source string) { + t.Helper() + dir := filepath.Join(moduleDir, "jsonrpc", serviceName, "server") + require.NoError(t, os.MkdirAll(dir, 0o750)) + require.NoError(t, os.WriteFile(filepath.Join(dir, "sse_error_runtime_test.go"), []byte(source), 0o600)) +} + +// runViewedResultRuntimeTests runs only the generated packages named by +// patterns so each failure identifies the client call or server request that +// supplied unexpected data. +func runViewedResultRuntimeTests(t *testing.T, moduleDir string, patterns ...string) { + t.Helper() + ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) + defer cancel() + args := append([]string{"test", "-mod=mod"}, patterns...) + cmd := exec.CommandContext(ctx, "go", args...) + cmd.Dir = moduleDir + cmd.Env = append(os.Environ(), "GOWORK=off") + output, err := cmd.CombinedOutput() + require.NoError(t, err, string(output)) +} + +// viewedResultRuntimeDSL defines the JSON-RPC methods rendered into the +// temporary module used by these tests. +func viewedResultRuntimeDSL() { + result := viewedStatusResult() + watchFailure := dsl.Type("WatchFailure", func() { + dsl.Attribute("reason", dsl.String) + dsl.Required("reason") + }) + dsl.Service("Unary Status", func() { + dsl.JSONRPC(func() { + dsl.POST("/unary") + }) + dsl.Method("fetch", func() { + dsl.Result(result) + dsl.JSONRPC(func() {}) + }) + }) + dsl.Service("SSE Status", func() { + dsl.JSONRPC(func() { + dsl.POST("/sse") + }) + dsl.Method("watch", func() { + dsl.StreamingResult(result) + dsl.Error("watch_failed", watchFailure) + dsl.JSONRPC(func() { + dsl.Response("watch_failed", func() { + dsl.Code(-32010) + }) + dsl.ServerSentEvents(func() { + dsl.SSEEventID("event_id") + dsl.SSEEventType("event_type") + dsl.SSEEventRetry("retry") + }) + }) + }) + }) + dsl.Service("SSE Decode", func() { + dsl.JSONRPC(func() { + dsl.POST("/decode") + }) + dsl.Method("watch", func() { + dsl.Payload(func() { + dsl.Attribute("topic", dsl.String) + dsl.Required("topic") + }) + dsl.StreamingResult(func() { + dsl.Attribute("message", dsl.String) + dsl.Required("message") + }) + dsl.JSONRPC(func() { + dsl.ServerSentEvents(func() {}) + }) + }) + }) + dsl.Service("Protocol", func() { + dsl.JSONRPC(func() { + dsl.POST("/protocol") + }) + dsl.Method("ping", func() { + dsl.JSONRPC(func() {}) + }) + dsl.Method("watch", func() { + dsl.StreamingResult(dsl.String) + dsl.JSONRPC(func() { + dsl.ServerSentEvents(func() {}) + }) + }) + }) + mapped := dsl.ResultType("application/vnd.mapped-body", func() { + dsl.TypeName("MappedBody") + dsl.Attribute("id", func() { + dsl.Attribute("value", dsl.String) + dsl.Required("value") + }) + dsl.Attribute("detail", dsl.String) + dsl.Required("id") + dsl.View("summary", func() { + dsl.Attribute("id") + }) + dsl.View("detailed", func() { + dsl.Attribute("id") + dsl.Attribute("detail") + }) + }) + dsl.Service("Mapped Body", func() { + dsl.JSONRPC(func() { + dsl.POST("/mapped") + }) + dsl.Method("fetch", func() { + dsl.Result(mapped) + dsl.JSONRPC(func() { + dsl.Response(func() { + dsl.Body("id") + }) + }) + }) + }) +} + +// viewedStatusResult defines two views so each client must decode both the +// selected view name and its corresponding JSON body. +func viewedStatusResult() *expr.ResultTypeExpr { + return dsl.ResultType("application/vnd.decoder-status", func() { + dsl.TypeName("DecoderStatus") + dsl.Attribute("label", dsl.String) + dsl.Attribute("detail", dsl.String) + dsl.Attribute("event_id", dsl.String, func() { + dsl.Pattern(`^(|item-[0-9]+)$`) + }) + dsl.Attribute("event_type", dsl.String, func() { + dsl.Enum("", "update") + }) + dsl.Attribute("retry", dsl.Int, func() { + dsl.Maximum(10) + }) + dsl.Required("label") + dsl.View("summary", func() { + dsl.Attribute("label") + }) + dsl.View("detailed", func() { + dsl.Attribute("label") + dsl.Attribute("detail") + }) + }) +} + +const unaryStatusRuntimeTest = `package client + +import ( + "context" + "encoding/json" + "errors" + "io" + "net/http" + "strconv" + "strings" + "testing" + + "github.com/stretchr/testify/require" + + goahttp "goa.design/goa/v3/http" +) + +type doerFunc func(*http.Request) (*http.Response, error) + +func (f doerFunc) Do(request *http.Request) (*http.Response, error) { + return f(request) +} + +type trackedResponseBody struct { + reader io.Reader + closeErr error +} + +type requestEnvelope struct { + ID string ` + "`json:\"id\"`" + ` +} + +func (body *trackedResponseBody) Read(buffer []byte) (int, error) { + return body.reader.Read(buffer) +} + +func (body *trackedResponseBody) Close() error { + return body.closeErr +} + +func requestID(t *testing.T, request *http.Request) string { + t.Helper() + var envelope requestEnvelope + require.NoError(t, json.NewDecoder(request.Body).Decode(&envelope)) + return envelope.ID +} + +func responseBody(id, result string) io.ReadCloser { + body := ` + "`" + `{"jsonrpc":"2.0","id":` + "`" + ` + strconv.Quote(id) + ` + "`" + `,"result":` + "`" + ` + result + "}" + return io.NopCloser(strings.NewReader(body)) +} + +func TestUnaryViewedDecoderReceivesHTTPStatusOK(t *testing.T) { + statuses := make([]int, 0, 3) + decoder := func(response *http.Response) goahttp.Decoder { + statuses = append(statuses, response.StatusCode) + return goahttp.ResponseDecoder(response) + } + doer := doerFunc(func(request *http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: http.StatusOK, + Header: make(http.Header), + Body: responseBody( + requestID(t, request), + ` + "`" + `{"view":"summary","body":{"label":"ready"}}` + "`" + `, + ), + }, nil + }) + client := NewClient("http", "example.test", doer, goahttp.RequestEncoder, decoder, false) + _, err := client.Fetch()(context.Background(), nil) + require.NoError(t, err) + require.NotEmpty(t, statuses) + for _, status := range statuses { + require.Equal(t, http.StatusOK, status) + } +} + +func TestUnaryDecoderReturnsResponseCloseFailure(t *testing.T) { + closeErr := errors.New("close failed") + doer := doerFunc(func(request *http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: http.StatusOK, + Header: make(http.Header), + Body: &trackedResponseBody{ + reader: responseBody( + requestID(t, request), + ` + "`" + `{"view":"summary","body":{"label":"ready"}}` + "`" + `, + ), + closeErr: closeErr, + }, + }, nil + }) + client := NewClient("http", "example.test", doer, goahttp.RequestEncoder, goahttp.ResponseDecoder, false) + _, err := client.Fetch()(context.Background(), nil) + assertDecodingError(t, err) + require.ErrorIs(t, err, closeErr) +} + +func TestUnaryDecoderRejectsMismatchedResponseID(t *testing.T) { + response := &http.Response{ + StatusCode: http.StatusOK, + Header: make(http.Header), + Body: responseBody( + "response-id", + ` + "`" + `{"view":"summary","body":{"label":"ready"}}` + "`" + `, + ), + } + + _, err := DecodeFetchResponse(goahttp.ResponseDecoder, false)(response, "request-id") + var clientErr *goahttp.ClientError + require.ErrorAs(t, err, &clientErr) + require.Equal(t, "invalid_response", clientErr.Name) + require.ErrorContains(t, err, ` + "`" + `response id "response-id" does not match request id "request-id"` + "`" + `) +} + +func TestUnaryDecoderReturnsDecodeAndCloseFailures(t *testing.T) { + decodeErr := errors.New("decode failed") + closeErr := errors.New("close failed") + doer := doerFunc(func(*http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: http.StatusOK, + Header: make(http.Header), + Body: &trackedResponseBody{ + reader: strings.NewReader("ignored"), + closeErr: closeErr, + }, + }, nil + }) + decoder := func(*http.Response) goahttp.Decoder { + return goahttp.EncodingFunc(func(any) error { + return decodeErr + }) + } + client := NewClient("http", "example.test", doer, goahttp.RequestEncoder, decoder, false) + + _, err := client.Fetch()(context.Background(), nil) + + assertDecodingError(t, err) + require.ErrorIs(t, err, decodeErr) + require.ErrorIs(t, err, closeErr) +} + +func assertDecodingError(t *testing.T, err error) { + t.Helper() + var clientErr *goahttp.ClientError + require.ErrorAs(t, err, &clientErr) + require.Equal(t, "decoding_error", clientErr.Name) +} +` + +const sseStatusRuntimeTest = `package client + +import ( + "context" + "encoding/json" + "errors" + "io" + "net/http" + "strconv" + "strings" + "testing" + "testing/iotest" + + "github.com/stretchr/testify/require" + + service "generated.local/gen/sse_status" + goahttp "goa.design/goa/v3/http" + "goa.design/goa/v3/jsonrpc" +) + +type doerFunc func(*http.Request) (*http.Response, error) + +func (f doerFunc) Do(request *http.Request) (*http.Response, error) { + return f(request) +} + +type failingResponseBody struct { + reader io.Reader + readErr error + closeErr error + closes int +} + +type requestEnvelope struct { + ID string ` + "`json:\"id\"`" + ` +} + +func (body *failingResponseBody) Read(buffer []byte) (int, error) { + if body.reader != nil { + return body.reader.Read(buffer) + } + return 0, body.readErr +} + +func (body *failingResponseBody) Close() error { + body.closes++ + return body.closeErr +} + +func requestID(t *testing.T, request *http.Request) string { + t.Helper() + var envelope requestEnvelope + require.NoError(t, json.NewDecoder(request.Body).Decode(&envelope)) + return envelope.ID +} + +func terminalResponse(id string) string { + return "data: " + + ` + "`" + `{"jsonrpc":"2.0","id":` + "`" + ` + strconv.Quote(id) + ` + "`" + `,"result":null}` + "`" + ` + "\n\n" +} + +func TestSSEViewedDecoderReceivesHTTPStatusOK(t *testing.T) { + statuses := make([]int, 0, 2) + decoder := func(response *http.Response) goahttp.Decoder { + statuses = append(statuses, response.StatusCode) + return goahttp.ResponseDecoder(response) + } + doer := doerFunc(func(request *http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"text/event-stream"}}, + Body: io.NopCloser(strings.NewReader( + "id: \nevent: \nretry: 0\ndata: " + ` + "`" + `{"jsonrpc":"2.0","method":"watch","params":{"view":"summary","body":{"label":"ready"}}}` + "`" + ` + "\n\n" + + terminalResponse(requestID(t, request)), + )), + }, nil + }) + client := NewClient("http", "example.test", doer, goahttp.RequestEncoder, decoder, false) + raw, err := client.Watch()(context.Background(), nil) + require.NoError(t, err) + stream := raw.(*WatchStreamImpl) + var serviceStream service.WatchClientStream = stream + result, err := serviceStream.Recv() + require.NoError(t, err) + require.NotNil(t, result.EventID) + require.Empty(t, *result.EventID) + require.NotNil(t, result.EventType) + require.Empty(t, *result.EventType) + require.NotNil(t, result.Retry) + require.Zero(t, *result.Retry) + _, err = serviceStream.Recv() + require.ErrorIs(t, err, io.EOF) + require.NotEmpty(t, statuses) + for _, status := range statuses { + require.Equal(t, http.StatusOK, status) + } +} + +func TestSSEViewedDecoderValidatesMappedOuterFields(t *testing.T) { + response := &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"text/event-stream"}}, + Body: io.NopCloser(strings.NewReader( + "id: item-1\nevent: update\nretry: 11\ndata: " + ` + "`" + `{"jsonrpc":"2.0","method":"watch","params":{"view":"summary","body":{"label":"ready"}}}` + "`" + ` + "\n\n", + )), + } + + _, err := NewWatchStream(response, goahttp.ResponseDecoder, "request-1").Recv() + + require.ErrorContains(t, err, "lesser or equal than 10") +} + +func TestSSETerminalErrorIsReturned(t *testing.T) { + stream := NewWatchStream(&http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"text/event-stream"}}, + Body: io.NopCloser(strings.NewReader( + "data: " + ` + "`" + `{"jsonrpc":"2.0","id":"1","error":{"code":-32603,"message":"watch failed","data":{"reason":"overload"}}}` + "`" + ` + "\n\n", + )), + }, goahttp.ResponseDecoder, "1") + + _, err := stream.Recv() + var response *jsonrpc.RawErrorResponse + require.ErrorAs(t, err, &response) + require.Equal(t, -32603, response.Code) + require.Equal(t, "watch failed", response.Message) + require.JSONEq(t, ` + "`" + `{"reason":"overload"}` + "`" + `, string(response.Data)) +} + +func TestSSETerminalDesignedErrorIsReturned(t *testing.T) { + stream := NewWatchStream(&http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"text/event-stream"}}, + Body: io.NopCloser(strings.NewReader( + "data: " + ` + "`" + `{"jsonrpc":"2.0","id":"1","error":{"code":-32010,"message":"watch failed","data":{"name":"watch_failed","body":{"reason":"overload"}}}}` + "`" + ` + "\n\n", + )), + }, goahttp.ResponseDecoder, "1") + + _, err := stream.Recv() + + var failure *service.WatchFailure + require.ErrorAs(t, err, &failure) + require.Equal(t, "overload", failure.Reason) +} + +func TestSSETerminalErrorPreservesCloseFailure(t *testing.T) { + closeErr := errors.New("close failed") + body := &failingResponseBody{ + reader: strings.NewReader( + "data: " + ` + "`" + `{"jsonrpc":"2.0","id":"1","error":{"code":-32603,"message":"watch failed"}}` + "`" + ` + "\n\n", + ), + closeErr: closeErr, + } + stream := NewWatchStream(&http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"text/event-stream"}}, + Body: body, + }, goahttp.ResponseDecoder, "1") + + _, err := stream.Recv() + var response *jsonrpc.RawErrorResponse + require.ErrorAs(t, err, &response) + require.Equal(t, -32603, response.Code) + require.Equal(t, "watch failed", response.Message) + require.ErrorIs(t, err, closeErr) + require.Equal(t, 1, body.closes) + _, err = stream.Recv() + require.Equal(t, io.EOF, err) +} + +func TestSSEInvalidEventClosesBody(t *testing.T) { + tests := []struct { + name string + event string + error string + }{ + {"malformed message", "data: {\n\n", "failed to parse JSON-RPC message"}, + {"notification with ID", "data: " + ` + "`" + `{"jsonrpc":"2.0","method":"watch","id":"unexpected","params":{}}` + "`" + ` + "\n\n", "invalid JSON-RPC notification"}, + {"wrong method", "data: " + ` + "`" + `{"jsonrpc":"2.0","method":"other","params":{}}` + "`" + ` + "\n\n", "received notification for JSON-RPC method"}, + {"response with params", "data: " + ` + "`" + `{"jsonrpc":"2.0","id":"1","params":{}}` + "`" + ` + "\n\n", "JSON-RPC response contains params"}, + {"message without method or response", "event: other\ndata: {}\n\n", ` + "`" + `response jsonrpc must be "2.0"` + "`" + `}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + body := &failingResponseBody{reader: strings.NewReader(test.event)} + stream := NewWatchStream(&http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"text/event-stream"}}, + Body: body, + }, goahttp.ResponseDecoder, "1") + + _, err := stream.Recv() + require.ErrorContains(t, err, test.error) + require.Equal(t, 1, body.closes) + _, err = stream.Recv() + require.Equal(t, io.EOF, err) + }) + } +} + +func TestSSEReadFailureClosesBody(t *testing.T) { + readErr := errors.New("read failed") + body := &failingResponseBody{readErr: readErr} + stream := NewWatchStream(&http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"text/event-stream"}}, + Body: body, + }, goahttp.ResponseDecoder, "1") + + _, err := stream.Recv() + require.ErrorIs(t, err, readErr) + require.Equal(t, 1, body.closes) + _, err = stream.Recv() + require.Equal(t, io.EOF, err) +} + +func TestSSEValidNotificationKeepsBodyOpen(t *testing.T) { + body := &failingResponseBody{reader: strings.NewReader( + "id: item-1\nevent: update\nretry: 0\ndata: " + ` + "`" + `{"jsonrpc":"2.0","method":"watch","params":{"view":"summary","body":{"label":"ready"}}}` + "`" + ` + "\n\n" + + "data: " + ` + "`" + `{"jsonrpc":"2.0","id":"1","result":null}` + "`" + ` + "\n\n", + )} + stream := NewWatchStream(&http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"text/event-stream"}}, + Body: body, + }, goahttp.ResponseDecoder, "1") + + _, err := stream.Recv() + require.NoError(t, err) + require.Zero(t, body.closes) + _, err = stream.Recv() + require.Equal(t, io.EOF, err) + require.Equal(t, 1, body.closes) +} + +func TestSSEAcceptsCRLFAndCRLineEndings(t *testing.T) { + message := ` + "`" + `{"jsonrpc":"2.0","method":"watch","params":{"view":"summary","body":{"label":"ready"}}}` + "`" + ` + for _, ending := range []string{"\r\n", "\r"} { + reader := io.Reader(strings.NewReader("data: " + message + ending + ending)) + if ending == "\r\n" { + reader = iotest.OneByteReader(reader) + } + stream := NewWatchStream(&http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"text/event-stream"}}, + Body: io.NopCloser(reader), + }, goahttp.ResponseDecoder, "1") + + result, err := stream.Recv() + + require.NoError(t, err) + require.Equal(t, "ready", result.Label) + require.NoError(t, stream.Close()) + } +} + +func TestSSEPersistsOnlyValidEventIDsAndStripsInitialBOM(t *testing.T) { + message := ` + "`" + `{"jsonrpc":"2.0","method":"watch","params":{"view":"summary","body":{"label":"ready"}}}` + "`" + ` + body := strings.Join([]string{ + "\uFEFFid: item-1\ndata: " + message + "\n\n", + "data: " + message + "\n\n", + "\uFEFFid: later\ndata: " + message + "\n\n", + "id: ignored\x00id\ndata: " + message + "\n\n", + "id:\ndata: " + message + "\n\n", + "data: " + message + "\n\n", + }, "") + stream := NewWatchStream(&http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"text/event-stream"}}, + Body: io.NopCloser(strings.NewReader(body)), + }, goahttp.ResponseDecoder, "1") + + for _, expected := range []string{"item-1", "item-1", "item-1", "item-1", "", ""} { + result, err := stream.Recv() + require.NoError(t, err) + require.NotNil(t, result.EventID) + require.Equal(t, expected, *result.EventID) + } + require.NoError(t, stream.Close()) +} + +func TestSSEEmptyEventKeepsOnlyItsID(t *testing.T) { + message := ` + "`" + `{"jsonrpc":"2.0","method":"watch","params":{"view":"summary","body":{"label":"ready"}}}` + "`" + ` + body := "id: item-1\nevent: stale\nretry: 7\n\n" + "data: " + message + "\n\n" + stream := NewWatchStream(&http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"text/event-stream"}}, + Body: io.NopCloser(strings.NewReader(body)), + }, goahttp.ResponseDecoder, "1") + + result, err := stream.Recv() + + require.NoError(t, err) + require.NotNil(t, result.EventID) + require.Equal(t, "item-1", *result.EventID) + require.Nil(t, result.EventType) + require.Nil(t, result.Retry) + require.Equal(t, "ready", result.Label) +} + +func TestSSEViewedFailuresUseClientBoundaryErrors(t *testing.T) { + tests := []struct { + name string + event string + errorName string + }{ + { + name: "representation decode", + event: "data: " + ` + "`" + `{"jsonrpc":"2.0","method":"watch","params":{"view":1,"body":{}}}` + "`" + ` + "\n\n", + errorName: "decoding_error", + }, + { + name: "missing view", + event: "data: " + ` + "`" + `{"jsonrpc":"2.0","method":"watch","params":{"body":{"label":"ready"}}}` + "`" + ` + "\n\n", + errorName: "validation_error", + }, + { + name: "outer retry decode", + event: "retry: 999999999999999999999999999999999999\ndata: " + ` + "`" + `{"jsonrpc":"2.0","method":"watch","params":{"view":"summary","body":{"label":"ready"}}}` + "`" + ` + "\n\n", + errorName: "decoding_error", + }, + { + name: "body validation", + event: "data: " + ` + "`" + `{"jsonrpc":"2.0","method":"watch","params":{"view":"summary","body":{}}}` + "`" + ` + "\n\n", + errorName: "validation_error", + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + stream := NewWatchStream(&http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"text/event-stream"}}, + Body: io.NopCloser(strings.NewReader(test.event)), + }, goahttp.ResponseDecoder, "1") + + _, err := stream.Recv() + + var clientError *goahttp.ClientError + require.ErrorAs(t, err, &clientError) + require.Equal(t, test.errorName, clientError.Name) + require.NotContains(t, err.Error(), "failed to decode result") + }) + } +} + +func TestSSEColonlessDataIsAnEmptyDataLine(t *testing.T) { + stream := NewWatchStream(&http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"text/event-stream"}}, + Body: io.NopCloser(strings.NewReader("data\n\n")), + }, goahttp.ResponseDecoder, "1") + + _, err := stream.Recv() + + require.ErrorContains(t, err, "failed to parse JSON-RPC message") +} + +func TestSSEDiscardsUnterminatedEventAtEOF(t *testing.T) { + message := ` + "`" + `{"jsonrpc":"2.0","method":"watch","params":{"view":"summary","body":{"label":"ready"}}}` + "`" + ` + stream := NewWatchStream(&http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"text/event-stream"}}, + Body: io.NopCloser(strings.NewReader("data: " + message)), + }, goahttp.ResponseDecoder, "1") + + _, err := stream.Recv() + + require.ErrorIs(t, err, io.EOF) +} + +func TestSSERetryUsesOnlyValidDigitFields(t *testing.T) { + message := ` + "`" + `{"jsonrpc":"2.0","method":"watch","params":{"view":"summary","body":{"label":"ready"}}}` + "`" + ` + for _, retry := range []string{"", "+1", "-1", "١"} { + stream := NewWatchStream(&http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"text/event-stream"}}, + Body: io.NopCloser(strings.NewReader("retry: " + retry + "\ndata: " + message + "\n\n")), + }, goahttp.ResponseDecoder, "1") + + result, err := stream.Recv() + require.NoError(t, err) + require.Nil(t, result.Retry) + require.Equal(t, "ready", result.Label) + } + + for _, fields := range []string{ + "retry: later\nretry: 7\n", + "retry: 7\nretry: later\n", + } { + stream := NewWatchStream(&http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"text/event-stream"}}, + Body: io.NopCloser(strings.NewReader(fields + "data: " + message + "\n\n")), + }, goahttp.ResponseDecoder, "1") + + result, err := stream.Recv() + require.NoError(t, err) + require.NotNil(t, result.Retry) + require.Equal(t, 7, *result.Retry) + require.Equal(t, "ready", result.Label) + } +} + +func TestSSETerminalResponseRejectsMismatchedID(t *testing.T) { + body := &failingResponseBody{reader: strings.NewReader(terminalResponse("response-id"))} + stream := NewWatchStream(&http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"text/event-stream"}}, + Body: body, + }, goahttp.ResponseDecoder, "request-id") + + _, err := stream.Recv() + require.ErrorContains(t, err, ` + "`" + `response id "response-id" does not match request id "request-id"` + "`" + `) + require.Equal(t, 1, body.closes) +} + +func TestSSEEndpointReturnsResponseBodyFailures(t *testing.T) { + readErr := errors.New("read failed") + closeErr := errors.New("close failed") + doer := doerFunc(func(*http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: http.StatusBadGateway, + Header: make(http.Header), + Body: &failingResponseBody{readErr: readErr, closeErr: closeErr}, + }, nil + }) + client := NewClient("http", "example.test", doer, goahttp.RequestEncoder, goahttp.ResponseDecoder, false) + _, err := client.Watch()(context.Background(), nil) + assertDecodingError(t, err) + require.ErrorIs(t, err, readErr) + require.ErrorIs(t, err, closeErr) +} + +func TestSSEEndpointReturnsContentTypeAndCloseFailures(t *testing.T) { + closeErr := errors.New("close failed") + doer := doerFunc(func(*http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"application/json"}}, + Body: &failingResponseBody{closeErr: closeErr}, + }, nil + }) + client := NewClient("http", "example.test", doer, goahttp.RequestEncoder, goahttp.ResponseDecoder, false) + + _, err := client.Watch()(context.Background(), nil) + + require.ErrorContains(t, err, "unexpected content type") + require.ErrorIs(t, err, closeErr) + assertDecodingError(t, err) +} + +func TestSSEEndpointContentTypeRemainsPlainWhenCloseSucceeds(t *testing.T) { + doer := doerFunc(func(*http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"application/json"}}, + Body: &failingResponseBody{}, + }, nil + }) + client := NewClient("http", "example.test", doer, goahttp.RequestEncoder, goahttp.ResponseDecoder, false) + + _, err := client.Watch()(context.Background(), nil) + + require.EqualError(t, err, "unexpected content type: application/json (expected text/event-stream)") + var clientErr *goahttp.ClientError + require.NotErrorAs(t, err, &clientErr) +} + +func TestSSEEndpointRequiresEventStreamContentType(t *testing.T) { + tests := []struct { + name string + contentType string + }{ + {name: "missing"}, + {name: "malformed", contentType: "text/event-stream; charset"}, + {name: "prefix lookalike", contentType: "text/event-stream-extra"}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + body := &failingResponseBody{} + doer := doerFunc(func(*http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{test.contentType}}, + Body: body, + }, nil + }) + client := NewClient("http", "example.test", doer, goahttp.RequestEncoder, goahttp.ResponseDecoder, false) + + _, err := client.Watch()(context.Background(), nil) + + require.ErrorContains(t, err, "unexpected content type") + require.Equal(t, 1, body.closes) + }) + } +} + +func TestSSEEndpointAllowsEventStreamParameters(t *testing.T) { + body := &failingResponseBody{} + doer := doerFunc(func(*http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"text/event-stream; charset=utf-8"}}, + Body: body, + }, nil + }) + client := NewClient("http", "example.test", doer, goahttp.RequestEncoder, goahttp.ResponseDecoder, false) + + raw, err := client.Watch()(context.Background(), nil) + + require.NoError(t, err) + require.NoError(t, raw.(*WatchStreamImpl).Close()) + require.Equal(t, 1, body.closes) +} + +func assertDecodingError(t *testing.T, err error) { + t.Helper() + var clientErr *goahttp.ClientError + require.ErrorAs(t, err, &clientErr) + require.Equal(t, "decoding_error", clientErr.Name) +} +` + +const sseCancellationRuntimeTest = `package client + +import ( + "context" + "errors" + "io" + "net/http" + "sync" + "testing" + "time" + + "github.com/stretchr/testify/require" + + goahttp "goa.design/goa/v3/http" +) + +type blockingResponseBody struct { + readStarted chan struct{} + closed chan struct{} + readOnce sync.Once + closeOnce sync.Once + closeErr error + closes int +} + +func newBlockingResponseBody(closeErr error) *blockingResponseBody { + return &blockingResponseBody{ + readStarted: make(chan struct{}), + closed: make(chan struct{}), + closeErr: closeErr, + } +} + +func (body *blockingResponseBody) Read([]byte) (int, error) { + body.readOnce.Do(func() { + close(body.readStarted) + }) + <-body.closed + return 0, io.ErrClosedPipe +} + +func (body *blockingResponseBody) Close() error { + body.closeOnce.Do(func() { + body.closes++ + close(body.closed) + }) + return body.closeErr +} + +func TestRecvWithContextReturnsCanceled(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + assertBlockedReceiveEndsWithContext(t, ctx, cancel, context.Canceled) +} + +func TestRecvWithContextReturnsDeadlineExceeded(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 25*time.Millisecond) + defer cancel() + assertBlockedReceiveEndsWithContext(t, ctx, func() {}, context.DeadlineExceeded) +} + +func assertBlockedReceiveEndsWithContext(t *testing.T, ctx context.Context, endContext func(), want error) { + t.Helper() + closeErr := errors.New("close failed") + body := newBlockingResponseBody(closeErr) + stream := NewWatchStream(&http.Response{ + StatusCode: http.StatusOK, + Header: http.Header{"Content-Type": []string{"text/event-stream"}}, + Body: body, + }, goahttp.ResponseDecoder, "1") + + received := make(chan error, 1) + go func() { + _, err := stream.RecvWithContext(ctx) + received <- err + }() + <-body.readStarted + endContext() + + select { + case err := <-received: + require.ErrorIs(t, err, want) + require.ErrorIs(t, err, closeErr) + case <-time.After(time.Second): + require.NoError(t, body.Close()) + err := <-received + t.Fatalf("receive remained blocked after context ended; returned %v after closing body", err) + } + select { + case <-body.closed: + default: + t.Fatal("receive returned without closing response body") + } + _, err := stream.Recv() + require.ErrorIs(t, err, io.EOF) + require.Equal(t, 1, body.closes) +} +` + +const mappedBodyRuntimeTest = `package client + +import ( + "io" + "net/http" + "strings" + "testing" + + "github.com/stretchr/testify/require" + + goahttp "goa.design/goa/v3/http" + goa "goa.design/goa/v3/pkg" +) + +func TestMappedObjectBodyAcceptsBothViews(t *testing.T) { + for _, view := range []string{"summary", "detailed"} { + response := mappedResponse(` + "`" + `{"view":"` + "`" + ` + view + ` + "`" + `","body":{"value":"record-1"}}` + "`" + `) + _, err := DecodeFetchResponse(goahttp.ResponseDecoder, false)(response, "1") + require.NoError(t, err) + } +} + +func TestMappedObjectBodyRejectsMissingRequiredField(t *testing.T) { + response := mappedResponse(` + "`" + `{"view":"summary","body":{}}` + "`" + `) + _, err := DecodeFetchResponse(goahttp.ResponseDecoder, false)(response, "1") + var serviceError *goa.ServiceError + require.ErrorAs(t, err, &serviceError) + require.Equal(t, goa.MissingField, serviceError.Name) + require.NotNil(t, serviceError.Field) + require.Equal(t, "value", *serviceError.Field) +} + +func mappedResponse(result string) *http.Response { + return &http.Response{ + StatusCode: http.StatusOK, + Header: make(http.Header), + Body: io.NopCloser(strings.NewReader( + ` + "`" + `{"jsonrpc":"2.0","id":"1","result":` + "`" + ` + result + "}", + )), + } +} +` + +const sseDecodeErrorRuntimeTest = `package server + +import ( + "context" + "errors" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/stretchr/testify/require" + + service "generated.local/gen/sse_decode" + goahttp "goa.design/goa/v3/http" +) + +var errWriteSSEError = errors.New("write SSE error") +var errEncodeSSE = errors.New("encode SSE event") +var errFlushSSE = errors.New("flush SSE event") + +type unusedService struct{} + +func (*unusedService) Watch(context.Context, *service.WatchPayload, service.WatchServerStream) error { + return nil +} + +type failingResponseWriter struct { + header http.Header + headerCalls int +} + +type stepResponseWriter struct { + header http.Header + headerCalls int + writes int + failWrite int + flushError error + body strings.Builder +} + +func (writer *stepResponseWriter) Header() http.Header { + return writer.header +} + +func (writer *stepResponseWriter) WriteHeader(int) { + writer.headerCalls++ +} + +func (writer *stepResponseWriter) Write(data []byte) (int, error) { + writer.writes++ + if writer.writes == writer.failWrite { + return 0, errWriteSSEError + } + return writer.body.Write(data) +} + +func (writer *stepResponseWriter) FlushError() error { + return writer.flushError +} + +func (writer *failingResponseWriter) Header() http.Header { + return writer.header +} + +func (writer *failingResponseWriter) WriteHeader(int) { + writer.headerCalls++ +} + +func (*failingResponseWriter) Write([]byte) (int, error) { + return 0, errWriteSSEError +} + +func TestSSERequestDecodeErrorReturnsWriteFailureOnce(t *testing.T) { + reported := make([]error, 0, 1) + server := New( + service.NewEndpoints(&unusedService{}), + goahttp.NewMuxer(), + goahttp.RequestDecoder, + goahttp.ResponseEncoder, + func(_ context.Context, _ http.ResponseWriter, err error) { + reported = append(reported, err) + }, + ) + writer := &failingResponseWriter{header: make(http.Header)} + request := httptest.NewRequest(http.MethodPost, "/decode", strings.NewReader( + ` + "`" + `{"jsonrpc":"2.0","id":"request-1","method":"watch","params":{}}` + "`" + `, + )) + server.ServeHTTP(writer, request) + + require.Len(t, reported, 1) + require.ErrorIs(t, reported[0], errWriteSSEError) + require.Equal(t, 1, writer.headerCalls) +} + +func TestSSEEventEncodesBeforeStartingResponse(t *testing.T) { + writer := &stepResponseWriter{header: make(http.Header)} + stream := &sseServerStream{ + w: writer, + encoder: func(context.Context, http.ResponseWriter) goahttp.Encoder { + return goahttp.EncodingFunc(func(any) error { return errEncodeSSE }) + }, + } + + err := stream.sendSSEEvent(context.Background(), map[string]any{"value": "one"}, nil, nil, nil) + require.ErrorIs(t, err, errEncodeSSE) + require.Zero(t, writer.headerCalls) + require.Zero(t, writer.writes) +} + +func TestSSEEventReturnsEveryWriteAndFlushError(t *testing.T) { + tests := []struct { + name string + failWrite int + flushError error + }{ + {name: "data label", failWrite: 1}, + {name: "encoded value", failWrite: 2}, + {name: "data line ending", failWrite: 3}, + {name: "event ending", failWrite: 4}, + {name: "flush", flushError: errFlushSSE}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + writer := &stepResponseWriter{ + header: make(http.Header), + failWrite: test.failWrite, + flushError: test.flushError, + } + stream := &sseServerStream{w: writer, encoder: goahttp.ResponseEncoder} + + err := stream.sendSSEEvent(context.Background(), map[string]any{"value": "one"}, nil, nil, nil) + if test.flushError != nil { + require.ErrorIs(t, err, test.flushError) + } else { + require.ErrorIs(t, err, errWriteSSEError) + } + require.Equal(t, 1, writer.headerCalls) + }) + } +} + +func TestSSEEventPrefixesEveryEncodedDataLine(t *testing.T) { + for _, encoded := range []string{ + "{\n \"jsonrpc\": \"2.0\"\n}\n", + "{\r \"jsonrpc\": \"2.0\"\r}\r", + "{\r\n \"jsonrpc\": \"2.0\"\r\n}\r\n", + } { + writer := &stepResponseWriter{header: make(http.Header)} + stream := &sseServerStream{ + w: writer, + encoder: func(_ context.Context, target http.ResponseWriter) goahttp.Encoder { + return goahttp.EncodingFunc(func(any) error { + _, err := target.Write([]byte(encoded)) + return err + }) + }, + } + + err := stream.sendSSEEvent(context.Background(), struct{}{}, nil, nil, nil) + + require.NoError(t, err) + require.Equal(t, "data: {\ndata: \"jsonrpc\": \"2.0\"\ndata: }\n\n", writer.body.String()) + } +} + +func TestSSEEventRejectsInvalidOuterFieldsBeforeStartingResponse(t *testing.T) { + tests := []struct { + name string + id *string + eventType *string + retry *string + }{ + {name: "id nul", id: stringPointer("one\x00two")}, + {name: "id line feed", id: stringPointer("one\ntwo")}, + {name: "event carriage return", eventType: stringPointer("one\rtwo")}, + {name: "retry plus", retry: stringPointer("+1")}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + writer := &stepResponseWriter{header: make(http.Header)} + stream := &sseServerStream{w: writer, encoder: goahttp.ResponseEncoder} + + err := stream.sendSSEEvent(context.Background(), struct{}{}, test.id, test.eventType, test.retry) + + require.Error(t, err) + require.Zero(t, writer.headerCalls) + require.Zero(t, writer.writes) + }) + } +} + +func stringPointer(value string) *string { + return &value +} +` + +const requestBodyFailureRuntimeTest = `package server + +import ( + "context" + "errors" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/stretchr/testify/require" + goahttp "goa.design/goa/v3/http" +) + +type failingRequestBody struct { + readErr error + closeErr error +} + +type failingResponseWriter struct { + *httptest.ResponseRecorder + failAt int + writes int + writeErr error +} + +func (writer *failingResponseWriter) Write(data []byte) (int, error) { + writer.writes++ + if writer.writes == writer.failAt { + return 0, writer.writeErr + } + return writer.ResponseRecorder.Write(data) +} + +func (body *failingRequestBody) Read([]byte) (int, error) { + return 0, body.readErr +} + +func (body *failingRequestBody) Close() error { + return body.closeErr +} + +func TestServerReportsRequestReadAndCloseFailures(t *testing.T) { + readErr := errors.New("read failed") + closeErr := errors.New("close failed") + var reported error + server := &Server{ + errhandler: func(_ context.Context, _ http.ResponseWriter, err error) { + reported = err + }, + } + request := httptest.NewRequest(http.MethodPost, "/unary", nil) + request.Body = &failingRequestBody{readErr: readErr, closeErr: closeErr} + + server.handleHTTP(httptest.NewRecorder(), request) + + require.ErrorIs(t, reported, readErr) + require.ErrorIs(t, reported, closeErr) +} + +func TestBatchWriterReturnsOpeningDelimiterFailure(t *testing.T) { + writeErr := errors.New("write failed") + response := &failingResponseWriter{ + ResponseRecorder: httptest.NewRecorder(), + failAt: 1, + writeErr: writeErr, + } + writer := &batchWriter{Writer: response} + + _, err := writer.Write([]byte("{\"jsonrpc\":\"2.0\",\"result\":null}")) + + require.ErrorIs(t, err, writeErr) +} + +func TestServerReportsBatchClosingDelimiterFailure(t *testing.T) { + writeErr := errors.New("write failed") + var reported []error + server := &Server{ + decoder: goahttp.RequestDecoder, + encoder: goahttp.ResponseEncoder, + errhandler: func(_ context.Context, _ http.ResponseWriter, err error) { + reported = append(reported, err) + }, + } + request := httptest.NewRequest( + http.MethodPost, + "/unary", + strings.NewReader("[{\"jsonrpc\":\"2.0\",\"id\":\"1\",\"method\":\"missing\"}]"), + ) + response := &failingResponseWriter{ + ResponseRecorder: httptest.NewRecorder(), + failAt: 3, + writeErr: writeErr, + } + + server.handleHTTP(response, request) + + require.ErrorIs(t, errors.Join(reported...), writeErr) +} +` + +const sseLifecycleRuntimeTest = `package server + +import ( + "context" + "errors" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/stretchr/testify/require" + + service "generated.local/gen/sse_decode" + goahttp "goa.design/goa/v3/http" + goa "goa.design/goa/v3/pkg" +) + +var errWatch = goa.NewServiceError(errors.New("watch failed"), "watch_failed", false, false, false) + +type lifecycleService struct { + fail bool + calls int +} + +func (s *lifecycleService) Watch(_ context.Context, _ *service.WatchPayload, stream service.WatchServerStream) error { + s.calls++ + if err := stream.Send(&service.WatchResult{Message: "ready"}); err != nil { + return err + } + if s.fail { + return errWatch + } + return nil +} + +func TestSSEStreamWritesNotificationThenNullCompletion(t *testing.T) { + body, reported := serveLifecycle(&lifecycleService{}, ` + "`" + `{"jsonrpc":"2.0","id":"request-1","method":"watch","params":{"topic":"alerts"}}` + "`" + `) + require.Empty(t, reported) + notification := strings.Index(body, ` + "`" + `"method":"watch"` + "`" + `) + response := strings.Index(body, ` + "`" + `"result":null` + "`" + `) + require.NotEqual(t, -1, notification) + require.Greater(t, response, notification) + require.NotContains(t, body, "event:") + require.Contains(t, body, ` + "`" + `"method":"watch"` + "`" + `) + require.Contains(t, body, ` + "`" + `"params":{"message":"ready"}` + "`" + `) + require.Contains(t, body, ` + "`" + `"id":"request-1"` + "`" + `) + require.Contains(t, body, ` + "`" + `"result":null` + "`" + `) +} + +func TestSSEStreamWritesReturnedErrorAsTerminalResponse(t *testing.T) { + body, reported := serveLifecycle(&lifecycleService{fail: true}, ` + "`" + `{"jsonrpc":"2.0","id":"request-1","method":"watch","params":{"topic":"alerts"}}` + "`" + `) + require.Empty(t, reported) + notification := strings.Index(body, ` + "`" + `"method":"watch"` + "`" + `) + response := strings.Index(body, ` + "`" + `"error":` + "`" + `) + require.NotEqual(t, -1, notification) + require.Greater(t, response, notification) + require.NotContains(t, body, "event:") + require.Contains(t, body, ` + "`" + `"code":-32603` + "`" + `) + require.Contains(t, body, ` + "`" + `"message":"watch failed"` + "`" + `) +} + +func TestSSEMethodRejectsNotificationWithoutAResponse(t *testing.T) { + svc := &lifecycleService{} + body, reported := serveLifecycle(svc, ` + "`" + `{"jsonrpc":"2.0","method":"watch","params":{"topic":"alerts"}}` + "`" + `) + require.Len(t, reported, 1) + require.Zero(t, svc.calls) + require.Empty(t, body) +} + +func TestSSEMethodRejectsNullIDWithAResponse(t *testing.T) { + svc := &lifecycleService{} + body, reported := serveLifecycle(svc, ` + "`" + `{"jsonrpc":"2.0","id":null,"method":"watch","params":{"topic":"alerts"}}` + "`" + `) + require.Empty(t, reported) + require.Zero(t, svc.calls) + require.Contains(t, body, "data: ") + require.Contains(t, body, ` + "`" + `"id":null` + "`" + `) + require.Contains(t, body, ` + "`" + `"code":-32600` + "`" + `) +} + +func TestSSEUnknownNotificationReceivesNoResponse(t *testing.T) { + body, reported := serveLifecycle(&lifecycleService{}, ` + "`" + `{"jsonrpc":"2.0","method":"missing"}` + "`" + `) + require.Empty(t, reported) + require.Empty(t, body) +} + +func TestSSEInvalidRequestsReceiveError(t *testing.T) { + for _, request := range []string{ + ` + "`" + `{"jsonrpc":"2.0"}` + "`" + `, + ` + "`" + `{"jsonrpc":"1.0","method":"watch","params":{"topic":"alerts"}}` + "`" + `, + } { + body, reported := serveLifecycle(&lifecycleService{}, request) + require.Empty(t, reported) + require.NotContains(t, body, "event:") + require.Contains(t, body, "data: ") + require.Contains(t, body, ` + "`" + `"id":null` + "`" + `) + require.Contains(t, body, ` + "`" + `"code":-32600` + "`" + `) + } +} + +func serveLifecycle(svc *lifecycleService, body string) (string, []error) { + reported := make([]error, 0, 1) + server := New( + service.NewEndpoints(svc), + goahttp.NewMuxer(), + goahttp.RequestDecoder, + goahttp.ResponseEncoder, + func(_ context.Context, _ http.ResponseWriter, err error) { + reported = append(reported, err) + }, + ) + writer := httptest.NewRecorder() + request := httptest.NewRequest(http.MethodPost, "/decode", strings.NewReader(body)) + server.ServeHTTP(writer, request) + return writer.Body.String(), reported +} +` diff --git a/jsonrpc/codegen/websocket_client.go b/jsonrpc/codegen/websocket_client.go deleted file mode 100644 index 13c1751328..0000000000 --- a/jsonrpc/codegen/websocket_client.go +++ /dev/null @@ -1,88 +0,0 @@ -package codegen - -import ( - "fmt" - "path/filepath" - - "goa.design/goa/v3/codegen" - "goa.design/goa/v3/expr" - httpcodegen "goa.design/goa/v3/http/codegen" -) - -func websocketClientFile(genpkg string, svc *expr.HTTPServiceExpr, services *httpcodegen.ServicesData) *codegen.File { - data := services.Get(svc.Name()) - if !httpcodegen.HasWebSocket(data) { - return nil - } - - svcName := data.Service.PathName - title := fmt.Sprintf("%s WebSocket JSON-RPC client", svc.Name()) - - // Build imports list for WebSocket clients - imports := make([]*codegen.ImportSpec, 0, 15+len(data.Service.UserTypeImports)) - imports = append(imports, - &codegen.ImportSpec{Path: "bytes"}, - &codegen.ImportSpec{Path: "context"}, - &codegen.ImportSpec{Path: "encoding/json"}, - &codegen.ImportSpec{Path: "fmt"}, - &codegen.ImportSpec{Path: "io"}, - &codegen.ImportSpec{Path: "net/http"}, - &codegen.ImportSpec{Path: "strconv"}, - &codegen.ImportSpec{Path: "sync"}, - &codegen.ImportSpec{Path: "sync/atomic"}, - &codegen.ImportSpec{Path: "time"}, - &codegen.ImportSpec{Path: "github.com/gorilla/websocket"}, - codegen.GoaImport(""), - codegen.GoaImport("jsonrpc"), - codegen.GoaNamedImport("http", "goahttp"), - &codegen.ImportSpec{Path: genpkg + "/" + svcName, Name: data.Service.PkgName}, - ) - imports = append(imports, data.Service.UserTypeImports...) - - sections := []*codegen.SectionTemplate{ - codegen.Header(title, "client", imports), - } - - // Add common error handling types for all streams - sections = append(sections, &codegen.SectionTemplate{ - Name: "jsonrpc-websocket-stream-error-types", - Source: jsonrpcTemplates.Read(websocketStreamErrorTypesT), - }) - - // Process only WebSocket endpoints and generate stream implementations only - for _, e := range data.Endpoints { - if !httpcodegen.IsWebSocketEndpoint(e) { - continue - } - - // Add stream implementation (endpoint methods are in client.go) - sections = append(sections, &codegen.SectionTemplate{ - Name: "jsonrpc-websocket-client-stream", - Source: jsonrpcTemplates.Read(websocketClientStreamT), - Data: e.ClientWebSocket, - }) - } - - return &codegen.File{ - Path: filepath.Join(codegen.Gendir, "jsonrpc", svcName, "client", "websocket.go"), - SectionTemplates: sections, - } -} - -// allErrors returns all errors for the given service. -func allErrors(data *httpcodegen.ServiceData) []*httpcodegen.ErrorData { - seen := make(map[string]struct{}) - var errors []*httpcodegen.ErrorData - for _, e := range data.Endpoints { - for _, gerr := range e.Errors { - for _, err := range gerr.Errors { - if _, ok := seen[err.Name]; ok { - continue - } - seen[err.Name] = struct{}{} - errors = append(errors, err) - } - } - } - return errors -} diff --git a/jsonrpc/codegen/websocket_server.go b/jsonrpc/codegen/websocket_server.go deleted file mode 100644 index d463ac4c8f..0000000000 --- a/jsonrpc/codegen/websocket_server.go +++ /dev/null @@ -1,83 +0,0 @@ -package codegen - -import ( - "fmt" - "path/filepath" - - "goa.design/goa/v3/codegen" - "goa.design/goa/v3/expr" - httpcodegen "goa.design/goa/v3/http/codegen" -) - -// websocketServerFile returns the file implementing the JSON-RPC WebSocket server -// streaming implementation if any. It follows the exact same pattern as the encode/decode -// files: get the HTTP file and modify it for JSON-RPC. -func websocketServerFile(genpkg string, svc *expr.HTTPServiceExpr, services *httpcodegen.ServicesData) *codegen.File { - data := services.Get(svc.Name()) - if !httpcodegen.HasWebSocket(data) { - return nil - } - funcs := map[string]any{ - "lowerInitial": lowerInitial, - "allErrors": allErrors, - "isWebSocketEndpoint": httpcodegen.IsWebSocketEndpoint, - } - svcName := data.Service.PathName - title := fmt.Sprintf("%s WebSocket server streaming", svc.Name()) - imports := make([]*codegen.ImportSpec, 0, 14+len(data.Service.UserTypeImports)) - imports = append(imports, - &codegen.ImportSpec{Path: "context"}, - &codegen.ImportSpec{Path: "encoding/json"}, - &codegen.ImportSpec{Path: "errors"}, - &codegen.ImportSpec{Path: "fmt"}, - &codegen.ImportSpec{Path: "io"}, - &codegen.ImportSpec{Path: "net/http"}, - &codegen.ImportSpec{Path: "strings"}, - &codegen.ImportSpec{Path: "sync"}, - &codegen.ImportSpec{Path: "time"}, - &codegen.ImportSpec{Path: "github.com/gorilla/websocket"}, - codegen.GoaImport(""), - codegen.GoaImport("jsonrpc"), - codegen.GoaNamedImport("http", "goahttp"), - &codegen.ImportSpec{Path: genpkg + "/" + svcName, Name: data.Service.PkgName}, - ) - imports = append(imports, data.Service.UserTypeImports...) - sections := []*codegen.SectionTemplate{ - codegen.Header(title, "server", imports), - { - Name: "jsonrpc-server-websocket-struct", - Source: jsonrpcTemplates.Read(websocketServerStreamT), - Data: data, - FuncMap: funcs, - }, - { - Name: "jsonrpc-server-websocket-stream-wrapper", - Source: jsonrpcTemplates.Read(websocketServerStreamWrapperT), - Data: data, - FuncMap: funcs, - }, - { - Name: "jsonrpc-server-websocket-send", - Source: jsonrpcTemplates.Read(websocketServerSendT), - Data: data, - FuncMap: funcs, - }, - { - Name: "jsonrpc-server-websocket-recv", - Source: jsonrpcTemplates.Read(websocketServerRecvT), - Data: data, - FuncMap: funcs, - }, - { - Name: "jsonrpc-server-websocket-close", - Source: jsonrpcTemplates.Read(websocketServerCloseT), - Data: data, - FuncMap: funcs, - }, - } - - return &codegen.File{ - Path: filepath.Join(codegen.Gendir, "jsonrpc", svcName, "server", "websocket.go"), - SectionTemplates: sections, - } -} diff --git a/jsonrpc/doc.go b/jsonrpc/doc.go index 7f4c1d1f6f..f31966dc2e 100644 --- a/jsonrpc/doc.go +++ b/jsonrpc/doc.go @@ -12,7 +12,7 @@ // - Notification requests (fire-and-forget) // - Batch requests for multiple calls // - Structured error handling with error codes -// - HTTP, Server-Sent Events (SSE) and WebSocket transports +// - HTTP requests and server streams sent as Server-Sent Events (SSE) // // Code generated by Goa uses this package to create JSON-RPC clients and // servers that seamlessly integrate with Goa's design-first approach and diff --git a/jsonrpc/integration_tests/README.md b/jsonrpc/integration_tests/README.md index eac368734e..f1cbfdaa6b 100644 --- a/jsonrpc/integration_tests/README.md +++ b/jsonrpc/integration_tests/README.md @@ -44,7 +44,7 @@ Adding a new test requires only a small addition to the scenarios file; no Go co transport: "http" request: params: "hello world" # What to send - id: 123 # Request ID (omit for notifications) + id: 123 # Required unless the method name ends in _notify expect: result: "hello world" # Echo returns the same value id: 123 # Response has same ID @@ -134,12 +134,12 @@ SSE tests need both: - `request`: Initiates the SSE connection - `sequence`: Defines expected stream events -### Notifications Have No ID -For fire-and-forget messages: +### Declared Notifications Have No ID +For a fire-and-forget method whose name ends in `_notify`: ```yaml request: params: "notification" - # No id field = notification + # A declared notification must omit the id field. expect: no_response: true ``` @@ -202,32 +202,10 @@ Use `request` to initiate, `sequence` for the event stream: # ... more events ``` -#### WebSocket Tests (Bidirectional Messages) -Use only `sequence` for back-and-forth communication: -```yaml -- name: "websocket_test" - method: "echo_string_ws" - transport: "websocket" - sequence: # Series of sends and receives - - type: "connect" # Optional: explicit connection - - type: "send" - data: - method: "echo_string_ws" - params: - id: "ws-1" - value: "hello" - id: "ws-1" - - type: "receive" - expect: - id: "ws-1" - result: - value: "hello" - - type: "close" # Close the connection -``` - ## 📜 Method Naming Convention -Server behavior is determined entirely by the method name, which follows the pattern: `[action]_[type]_[modifier]`. +Server behavior is determined entirely by the method name. Unary methods use +`[action]_[type]_[modifier]`; SSE methods add the `_sse` suffix. ### Quick Reference Table @@ -251,9 +229,7 @@ Server behavior is determined entirely by the method name, which follows the pat * `echo`: Returns the `params` payload exactly as it was received. * `transform`: Returns a predictably modified version of the `params`. * `generate`: Ignores `params` and returns a fixed, predictable value. - * `stream`: (SSE/WebSocket) Sends a stream of messages to the client. Ideal for testing server-streaming RPC. - * `collect`: (WebSocket) Receives a stream of messages from a client and returns a single summary response after the stream is closed. Useful for testing client-streaming RPC. - * `broadcast`: (WebSocket) Tests the server's ability to send unsolicited messages to a client (server-initiated notifications). + * `stream`: Sends a stream of server-sent events to the client. ### Types and Their Structure @@ -292,7 +268,6 @@ Server behavior is determined entirely by the method name, which follows the pat * `_notify`: Indicates a JSON-RPC notification (no response expected). * `_error`: The method is hardcoded to always return a predefined JSON-RPC error. * `_validate`: The method includes Goa validation logic on the payload, which will return an error if the payload is invalid. - * `_final`: (SSE) The method sends several notifications before sending a final, ID-tagged response. ## 📊 Data-Driven Behavior @@ -347,7 +322,7 @@ sequence: value: "generated-3" ``` -#### `stream` Action (SSE/WebSocket) +#### `stream` Action (SSE) The payload data controls the streaming behavior: **For `string` type:** @@ -433,29 +408,6 @@ sequence: ### Modifier Effects -**`_final` modifier (SSE):** -Sends notifications followed by a final response with the request ID: - -```yaml -# Example: stream_string_final_sse -request: - params: "ab" # 2 characters = 2 notifications - id: "req-1" -sequence: - - expect: # Notification (no ID) - method: "stream_string_final_sse" - params: - value: "Stream 1 of 2" - - expect: # Notification (no ID) - method: "stream_string_final_sse" - params: - value: "Stream 2 of 2" - - expect: # Final response (with ID) - id: "req-1" - result: - value: "Final response" -``` - **`_error` modifier:** For streaming, sends notifications then returns an error: @@ -563,11 +515,11 @@ Each item in the top-level `scenarios` list is a `Scenario` object. It defines a | Key | Type | Required? | Description | | :--- | :--- | :--- | :--- | | **`name`** | `string` | **Yes** | A unique, human-readable name for the test. Used in test runner output. | -| **`method`** | `string` | **Yes** | The name of the server method to test. Must follow the `action_type_modifier` convention. | -| **`transport`** | `string` | **Yes** | The transport protocol. Must be one of `"http"`, `"websocket"`, or `"sse"`. | +| **`method`** | `string` | **Yes** | The server method. SSE method names end with `_sse`. | +| **`transport`** | `string` | **Yes** | The transport protocol. Must be either `"http"` or `"sse"`. | | `request` | `object` | Conditional | An object describing the request to send. **Required** for non-streaming (`http`) tests. | | `expect` | `object` | Conditional | An object describing the expected response. **Required** for non-streaming (`http`) tests. | -| `sequence` | `list` | Conditional | A list of steps for stateful interactions. **Required** for streaming (`websocket`, `sse`) tests. | +| `sequence` | `list` | Conditional | The events expected from an `sse` stream. | > A `Scenario` object must contain **either** a `request`/`expect` pair **or** a `sequence`, but not both. @@ -577,7 +529,7 @@ Describes a single JSON-RPC request. | Key | Type | Description | | :--- | :--- | :--- | -| `id` | `any` | The JSON-RPC request ID. If omitted, the request is treated as a notification. | +| `id` | `any` | The JSON-RPC request ID. It is required and non-null unless the generated method is declared as a notification with the `_notify` suffix. Declared notifications must omit it. | | `params` | `array` or `object` | The parameters for the method call. See **Parameter Structures** below. | | `method` | `string` | An optional override for the JSON-RPC `method` field in the payload. Defaults to the scenario's `method` value. | @@ -598,8 +550,7 @@ Each item in a `sequence` list is a step object that defines a single action in | Key | Type | Required? | Description | | :--- | :--- | :--- | :--- | -| **`type`** | `string` | **Yes** | The type of action. Must be one of `"send"`, `"receive"`, or `"close"`. | -| `data` | `object` | Conditional | The JSON-RPC payload to send. **Required** for `type: "send"`. | +| **`type`** | `string` | **Yes** | The event action. SSE sequences use `"receive"`. | | `expect`| `object` | Conditional | The expected JSON-RPC payload to receive. **Required** for `type: "receive"`. | | `delay` | `string` | No | A duration to wait before executing this step (e.g., `"100ms"`, `"1s"`). | @@ -632,7 +583,7 @@ In addition to the structure, the content of the YAML file must adhere to these * **Exclusivity**: A scenario cannot have both `request`/`expect` and `sequence` defined. * **ID Matching**: If a `request.id` is present, the corresponding `expect.id` must be identical. * **Result vs. Error**: An `expect` object cannot define both a `result` and an `error`. - * **Method Convention**: The `method` field must follow the `[action]_[type]_[modifier]` pattern, which determines the generated server's behavior. + * **Method Convention**: Unary methods follow `[action]_[type]_[modifier]`; SSE methods end with `_sse`. ## 🌐 Complete Examples @@ -652,39 +603,3 @@ scenarios: code: -32000 message: "A simulated server error occurred" ``` - -### WebSocket Bidirectional Streaming - -This example shows a client subscribing to a channel and then receiving a server-initiated broadcast. - -```yaml -scenarios: - - name: "broadcast_websocket_interaction" - method: "broadcast_string" - transport: "websocket" - sequence: - # 1. Client sends a subscription request - - type: "send" - data: - jsonrpc: "2.0" - method: "broadcast_string" # Method to call on the server - params: { "channel": "news" } - id: "sub-1" - - # 2. Client expects a confirmation response - - type: "receive" - expect: - jsonrpc: "2.0" - id: "sub-1" - result: { "status": "subscribed", "channel": "news" } - - # 3. Client waits to receive an unsolicited broadcast from the server - - type: "receive" - expect: - jsonrpc: "2.0" - method: "broadcast" # Note: This is a server-initiated method, not a response - params: { "message": "Server update!" } -``` - - -Review each file one by one and each function one by one and think of ways it can be streamlined, improved, simplify, made more tuitive and follow Go best practice.  \ No newline at end of file diff --git a/jsonrpc/integration_tests/framework/codegen_data.go b/jsonrpc/integration_tests/framework/codegen_data.go index 98a3523993..19d00a9171 100644 --- a/jsonrpc/integration_tests/framework/codegen_data.go +++ b/jsonrpc/integration_tests/framework/codegen_data.go @@ -44,9 +44,8 @@ type MethodData struct { Info MethodInfo // Type information - Payload *TypeSpec // Initial payload (if any) - StreamingPayload *TypeSpec // Streaming payload (if any) - Result *TypeSpec // Result - can be regular or streaming + Payload *TypeSpec // Request payload, if any + Result *TypeSpec // Unary result or streamed event // Behavior flags IsNotification bool // No response expected @@ -55,11 +54,8 @@ type MethodData struct { // Streaming information IsStreaming bool - StreamKind string // "payload", "result", "bidirectional" - Transport string // "http", "sse", "ws" + Transport string // "http" or "sse" - // For SSE with final response - HasFinalResponse bool } // TypeSpec describes a type semantically @@ -79,9 +75,6 @@ type TypeSpec struct { // For maps MapKey *TypeSpec MapValue *TypeSpec - - // Whether this type needs ID field (for bidirectional WebSocket) - NeedsID bool } // FieldSpec describes a field in an object @@ -92,6 +85,8 @@ type FieldSpec struct { Type *TypeSpec Description string Required bool + // JSONRPCID reports whether the field receives the JSON-RPC request ID. + JSONRPCID bool } // ImplementationData holds the semantic data for generating service implementations @@ -130,34 +125,9 @@ type MethodImplData struct { StreamInterface string } -// ActionBehavior describes how a method should behave based on its action -type ActionBehavior struct { - // Action type (echo, transform, generate, collect, stream, broadcast) - Action string - // Type being operated on (string, array, object, map) - Type string - // Additional context (e.g., for streaming methods) - Context map[string]any -} - // Helper methods // IsSSE returns true if this method uses SSE transport func (m *MethodData) IsSSE() bool { return m.Transport == "sse" } - -// IsWebSocket returns true if this method uses WebSocket transport -func (m *MethodData) IsWebSocket() bool { - return m.Transport == "ws" -} - -// IsBidirectional returns true if this is a bidirectional streaming method -func (m *MethodData) IsBidirectional() bool { - return m.StreamKind == "bidirectional" -} - -// NeedsStreamingService returns true if this method requires a separate streaming service -func (m *MethodData) NeedsStreamingService() bool { - return m.IsStreaming && (m.IsSSE() || m.IsWebSocket()) -} diff --git a/jsonrpc/integration_tests/framework/constants.go b/jsonrpc/integration_tests/framework/constants.go index c729ab9107..6dd2992403 100644 --- a/jsonrpc/integration_tests/framework/constants.go +++ b/jsonrpc/integration_tests/framework/constants.go @@ -2,9 +2,8 @@ package framework // Transport constants define available transport protocols const ( - TransportHTTP = "http" - TransportWebSocket = "websocket" - TransportSSE = "sse" + TransportHTTP = "http" + TransportSSE = "sse" ) // Action constants define server behavior patterns @@ -12,9 +11,7 @@ const ( ActionEcho = "echo" // Returns input unchanged ActionTransform = "transform" // Modifies input predictably ActionGenerate = "generate" // Returns fixed values - ActionStream = "stream" // Server-side streaming - ActionCollect = "collect" // Client-side streaming - ActionBroadcast = "broadcast" // Server-initiated messages + ActionStream = "stream" // Sends results with server-sent events ) // Type constants define data structures @@ -33,6 +30,5 @@ const ( ModifierNotify = "notify" // No response expected ModifierError = "error" // Always returns error ModifierValidate = "validate" // Includes validation - ModifierFinal = "final" // SSE: final response - ModifierIDMap = "idmap" // Map envelope ID to payload/result field + ModifierIDMap = "idmap" // Map the request ID to a payload field ) diff --git a/jsonrpc/integration_tests/framework/executor.go b/jsonrpc/integration_tests/framework/executor.go index 01af486778..79ab6ad966 100644 --- a/jsonrpc/integration_tests/framework/executor.go +++ b/jsonrpc/integration_tests/framework/executor.go @@ -5,7 +5,6 @@ import ( "encoding/json" "strings" "testing" - "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -21,8 +20,7 @@ type executor struct { // newExecutor creates a new test executor func newExecutor(serverURL string, opts ...executorOption) *executor { config := executorConfig{ - WebSocketTimeout: 30 * time.Second, - Debug: false, + Debug: false, } for _, opt := range opts { @@ -35,6 +33,24 @@ func newExecutor(serverURL string, opts ...executorOption) *executor { } } +// jsonRPCParams writes primitive payloads as the one-item positional array +// used by the generated JSON-RPC request decoder. +func jsonRPCParams(method string, params any) any { + if params == nil { + return nil + } + info, err := ParseMethod(method) + if err != nil || info.Modifier == ModifierIDMap { + return params + } + switch info.Type { + case TypeString, TypeInt, TypeBool: + return []any{params} + default: + return params + } +} + // Execute runs a test scenario func (e *executor) Execute(t *testing.T, scenario Scenario) { t.Helper() @@ -62,8 +78,6 @@ func (e *executor) executeSimple(t *testing.T, scenario Scenario) { switch scenario.Transport { case TransportHTTP: e.executeHTTP(ctx, t, scenario) - case TransportWebSocket: - e.executeWebSocket(ctx, t, scenario) case TransportSSE: e.executeSSE(ctx, t, scenario) default: @@ -127,7 +141,7 @@ func (e *executor) executeHTTP(ctx context.Context, t *testing.T, scenario Scena req := harness.JSONRPCRequest{ Method: method, - Params: scenario.Request.Params, + Params: jsonRPCParams(method, scenario.Request.Params), ID: scenario.Request.ID, } // Handle JSONRPC field: @@ -169,35 +183,6 @@ func (e *executor) executeHTTP(ctx context.Context, t *testing.T, scenario Scena } } -// executeWebSocket handles WebSocket transport scenarios -func (e *executor) executeWebSocket(ctx context.Context, t *testing.T, scenario Scenario) { - t.Helper() - - // WebSocket scenarios always use sequence - if len(scenario.Sequence) > 0 { - e.executeWebSocketSequence(ctx, t, scenario) - return - } - - // If no sequence, create a simple send/receive sequence from request/expect - if scenario.Request.Params != nil { - // Pass method, params, and id as separate fields - data := map[string]any{ - "method": scenario.Method, - "params": scenario.Request.Params, - } - if scenario.Request.ID != nil { - data["id"] = scenario.Request.ID - } - - scenario.Sequence = []Action{ - {Type: "send", Data: data}, - {Type: "receive", Expect: scenario.Expect}, - } - e.executeWebSocketSequence(ctx, t, scenario) - } -} - // executeSSE handles Server-Sent Events scenarios func (e *executor) executeSSE(_ context.Context, t *testing.T, _ Scenario) { t.Helper() @@ -213,10 +198,8 @@ func (e *executor) executeStreaming(t *testing.T, scenario Scenario) { ctx := context.Background() - // Only WebSocket and SSE support streaming + // JSON-RPC streaming uses server-sent events. switch scenario.Transport { - case TransportWebSocket: - e.executeWebSocketSequence(ctx, t, scenario) case TransportSSE: e.executeSSESequence(ctx, t, scenario) default: @@ -224,91 +207,6 @@ func (e *executor) executeStreaming(t *testing.T, scenario Scenario) { } } -// executeWebSocketSequence handles WebSocket streaming sequences -func (e *executor) executeWebSocketSequence(ctx context.Context, t *testing.T, scenario Scenario) { - t.Helper() - - client, err := harness.NewClient(e.serverURL, nil) - require.NoError(t, err, "Failed to create client") - - // Execute sequence steps - for i, step := range scenario.Sequence { - switch step.Type { - case "connect": - err := client.ConnectWebSocket(ctx) - require.NoErrorf(t, err, "Step %d: failed to connect WebSocket", i) - - case "send": - // Auto-connect if not connected - if !client.IsConnected() { - err := client.ConnectWebSocket(ctx) - require.NoErrorf(t, err, "Step %d: failed to auto-connect WebSocket", i) - } - - require.NotNilf(t, step.Data, "Step %d: send step requires data", i) - - // Extract method, params, and id from the data - reqData, ok := step.Data.(map[string]any) - require.Truef(t, ok, "Step %d: invalid request data format", i) - - req := harness.JSONRPCRequest{ - Method: reqData["method"].(string), - Params: reqData["params"], - ID: reqData["id"], - } - - // Mark HasID when id key present (even if it's null) - if _, hasID := reqData["id"]; hasID { - req.HasID = true - } - - // Handle custom jsonrpc field if specified - if jsonrpcVal, ok := reqData["jsonrpc"]; ok { - if jsonrpcStr, ok := jsonrpcVal.(string); ok { - if jsonrpcStr == "-" { - // Special value to omit the field - emptyStr := "" - req.JSONRPC = &emptyStr - } else { - req.JSONRPC = &jsonrpcStr - } - } - } - // If not specified, JSONRPC remains nil and defaults to "2.0" - - err := client.SendWebSocket(ctx, req) - require.NoErrorf(t, err, "Step %d: failed to send", i) - - case "receive": - msg, err := client.ReceiveWebSocket(ctx) - require.NoErrorf(t, err, "Step %d: failed to receive", i) - - var response map[string]any - err = json.Unmarshal(msg, &response) - require.NoErrorf(t, err, "Step %d: failed to unmarshal response", i) - - // Compare the response with expected - if expected, ok := step.Expect.(map[string]any); ok { - e.compareJSONRPCMessages(t, response, expected) - } else { - require.Failf(t, "Invalid expected value", "Step %d: expected value must be a map", i) - } - - case "close": - err := client.CloseWebSocket() - require.NoErrorf(t, err, "Step %d: failed to close WebSocket", i) - - default: - require.Failf(t, "Unknown step type", "Step %d: unknown step type: %s", i, step.Type) - } - - // Apply delay if specified - if step.Delay > 0 { - time.Sleep(step.Delay) - } - } -} - // executeSSESequence handles SSE streaming sequences func (e *executor) executeSSESequence(ctx context.Context, t *testing.T, scenario Scenario) { t.Helper() @@ -323,7 +221,7 @@ func (e *executor) executeSSESequence(ctx context.Context, t *testing.T, scenari method := scenario.Request.GetMethod(scenario.Method) req := harness.JSONRPCRequest{ Method: method, - Params: scenario.Request.Params, + Params: jsonRPCParams(method, scenario.Request.Params), ID: scenario.Request.ID, } // Handle JSONRPC field: @@ -382,7 +280,9 @@ func (e *executor) executeBatch(t *testing.T, scenario Scenario) { jsonReq := map[string]any{ "jsonrpc": "2.0", "method": method, - "params": req.Params, + } + if req.Params != nil { + jsonReq["params"] = jsonRPCParams(method, req.Params) } if req.ID != nil { jsonReq["id"] = req.ID @@ -480,7 +380,7 @@ func (e *executor) validateJSONRPCResponse(t *testing.T, response any, expect Ex } } -// compareJSONRPCMessages compares two JSON-RPC messages (used for SSE/WebSocket validation) +// compareJSONRPCMessages compares two JSON-RPC messages from an SSE stream. func (e *executor) compareJSONRPCMessages(t *testing.T, actual, expected map[string]any) { t.Helper() diff --git a/jsonrpc/integration_tests/framework/framework_test.go b/jsonrpc/integration_tests/framework/framework_test.go index 5d743f5a58..9b9ca6012b 100644 --- a/jsonrpc/integration_tests/framework/framework_test.go +++ b/jsonrpc/integration_tests/framework/framework_test.go @@ -2,6 +2,8 @@ package framework import ( "testing" + + "github.com/stretchr/testify/require" ) // TestParseMethod verifies method name parsing @@ -19,8 +21,11 @@ func TestParseMethod(t *testing.T) { {"generate_object", "generate", "object", "", false}, {"echo_string_notify", "echo", "string", "notify", false}, {"transform_map_error", "transform", "map", "error", false}, - {"stream_string_final", "stream", "string", "final", false}, - + {"stream_string_sse", "stream", "string", "", false}, + {"stream_string_unknown_sse", "", "", "", true}, + {"stream_string", "", "", "", true}, + {"echo_string_ws", "", "", "", true}, + // Invalid methods {"invalid", "", "", "", true}, {"echo", "", "", "", true}, @@ -29,7 +34,7 @@ func TestParseMethod(t *testing.T) { {"", "", "", "", true}, {"echo__string", "", "", "", true}, } - + for _, tt := range tests { t.Run(tt.method, func(t *testing.T) { info, err := ParseMethod(tt.method) @@ -39,11 +44,11 @@ func TestParseMethod(t *testing.T) { } return } - + if err != nil { t.Fatalf("ParseMethod(%q) failed: %v", tt.method, err) } - + if info.Action != tt.action { t.Errorf("Action: got %q, want %q", info.Action, tt.action) } @@ -55,4 +60,31 @@ func TestParseMethod(t *testing.T) { } }) } -} \ No newline at end of file +} + +// TestScenariosUseSupportedTransports checks that every checked-in scenario +// uses HTTP or server-sent events and that each server-sent event scenario +// contains only receive steps and ends with its request ID when one is present. +func TestScenariosUseSupportedTransports(t *testing.T) { + runner, err := NewRunner("../scenarios/scenarios.yaml") + require.NoError(t, err) + + for _, scenario := range runner.config.Scenarios { + t.Run(scenario.Name, func(t *testing.T) { + require.Contains(t, []string{TransportHTTP, TransportSSE}, scenario.Transport) + if scenario.Transport != TransportSSE { + return + } + for _, step := range scenario.Sequence { + require.Equal(t, "receive", step.Type) + } + if scenario.Request.ID == nil { + return + } + require.NotEmpty(t, scenario.Sequence) + response, ok := scenario.Sequence[len(scenario.Sequence)-1].Expect.(map[string]any) + require.True(t, ok) + require.EqualValues(t, scenario.Request.ID, response["id"]) + }) + } +} diff --git a/jsonrpc/integration_tests/framework/generator.go b/jsonrpc/integration_tests/framework/generator.go index fb082ed02b..71183ebfb5 100644 --- a/jsonrpc/integration_tests/framework/generator.go +++ b/jsonrpc/integration_tests/framework/generator.go @@ -128,56 +128,41 @@ func (g *Generator) renderImplementation(impl *ImplementationData) error { // buildMethodData creates semantic data for a method. func (g *Generator) buildMethodData(info MethodInfo) *MethodData { data := &MethodData{ - Name: info.Name(), - GoName: goify(info.Name()), - Description: g.getMethodDescription(info), - Info: info, - IsNotification: info.Modifier == ModifierNotify, - ReturnsError: info.Modifier == ModifierError, - HasValidation: info.Modifier == ModifierValidate, - HasFinalResponse: info.Modifier == ModifierFinal, - Transport: info.Transport, - IsStreaming: info.IsStreaming(), - } - // Non-streaming payload - if info.Modifier != ModifierNotify && info.Action != ActionGenerate && (!info.HasStreamingPayload() || info.IsSSE()) { + Name: info.Name(), + GoName: goify(info.Name()), + Description: g.getMethodDescription(info), + Info: info, + IsNotification: info.Modifier == ModifierNotify && !info.IsStreaming(), + ReturnsError: info.Modifier == ModifierError, + HasValidation: info.Modifier == ModifierValidate, + Transport: info.Transport, + IsStreaming: info.IsStreaming(), + } + if info.Action != ActionGenerate { data.Payload = g.buildTypeSpec(info.Type, info.Modifier) } - // Streaming if info.IsStreaming() { - isBidi := info.IsWebSocket() && info.HasStreamingPayload() && info.HasStreamingResult() - if info.HasStreamingPayload() { - data.StreamingPayload = g.buildStreamingTypeSpec(info.Type, true, isBidi, info) - data.StreamKind = "payload" - } - if info.HasStreamingResult() { - data.Result = g.buildStreamingTypeSpec(info.Type, false, isBidi, info) - if data.StreamKind == "payload" { - data.StreamKind = "bidirectional" - } else { - data.StreamKind = "result" - } - if info.IsSSE() && info.Modifier == ModifierFinal && data.Result != nil { - data.Result.Fields = append(data.Result.Fields, FieldSpec{Position: len(data.Result.Fields) + 1, Name: "id", GoName: "ID", Type: &TypeSpec{Kind: "primitive", Primitive: "String"}, Description: "Response ID (for final response)", Required: false}) - } - } + data.Result = g.buildStreamingTypeSpec(info.Type) } else if info.Modifier != ModifierNotify && info.Modifier != ModifierError { - data.Result = g.buildTypeSpec(info.Type, "") + if info.Modifier == ModifierIDMap { + data.Result = g.buildTypeSpec(TypeString, "") + } else { + data.Result = g.buildTypeSpec(info.Type, "") + } } return data } // buildTypeSpec creates a TypeSpec based on the type string and modifier. func (g *Generator) buildTypeSpec(typeStr, modifier string) *TypeSpec { - // Special handling for id mapping: force object with value + id + request_id (strings) + // ID mapping wraps the input with the field populated from the request envelope. if modifier == ModifierIDMap { wrap := func(val *TypeSpec) *TypeSpec { return &TypeSpec{ Kind: "object", Fields: []FieldSpec{ {Position: 1, Name: "value", GoName: "Value", Type: val, Required: true}, - {Position: 2, Name: "id", GoName: "ID", Type: &TypeSpec{Kind: "primitive", Primitive: "String"}}, - {Position: 3, Name: "request_id", GoName: "RequestID", Type: &TypeSpec{Kind: "primitive", Primitive: "String"}}, + {Position: 2, Name: "request_id", GoName: "RequestID", Type: &TypeSpec{Kind: "primitive", Primitive: "String"}, Required: true, JSONRPCID: true}, }, } } @@ -245,62 +230,9 @@ func (g *Generator) buildTypeSpec(typeStr, modifier string) *TypeSpec { } } -// buildStreamingTypeSpec creates a TypeSpec for streaming types -func (g *Generator) buildStreamingTypeSpec(typeStr string, _ bool, isBidirectional bool, info MethodInfo) *TypeSpec { - // For WebSocket bidirectional methods, include mapping fields only when explicitly requested via idmap - if isBidirectional { - switch typeStr { - case TypeString: - if info.Modifier == ModifierIDMap { - return &TypeSpec{ - Kind: "object", - NeedsID: true, - Fields: []FieldSpec{ - {Position: 1, Name: "id", GoName: "ID", Type: &TypeSpec{Kind: "primitive", Primitive: "String"}, Required: false, Description: "Business-level ID"}, - {Position: 2, Name: "request_id", GoName: "RequestID", Type: &TypeSpec{Kind: "primitive", Primitive: "String"}, Required: false, Description: "Mapped JSON-RPC envelope ID"}, - {Position: 3, Name: "value", GoName: "Value", Type: &TypeSpec{Kind: "primitive", Primitive: "String"}, Required: true, Description: "String value"}, - }, - } - } - // No id mapping: only value field - return &TypeSpec{Kind: "object", Fields: []FieldSpec{{Position: 1, Name: "value", GoName: "Value", Type: &TypeSpec{Kind: "primitive", Primitive: "String"}, Required: true}}} - case TypeArray: - if info.Modifier == ModifierIDMap { - return &TypeSpec{Kind: "object", NeedsID: true, Fields: []FieldSpec{ - {Position: 1, Name: "id", GoName: "ID", Type: &TypeSpec{Kind: "primitive", Primitive: "String"}}, - {Position: 2, Name: "request_id", GoName: "RequestID", Type: &TypeSpec{Kind: "primitive", Primitive: "String"}}, - {Position: 3, Name: "items", GoName: "Items", Type: &TypeSpec{Kind: "array", ArrayElem: &TypeSpec{Kind: "primitive", Primitive: "String"}}, Required: true}, - }} - } - return &TypeSpec{Kind: "object", Fields: []FieldSpec{{Position: 1, Name: "items", GoName: "Items", Type: &TypeSpec{Kind: "array", ArrayElem: &TypeSpec{Kind: "primitive", Primitive: "String"}}, Required: true}}} - case TypeObject: - if info.Modifier == ModifierIDMap { - return &TypeSpec{Kind: "object", NeedsID: true, Fields: []FieldSpec{ - {Position: 1, Name: "id", GoName: "ID", Type: &TypeSpec{Kind: "primitive", Primitive: "String"}}, - {Position: 2, Name: "request_id", GoName: "RequestID", Type: &TypeSpec{Kind: "primitive", Primitive: "String"}}, - {Position: 3, Name: "field1", GoName: "Field1", Type: &TypeSpec{Kind: "primitive", Primitive: "String"}, Required: true}, - {Position: 4, Name: "field2", GoName: "Field2", Type: &TypeSpec{Kind: "primitive", Primitive: "Int"}, Required: true}, - {Position: 5, Name: "field3", GoName: "Field3", Type: &TypeSpec{Kind: "primitive", Primitive: "Boolean"}, Required: true}, - }} - } - return &TypeSpec{Kind: "object", Fields: []FieldSpec{ - {Position: 1, Name: "field1", GoName: "Field1", Type: &TypeSpec{Kind: "primitive", Primitive: "String"}, Required: true}, - {Position: 2, Name: "field2", GoName: "Field2", Type: &TypeSpec{Kind: "primitive", Primitive: "Int"}, Required: true}, - {Position: 3, Name: "field3", GoName: "Field3", Type: &TypeSpec{Kind: "primitive", Primitive: "Boolean"}, Required: true}, - }} - default: - if info.Modifier == ModifierIDMap { - return &TypeSpec{Kind: "object", NeedsID: true, Fields: []FieldSpec{ - {Position: 1, Name: "id", GoName: "ID", Type: &TypeSpec{Kind: "primitive", Primitive: "String"}}, - {Position: 2, Name: "request_id", GoName: "RequestID", Type: &TypeSpec{Kind: "primitive", Primitive: "String"}}, - {Position: 3, Name: "data", GoName: "Data", Type: &TypeSpec{Kind: "primitive", Primitive: "Any"}, Required: true}, - }} - } - return &TypeSpec{Kind: "object", Fields: []FieldSpec{{Position: 1, Name: "data", GoName: "Data", Type: &TypeSpec{Kind: "primitive", Primitive: "Any"}, Required: true}}} - } - } - - // For non-bidirectional streaming, wrap primitives in objects +// buildStreamingTypeSpec creates the object sent in each SSE notification. +func (g *Generator) buildStreamingTypeSpec(typeStr string) *TypeSpec { + // Wrap primitives so each notification has a named JSON field. spec := g.buildTypeSpec(typeStr, "") if spec.Kind == "primitive" { return &TypeSpec{Kind: "object", Fields: []FieldSpec{{Position: 1, Name: "value", GoName: "Value", Type: spec, Required: true, Description: fmt.Sprintf("%s value", spec.Primitive)}}} @@ -341,20 +273,18 @@ func (g *Generator) buildImplementationData(design *DesignData) *ImplementationD // buildMethodImplData creates implementation data for a method func (g *Generator) buildMethodImplData(method *MethodData, serviceName string) *MethodImplData { - data := &MethodImplData{MethodData: method, ServicePackage: serviceName, HasPayload: method.Payload != nil || method.StreamingPayload != nil, HasResult: method.Result != nil} + data := &MethodImplData{MethodData: method, ServicePackage: serviceName, HasPayload: method.Payload != nil, HasResult: method.Result != nil} if method.Payload != nil { if method.Payload.Kind == "primitive" { data.PayloadRef = strings.ToLower(method.Payload.Primitive) } else { data.PayloadRef = fmt.Sprintf("*%s.%sPayload", serviceName, method.GoName) } - } else if method.StreamingPayload != nil && data.StreamKind == "bidirectional" { - data.PayloadRef = fmt.Sprintf("*%s.%sPayload", serviceName, method.GoName) } if method.Result != nil { - if method.Result.Kind == "primitive" { + if !method.IsStreaming && method.Result.Kind == "primitive" { data.ResultRef = strings.ToLower(method.Result.Primitive) - } else { + } else if !method.IsStreaming { data.ResultRef = fmt.Sprintf("*%s.%sResult", serviceName, method.GoName) } } @@ -368,14 +298,6 @@ func (g *Generator) buildMethodImplData(method *MethodData, serviceName string) func (g *Generator) templateFuncs() template.FuncMap { return template.FuncMap{ "goify": goify, - "hasStreamingMethod": func(methods []*MethodImplData) bool { - for _, m := range methods { - if m.IsStreaming { - return true - } - } - return false - }, "collectRequired": func(fields []FieldSpec) []string { var required []string for _, f := range fields { @@ -393,9 +315,6 @@ func (g *Generator) getServiceName(info MethodInfo) string { if info.IsSSE() { return "testsse" } - if info.IsWebSocket() { - return "testws" - } return "test" } @@ -404,8 +323,6 @@ func (g *Generator) getJSONRPCPath(serviceName string) string { switch serviceName { case "testsse": return "/jsonrpc/sse" - case "testws": - return "/jsonrpc/ws" default: return "/jsonrpc" } @@ -487,9 +404,6 @@ func (g *Generator) parseServiceMethodPairs(serviceName string) []methodPair { continue } name := fld.Names[0].Name - if name == "HandleStream" { - continue - } goNames = append(goNames, name) } return false @@ -540,7 +454,6 @@ func (g *Generator) filesImpl(impl *ImplementationData) []*codegen.File { {Path: "time"}, {Path: "strings"}, {Path: "sort"}, - {Path: "io"}, {Name: "goa", Path: "goa.design/goa/v3/pkg"}, {Name: service.ServicePackage, Path: fmt.Sprintf("testservice/gen/%s", service.ServicePackage)}, } @@ -548,7 +461,7 @@ func (g *Generator) filesImpl(impl *ImplementationData) []*codegen.File { codegen.Header(fmt.Sprintf("%s service implementation", service.Title), "testservice", imports), { Name: "service-impl", - Source: generatorTemplates.Read("impl/service", "method_signature", "error", "echo", "transform", "generate", "streaming_sse", "streaming_websocket", "notify", "validate"), + Source: generatorTemplates.Read("impl/service", "method_signature", "error", "echo", "transform", "generate", "streaming_sse", "notify", "validate"), FuncMap: g.templateFuncs(), Data: service, }, diff --git a/jsonrpc/integration_tests/framework/generator_test.go b/jsonrpc/integration_tests/framework/generator_test.go new file mode 100644 index 0000000000..5417c993df --- /dev/null +++ b/jsonrpc/integration_tests/framework/generator_test.go @@ -0,0 +1,32 @@ +// This file checks the design source rendered by the JSON-RPC integration +// test generator before Goa turns that design into transport code. +package framework + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/require" +) + +// TestRenderDesignUsesIDForMappedRequestID ensures the integration fixture +// declares the envelope-owned request field with Goa's JSON-RPC ID function. +func TestRenderDesignUsesIDForMappedRequestID(t *testing.T) { + dir := t.TempDir() + generator := NewGenerator(dir, map[string]MethodInfo{ + "echo_string_idmap": { + Action: ActionEcho, + Type: TypeString, + Modifier: ModifierIDMap, + Transport: TransportHTTP, + }, + }) + + require.NoError(t, generator.renderDesign(generator.buildDesignData())) + design, err := os.ReadFile(filepath.Join(dir, "design", "design.go")) + require.NoError(t, err) + require.Contains(t, string(design), `ID("request_id", String)`) + require.False(t, strings.Contains(string(design), `Field(2, "request_id", String)`)) +} diff --git a/jsonrpc/integration_tests/framework/options.go b/jsonrpc/integration_tests/framework/options.go index 8717a58684..1bc6174e00 100644 --- a/jsonrpc/integration_tests/framework/options.go +++ b/jsonrpc/integration_tests/framework/options.go @@ -113,15 +113,8 @@ func ApplyOptions(config *RunnerConfig, opts ...RunnerOption) { type executorOption func(*executorConfig) type executorConfig struct { - WebSocketTimeout time.Duration - Debug bool - WorkDir string -} - -func withWebSocketTimeout(d time.Duration) executorOption { - return func(c *executorConfig) { - c.WebSocketTimeout = d - } + Debug bool + WorkDir string } func withExecutorDebug(debug bool) executorOption { diff --git a/jsonrpc/integration_tests/framework/runner.go b/jsonrpc/integration_tests/framework/runner.go index 378d7e8e2a..35c3b70905 100644 --- a/jsonrpc/integration_tests/framework/runner.go +++ b/jsonrpc/integration_tests/framework/runner.go @@ -254,11 +254,8 @@ func (r *Runner) runScenario(t *testing.T, scenario Scenario) { t.Fatal("No server URL configured") } - // Create executor with timeout from settings + // Create the executor for this generated service. opts := []executorOption{} - if r.config.Settings.Timeout > 0 { - opts = append(opts, withWebSocketTimeout(r.config.Settings.Timeout)) - } opts = append(opts, withWorkDir(r.testDir)) // Enable debug if requested diff --git a/jsonrpc/integration_tests/framework/templates/dsl/design.go.tpl b/jsonrpc/integration_tests/framework/templates/dsl/design.go.tpl index 492e3ec392..d87b3c8607 100644 --- a/jsonrpc/integration_tests/framework/templates/dsl/design.go.tpl +++ b/jsonrpc/integration_tests/framework/templates/dsl/design.go.tpl @@ -16,11 +16,8 @@ var _ = Service("{{ .Name }}", func() { POST("{{ .JSONRPCPath }}") }) {{- range .Methods }} -{{- if or (not .IsNotification) (and .IsNotification .IsStreaming) }} {{ template "partial_method" . }} {{- end }} -{{- end }} }) {{- end }} - diff --git a/jsonrpc/integration_tests/framework/templates/dsl/method.go.tpl b/jsonrpc/integration_tests/framework/templates/dsl/method.go.tpl index 1060b26148..c427c2350d 100644 --- a/jsonrpc/integration_tests/framework/templates/dsl/method.go.tpl +++ b/jsonrpc/integration_tests/framework/templates/dsl/method.go.tpl @@ -4,10 +4,7 @@ Method("{{ .Name }}", func() { {{- if .Payload }} Payload({{ template "inline_type" .Payload }}) {{- end }} -{{- if .StreamingPayload }} - StreamingPayload({{ template "inline_type" .StreamingPayload }}) -{{- end }} -{{- if and .Result .IsStreaming (or (eq .StreamKind "result") (eq .StreamKind "bidirectional")) }} +{{- if and .Result .IsStreaming }} StreamingResult({{ template "inline_type" .Result }}) {{- else if and .Result (not .IsNotification) }} Result({{ template "inline_type" .Result }}) @@ -59,4 +56,4 @@ func() { {{- else -}} Any {{- end -}} -{{- end -}} \ No newline at end of file +{{- end -}} diff --git a/jsonrpc/integration_tests/framework/templates/dsl/type.go.tpl b/jsonrpc/integration_tests/framework/templates/dsl/type.go.tpl index e9aa2a019d..97b9bc4933 100644 --- a/jsonrpc/integration_tests/framework/templates/dsl/type.go.tpl +++ b/jsonrpc/integration_tests/framework/templates/dsl/type.go.tpl @@ -19,10 +19,6 @@ func() { {{- if $required }} Required({{ range $i, $f := $required }}{{ if $i }}, {{ end }}"{{ $f }}"{{ end }}) {{- end }} - {{- if .NeedsID }} - // Accept JSON-RPC ID in payload for WS, optional; transport-level ID is handled separately - Field(99, "id", String) - {{- end }} } {{- else if eq .Kind "map" -}} func() { @@ -61,4 +57,4 @@ func() { {{- define "map_type" -}} MapOf({{ if .MapKey }}{{ template "type" .MapKey }}{{ else }}String{{ end }}, {{ if .MapValue }}{{ template "type" .MapValue }}{{ else }}Any{{ end }}) -{{- end -}} \ No newline at end of file +{{- end -}} diff --git a/jsonrpc/integration_tests/framework/templates/go_mod.go.tpl b/jsonrpc/integration_tests/framework/templates/go_mod.go.tpl index ec7fb48734..1879e5cd00 100644 --- a/jsonrpc/integration_tests/framework/templates/go_mod.go.tpl +++ b/jsonrpc/integration_tests/framework/templates/go_mod.go.tpl @@ -1,10 +1,13 @@ module testservice -go 1.21 +go 1.25.0 require ( goa.design/goa/v3 v3.19.2 - goa.design/clue v1.2.0 ) -replace goa.design/goa/v3 => {{ .GoaPath }} \ No newline at end of file +replace goa.design/goa/v3 => {{ .GoaPath }} + +// The first tidy runs before starter code imports Clue. Keep the compatible +// version pinned even when tidy removes its unused require directive. +replace goa.design/clue => goa.design/clue v1.2.6 diff --git a/jsonrpc/integration_tests/framework/templates/impl/service.go.tpl b/jsonrpc/integration_tests/framework/templates/impl/service.go.tpl index 6209f0c9e1..328c7dbf3f 100644 --- a/jsonrpc/integration_tests/framework/templates/impl/service.go.tpl +++ b/jsonrpc/integration_tests/framework/templates/impl/service.go.tpl @@ -1,56 +1,21 @@ // {{ .ServicePackage }}srvc implements the {{ .ServicePackage }} service. type {{ .ServicePackage }}srvc struct { logger *log.Logger -{{- range .Methods }} -{{- if and (eq .Info.Action "collect") (eq .Info.Type "array") (eq .Transport "ws") }} - // State for accumulating items in {{ .Name }} - collectedItems []string -{{- end }} -{{- end }} } // New{{ .Title }} returns the {{ .ServicePackage }} service implementation. func New{{ .Title }}() {{ .ServicePackage }}.Service { return &{{ .ServicePackage }}srvc{} } -{{- if eq .Name "testws" }} - -// HandleStream handles the JSON-RPC WebSocket streaming connection -func (s *{{ .ServicePackage }}srvc) HandleStream(ctx context.Context, stream {{ .ServicePackage }}.Stream) error { - // For testing purposes, we only send broadcasts when explicitly called through the broadcast method - // In a real application, you might send broadcasts based on external events or timers - - // Ensure the stream is closed on exit - defer func() { - _ = stream.Close() - }() - - // Loop to handle incoming requests - for { - // Recv reads and dispatches the next request - if err := stream.Recv(ctx); err != nil { - // Log the error type and value for diagnostics - log.Printf("HandleStream Recv error: %T %v", err, err) - if err == io.EOF { - return nil - } - return err - } - } -} -{{- end }} {{- range .Methods }} -{{- if or (not .IsNotification) (and .IsNotification .IsStreaming) }} // {{ .GoName }} implements {{ .Name }}. {{ template "partial_method_signature" . }} { log.Printf("{{ .GoName }} called") {{- if .IsStreaming }} -{{- if .IsSSE }} {{ template "partial_streaming_sse" . }} -{{- else if .IsWebSocket }} -{{ template "partial_streaming_websocket" . }} -{{- end }} +{{- else if .IsNotification }} +{{ template "partial_notify" . }} {{- else if .ReturnsError }} {{ template "partial_error" . }} {{- else }} @@ -71,4 +36,3 @@ func (s *{{ .ServicePackage }}srvc) HandleStream(ctx context.Context, stream {{ {{- end }} } {{- end }} -{{- end }} \ No newline at end of file diff --git a/jsonrpc/integration_tests/framework/templates/partial/echo.go.tpl b/jsonrpc/integration_tests/framework/templates/partial/echo.go.tpl index b91a6a3ac9..6f7a3f8e43 100644 --- a/jsonrpc/integration_tests/framework/templates/partial/echo.go.tpl +++ b/jsonrpc/integration_tests/framework/templates/partial/echo.go.tpl @@ -1,60 +1,32 @@ {{- /* Template for echo method implementation */ -}} +{{- if eq .Info.Modifier "idmap" -}} + return p.RequestID, nil +{{- else -}} {{- if eq .Info.Type "string" -}} - {{- if or (eq .Info.Modifier "validate") (eq .Info.Modifier "idmap") -}} + {{- if eq .Info.Modifier "validate" -}} return p.Value, nil {{- else -}} return p, nil {{- end -}} {{- else if eq .Info.Type "int" -}} - {{- if eq .Info.Modifier "idmap" -}} - return p.Value, nil - {{- else -}} return p, nil - {{- end -}} {{- else if eq .Info.Type "bool" -}} - {{- if eq .Info.Modifier "idmap" -}} - return p.Value, nil - {{- else -}} return p, nil - {{- end -}} {{- else if eq .Info.Type "array" -}} - {{- if eq .Info.Modifier "idmap" -}} return &{{ .ServicePackage }}.{{ .GoName }}Result{ Items: p.Items, }, nil - {{- else -}} - return &{{ .ServicePackage }}.{{ .GoName }}Result{ - Items: p.Items, - }, nil - {{- end -}} {{- else if eq .Info.Type "object" -}} - {{- if eq .Info.Modifier "idmap" -}} return &{{ $.ServicePackage }}.{{ .GoName }}Result{ Field1: p.Field1, Field2: p.Field2, Field3: p.Field3, }, nil - {{- else -}} - return &{{ $.ServicePackage }}.{{ .GoName }}Result{ - Field1: p.Field1, - Field2: p.Field2, - Field3: p.Field3, - }, nil - {{- end -}} {{- else if eq .Info.Type "map" -}} - {{- if eq .Info.Modifier "idmap" -}} return &{{ .ServicePackage }}.{{ .GoName }}Result{ Data: p.Data, }, nil - {{- else -}} - return &{{ .ServicePackage }}.{{ .GoName }}Result{ - Data: p.Data, - }, nil - {{- end -}} {{- else -}} - {{- if eq .Info.Modifier "idmap" -}} - return p.Value, nil - {{- else -}} return p, nil - {{- end -}} -{{- end -}} \ No newline at end of file +{{- end -}} +{{- end -}} diff --git a/jsonrpc/integration_tests/framework/templates/partial/method.go.tpl b/jsonrpc/integration_tests/framework/templates/partial/method.go.tpl index aa78a2ac28..c51a8fc291 100644 --- a/jsonrpc/integration_tests/framework/templates/partial/method.go.tpl +++ b/jsonrpc/integration_tests/framework/templates/partial/method.go.tpl @@ -2,16 +2,9 @@ Method("{{ .Name }}", func() { Description("{{ .Description }}") {{- if .Payload }} Payload({{ template "partial_type" .Payload }}) -{{- else if and .StreamingPayload (eq .StreamKind "bidirectional") }} - Payload(func() { - Description("Initial payload") - }) -{{- end }} -{{- if .StreamingPayload }} - StreamingPayload({{ template "partial_type" .StreamingPayload }}) {{- end }} {{- if .Result }} -{{- if or (eq .StreamKind "result") (eq .StreamKind "bidirectional") }} +{{- if .IsStreaming }} StreamingResult({{ template "partial_type" .Result }}) {{- else if not .IsNotification }} Result({{ template "partial_type" .Result }}) @@ -23,6 +16,9 @@ Method("{{ .Name }}", func() { JSONRPC(func() { {{- if .IsSSE }} ServerSentEvents() +{{- end }} +{{- if .IsNotification }} + Notification() {{- end }} }) -}) \ No newline at end of file +}) diff --git a/jsonrpc/integration_tests/framework/templates/partial/method_signature.go.tpl b/jsonrpc/integration_tests/framework/templates/partial/method_signature.go.tpl index f2fd7f1acf..4061f4ea87 100644 --- a/jsonrpc/integration_tests/framework/templates/partial/method_signature.go.tpl +++ b/jsonrpc/integration_tests/framework/templates/partial/method_signature.go.tpl @@ -1,18 +1,6 @@ {{- /* Template for generating method signature */ -}} {{- if .IsStreaming -}} - {{- if .IsSSE -}} func (s *{{ $.ServicePackage }}srvc) {{ .GoName }}(ctx context.Context{{ if .HasPayload }}, p {{ .PayloadRef }}{{ end }}, stream {{ $.ServicePackage }}.{{ .StreamInterface }}) error - {{- else if .IsWebSocket -}} - {{- if .IsBidirectional -}} -func (s *{{ $.ServicePackage }}srvc) {{ .GoName }}(ctx context.Context{{ if .HasPayload }}, p {{ .PayloadRef }}{{ end }}, stream {{ $.ServicePackage }}.{{ .StreamInterface }}) error - {{- else if eq .StreamKind "payload" -}} -func (s *{{ $.ServicePackage }}srvc) {{ .GoName }}(ctx context.Context, stream {{ $.ServicePackage }}.{{ .StreamInterface }}) error - {{- else -}} -func (s *{{ $.ServicePackage }}srvc) {{ .GoName }}(ctx context.Context{{ if .HasPayload }}, p {{ .PayloadRef }}{{ end }}, stream {{ $.ServicePackage }}.{{ .StreamInterface }}) error - {{- end -}} - {{- else -}} -func (s *{{ $.ServicePackage }}srvc) {{ .GoName }}(ctx context.Context{{ if .HasPayload }}, p {{ .PayloadRef }}{{ end }}) {{ if .HasResult }}({{ .ResultRef }}, error){{ else }}error{{ end }} - {{- end -}} {{- else -}} func (s *{{ $.ServicePackage }}srvc) {{ .GoName }}(ctx context.Context{{ if .HasPayload }}, p {{ .PayloadRef }}{{ end }}) {{ if .HasResult }}({{ .ResultRef }}, error){{ else }}error{{ end }} -{{- end -}} \ No newline at end of file +{{- end -}} diff --git a/jsonrpc/integration_tests/framework/templates/partial/streaming_sse.go.tpl b/jsonrpc/integration_tests/framework/templates/partial/streaming_sse.go.tpl index 056d33b58f..43eb48e367 100644 --- a/jsonrpc/integration_tests/framework/templates/partial/streaming_sse.go.tpl +++ b/jsonrpc/integration_tests/framework/templates/partial/streaming_sse.go.tpl @@ -9,7 +9,7 @@ result := &{{ $.ServicePackage }}.{{ .GoName }}Result{ Value: p, } - if err := stream.Send(ctx, result); err != nil { + if err := stream.Send(result); err != nil { return err } {{- else if eq .Info.Type "array" -}} @@ -18,7 +18,7 @@ result := &{{ $.ServicePackage }}.{{ .GoName }}Result{ Items: []string{item}, } - if err := stream.Send(ctx, result); err != nil { + if err := stream.Send(result); err != nil { return err } } @@ -29,7 +29,7 @@ Field2: p.Field2, Field3: p.Field3, } - if err := stream.Send(ctx, result); err != nil { + if err := stream.Send(result); err != nil { return err } {{- else if eq .Info.Type "map" -}} @@ -37,7 +37,7 @@ result := &{{ $.ServicePackage }}.{{ .GoName }}Result{ Data: p.Data, } - if err := stream.Send(ctx, result); err != nil { + if err := stream.Send(result); err != nil { return err } {{- end -}} @@ -54,7 +54,7 @@ result := &{{ $.ServicePackage }}.{{ .GoName }}Result{ Value: strings.ToUpper(p), } - if err := stream.Send(ctx, result); err != nil { + if err := stream.Send(result); err != nil { return err } {{- else if eq .Info.Type "array" -}} @@ -66,7 +66,7 @@ result := &{{ $.ServicePackage }}.{{ .GoName }}Result{ Items: reversed, } - if err := stream.Send(ctx, result); err != nil { + if err := stream.Send(result); err != nil { return err } {{- else if eq .Info.Type "object" -}} @@ -76,7 +76,7 @@ Field2: p.Field2 * 2, Field3: !p.Field3, } - if err := stream.Send(ctx, result); err != nil { + if err := stream.Send(result); err != nil { return err } {{- else if eq .Info.Type "map" -}} @@ -88,7 +88,7 @@ result := &{{ $.ServicePackage }}.{{ .GoName }}Result{ Data: transformed, } - if err := stream.Send(ctx, result); err != nil { + if err := stream.Send(result); err != nil { return err } {{- end -}} @@ -105,7 +105,7 @@ result := &{{ $.ServicePackage }}.{{ .GoName }}Result{ Value: fmt.Sprintf("generated-%d", i), } - if err := stream.Send(ctx, result); err != nil { + if err := stream.Send(result); err != nil { return err } } @@ -115,7 +115,7 @@ result := &{{ $.ServicePackage }}.{{ .GoName }}Result{ Items: []string{fmt.Sprintf("item-%d", i)}, } - if err := stream.Send(ctx, result); err != nil { + if err := stream.Send(result); err != nil { return err } } @@ -127,7 +127,7 @@ Field2: i * 10, Field3: i%2 == 0, } - if err := stream.Send(ctx, result); err != nil { + if err := stream.Send(result); err != nil { return err } } @@ -140,7 +140,7 @@ "status": fmt.Sprintf("step-%d", i), }, } - if err := stream.Send(ctx, result); err != nil { + if err := stream.Send(result); err != nil { return err } } @@ -165,7 +165,7 @@ result := &{{ $.ServicePackage }}.{{ .GoName }}Result{ Value: fmt.Sprintf("Stream %d of %d", i, count), } - if err := stream.Send(ctx, result); err != nil { + if err := stream.Send(result); err != nil { return err } // Small delay to simulate streaming @@ -179,7 +179,7 @@ result := &{{ $.ServicePackage }}.{{ .GoName }}Result{ Items: []string{"empty"}, } - if err := stream.Send(ctx, result); err != nil { + if err := stream.Send(result); err != nil { return err } } else { @@ -188,7 +188,7 @@ result := &{{ $.ServicePackage }}.{{ .GoName }}Result{ Items: []string{fmt.Sprintf("Processing: %s", item)}, } - if err := stream.Send(ctx, result); err != nil { + if err := stream.Send(result); err != nil { return err } // Small delay between items @@ -202,7 +202,7 @@ result := &{{ $.ServicePackage }}.{{ .GoName }}Result{ Items: []string{"stream-1", "stream-2", "stream-3"}, } - if err := stream.Send(ctx, result); err != nil { + if err := stream.Send(result); err != nil { return err } {{- end }} @@ -229,7 +229,7 @@ Field2: i, Field3: i == count, // true for last item } - if err := stream.Send(ctx, result); err != nil { + if err := stream.Send(result); err != nil { return err } time.Sleep(10 * time.Millisecond) @@ -242,7 +242,7 @@ result := &{{ $.ServicePackage }}.{{ .GoName }}Result{ Data: map[string]any{"status": "empty"}, } - if err := stream.Send(ctx, result); err != nil { + if err := stream.Send(result); err != nil { return err } } else { @@ -262,7 +262,7 @@ "value": v, }, } - if err := stream.Send(ctx, result); err != nil { + if err := stream.Send(result); err != nil { return err } time.Sleep(10 * time.Millisecond) @@ -277,7 +277,7 @@ "status": fmt.Sprintf("step-%d", i), }, } - if err := stream.Send(ctx, result); err != nil { + if err := stream.Send(result); err != nil { return err } time.Sleep(10 * time.Millisecond) @@ -293,14 +293,14 @@ result := &{{ $.ServicePackage }}.{{ .GoName }}Result{ Value: p, } - if err := stream.Send(ctx, result); err != nil { + if err := stream.Send(result); err != nil { return err } {{- else if eq .Info.Type "array" -}} result := &{{ $.ServicePackage }}.{{ .GoName }}Result{ Items: p.Items, } - if err := stream.Send(ctx, result); err != nil { + if err := stream.Send(result); err != nil { return err } {{- else if eq .Info.Type "object" -}} @@ -309,14 +309,14 @@ Field2: p.Field2, Field3: p.Field3, } - if err := stream.Send(ctx, result); err != nil { + if err := stream.Send(result); err != nil { return err } {{- else if eq .Info.Type "map" -}} result := &{{ $.ServicePackage }}.{{ .GoName }}Result{ Data: p.Data, } - if err := stream.Send(ctx, result); err != nil { + if err := stream.Send(result); err != nil { return err } {{- end -}} @@ -326,14 +326,14 @@ result := &{{ $.ServicePackage }}.{{ .GoName }}Result{ Value: "default", } - if err := stream.Send(ctx, result); err != nil { + if err := stream.Send(result); err != nil { return err } {{- else if eq .Info.Type "array" -}} result := &{{ $.ServicePackage }}.{{ .GoName }}Result{ Items: []string{"default"}, } - if err := stream.Send(ctx, result); err != nil { + if err := stream.Send(result); err != nil { return err } {{- else if eq .Info.Type "object" -}} @@ -342,41 +342,23 @@ Field2: 0, Field3: false, } - if err := stream.Send(ctx, result); err != nil { + if err := stream.Send(result); err != nil { return err } {{- else if eq .Info.Type "map" -}} result := &{{ $.ServicePackage }}.{{ .GoName }}Result{ Data: map[string]any{"status": "default"}, } - if err := stream.Send(ctx, result); err != nil { + if err := stream.Send(result); err != nil { return err } {{- end -}} {{- end -}} -{{- end -}} +{{- end }} -{{- /* Handle modifiers for protocol-level behavior */ -}} -{{- if eq .Info.Modifier "final" -}} - // Send final response with ID using SendAndClose - finalResult := &{{ $.ServicePackage }}.{{ .GoName }}Result{ - {{- if eq .Info.Type "string" -}} - Value: "Final response", - {{- else if eq .Info.Type "array" -}} - Items: []string{"completed"}, - {{- else if eq .Info.Type "object" -}} - Field1: "completed", - Field2: 100, - Field3: true, - {{- else if eq .Info.Type "map" -}} - Data: map[string]any{"status": "completed", "final": true}, - {{- end -}} - } - return stream.SendAndClose(ctx, finalResult) -{{- else if eq .Info.Modifier "error" -}} - // Return an error after streaming +{{ if eq .Info.Modifier "error" -}} + // Return an error after streaming. return &goa.ServiceError{Message: "Streaming error occurred"} -{{- else -}} - // No final response for pure notifications +{{ else -}} return nil -{{- end -}} \ No newline at end of file +{{- end -}} diff --git a/jsonrpc/integration_tests/framework/templates/partial/streaming_websocket.go.tpl b/jsonrpc/integration_tests/framework/templates/partial/streaming_websocket.go.tpl deleted file mode 100644 index dc1ffcf307..0000000000 --- a/jsonrpc/integration_tests/framework/templates/partial/streaming_websocket.go.tpl +++ /dev/null @@ -1,522 +0,0 @@ -{{- /* Template for WebSocket streaming method implementation */ -}} -{{- /* WebSocket behavior is determined by the action, similar to SSE */ -}} - -{{- /* Handle validation first if validate modifier is set */ -}} -{{- if eq .Info.Modifier "validate" }} - {{- if eq .Info.Type "object" }} - if p != nil && (p.Field1 == "" || p.Field2 < 0) { - validationErr := &goa.ServiceError{ - Name: "validation_error", - Message: "validation error", - } - if err := stream.SendError(ctx, validationErr); err != nil { - return err - } - return nil - } - {{- else if eq .Info.Type "string" }} - if p != nil && p.Value == "" { - validationErr := &goa.ServiceError{ - Name: "validation_error", - Message: "validation error", - } - if err := stream.SendError(ctx, validationErr); err != nil { - return err - } - return nil - } - {{- end }} -{{- end }} - -{{- /* For echo with error modifier, return error immediately */ -}} -{{- if and (eq .Info.Action "echo") (eq .Info.Modifier "error") }} - // For echo methods with error modifier, always send error response - testErr := &goa.ServiceError{ - Name: "test_error", - Message: "Invalid params", - } - if err := stream.SendError(ctx, testErr); err != nil { - return err - } - return nil -{{- else if eq .Info.Action "echo" }} - {{- /* Echo action: Return the payload exactly as received */ -}} - {{- if .IsBidirectional }} - {{- if eq .Info.Type "string" }} - // Echo back the received payload - if p != nil { - result := &{{ $.ServicePackage }}.{{ .GoName }}Result{ - Value: p.Value, - } - if err := stream.SendResponse(ctx, result); err != nil { - return err - } - } - {{- else if eq .Info.Type "array" }} - // Echo back the received array - if p != nil { - result := &{{ $.ServicePackage }}.{{ .GoName }}Result{ - Items: p.Items, - } - if err := stream.SendResponse(ctx, result); err != nil { - return err - } - } - {{- else if eq .Info.Type "object" }} - // Echo back the received object - if p != nil { - result := &{{ $.ServicePackage }}.{{ .GoName }}Result{ - Field1: p.Field1, - Field2: p.Field2, - Field3: p.Field3, - } - if err := stream.SendResponse(ctx, result); err != nil { - return err - } - } - {{- else if eq .Info.Type "map" }} - // Echo back the received map - if p != nil { - result := &{{ $.ServicePackage }}.{{ .GoName }}Result{ - Data: p.Data, - } - if err := stream.SendResponse(ctx, result); err != nil { - return err - } - } - {{- end }} - return nil - {{- else }} - // Non-bidirectional echo - shouldn't happen for WebSocket - return fmt.Errorf("echo action requires bidirectional streaming") - {{- end }} - -{{- else if eq .Info.Action "transform" }} - {{- /* Transform action: Apply transformations to the payload */ -}} - {{- if .IsBidirectional }} - {{- if eq .Info.Type "string" }} - // Transform and return: uppercase - if p != nil { - result := &{{ $.ServicePackage }}.{{ .GoName }}Result{ - Value: strings.ToUpper(p.Value), - } - if err := stream.SendResponse(ctx, result); err != nil { - return err - } - } - {{- else if eq .Info.Type "array" }} - // Transform and return: reverse the array - if p != nil { - reversed := make([]string, len(p.Items)) - for i, item := range p.Items { - reversed[len(p.Items)-1-i] = item - } - result := &{{ $.ServicePackage }}.{{ .GoName }}Result{ - Items: reversed, - } - if err := stream.SendResponse(ctx, result); err != nil { - return err - } - } - {{- else if eq .Info.Type "object" }} - // Transform and return: uppercase field1, double field2, negate field3 - if p != nil { - result := &{{ $.ServicePackage }}.{{ .GoName }}Result{ - Field1: strings.ToUpper(p.Field1), - Field2: p.Field2 * 2, - Field3: !p.Field3, - } - if err := stream.SendResponse(ctx, result); err != nil { - return err - } - } - {{- else if eq .Info.Type "map" }} - // Transform and return: prefix all keys with "transformed_" - if p != nil && p.Data != nil { - transformed := make(map[string]any) - if data, ok := p.Data.(map[string]any); ok { - for k, v := range data { - transformed["transformed_"+k] = v - } - } - result := &{{ $.ServicePackage }}.{{ .GoName }}Result{ - Data: transformed, - } - if err := stream.SendResponse(ctx, result); err != nil { - return err - } - } - {{- end }} - return nil - {{- else }} - return fmt.Errorf("transform action requires bidirectional streaming") - {{- end }} - -{{- else if eq .Info.Action "generate" }} - {{- /* Generate action: Return fixed values, ignoring payload */ -}} - {{- if .IsBidirectional }} - {{- if eq .Info.Type "string" }} - // Generate and return fixed string - if p != nil { - result := &{{ $.ServicePackage }}.{{ .GoName }}Result{ - Value: "generated-string", - } - if err := stream.SendResponse(ctx, result); err != nil { - return err - } - } - {{- else if eq .Info.Type "array" }} - // Generate and return fixed array - if p != nil { - result := &{{ $.ServicePackage }}.{{ .GoName }}Result{ - Items: []string{"item1", "item2", "item3"}, - } - if err := stream.SendResponse(ctx, result); err != nil { - return err - } - } - {{- else if eq .Info.Type "object" }} - // Generate and return fixed object - if p != nil { - result := &{{ $.ServicePackage }}.{{ .GoName }}Result{ - Field1: "generated-value1", - Field2: 42, - Field3: true, - } - if err := stream.SendResponse(ctx, result); err != nil { - return err - } - } - {{- else if eq .Info.Type "map" }} - // Generate and return fixed map - if p != nil { - result := &{{ $.ServicePackage }}.{{ .GoName }}Result{ - ID: p.ID, - Data: map[string]any{ - "generated": true, - "count": 3, - "status": "ok", - }, - } - if err := stream.SendResponse(ctx, result); err != nil { - return err - } - } - {{- end }} - return nil - {{- else }} - // Server-initiated generation (no client request) - {{- if eq .Info.Type "string" }} - for i := 1; i <= 3; i++ { - result := &{{ $.ServicePackage }}.{{ .GoName }}Result{ - Value: fmt.Sprintf("generated-%d", i), - } - if err := stream.SendResponse(ctx, result); err != nil { - return err - } - } - return nil - {{- else }} - // Generate default values - result := &{{ $.ServicePackage }}.{{ .GoName }}Result{} - if err := stream.SendResponse(ctx, result); err != nil { - return err - } - return nil - {{- end }} - {{- end }} - -{{- else if eq .Info.Action "stream" }} - {{- /* Stream action: Send a series of messages based on payload */ -}} - {{- if .IsBidirectional }} - {{- if eq .Info.Type "string" }} - // Stream notifications based on string payload - if p != nil { - count := 3 // default - if p.Value != "" { - count = len(p.Value) - if count > 10 { - count = 10 - } - } - - // For error modifier, send fewer notifications - streamCount := count - {{- if eq .Info.Modifier "error" }} - if streamCount > 2 { - streamCount = 2 - } - {{- end }} - - // Send notifications without ID - for i := 1; i <= streamCount; i++ { - notification := &{{ $.ServicePackage }}.{{ .GoName }}Result{ - Value: fmt.Sprintf("Stream %d of %d", i, count), - } - if err := stream.SendNotification(ctx, notification); err != nil { - return err - } - } - {{- if ne .Info.Modifier "error" }} - // Send final response with ID - result := &{{ $.ServicePackage }}.{{ .GoName }}Result{ - Value: "completed", - } - if err := stream.SendResponse(ctx, result); err != nil { - return err - } - {{- end }} - } - {{- else if eq .Info.Type "array" }} - // Stream notifications for each array item - if p != nil { - // Send notification for each item - for _, item := range p.Items { - notification := &{{ $.ServicePackage }}.{{ .GoName }}Result{ - Items: []string{fmt.Sprintf("Processing: %s", item)}, - } - if err := stream.SendNotification(ctx, notification); err != nil { - return err - } - } - {{- if ne .Info.Modifier "error" }} - // Send final response with ID - result := &{{ $.ServicePackage }}.{{ .GoName }}Result{ - Items: []string{"completed"}, - } - if err := stream.SendResponse(ctx, result); err != nil { - return err - } - {{- end }} - } - {{- else if eq .Info.Type "object" }} - // Stream notifications based on field2 count - if p != nil { - count := p.Field2 - if count <= 0 { - count = 3 - } - if count > 10 { - count = 10 - } - // Send notifications without ID - for i := 1; i <= count; i++ { - notification := &{{ $.ServicePackage }}.{{ .GoName }}Result{ - Field1: fmt.Sprintf("%s-%d", p.Field1, i), - Field2: i, - Field3: i == count, - } - if err := stream.SendNotification(ctx, notification); err != nil { - return err - } - } - {{- if ne .Info.Modifier "error" }} - // Send final response with ID - result := &{{ $.ServicePackage }}.{{ .GoName }}Result{ - Field1: "completed", - Field2: 100, - Field3: true, - } - if err := stream.SendResponse(ctx, result); err != nil { - return err - } - {{- end }} - } - {{- else if eq .Info.Type "map" }} - // Stream notifications for each key-value pair - if p != nil && p.Data != nil { - // Send notification for each pair - if data, ok := p.Data.(map[string]any); ok { - // Sort keys for deterministic ordering - keys := make([]string, 0, len(data)) - for k := range data { - keys = append(keys, k) - } - sort.Strings(keys) - - for _, k := range keys { - v := data[k] - notification := &{{ $.ServicePackage }}.{{ .GoName }}Result{ - Data: map[string]any{ - "key": k, - "value": v, - }, - } - if err := stream.SendNotification(ctx, notification); err != nil { - return err - } - } - } - {{- if ne .Info.Modifier "error" }} - // Send final response with ID - result := &{{ $.ServicePackage }}.{{ .GoName }}Result{ - Data: map[string]any{ - "status": "completed", - }, - } - if err := stream.SendResponse(ctx, result); err != nil { - return err - } - {{- end }} - } - {{- end }} - {{- if ne .Info.Modifier "error" }} - return nil - {{- end }} - {{- else }} - // Server-side streaming without client payload - return fmt.Errorf("stream action requires bidirectional streaming for WebSocket") - {{- end }} - -{{- else if eq .Info.Action "collect" }} - {{- /* Collect action: Accumulate client messages */ -}} - {{- if eq .Info.Type "array" }} - // For JSON-RPC WebSocket, each request comes as a separate call to this method - // We accumulate items across requests using service-level state - if p != nil && p.Items != nil { - s.collectedItems = append(s.collectedItems, p.Items...) - } - - // Return the accumulated items - result := &{{ $.ServicePackage }}.{{ .GoName }}Result{ - Items: s.collectedItems, - } - if err := stream.SendResponse(ctx, result); err != nil { - return err - } - return nil - {{- else }} - // Collect only supports array type currently - return fmt.Errorf("collect action only supports array type") - {{- end }} - -{{- else if eq .Info.Action "broadcast" }} - {{- /* Broadcast action: Server-initiated messages */ -}} - {{- if .IsBidirectional }} - // Broadcast method implementation - bidirectional streaming - // When called (e.g., with "subscribe"), send test broadcast notifications - {{- if eq .Info.Type "string" }} - // Send test broadcasts - for i := 1; i <= 2; i++ { - result := &{{ .ServicePackage }}.{{ .GoName }}Result{ - Value: fmt.Sprintf("Server announcement %d", i), - } - if err := stream.SendNotification(ctx, result); err != nil { - return err - } - } - return nil - {{- else if eq .Info.Type "array" }} - // Send test array broadcasts - for i := 1; i <= 2; i++ { - result := &{{ .ServicePackage }}.{{ .GoName }}Result{ - Items: []string{fmt.Sprintf("broadcast-%d", i)}, - } - if err := stream.SendNotification(ctx, result); err != nil { - return err - } - } - return nil - {{- else if eq .Info.Type "object" }} - // Send test object broadcasts - for i := 1; i <= 2; i++ { - result := &{{ .ServicePackage }}.{{ .GoName }}Result{ - Field1: fmt.Sprintf("broadcast-%d", i), - Field2: i, - Field3: i%2 == 0, - } - if err := stream.SendNotification(ctx, result); err != nil { - return err - } - } - return nil - {{- else if eq .Info.Type "map" }} - // Send test map broadcasts - for i := 1; i <= 2; i++ { - result := &{{ .ServicePackage }}.{{ .GoName }}Result{ - Data: map[string]any{ - "broadcast": i, - "timestamp": time.Now().Unix(), - }, - } - if err := stream.SendResponse(ctx, result); err != nil { - return err - } - } - return nil - {{- else }} - // Send default broadcasts - for i := 1; i <= 2; i++ { - result := &{{ .ServicePackage }}.{{ .GoName }}Result{} - if err := stream.SendNotification(ctx, result); err != nil { - return err - } - } - return nil - {{- end }} - {{- else }} - // Broadcast action requires bidirectional streaming - return fmt.Errorf("broadcast action requires bidirectional streaming") - {{- end }} - -{{- else }} - {{- /* Default: echo behavior for unknown actions */ -}} - // Default WebSocket implementation for JSON-RPC - // Each request comes as a separate call - if p != nil { - // Echo payload back - {{- if eq .Info.Type "string" }} - result := &{{ $.ServicePackage }}.{{ .GoName }}Result{ - ID: p.ID, - Value: p.Value, - } - {{- else if eq .Info.Type "array" }} - result := &{{ $.ServicePackage }}.{{ .GoName }}Result{ - ID: p.ID, - Items: p.Items, - } - {{- else if eq .Info.Type "object" }} - result := &{{ $.ServicePackage }}.{{ .GoName }}Result{ - ID: p.ID, - Field1: p.Field1, - Field2: p.Field2, - Field3: p.Field3, - } - {{- else if eq .Info.Type "map" }} - result := &{{ $.ServicePackage }}.{{ .GoName }}Result{ - ID: p.ID, - Data: p.Data, - } - {{- else }} - result := &{{ $.ServicePackage }}.{{ .GoName }}Result{} - {{- end }} - if err := stream.SendResponse(ctx, result); err != nil { - return err - } - } -{{- end }} - -{{- /* Handle remaining modifiers */ -}} -{{- if eq .Info.Modifier "error" }} - {{- /* For stream action with error, send error after streaming */ -}} - {{- if eq .Info.Action "stream" }} - // For stream methods with error modifier, send error after streaming - testErr := &goa.ServiceError{ - Name: "test_error", - Message: "Streaming error occurred", - } - if err := stream.SendError(ctx, testErr); err != nil { - return err - } - return nil - {{- else }} - // Other actions with error modifier should have been handled above - return nil - {{- end }} -{{- else if eq .Info.Modifier "notify" }} - // Notification - no response sent (already handled above) - return nil -{{- else }} - // Normal completion - return nil -{{- end }} \ No newline at end of file diff --git a/jsonrpc/integration_tests/framework/templates/partial/type.go.tpl b/jsonrpc/integration_tests/framework/templates/partial/type.go.tpl index d876481209..5762a77f12 100644 --- a/jsonrpc/integration_tests/framework/templates/partial/type.go.tpl +++ b/jsonrpc/integration_tests/framework/templates/partial/type.go.tpl @@ -12,15 +12,16 @@ func() { {{- else if eq .Kind "object" -}} func() { {{- range .Fields }} +{{- if .JSONRPCID }} + ID("{{ .Name }}", {{ template "partial_type" .Type }}{{ if .Description }}, "{{ .Description }}"{{ end }}) +{{- else }} Field({{ .Position }}, "{{ .Name }}", {{ template "partial_type" .Type }}{{ if .Description }}, "{{ .Description }}"{{ end }}) {{- end }} +{{- end }} {{- $required := collectRequired .Fields }} {{- if $required }} Required({{ range $i, $f := $required }}{{ if $i }}, {{ end }}"{{ $f }}"{{ end }}) {{- end }} -{{- if .NeedsID }} - ID("id") -{{- end }} } {{- else if eq .Kind "map" -}} func() { @@ -29,4 +30,4 @@ func() { } {{- else -}} Any -{{- end -}} \ No newline at end of file +{{- end -}} diff --git a/jsonrpc/integration_tests/framework/types.go b/jsonrpc/integration_tests/framework/types.go index ca0a02c859..bd3772f49e 100644 --- a/jsonrpc/integration_tests/framework/types.go +++ b/jsonrpc/integration_tests/framework/types.go @@ -6,13 +6,6 @@ import ( "time" ) -// Sequence action types for streaming scenarios -const ( - SequenceActionSend = "send" - SequenceActionReceive = "receive" - SequenceActionClose = "close" -) - // Scenario represents a test scenario loaded from YAML type Scenario struct { Name string `yaml:"name"` @@ -79,14 +72,14 @@ type Settings struct { type MethodInfo struct { Action string // echo, transform, generate, etc. Type string // string, array, object, etc. - Modifier string // notify, error, validate, final - Transport string // sse, ws (extracted from method suffix) + Modifier string // notify, error, validate, or idmap + Transport string // sse when the method sends server-sent events GRPC bool // whether to emit GRPC endpoint (method name starts with grpc_) } // ParseMethod parses a method name into its components. -// Format: action_type[_modifier][_transport] -// Examples: echo_string, stream_object_final_sse, broadcast_string_ws +// Format: action_type[_modifier][_sse] +// Examples: echo_string, stream_object_sse // Returns error if the method name is invalid. func ParseMethod(method string) (MethodInfo, error) { // Check for grpc_ prefix convention @@ -98,7 +91,7 @@ func ParseMethod(method string) (MethodInfo, error) { parts := strings.Split(method, "_") if len(parts) < 2 { - return MethodInfo{}, fmt.Errorf("invalid method name %q: must have format action_type[_modifier][_transport]", method) + return MethodInfo{}, fmt.Errorf("invalid method name %q: must have format action_type[_modifier][_sse]", method) } info := MethodInfo{ @@ -110,7 +103,7 @@ func ParseMethod(method string) (MethodInfo, error) { // Check if last part is a transport if len(parts) > 2 { lastPart := parts[len(parts)-1] - if lastPart == "sse" || lastPart == "ws" { + if lastPart == "sse" { info.Transport = lastPart parts = parts[:len(parts)-1] // Remove transport from parts } @@ -119,7 +112,7 @@ func ParseMethod(method string) (MethodInfo, error) { // Validate action validActions := map[string]bool{ ActionEcho: true, ActionTransform: true, ActionGenerate: true, - ActionStream: true, ActionCollect: true, ActionBroadcast: true, + ActionStream: true, } if !validActions[info.Action] { return MethodInfo{}, fmt.Errorf("invalid action %q in method %q: must be one of: %s", @@ -141,13 +134,16 @@ func ParseMethod(method string) (MethodInfo, error) { info.Modifier = parts[2] // Validate modifier validModifiers := map[string]bool{ - ModifierNotify: true, ModifierError: true, ModifierValidate: true, ModifierFinal: true, ModifierIDMap: true, + ModifierNotify: true, ModifierError: true, ModifierValidate: true, ModifierIDMap: true, } if !validModifiers[info.Modifier] { return MethodInfo{}, fmt.Errorf("invalid modifier %q in method %q: must be one of: %s", info.Modifier, method, strings.Join(getMapKeys(validModifiers), ", ")) } } + if info.Action == ActionStream && !info.IsSSE() { + return MethodInfo{}, fmt.Errorf("streaming method %q must end with _sse", method) + } return info, nil } @@ -178,41 +174,9 @@ func (info MethodInfo) IsSSE() bool { return info.Transport == "sse" } -// IsWebSocket returns true if this method uses WebSocket transport -func (info MethodInfo) IsWebSocket() bool { - return info.Transport == "ws" -} - // IsStreaming returns true if this method involves streaming func (info MethodInfo) IsStreaming() bool { - return info.IsSSE() || info.IsWebSocket() || info.Action == ActionStream || info.Action == ActionCollect || info.Action == ActionBroadcast -} - -// HasStreamingResult returns true if this method streams results -func (info MethodInfo) HasStreamingResult() bool { - if info.IsSSE() { - return true // SSE always streams results - } - if info.IsWebSocket() { - // WebSocket methods can stream results based on action - return info.Action == ActionStream || info.Action == ActionBroadcast || - info.Action == ActionEcho || info.Action == ActionTransform || info.Action == ActionGenerate || - info.Action == ActionCollect - } - return false -} - -// HasStreamingPayload returns true if this method streams payload -func (info MethodInfo) HasStreamingPayload() bool { - if info.IsSSE() { - return false // SSE doesn't support streaming payload - } - if info.IsWebSocket() { - // All WebSocket methods have streaming payload for bidirectional support - // This allows them to receive requests and send responses/notifications - return true - } - return false + return info.IsSSE() } // GetMethod returns the effective JSON-RPC method name to use on the wire. diff --git a/jsonrpc/integration_tests/go.mod b/jsonrpc/integration_tests/go.mod index 23bbfbe3d3..a433fd0519 100644 --- a/jsonrpc/integration_tests/go.mod +++ b/jsonrpc/integration_tests/go.mod @@ -3,7 +3,6 @@ module goa.design/goa/v3/jsonrpc/integration_tests go 1.25.0 require ( - github.com/gorilla/websocket v1.5.3 github.com/stretchr/testify v1.12.1 goa.design/goa/v3 v3.0.0 gopkg.in/yaml.v3 v3.0.1 diff --git a/jsonrpc/integration_tests/go.sum b/jsonrpc/integration_tests/go.sum index 369b4fb313..890f3eb085 100644 --- a/jsonrpc/integration_tests/go.sum +++ b/jsonrpc/integration_tests/go.sum @@ -7,8 +7,6 @@ github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= -github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= diff --git a/jsonrpc/integration_tests/harness/cli_client.go b/jsonrpc/integration_tests/harness/cli_client.go index 6a46dd275f..e4aec27272 100644 --- a/jsonrpc/integration_tests/harness/cli_client.go +++ b/jsonrpc/integration_tests/harness/cli_client.go @@ -49,8 +49,7 @@ func NewCLIClient(workDir, serverURL string) (*CLIClient, error) { func (c *CLIClient) CallMethod(ctx context.Context, service, method string, payload any) (json.RawMessage, error) { // Convert method name from snake_case to kebab-case for CLI cliMethod := strings.ReplaceAll(method, "_", "-") - - + // Build command arguments - use go run to execute the CLI // URL must come before service and method for proper flag parsing args := []string{ @@ -85,7 +84,7 @@ func (c *CLIClient) CallMethod(ctx context.Context, service, method string, payl } } // If payload is nil, don't add any body argument - let the CLI handle it - + // Create command with all args cmd = exec.CommandContext(ctx, "go", args...) cmd.Dir = c.cliPath @@ -111,7 +110,7 @@ func (c *CLIClient) CallMethod(ctx context.Context, service, method string, payl // Parse verbose output from stderr to get the raw JSON-RPC response verboseOutput := stderr.String() lines := strings.Split(verboseOutput, "\n") - + // Find the JSON-RPC response - it's the last line starting with { for i := len(lines) - 1; i >= 0; i-- { line := strings.TrimSpace(lines[i]) @@ -123,7 +122,7 @@ func (c *CLIClient) CallMethod(ctx context.Context, service, method string, payl Message string `json:"message"` } `json:"error"` } - + if err := json.Unmarshal([]byte(line), &resp); err == nil && resp.Result != nil { return resp.Result, nil } @@ -165,17 +164,16 @@ func (c *CLIClient) CallJSONRPC(ctx context.Context, request map[string]any) (js // CanHandle returns true if the CLI can handle this method func (c *CLIClient) CanHandle(method string, params any) bool { - // CLI can handle HTTP methods but not streaming - // Check if it's a streaming method by looking for WebSocket or SSE in the method name - if strings.Contains(method, "_ws") || strings.Contains(method, "_sse") { + // CLI can handle unary HTTP methods but not SSE streams. + if strings.Contains(method, "_sse") { return false } - + // CLI doesn't handle notification methods (no response expected) if strings.Contains(method, "_notify") { return false } - + // CLI can handle methods with payloads return true } diff --git a/jsonrpc/integration_tests/harness/client.go b/jsonrpc/integration_tests/harness/client.go index dfd027642b..80e0beb501 100644 --- a/jsonrpc/integration_tests/harness/client.go +++ b/jsonrpc/integration_tests/harness/client.go @@ -11,8 +11,6 @@ import ( "net/url" "strings" "time" - - "github.com/gorilla/websocket" ) // JSONRPCRequest represents a JSON-RPC 2.0 request @@ -21,7 +19,6 @@ type JSONRPCRequest struct { Method string `json:"method"` Params any `json:"params,omitempty"` ID any `json:"id,omitempty"` - HasID bool `json:"-"` // true if the id key must be included even if null } // Default values @@ -29,7 +26,6 @@ const ( DefaultHTTPTimeout = 10 * time.Second DefaultJSONRPCPath = "/jsonrpc" DefaultSSEPath = "/jsonrpc/sse" - DefaultWSPath = "/jsonrpc/ws" ) // ClientConfig holds client configuration @@ -40,14 +36,10 @@ type ClientConfig struct { JSONRPCPath string // SSEPath is the path for SSE endpoint SSEPath string - // WSPath is the path for WebSocket endpoint - WSPath string // Headers are additional headers to send with requests Headers map[string]string // HTTPClient allows using a custom HTTP client HTTPClient *http.Client - // WSDialer allows using a custom WebSocket dialer - WSDialer *websocket.Dialer } // DefaultConfig returns default client configuration @@ -56,7 +48,6 @@ func DefaultConfig() *ClientConfig { HTTPTimeout: DefaultHTTPTimeout, JSONRPCPath: DefaultJSONRPCPath, SSEPath: DefaultSSEPath, - WSPath: DefaultWSPath, Headers: make(map[string]string), } } @@ -66,8 +57,6 @@ type Client struct { baseURL *url.URL config *ClientConfig httpClient *http.Client - wsDialer *websocket.Dialer - wsConn *websocket.Conn } // NewClient creates a new JSON-RPC client @@ -89,25 +78,10 @@ func NewClient(baseURL string, config *ClientConfig) (*Client, error) { } } - // Create WebSocket dialer if not provided - wsDialer := config.WSDialer - if wsDialer == nil { - // Do not use proxies from environment variables for integration tests. - // A developer shell may have HTTP(S)_PROXY set, which breaks localhost - // WebSocket upgrades and causes "websocket: bad handshake" failures. - // - // Gorilla uses ProxyFromEnvironment in websocket.DefaultDialer; setting - // Proxy to nil disables proxy use entirely. - defaultDialer := *websocket.DefaultDialer - defaultDialer.Proxy = nil - wsDialer = &defaultDialer - } - return &Client{ baseURL: u, config: config, httpClient: httpClient, - wsDialer: wsDialer, }, nil } @@ -319,165 +293,3 @@ func (c *Client) parseSSEEvents(r io.Reader) ([]json.RawMessage, error) { return events, scanner.Err() } - -// ConnectWebSocket establishes a WebSocket connection -func (c *Client) ConnectWebSocket(ctx context.Context) error { - // Build WebSocket URL - wsURL := *c.baseURL - wsURL.Path = c.config.WSPath - - // Convert scheme - switch wsURL.Scheme { - case "http": - wsURL.Scheme = "ws" - case "https": - wsURL.Scheme = "wss" - default: - // Keep as is (might already be ws/wss) - } - - // Set headers - headers := http.Header{} - for k, v := range c.config.Headers { - headers.Set(k, v) - } - - conn, resp, err := c.wsDialer.DialContext(ctx, wsURL.String(), headers) - if err != nil { - if resp != nil { - if resp.Body == nil { - return fmt.Errorf("websocket dial failed (status %d): %w", resp.StatusCode, err) - } - body, readErr := io.ReadAll(resp.Body) - _ = resp.Body.Close() - if readErr != nil { - return fmt.Errorf("websocket dial failed (status %d): %w", resp.StatusCode, err) - } - return fmt.Errorf("websocket dial failed (status %d): %w: %s", resp.StatusCode, err, string(body)) - } - return fmt.Errorf("websocket dial failed: %w", err) - } - if resp != nil && resp.Body != nil { - defer resp.Body.Close() //nolint:errcheck - } - - c.wsConn = conn - return nil -} - -// SendWebSocket sends a JSON-RPC request over WebSocket -func (c *Client) SendWebSocket(ctx context.Context, req JSONRPCRequest) error { - if c.wsConn == nil { - return fmt.Errorf("websocket not connected") - } - - // Build JSON-RPC request envelope - envelope := map[string]any{} - // Allow tests to omit the method by passing "-" (treated as missing field) - if req.Method != "-" && req.Method != "" { - envelope["method"] = req.Method - } - - // Add jsonrpc field if provided, or default to "2.0" - if req.JSONRPC != nil { - if *req.JSONRPC != "" { - envelope["jsonrpc"] = *req.JSONRPC - } - // If JSONRPC is explicitly set to empty string, omit the field - } else { - // Default behavior: include "jsonrpc": "2.0" - envelope["jsonrpc"] = "2.0" - } - - if req.Params != nil { - envelope["params"] = req.Params - } - // Preserve explicit id presence, even if null - if req.HasID { - envelope["id"] = req.ID - } else if req.ID != nil { - envelope["id"] = req.ID - } - - data, err := json.Marshal(envelope) - if err != nil { - return fmt.Errorf("failed to marshal request: %w", err) - } - - // Set write deadline from context - if deadline, ok := ctx.Deadline(); ok { - if err := c.wsConn.SetWriteDeadline(deadline); err != nil { - return fmt.Errorf("failed to set write deadline: %w", err) - } - } - - return c.wsConn.WriteMessage(websocket.TextMessage, data) -} - -// ReceiveWebSocket receives a message from WebSocket -func (c *Client) ReceiveWebSocket(ctx context.Context) (json.RawMessage, error) { - if c.wsConn == nil { - return nil, fmt.Errorf("websocket not connected") - } - - // Set read deadline from context - if deadline, ok := ctx.Deadline(); ok { - if err := c.wsConn.SetReadDeadline(deadline); err != nil { - return nil, fmt.Errorf("failed to set read deadline: %w", err) - } - } - - messageType, data, err := c.wsConn.ReadMessage() - if err != nil { - // Retry once on abnormal closure to tolerate immediate server close after response - if websocket.IsUnexpectedCloseError(err, websocket.CloseAbnormalClosure) || strings.Contains(err.Error(), "unexpected EOF") { - // Briefly wait and retry a single read within the same deadline window - time.Sleep(10 * time.Millisecond) - messageType, data, err = c.wsConn.ReadMessage() - } - if err != nil { - return nil, err - } - } - - if messageType != websocket.TextMessage { - return nil, fmt.Errorf("unexpected message type: %d", messageType) - } - - return json.RawMessage(data), nil -} - -// CloseWebSocket closes the WebSocket connection gracefully -func (c *Client) CloseWebSocket() error { - if c.wsConn == nil { - return nil - } - - // Send close message - deadline := time.Now().Add(5 * time.Second) - closeMsg := websocket.FormatCloseMessage(websocket.CloseNormalClosure, "") - err := c.wsConn.WriteControl(websocket.CloseMessage, closeMsg, deadline) - - // Always close the connection - closeErr := c.wsConn.Close() - c.wsConn = nil - - // Ignore "broken pipe" errors on close - the server may have already closed - if err != nil && strings.Contains(err.Error(), "broken pipe") { - err = nil - } - if closeErr != nil && strings.Contains(closeErr.Error(), "broken pipe") { - closeErr = nil - } - - // Return the first error - if err != nil { - return err - } - return closeErr -} - -// IsConnected returns true if WebSocket is connected -func (c *Client) IsConnected() bool { - return c.wsConn != nil -} diff --git a/jsonrpc/integration_tests/scenarios/scenarios.yaml b/jsonrpc/integration_tests/scenarios/scenarios.yaml index c9aaca982f..90b9cf8794 100644 --- a/jsonrpc/integration_tests/scenarios/scenarios.yaml +++ b/jsonrpc/integration_tests/scenarios/scenarios.yaml @@ -181,7 +181,7 @@ scenarios: message: "Invalid params" - # SSE streaming without final response + # SSE streams with event notifications and a terminal null result - name: "stream_object_sse" method: "stream_object_sse" transport: "sse" @@ -217,38 +217,11 @@ scenarios: field2: 3 field3: true # Last item is true - # SSE streaming with final response - - name: "stream_string_final_sse" - method: "stream_string_final_sse" - transport: "sse" - request: - params: "abc" # Length 3 = 3 notifications - id: "sse-1" - sequence: - - type: "receive" - expect: - jsonrpc: "2.0" - method: "stream_string_final_sse" - params: - value: "Stream 1 of 3" - - type: "receive" - expect: - jsonrpc: "2.0" - method: "stream_string_final_sse" - params: - value: "Stream 2 of 3" - - type: "receive" - expect: - jsonrpc: "2.0" - method: "stream_string_final_sse" - params: - value: "Stream 3 of 3" - type: "receive" expect: jsonrpc: "2.0" - id: "sse-1" - result: - value: "Final response" + id: "sse-2" + result: null # SSE additional streaming tests - name: "stream_array_sse" @@ -271,6 +244,11 @@ scenarios: method: "stream_array_sse" params: items: ["Processing: second"] + - type: "receive" + expect: + jsonrpc: "2.0" + id: "sse-array-1" + result: null - name: "stream_map_sse" method: "stream_map_sse" @@ -298,6 +276,11 @@ scenarios: data: key: "key2" value: "value2" + - type: "receive" + expect: + jsonrpc: "2.0" + id: "sse-map-1" + result: null - name: "stream_string_sse" method: "stream_string_sse" @@ -319,111 +302,11 @@ scenarios: params: value: "Stream 2 of 2" - # SSE with final modifier - stream then send final response - - name: "stream_array_final_sse" - method: "stream_array_final_sse" - transport: "sse" - request: - params: - items: ["item1", "item2"] - id: "sse-array-final-1" - sequence: - - type: "receive" - expect: - jsonrpc: "2.0" - method: "stream_array_final_sse" - params: - items: ["Processing: item1"] - - type: "receive" - expect: - jsonrpc: "2.0" - method: "stream_array_final_sse" - params: - items: ["Processing: item2"] - type: "receive" expect: jsonrpc: "2.0" - id: "sse-array-final-1" - result: - items: ["completed"] - - - name: "stream_object_final_sse" - method: "stream_object_final_sse" - transport: "sse" - request: - params: - field1: "start" - field2: 3 # Count of notifications before final - field3: false - id: "sse-obj-final-1" - sequence: - - type: "receive" - expect: - jsonrpc: "2.0" - method: "stream_object_final_sse" - params: - field1: "start-1" - field2: 1 - field3: false - - type: "receive" - expect: - jsonrpc: "2.0" - method: "stream_object_final_sse" - params: - field1: "start-2" - field2: 2 - field3: false - - type: "receive" - expect: - jsonrpc: "2.0" - method: "stream_object_final_sse" - params: - field1: "start-3" - field2: 3 - field3: true # Last is true - - type: "receive" - expect: - jsonrpc: "2.0" - id: "sse-obj-final-1" - result: - field1: "completed" - field2: 100 - field3: true - - - name: "stream_map_final_sse" - method: "stream_map_final_sse" - transport: "sse" - request: - params: - data: - first: "value1" - second: "value2" - id: "sse-map-final-1" - sequence: - - type: "receive" - expect: - jsonrpc: "2.0" - method: "stream_map_final_sse" - params: - data: - key: "first" - value: "value1" - - type: "receive" - expect: - jsonrpc: "2.0" - method: "stream_map_final_sse" - params: - data: - key: "second" - value: "value2" - - type: "receive" - expect: - jsonrpc: "2.0" - id: "sse-map-final-1" - result: - data: - status: "completed" - final: true + id: "sse-string-1" + result: null # SSE error scenario - stream then error - name: "stream_string_error_sse" @@ -450,35 +333,17 @@ scenarios: jsonrpc: "2.0" id: "sse-err-1" error: - code: -32602 + code: -32603 message: "Streaming error occurred" - # SSE with no ID (pure notifications) - - name: "stream_string_notify_sse" - method: "stream_string_notify_sse" + # A missing ID makes the message a notification. Request-only methods reject + # it without sending an event. + - name: "stream_string_missing_id_sse" + method: "stream_string_sse" transport: "sse" request: - params: "abc" # 3 chars = 3 notifications - # No ID - pure notification stream - sequence: - - type: "receive" - expect: - jsonrpc: "2.0" - method: "stream_string_notify_sse" - params: - value: "Stream 1 of 3" - - type: "receive" - expect: - jsonrpc: "2.0" - method: "stream_string_notify_sse" - params: - value: "Stream 2 of 3" - - type: "receive" - expect: - jsonrpc: "2.0" - method: "stream_string_notify_sse" - params: - value: "Stream 3 of 3" + params: "abc" + sequence: [] # SSE edge cases and additional tests # Empty array test - streams single "empty" notification @@ -497,6 +362,12 @@ scenarios: params: items: ["empty"] + - type: "receive" + expect: + jsonrpc: "2.0" + id: "sse-empty-1" + result: null + # Single string stream - one character = one notification - name: "stream_string_single_sse" method: "stream_string_sse" @@ -512,6 +383,12 @@ scenarios: params: value: "Stream 1 of 1" + - type: "receive" + expect: + jsonrpc: "2.0" + id: "sse-single-1" + result: null + # Multiple items streamed rapidly - name: "stream_string_multiple_sse" method: "stream_string_sse" @@ -551,43 +428,11 @@ scenarios: params: value: "Stream 5 of 5" - # Stream with count control then final response - - name: "stream_object_count_final_sse" - method: "stream_object_final_sse" - transport: "sse" - request: - params: - field1: "test" - field2: 2 # This controls the count of notifications - field3: true - id: "sse-mixed-1" - sequence: - # Notifications based on field2 count - - type: "receive" - expect: - jsonrpc: "2.0" - method: "stream_object_final_sse" - params: - field1: "test-1" - field2: 1 - field3: false - type: "receive" expect: jsonrpc: "2.0" - method: "stream_object_final_sse" - params: - field1: "test-2" - field2: 2 - field3: true # Last item is true - # Then the final response with ID - - type: "receive" - expect: - jsonrpc: "2.0" - id: "sse-mixed-1" - result: - field1: "completed" - field2: 100 - field3: true + id: "sse-rapid-1" + result: null # Echo test for SSE - name: "echo_string_sse" @@ -604,6 +449,12 @@ scenarios: params: value: "echo this" + - type: "receive" + expect: + jsonrpc: "2.0" + id: "sse-echo-1" + result: null + # Transform test for SSE - name: "transform_string_sse" method: "transform_string_sse" @@ -619,6 +470,12 @@ scenarios: params: value: "HELLO WORLD" + - type: "receive" + expect: + jsonrpc: "2.0" + id: "sse-transform-1" + result: null + # Generate test for SSE - name: "generate_string_sse" method: "generate_string_sse" @@ -646,520 +503,11 @@ scenarios: params: value: "generated-3" - # WebSocket tests - TODO: Fix ID field mapping for bidirectional streaming - - name: "echo_string_websocket" - method: "echo_string_ws" - transport: "websocket" - sequence: - - type: "connect" - - type: "send" - data: - jsonrpc: "2.0" - method: "echo_string_ws" - params: - id: "ws-1" - value: "hello websocket" - id: "ws-1" - - type: "receive" - expect: - jsonrpc: "2.0" - id: "ws-1" - result: - value: "hello websocket" - - type: "close" - - # WebSocket with server broadcasts - - name: "broadcast_string_websocket" - method: "broadcast_string_ws" - transport: "websocket" - sequence: - - type: "connect" - - type: "send" - data: - jsonrpc: "2.0" - method: "broadcast_string_ws" - params: - id: "subscribe" - value: "start" - id: "broadcast-1" - type: "receive" expect: jsonrpc: "2.0" - method: "broadcast_string_ws" - params: - value: "Server announcement 1" - - type: "receive" - expect: - jsonrpc: "2.0" - method: "broadcast_string_ws" - params: - value: "Server announcement 2" - - type: "close" - - # WebSocket transform tests - - name: "transform_string_websocket" - method: "transform_string_ws" - transport: "websocket" - sequence: - - type: "send" - data: - jsonrpc: "2.0" - method: "transform_string_ws" - params: - id: "ws-transform-1" - value: "hello" - id: "ws-transform-1" - - type: "receive" - expect: - jsonrpc: "2.0" - id: "ws-transform-1" - result: - value: "HELLO" - - type: "close" - - - name: "transform_object_websocket" - method: "transform_object_ws" - transport: "websocket" - sequence: - - type: "send" - data: - jsonrpc: "2.0" - method: "transform_object_ws" - params: - id: "ws-obj-1" - field1: "lower" - field2: 10 - field3: false - id: "ws-obj-1" - - type: "receive" - expect: - jsonrpc: "2.0" - id: "ws-obj-1" - result: - field1: "LOWER" - field2: 20 - field3: true - - type: "close" - - - name: "transform_map_websocket" - method: "transform_map_ws" - transport: "websocket" - sequence: - - type: "send" - data: - jsonrpc: "2.0" - method: "transform_map_ws" - params: - id: "ws-map-1" - data: - key1: "value1" - key2: "value2" - id: "ws-map-1" - - type: "receive" - expect: - jsonrpc: "2.0" - id: "ws-map-1" - result: - data: - transformed_key1: "value1" - transformed_key2: "value2" - - type: "close" - - # WebSocket generate tests - - name: "generate_string_websocket" - method: "generate_string_ws" - transport: "websocket" - sequence: - - type: "send" - data: - jsonrpc: "2.0" - method: "generate_string_ws" - params: - id: "ws-gen-1" - value: "ignored" - id: "ws-gen-1" - - type: "receive" - expect: - jsonrpc: "2.0" - id: "ws-gen-1" - result: - value: "generated-string" - - type: "close" - - - name: "generate_array_websocket" - method: "generate_array_ws" - transport: "websocket" - sequence: - - type: "send" - data: - jsonrpc: "2.0" - method: "generate_array_ws" - params: - id: "ws-gen-array-1" - items: [] # Ignored - id: "ws-gen-array-1" - - type: "receive" - expect: - jsonrpc: "2.0" - id: "ws-gen-array-1" - result: - items: ["item1", "item2", "item3"] - - type: "close" - - - name: "generate_object_websocket" - method: "generate_object_ws" - transport: "websocket" - sequence: - - type: "send" - data: - jsonrpc: "2.0" - method: "generate_object_ws" - params: - id: "ws-gen-obj-1" - field1: "" - field2: 0 - field3: false - id: "ws-gen-obj-1" - - type: "receive" - expect: - jsonrpc: "2.0" - id: "ws-gen-obj-1" - result: - field1: "generated-value1" - field2: 42 - field3: true - - type: "close" - - # WebSocket stream tests (server streaming) - - name: "stream_string_websocket" - method: "stream_string_ws" - transport: "websocket" - sequence: - - type: "send" - data: - jsonrpc: "2.0" - method: "stream_string_ws" - params: - id: "ws-stream-1" - value: "ab" # 2 chars = 2 messages - id: "ws-stream-1" - - type: "receive" - expect: - jsonrpc: "2.0" - method: "stream_string_ws" - params: - value: "Stream 1 of 2" - - type: "receive" - expect: - jsonrpc: "2.0" - method: "stream_string_ws" - params: - value: "Stream 2 of 2" - - type: "receive" - expect: - jsonrpc: "2.0" - id: "ws-stream-1" - result: - value: "completed" - - type: "close" - - - name: "stream_array_websocket" - method: "stream_array_ws" - transport: "websocket" - sequence: - - type: "send" - data: - jsonrpc: "2.0" - method: "stream_array_ws" - params: - id: "ws-stream-array-1" - items: ["first", "second"] - id: "ws-stream-array-1" - - type: "receive" - expect: - jsonrpc: "2.0" - method: "stream_array_ws" - params: - items: ["Processing: first"] - - type: "receive" - expect: - jsonrpc: "2.0" - method: "stream_array_ws" - params: - items: ["Processing: second"] - - type: "receive" - expect: - jsonrpc: "2.0" - id: "ws-stream-array-1" - result: - items: ["completed"] - - type: "close" - - - name: "stream_object_websocket" - method: "stream_object_ws" - transport: "websocket" - sequence: - - type: "send" - data: - jsonrpc: "2.0" - method: "stream_object_ws" - params: - id: "ws-stream-obj-1" - field1: "test" - field2: 2 # Controls stream count - field3: false - id: "ws-stream-obj-1" - - type: "receive" - expect: - jsonrpc: "2.0" - method: "stream_object_ws" - params: - field1: "test-1" - field2: 1 - field3: false - - type: "receive" - expect: - jsonrpc: "2.0" - method: "stream_object_ws" - params: - field1: "test-2" - field2: 2 - field3: true - - type: "receive" - expect: - jsonrpc: "2.0" - id: "ws-stream-obj-1" - result: - field1: "completed" - field2: 100 - field3: true - - type: "close" - - # WebSocket error handling tests - - name: "echo_string_error_websocket" - method: "echo_string_error_ws" - transport: "websocket" - sequence: - - type: "send" - data: - jsonrpc: "2.0" - method: "echo_string_error_ws" - params: - id: "ws-error-1" - value: "will fail" - id: "ws-error-1" - - type: "receive" - expect: - jsonrpc: "2.0" - id: "ws-error-1" - error: - code: -32602 - message: "Invalid params" - - type: "close" - - - name: "stream_string_error_websocket" - method: "stream_string_error_ws" - transport: "websocket" - sequence: - - type: "send" - data: - jsonrpc: "2.0" - method: "stream_string_error_ws" - params: - id: "ws-stream-error-1" - value: "fail" - id: "ws-stream-error-1" - - type: "receive" - expect: - jsonrpc: "2.0" - method: "stream_string_error_ws" - params: - value: "Stream 1 of 4" - - type: "receive" - expect: - jsonrpc: "2.0" - method: "stream_string_error_ws" - params: - value: "Stream 2 of 4" - - type: "receive" - expect: - jsonrpc: "2.0" - id: "ws-stream-error-1" - error: - code: -32602 - message: "Streaming error occurred" - - type: "close" - - # WebSocket protocol compliance: request vs notification (JSON-RPC version and method presence) - - name: "websocket_invalid_version_request" - method: "echo_string_ws" - transport: "websocket" - sequence: - - type: "send" - data: - jsonrpc: "-" # Omit version field - method: "echo_string_ws" - params: - id: "ws-invalid-1" - value: "ignore" - id: "ws-invalid-1" - - type: "receive" - expect: - jsonrpc: "2.0" - id: "ws-invalid-1" - error: - code: -32600 - message: "Invalid request" - - - name: "websocket_invalid_version_notification" - method: "echo_string_ws" - transport: "websocket" - sequence: - - type: "send" - data: - jsonrpc: "-" # Omit version field - method: "echo_string_ws" - params: - value: "notification without version" - # No receive expected for notifications with invalid request - - - name: "websocket_missing_method_with_id" - transport: "websocket" - sequence: - - type: "send" - data: - jsonrpc: "2.0" - method: "-" # Omit method field - params: - value: "test" - id: "ws-missing-method-1" - - type: "receive" - expect: - jsonrpc: "2.0" - id: "ws-missing-method-1" - error: - code: -32600 - message: "Invalid request" - - - name: "websocket_missing_method_notification" - transport: "websocket" - sequence: - - type: "send" - data: - jsonrpc: "2.0" - method: "-" # Omit method field - params: - value: "notification with missing method" - # No receive expected - - - name: "websocket_method_not_found" - transport: "websocket" - sequence: - - type: "send" - data: - jsonrpc: "2.0" - method: "non_existent_method_ws" - params: - value: "test" - id: "ws-not-found-1" - - type: "receive" - expect: - jsonrpc: "2.0" - id: "ws-not-found-1" - error: - code: -32601 - message: "Method not found" - - # WebSocket notification tests (no response expected) - - name: "echo_string_notify_websocket" - method: "echo_string_notify_ws" - transport: "websocket" - sequence: - - type: "send" - data: - jsonrpc: "2.0" - method: "echo_string_notify_ws" - params: - value: "notification" - # No id field - this is a notification - - type: "send" - data: - jsonrpc: "2.0" - method: "echo_string_notify_ws" - params: - value: "another notification" - # No id field - # No receive expected for notifications - - type: "close" - - # WebSocket validation test - - name: "echo_object_validate_websocket" - method: "echo_object_validate_ws" - transport: "websocket" - sequence: - - type: "send" - data: - jsonrpc: "2.0" - method: "echo_object_validate_ws" - params: - id: "ws-validate-1" - field1: "" # Empty string might fail validation - field2: -1 # Negative number might fail validation - field3: true - id: "ws-validate-1" - - type: "receive" - expect: - jsonrpc: "2.0" - id: "ws-validate-1" - error: - code: -32602 - message: "validation error" - - type: "close" - - # WebSocket bidirectional streaming - - name: "collect_array_websocket" - method: "collect_array_ws" - transport: "websocket" - sequence: - - type: "send" - data: - method: "collect_array_ws" - params: - id: "collect-1" - items: ["first"] - id: "collect-1" - - type: "receive" - expect: - jsonrpc: "2.0" - id: "collect-1" - result: - items: ["first"] - - type: "send" - data: - method: "collect_array_ws" - params: - id: "collect-2" - items: ["second"] - id: "collect-2" - - type: "receive" - expect: - jsonrpc: "2.0" - id: "collect-2" - result: - items: ["first", "second"] - - type: "send" - data: - method: "collect_array_ws" - params: - id: "collect-3" - items: ["third"] - id: "collect-3" - - type: "receive" - expect: - jsonrpc: "2.0" - id: "collect-3" - result: - items: ["first", "second", "third"] + id: "sse-generate-1" + result: null # Batch request tests - name: "batch_mixed_requests" @@ -1170,9 +518,9 @@ scenarios: id: "batch-1" - method: "generate_array" id: "batch-2" - - method: "echo_string" + - method: "echo_string_notify" params: "notification in batch" - # No ID for notifications + # Declared notifications omit the ID. expect_batch: - id: "batch-1" result: "hello" @@ -1219,13 +567,12 @@ scenarios: # Edge case tests - name: "null_id" transport: "http" - request: - method: "echo_string" - params: "test with null id" - id: null + raw_request: '{"jsonrpc":"2.0","method":"echo_string","params":["test with null id"],"id":null}' expect: id: null - result: "test with null id" + error: + code: -32600 + message: "Invalid request" - name: "numeric_string_id" transport: "http" @@ -1292,6 +639,12 @@ scenarios: params: value: "Stream 4 of 4" + - type: "receive" + expect: + jsonrpc: "2.0" + id: "mixed-sse-1" + result: null + # Mixed HTTP (CLI) and JSON-RPC (direct) for the same method - name: "mixed_http_cli_echo" method: "echo_string" @@ -1332,24 +685,25 @@ scenarios: # (to be added in harness/runner extensions if we choose to assert both paths here). # Additional HTTP protocol edge cases - - name: "http_invalid_version_notification" + - name: "http_invalid_version_request" transport: "http" request: jsonrpc: "-" # Omit version field method: "echo_string" params: "notify" - # No id -> notification + # No ID. The malformed object is still an invalid request, not a notification. expect: - no_response: true + error: + code: -32600 + message: "Invalid request" - - name: "http_missing_method_notification" + - name: "http_missing_method_request" transport: "http" - request: - jsonrpc: "2.0" - # No method field (invalid request) but notification (no id) - params: "notify" + raw_request: '{"jsonrpc":"2.0","params":["notify"]}' expect: - no_response: true + error: + code: -32600 + message: "Missing method field" - name: "http_missing_method_with_id_raw" transport: "http" @@ -1360,49 +714,6 @@ scenarios: code: -32600 message: "Invalid request" - # Additional WebSocket ID-type coverage - - name: "websocket_null_id" - method: "echo_string_ws" - transport: "websocket" - sequence: - - type: "connect" - - type: "send" - data: - jsonrpc: "2.0" - method: "echo_string_ws" - params: - id: null - value: "null id" - id: null - - type: "receive" - expect: - jsonrpc: "2.0" - id: null - result: - value: "null id" - - type: "close" - - - name: "websocket_numeric_id" - method: "echo_string_ws" - transport: "websocket" - sequence: - - type: "connect" - - type: "send" - data: - jsonrpc: "2.0" - method: "echo_string_ws" - params: - id: 42 - value: "number id" - id: 42 - - type: "receive" - expect: - jsonrpc: "2.0" - id: 42 - result: - value: "number id" - - type: "close" - # Additional SSE protocol edge cases - name: "sse_invalid_version_with_id" method: "echo_string_sse" @@ -1450,13 +761,11 @@ scenarios: request: jsonrpc: "2.0" params: - id: "12345" - request_id: "ignored-by-client" # mapping should come from envelope, not this field value: "hello" id: "env-1" expect: id: "env-1" - result: "hello" + result: "env-1" - name: "http_params_id_with_request_id_mapping_numeric_env_id" method: "echo_string_idmap" @@ -1464,13 +773,11 @@ scenarios: request: jsonrpc: "2.0" params: - id: "biz-123" - request_id: "ignored" value: "hello-num" id: 101 expect: id: 101 - result: "hello-num" + result: "101" - name: "sse_params_id_with_request_id_mapping" method: "stream_string_sse" @@ -1492,47 +799,8 @@ scenarios: method: "stream_string_sse" params: value: "Stream 2 of 2" - - - name: "websocket_params_id_with_request_id_mapping" - method: "echo_string_idmap_ws" - transport: "websocket" - sequence: - - type: "connect" - - type: "send" - data: - jsonrpc: "2.0" - method: "echo_string_idmap_ws" - params: - id: "biz-42" # business-level id as string - request_id: "foo" # separate app field; mapping uses envelope id - value: "ws-hello" - id: "env-ws-1" - - type: "receive" - expect: - jsonrpc: "2.0" - id: "env-ws-1" - result: - value: "ws-hello" - - type: "close" - - - name: "websocket_params_id_with_request_id_mapping_numeric_env_id" - method: "echo_string_idmap_ws" - transport: "websocket" - sequence: - - type: "connect" - - type: "send" - data: - jsonrpc: "2.0" - method: "echo_string_idmap_ws" - params: - id: "biz-456" - request_id: "foo" - value: "ws-hello-num" - id: 202 - type: "receive" expect: jsonrpc: "2.0" - id: 202 - result: - value: "ws-hello-num" - - type: "close" \ No newline at end of file + id: "env-sse-1" + result: null diff --git a/jsonrpc/types.go b/jsonrpc/types.go index 74155e98d8..4133773d2b 100644 --- a/jsonrpc/types.go +++ b/jsonrpc/types.go @@ -1,9 +1,12 @@ +// This file defines the JSON-RPC messages shared by generated clients and +// servers. It preserves request IDs exactly so a server can return the value +// it received and a client can reject a response for another request. package jsonrpc import ( + "bytes" "encoding/json" "fmt" - "strconv" ) type ( @@ -36,10 +39,16 @@ type ( Method string `json:"method"` Params json.RawMessage `json:"params,omitempty"` ID any `json:"id"` - // HasID is true when the "id" key is present in the incoming JSON (even if null). - // It is consumed by generated templates (WebSocket/SSE/HTTP) to decide whether - // to send a response for this request. Do not remove even if unused by this package. + // Invalid is true when the JSON value is not shaped like a JSON-RPC + // request object. + Invalid bool `json:"-"` + // HasID is true when the "id" key is present in the incoming JSON, even + // when its value is null. Generated servers compare it with the method's + // declared request or notification contract before calling the service. HasID bool `json:"-"` + // HasMethod is true when the "method" key is present, including when its + // value is an empty string. + HasMethod bool `json:"-"` } // RawResponse represents a JSON-RPC response with a marshalled result @@ -49,6 +58,17 @@ type ( Result json.RawMessage `json:"result,omitempty"` Error *RawErrorResponse `json:"error,omitempty"` ID any `json:"id,omitempty"` + // Invalid is true when the JSON value is not shaped like a JSON-RPC + // response object. + Invalid bool `json:"-"` + // HasResult is true when the response contains the "result" key, + // including a null result. + HasResult bool `json:"-"` + // HasError is true when the response contains the "error" key. + HasError bool `json:"-"` + // HasID is true when the response contains the "id" key, including a + // null ID. + HasID bool `json:"-"` } // RawErrorResponse represents a JSON-RPC error response with marshalled @@ -105,6 +125,20 @@ func MakeErrorResponse(id any, code Code, message string, data any) *Response { } } +// DecodeServiceErrorData decodes the data object written for a designed Goa +// error. It returns ok false and empty name and body values when data is not +// that object, so callers can preserve the original JSON-RPC error. +func DecodeServiceErrorData(data json.RawMessage) (string, json.RawMessage, bool) { + var value struct { + Name *string `json:"name"` + Body json.RawMessage `json:"body"` + } + if err := json.Unmarshal(data, &value); err != nil || value.Name == nil || len(value.Body) == 0 { + return "", nil, false + } + return *value.Name, value.Body, true +} + // MakeNotification creates a notification. func MakeNotification(method string, params any) *Request { return &Request{ @@ -114,6 +148,31 @@ func MakeNotification(method string, params any) *Request { } } +// MarshalJSON writes the result member for every success response, including +// responses whose result is null, and writes only the error for failures. +func (r *Response) MarshalJSON() ([]byte, error) { + if r.Error != nil { + return json.Marshal(struct { + JSONRPC string `json:"jsonrpc"` + Error *ErrorResponse `json:"error"` + ID any `json:"id"` + }{ + JSONRPC: r.JSONRPC, + Error: r.Error, + ID: r.ID, + }) + } + return json.Marshal(struct { + JSONRPC string `json:"jsonrpc"` + Result any `json:"result"` + ID any `json:"id"` + }{ + JSONRPC: r.JSONRPC, + Result: r.Result, + ID: r.ID, + }) +} + // Error returns a string representation of the error. func (e *ErrorResponse) Error() string { return fmt.Sprintf("jsonrpc: code %d: %s", e.Code, e.Message) @@ -124,53 +183,192 @@ func (e *RawErrorResponse) Error() string { return fmt.Sprintf("jsonrpc: code %d: %s", e.Code, e.Message) } -// IDToString converts a JSON-RPC ID to a string. -// JSON unmarshaling produces string or float64 for numeric values. -func IDToString(id any) string { +// IDToString converts a decoded JSON-RPC string or number ID to its exact text. +func IDToString(id any) (string, error) { switch v := id.(type) { case string: - return v - case float64: - return strconv.FormatFloat(v, 'f', -1, 64) + return v, nil + case json.Number: + return v.String(), nil default: - return "" + return "", fmt.Errorf("JSON-RPC id has unexpected type %T", id) + } +} + +// SinglePositionalParam returns the only value in a positional params array. +// It rejects named params and arrays that do not contain exactly one value. +func SinglePositionalParam(params json.RawMessage) (json.RawMessage, error) { + var values []json.RawMessage + if err := json.Unmarshal(params, &values); err != nil || values == nil || len(values) != 1 { + return nil, fmt.Errorf("params must be an array with exactly one value") } + return values[0], nil } -// UnmarshalJSON decodes RawRequest and records whether the id field was present. +// UnmarshalJSON decodes one request and records invalid input and ID presence. func (r *RawRequest) UnmarshalJSON(data []byte) error { + *r = RawRequest{} var raw map[string]json.RawMessage if err := json.Unmarshal(data, &raw); err != nil { + if json.Valid(data) { + r.Invalid = true + return nil + } return err } + if raw == nil { + r.Invalid = true + return nil + } + if v, ok := raw["id"]; ok { + r.HasID = true + var valid bool + r.ID, valid = decodeID(v) + if !valid { + r.Invalid = true + } + } if v, ok := raw["jsonrpc"]; ok { - if err := json.Unmarshal(v, &r.JSONRPC); err != nil { - return err + if json.Unmarshal(v, &r.JSONRPC) != nil { + r.Invalid = true } } if v, ok := raw["method"]; ok { - if err := json.Unmarshal(v, &r.Method); err != nil { - return err + r.HasMethod = true + var method *string + if json.Unmarshal(v, &method) != nil || method == nil { + r.Invalid = true + } else { + r.Method = *method } } if v, ok := raw["params"]; ok { r.Params = v + params := bytes.TrimSpace(v) + if len(params) == 0 || (params[0] != '{' && params[0] != '[') { + r.Invalid = true + } } - if v, ok := raw["id"]; ok { + return nil +} + +// UnmarshalJSON decodes one response and records the fields whose presence is +// required to distinguish a valid response from a zero Go value. +func (r *RawResponse) UnmarshalJSON(data []byte) error { + *r = RawResponse{} + var raw map[string]json.RawMessage + if err := json.Unmarshal(data, &raw); err != nil { + return err + } + if raw == nil { + r.Invalid = true + return nil + } + if value, ok := raw["jsonrpc"]; ok { + if err := json.Unmarshal(value, &r.JSONRPC); err != nil { + r.Invalid = true + } + } + if value, ok := raw["result"]; ok { + r.HasResult = true + r.Result = append(r.Result[:0], value...) + } + if value, ok := raw["error"]; ok { + r.HasError = true + var valid bool + r.Error, valid = decodeRawError(value) + if !valid { + r.Invalid = true + } + } + if value, ok := raw["id"]; ok { r.HasID = true - // Preserve null vs non-null values - if string(v) == "null" { - r.ID = nil - } else { - var id any - if err := json.Unmarshal(v, &id); err != nil { - return err - } - r.ID = id + var valid bool + r.ID, valid = decodeID(value) + if !valid { + r.Invalid = true + } + } + return nil +} + +// Validate checks that the response is a complete JSON-RPC 2.0 envelope for +// expectedID. Parse and invalid-request errors may use a null ID because the +// server could not recover the request ID from malformed input. +func (r *RawResponse) Validate(expectedID string) error { + if r.Invalid { + return fmt.Errorf("response is not a valid JSON-RPC object") + } + if r.JSONRPC != "2.0" { + return fmt.Errorf("response jsonrpc must be \"2.0\"") + } + if r.HasResult == r.HasError { + return fmt.Errorf("response must contain exactly one of result or error") + } + if !r.HasID { + return fmt.Errorf("response has no id") + } + if r.ID == nil { + if r.Error != nil && (r.Error.Code == int(ParseError) || r.Error.Code == int(InvalidRequest)) { + return nil + } + return fmt.Errorf("response id is null") + } + actualID, ok := r.ID.(string) + if !ok { + switch r.ID.(type) { + case json.Number, float64: + return fmt.Errorf("response id is a number") + default: + return fmt.Errorf("response is not a valid JSON-RPC object") } - } else { - r.HasID = false - r.ID = nil + } + if actualID != expectedID { + return fmt.Errorf("response id %q does not match request id %q", actualID, expectedID) } return nil } + +// decodeRawError decodes the required members of one JSON-RPC error object. +// Empty messages and any integer code remain valid values. +func decodeRawError(raw json.RawMessage) (*RawErrorResponse, bool) { + var values map[string]json.RawMessage + if err := json.Unmarshal(raw, &values); err != nil || values == nil { + //nolint:nilerr // A malformed error object is not a valid response. + return nil, false + } + code, hasCode := values["code"] + message, hasMessage := values["message"] + if !hasCode || !hasMessage { + return nil, false + } + result := new(RawErrorResponse) + if json.Unmarshal(code, &result.Code) != nil || json.Unmarshal(message, &result.Message) != nil { + //nolint:nilerr // A malformed error member is not a valid response. + return nil, false + } + if data, ok := values["data"]; ok { + result.Data = append(result.Data[:0], data...) + } + return result, true +} + +// decodeID preserves a JSON-RPC string, number, or null ID without converting +// numbers through float64. The boolean is false for every other JSON value. +func decodeID(raw json.RawMessage) (any, bool) { + if string(raw) == "null" { + return nil, true + } + decoder := json.NewDecoder(bytes.NewReader(raw)) + decoder.UseNumber() + var id any + if err := decoder.Decode(&id); err != nil { + return nil, false + } + switch id.(type) { + case string, json.Number: + return id, true + default: + return nil, false + } +} diff --git a/jsonrpc/types_test.go b/jsonrpc/types_test.go new file mode 100644 index 0000000000..ae13adfe84 --- /dev/null +++ b/jsonrpc/types_test.go @@ -0,0 +1,233 @@ +// This file checks the JSON-RPC envelope values shared by generated clients +// and servers. +package jsonrpc_test + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/require" + + "goa.design/goa/v3/jsonrpc" +) + +// TestRequestKeepsEmptyStringID checks that an explicitly selected empty +// string remains an ID instead of turning the request into a notification. +func TestRequestKeepsEmptyStringID(t *testing.T) { + encoded, err := json.Marshal(&jsonrpc.Request{ + JSONRPC: "2.0", + Method: "lookup", + ID: "", + }) + require.NoError(t, err) + require.JSONEq(t, `{"jsonrpc":"2.0","id":"","method":"lookup"}`, string(encoded)) +} + +// TestRawRequestPreservesNumericID checks that a server can repeat a numeric +// request ID without converting it through floating-point arithmetic. +func TestRawRequestPreservesNumericID(t *testing.T) { + const request = `{"jsonrpc":"2.0","id":9007199254740993123456789,"method":"lookup"}` + var decoded jsonrpc.RawRequest + require.NoError(t, json.Unmarshal([]byte(request), &decoded)) + require.True(t, decoded.HasID) + require.Equal(t, json.Number("9007199254740993123456789"), decoded.ID) + + encoded, err := json.Marshal(jsonrpc.MakeSuccessResponse(decoded.ID, nil)) + require.NoError(t, err) + require.JSONEq(t, `{"jsonrpc":"2.0","id":9007199254740993123456789,"result":null}`, string(encoded)) +} + +// TestIDToString checks the exact conversion used when a JSON-RPC ID is +// assigned to a string field in a service payload. +func TestIDToString(t *testing.T) { + tests := []struct { + name string + id any + want string + wantErr string + }{ + {name: "string", id: "call-1", want: "call-1"}, + {name: "empty string", id: ""}, + {name: "number", id: json.Number("9007199254740993123456789"), want: "9007199254740993123456789"}, + {name: "floating point number", id: float64(1), wantErr: "JSON-RPC id has unexpected type float64"}, + {name: "nil", wantErr: "JSON-RPC id has unexpected type "}, + {name: "object", id: map[string]any{}, wantErr: "JSON-RPC id has unexpected type map[string]interface {}"}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + value, err := jsonrpc.IDToString(test.id) + if test.wantErr != "" { + require.EqualError(t, err, test.wantErr) + return + } + require.NoError(t, err) + require.Equal(t, test.want, value) + }) + } +} + +// TestRawRequestRequiresStructuredParams checks the JSON-RPC rule that params, +// when present, must be an object or array. +func TestRawRequestRequiresStructuredParams(t *testing.T) { + tests := []struct { + name string + params string + invalid bool + }{ + {name: "object", params: `{}`}, + {name: "array", params: `[]`}, + {name: "string", params: `"value"`, invalid: true}, + {name: "number", params: `1`, invalid: true}, + {name: "boolean", params: `true`, invalid: true}, + {name: "null", params: `null`, invalid: true}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + request := `{"jsonrpc":"2.0","method":"lookup","params":` + test.params + `}` + var decoded jsonrpc.RawRequest + require.NoError(t, json.Unmarshal([]byte(request), &decoded)) + require.Equal(t, test.invalid, decoded.Invalid) + }) + } +} + +// TestRawRequestRecordsMethodPresence checks that a missing method can be +// distinguished from an explicitly empty method name. +func TestRawRequestRecordsMethodPresence(t *testing.T) { + tests := []struct { + name string + request string + hasMethod bool + method string + invalid bool + }{ + {name: "missing", request: `{"jsonrpc":"2.0"}`}, + {name: "empty", request: `{"jsonrpc":"2.0","method":""}`, hasMethod: true}, + {name: "named", request: `{"jsonrpc":"2.0","method":"lookup"}`, hasMethod: true, method: "lookup"}, + {name: "null", request: `{"jsonrpc":"2.0","method":null}`, hasMethod: true, invalid: true}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + var request jsonrpc.RawRequest + require.NoError(t, json.Unmarshal([]byte(test.request), &request)) + require.Equal(t, test.hasMethod, request.HasMethod) + require.Equal(t, test.method, request.Method) + require.Equal(t, test.invalid, request.Invalid) + }) + } +} + +// TestSinglePositionalParam checks the JSON-RPC array used to carry one Goa +// value without changing the value's service type. +func TestSinglePositionalParam(t *testing.T) { + tests := []struct { + name string + params string + want string + wantErr string + }{ + {name: "string", params: `["value"]`, want: `"value"`}, + {name: "object value", params: `[{"name":"value"}]`, want: `{"name":"value"}`}, + {name: "null value", params: `[null]`, want: `null`}, + {name: "object container", params: `{"name":"value"}`, wantErr: "params must be an array with exactly one value"}, + {name: "empty array", params: `[]`, wantErr: "params must be an array with exactly one value"}, + {name: "two values", params: `["one","two"]`, wantErr: "params must be an array with exactly one value"}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + value, err := jsonrpc.SinglePositionalParam(json.RawMessage(test.params)) + if test.wantErr != "" { + require.EqualError(t, err, test.wantErr) + return + } + require.NoError(t, err) + require.JSONEq(t, test.want, string(value)) + }) + } +} + +// TestDecodeServiceErrorData checks the typed data object that distinguishes a +// designed Goa error from a JSON-RPC protocol failure using the same code. +func TestDecodeServiceErrorData(t *testing.T) { + for _, test := range []struct { + name string + data string + wantName string + wantBody string + wantOK bool + }{ + {name: "structured body", data: `{"name":"busy","body":{"retryAfter":3}}`, wantName: "busy", wantBody: `{"retryAfter":3}`, wantOK: true}, + {name: "empty body", data: `{"name":"quiet","body":null}`, wantName: "quiet", wantBody: `null`, wantOK: true}, + {name: "unknown field", data: `{"name":"busy","body":{},"future":true}`, wantName: "busy", wantBody: `{}`, wantOK: true}, + {name: "missing name", data: `{"body":{}}`}, + {name: "null name", data: `{"name":null,"body":{}}`}, + {name: "wrong name type", data: `{"name":1,"body":{}}`}, + {name: "missing body", data: `{"name":"busy"}`}, + {name: "array", data: `[]`}, + {name: "invalid JSON", data: `{`}, + } { + t.Run(test.name, func(t *testing.T) { + name, body, ok := jsonrpc.DecodeServiceErrorData(json.RawMessage(test.data)) + + require.Equal(t, test.wantOK, ok) + if !test.wantOK { + require.Empty(t, name) + require.Nil(t, body) + return + } + require.Equal(t, test.wantName, name) + require.JSONEq(t, test.wantBody, string(body)) + }) + } +} + +// TestRawResponseValidation checks the full response envelope before generated +// clients decode its result or service error. +func TestRawResponseValidation(t *testing.T) { + tests := []struct { + name string + response string + expectedID string + wantErr string + }{ + {name: "matching result", response: `{"jsonrpc":"2.0","id":"call-1","result":null}`, expectedID: "call-1"}, + {name: "matching empty ID", response: `{"jsonrpc":"2.0","id":"","result":null}`}, + {name: "matching error", response: `{"jsonrpc":"2.0","id":"call-1","error":{"code":-32602,"message":"bad params"}}`, expectedID: "call-1"}, + {name: "parse error with null ID", response: `{"jsonrpc":"2.0","id":null,"error":{"code":-32700,"message":"parse error"}}`, expectedID: "call-1"}, + {name: "invalid request with null ID", response: `{"jsonrpc":"2.0","id":null,"error":{"code":-32600,"message":"invalid request"}}`, expectedID: "call-1"}, + {name: "method not found with null ID", response: `{"jsonrpc":"2.0","id":null,"error":{"code":-32601,"message":"method not found"}}`, expectedID: "call-1", wantErr: "response id is null"}, + {name: "invalid params with null ID", response: `{"jsonrpc":"2.0","id":null,"error":{"code":-32602,"message":"invalid params"}}`, expectedID: "call-1", wantErr: "response id is null"}, + {name: "internal error with null ID", response: `{"jsonrpc":"2.0","id":null,"error":{"code":-32603,"message":"internal error"}}`, expectedID: "call-1", wantErr: "response id is null"}, + {name: "server error with null ID", response: `{"jsonrpc":"2.0","id":null,"error":{"code":-32000,"message":"server error"}}`, expectedID: "call-1", wantErr: "response id is null"}, + {name: "application error with null ID", response: `{"jsonrpc":"2.0","id":null,"error":{"code":7001,"message":"application error"}}`, expectedID: "call-1", wantErr: "response id is null"}, + {name: "missing ID", response: `{"jsonrpc":"2.0","result":null}`, expectedID: "call-1", wantErr: "response has no id"}, + {name: "null result ID", response: `{"jsonrpc":"2.0","id":null,"result":null}`, expectedID: "call-1", wantErr: "response id is null"}, + {name: "numeric ID", response: `{"jsonrpc":"2.0","id":1,"result":null}`, expectedID: "1", wantErr: "response id is a number"}, + {name: "different ID", response: `{"jsonrpc":"2.0","id":"call-2","result":null}`, expectedID: "call-1", wantErr: `response id "call-2" does not match request id "call-1"`}, + {name: "missing version", response: `{"id":"call-1","result":null}`, expectedID: "call-1", wantErr: "response jsonrpc must be \"2.0\""}, + {name: "wrong version", response: `{"jsonrpc":"1.0","id":"call-1","result":null}`, expectedID: "call-1", wantErr: "response jsonrpc must be \"2.0\""}, + {name: "result and error", response: `{"jsonrpc":"2.0","id":"call-1","result":null,"error":{"code":-32603,"message":"failed"}}`, expectedID: "call-1", wantErr: "response must contain exactly one of result or error"}, + {name: "no result or error", response: `{"jsonrpc":"2.0","id":"call-1"}`, expectedID: "call-1", wantErr: "response must contain exactly one of result or error"}, + {name: "error missing code", response: `{"jsonrpc":"2.0","id":"call-1","error":{"message":"failed"}}`, expectedID: "call-1", wantErr: "response is not a valid JSON-RPC object"}, + {name: "error code is not an integer", response: `{"jsonrpc":"2.0","id":"call-1","error":{"code":"bad","message":"failed"}}`, expectedID: "call-1", wantErr: "response is not a valid JSON-RPC object"}, + {name: "error missing message", response: `{"jsonrpc":"2.0","id":"call-1","error":{"code":7}}`, expectedID: "call-1", wantErr: "response is not a valid JSON-RPC object"}, + {name: "error message is not a string", response: `{"jsonrpc":"2.0","id":"call-1","error":{"code":7,"message":false}}`, expectedID: "call-1", wantErr: "response is not a valid JSON-RPC object"}, + {name: "invalid ID type", response: `{"jsonrpc":"2.0","id":{},"result":null}`, expectedID: "call-1", wantErr: "response is not a valid JSON-RPC object"}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + var response jsonrpc.RawResponse + require.NoError(t, json.Unmarshal([]byte(test.response), &response)) + err := response.Validate(test.expectedID) + if test.wantErr == "" { + require.NoError(t, err) + return + } + require.EqualError(t, err, test.wantErr) + }) + } +} diff --git a/jsonrpc/websocket_config.go b/jsonrpc/websocket_config.go deleted file mode 100644 index 7291ba538e..0000000000 --- a/jsonrpc/websocket_config.go +++ /dev/null @@ -1,193 +0,0 @@ -package jsonrpc - -import ( - "context" - "time" -) - -type ( - // StreamErrorType represents different types of WebSocket stream errors - StreamErrorType int - - // StreamErrorHandler allows users to handle stream errors - StreamErrorHandler func(ctx context.Context, errorType StreamErrorType, err error, response *RawResponse) - - // StreamConfig contains configuration options for WebSocket streams - StreamConfig struct { - // Timeouts - RequestTimeout time.Duration // Timeout for individual requests (default: 30s) - ConnectionTimeout time.Duration // Timeout for establishing connections (default: 10s) - CloseTimeout time.Duration // Timeout for graceful stream closure (default: 5s) - - // Buffer Sizes - ResultChannelBuffer int // Buffer size for result channels (default: 1) - WriteBufferSize int // WebSocket write buffer size (default: 4096) - ReadBufferSize int // WebSocket read buffer size (default: 4096) - - // Retry Configuration - MaxRetries int // Maximum number of connection retries (default: 3) - RetryBackoffBase time.Duration // Base delay for exponential backoff (default: 1s) - RetryBackoffMax time.Duration // Maximum retry delay (default: 30s) - - // Advanced Options - EnableCompression bool // Enable WebSocket compression (default: false) - PingInterval time.Duration // Interval for sending ping frames (default: 30s) - - // Error Handling - ErrorHandler StreamErrorHandler // Optional error handler for stream events (default: nil) - } - - // StreamConfigOption is a function that modifies StreamConfig - StreamConfigOption func(*StreamConfig) -) - -const ( - StreamErrorConnection StreamErrorType = iota // WebSocket connection errors - StreamErrorProtocol // Invalid JSON-RPC protocol - StreamErrorParsing // Failed to parse/decode response - StreamErrorOrphaned // Response with no matching request - StreamErrorTimeout // Request timeout - StreamErrorNotification // Server-initiated notification received -) - -// WithRequestTimeout sets the timeout for individual requests -func WithRequestTimeout(timeout time.Duration) StreamConfigOption { - return func(c *StreamConfig) { - c.RequestTimeout = timeout - } -} - -// WithConnectionTimeout sets the timeout for establishing connections -func WithConnectionTimeout(timeout time.Duration) StreamConfigOption { - return func(c *StreamConfig) { - c.ConnectionTimeout = timeout - } -} - -// WithCloseTimeout sets the timeout for graceful stream closure -func WithCloseTimeout(timeout time.Duration) StreamConfigOption { - return func(c *StreamConfig) { - c.CloseTimeout = timeout - } -} - -// WithResultChannelBuffer sets the buffer size for result channels -func WithResultChannelBuffer(size int) StreamConfigOption { - return func(c *StreamConfig) { - c.ResultChannelBuffer = size - } -} - -// WithWebSocketBuffers sets both read and write buffer sizes -func WithWebSocketBuffers(readSize, writeSize int) StreamConfigOption { - return func(c *StreamConfig) { - c.ReadBufferSize = readSize - c.WriteBufferSize = writeSize - } -} - -// WithRetryConfig sets retry behavior parameters -func WithRetryConfig(maxRetries int, baseDelay, maxDelay time.Duration) StreamConfigOption { - return func(c *StreamConfig) { - c.MaxRetries = maxRetries - c.RetryBackoffBase = baseDelay - c.RetryBackoffMax = maxDelay - } -} - -// WithCompression enables or disables WebSocket compression -func WithCompression(enabled bool) StreamConfigOption { - return func(c *StreamConfig) { - c.EnableCompression = enabled - } -} - -// WithPingInterval sets the interval for sending ping frames -func WithPingInterval(interval time.Duration) StreamConfigOption { - return func(c *StreamConfig) { - c.PingInterval = interval - } -} - -// WithErrorHandler sets the error handler for stream events -func WithErrorHandler(handler StreamErrorHandler) StreamConfigOption { - return func(c *StreamConfig) { - c.ErrorHandler = handler - } -} - -// NewStreamConfig creates a StreamConfig with the given options -func NewStreamConfig(opts ...StreamConfigOption) *StreamConfig { - config := defaultStreamConfig() - for _, opt := range opts { - opt(config) - } - return config.Validate() -} - -// defaultStreamConfig returns a StreamConfig with sensible production defaults -func defaultStreamConfig() *StreamConfig { - return &StreamConfig{ - // Reasonable timeout defaults - RequestTimeout: 30 * time.Second, - ConnectionTimeout: 10 * time.Second, - CloseTimeout: 5 * time.Second, - - // Conservative buffer sizes - ResultChannelBuffer: 1, - WriteBufferSize: 4096, - ReadBufferSize: 4096, - - // Moderate retry behavior - MaxRetries: 3, - RetryBackoffBase: 1 * time.Second, - RetryBackoffMax: 30 * time.Second, - - // Safe advanced defaults - EnableCompression: false, - PingInterval: 30 * time.Second, - } -} - -// Validate checks the configuration and applies constraints -func (c *StreamConfig) Validate() *StreamConfig { - // Ensure positive timeouts - if c.RequestTimeout <= 0 { - c.RequestTimeout = 30 * time.Second - } - if c.ConnectionTimeout <= 0 { - c.ConnectionTimeout = 10 * time.Second - } - if c.CloseTimeout <= 0 { - c.CloseTimeout = 5 * time.Second - } - - // Ensure reasonable buffer sizes - if c.ResultChannelBuffer < 1 { - c.ResultChannelBuffer = 1 - } - if c.WriteBufferSize < 1024 { - c.WriteBufferSize = 1024 - } - if c.ReadBufferSize < 1024 { - c.ReadBufferSize = 1024 - } - - // Ensure reasonable retry configuration - if c.MaxRetries < 0 { - c.MaxRetries = 0 - } - if c.RetryBackoffBase <= 0 { - c.RetryBackoffBase = 1 * time.Second - } - if c.RetryBackoffMax < c.RetryBackoffBase { - c.RetryBackoffMax = c.RetryBackoffBase * 30 - } - - // Ensure reasonable ping interval - if c.PingInterval <= 0 { - c.PingInterval = 30 * time.Second - } - - return c -} diff --git a/pkg/error.go b/pkg/error.go index c4b89ead29..127caebbeb 100644 --- a/pkg/error.go +++ b/pkg/error.go @@ -186,13 +186,13 @@ func InvalidRangeError(name string, target, value any, min bool) error { // InvalidLengthError is the error produced by the generated code when the value // of a payload field does not match the length validation defined in the // design. -func InvalidLengthError(name string, target any, ln, value int, min bool) error { - comp := "greater or equal" +func InvalidLengthError(name string, _ any, ln, value int, min bool) error { + comp := "at least" if !min { - comp = "lesser or equal" + comp = "at most" } return withField(name, PermanentError( - InvalidLength, "length of %s must be %s than %d but got value %#v (len=%d)", name, comp, value, target, ln)) + InvalidLength, "length of %s must be %s %d but got %d", name, comp, value, ln)) } // NewErrorID creates a unique 8 character ID that is well suited to use as an diff --git a/pkg/error_test.go b/pkg/error_test.go index 023dcbbd1c..77db13871e 100644 --- a/pkg/error_test.go +++ b/pkg/error_test.go @@ -1,8 +1,12 @@ package goa import ( + "bytes" "errors" + "strings" "testing" + + "github.com/stretchr/testify/require" ) func TestServiceErrorUnwrap(t *testing.T) { @@ -48,3 +52,58 @@ func TestAsError(t *testing.T) { t.Errorf("got %#v, want %#v", se, err) } } + +func TestInvalidLengthErrorDoesNotIncludeRejectedValue(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + target any + length int + bound int + min bool + want string + secret string + }{ + { + name: "string", + target: strings.Repeat("string-secret", 1000), + length: 13000, + bound: 8, + want: "length of payload must be at most 8 but got 13000", + secret: "string-secret", + }, + { + name: "bytes", + target: bytes.Repeat([]byte{0xab}, 1<<20), + length: 1 << 20, + bound: 1024, + want: "length of payload must be at most 1024 but got 1048576", + secret: "0xab", + }, + { + name: "array", + target: []string{"array-secret", "array-secret", "array-secret"}, + length: 3, + bound: 4, + min: true, + want: "length of payload must be at least 4 but got 3", + secret: "array-secret", + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + + err := InvalidLengthError("payload", test.target, test.length, test.bound, test.min) + require.EqualError(t, err, test.want) + require.NotContains(t, err.Error(), test.secret) + require.Less(t, len(err.Error()), 128) + var serviceErr *ServiceError + require.ErrorAs(t, err, &serviceErr) + require.Equal(t, InvalidLength, serviceErr.Name) + require.NotNil(t, serviceErr.Field) + require.Equal(t, "payload", *serviceErr.Field) + }) + } +} diff --git a/pkg/version.go b/pkg/version.go index d284d0d674..efedae0de9 100644 --- a/pkg/version.go +++ b/pkg/version.go @@ -10,11 +10,11 @@ const ( // Major version number Major = 3 // Minor version number - Minor = 30 + Minor = 31 // Build number Build = 0 // Suffix - set to empty string in release tag commits. - Suffix = "" + Suffix = "preview.5" ) var (