Doc comments reach a service whose handlers are in package main

zipdoc files an operation's prose under its package's import path. At run
time pkgOf read the package off the handler's function name, and a built
binary names package main's functions "main.", so the lookup missed: the
document, the tool list and the CLI help carried no prose. Under go test
the package keeps its import path, so every test passed.

pkgOf now reads "main" as the build info path, which is the import path
zipdoc wrote. TestProseReachesAServiceInPackageMain runs zipdoc over a
package main service, builds it and reads the document the binary
writes. It fails without the change.

gofmt realigns one struct field in typed.go, and the WithStatus comment
names the problem document instead of the envelope it replaced.

Co-authored-by: Hanzo Dev <[email protected]>
This commit is contained in:
2026-09-11 10:36:14 -07:00
co-authored by Hanzo Dev
parent 8993ea96da
commit 4f5f5a34a4
2 changed files with 148 additions and 11 deletions
+113
View File
@@ -0,0 +1,113 @@
package zip_test
import (
"encoding/json"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
)
// TestProseReachesAServiceInPackageMain runs cmd/zipdoc over a service whose
// handler is in package main, then asks the built program for its OpenAPI
// document. A test binary names package main by its import path, so only a real
// program shows whether the prose zipdoc filed is found at run time.
func TestProseReachesAServiceInPackageMain(t *testing.T) {
if testing.Short() {
t.Skip("compiles a program with the go toolchain")
}
zipDir, err := filepath.Abs(".")
if err != nil {
t.Fatal(err)
}
sum, err := os.ReadFile("go.sum")
if err != nil {
t.Fatal(err)
}
mod, err := os.ReadFile("go.mod")
if err != nil {
t.Fatal(err)
}
directive := ""
for _, line := range strings.Split(string(mod), "\n") {
if strings.HasPrefix(line, "go ") {
directive = line
break
}
}
dir := t.TempDir()
write := func(name, body string) {
if err := os.WriteFile(filepath.Join(dir, name), []byte(body), 0o644); err != nil {
t.Fatal(err)
}
}
write("go.mod", "module example.com/prose\n\n"+directive+
"\n\nrequire github.com/zap-proto/zip v1.36.47\n\nreplace github.com/zap-proto/zip => "+zipDir+"\n")
write("go.sum", string(sum))
write("main.go", proseMain)
run := func(args ...string) {
t.Helper()
cmd := exec.Command("go", args...)
cmd.Dir = dir
cmd.Env = append(os.Environ(), "GOFLAGS=-mod=mod", "GOPROXY=off")
out, err := cmd.CombinedOutput()
if err != nil {
if strings.Contains(string(out), "missing go.sum entry") || strings.Contains(string(out), "cannot find module") {
t.Skipf("module cache does not hold zip's dependencies: %s", out)
}
t.Fatalf("go %s: %v\n%s", strings.Join(args, " "), err, out)
}
}
run("run", "github.com/zap-proto/zip/cmd/zipdoc")
run("run", ".", "openapi", "openapi.json")
raw, err := os.ReadFile(filepath.Join(dir, "openapi.json"))
if err != nil {
t.Fatal(err)
}
var doc struct {
Paths map[string]map[string]struct {
Description string `json:"description"`
} `json:"paths"`
}
if err := json.Unmarshal(raw, &doc); err != nil {
t.Fatal(err)
}
if got := doc.Paths["/v1/hello"]["get"].Description; got != "Greets whoever asks." {
t.Errorf("description = %q, want the doc comment zipdoc read", got)
}
}
const proseMain = `package main
import (
"context"
"os"
"github.com/zap-proto/zip"
)
// Greeting is what Hello answers.
type Greeting struct {
Message string ` + "`json:\"message\"`" + `
}
// Empty takes nothing.
type Empty struct{}
// Hello greets whoever asks.
func Hello(context.Context, *Empty) (*Greeting, error) {
return &Greeting{Message: "hello"}, nil
}
func main() {
app := zip.New(zip.Config{AppName: "prose", DisableStartupMessage: true})
zip.Get(app, "/v1/hello", Hello)
if done, err := app.Described(); done && err != nil {
os.Exit(1)
}
}
`
+35 -11
View File
@@ -7,8 +7,10 @@ import (
"fmt"
"reflect"
"runtime"
"runtime/debug"
"strconv"
"strings"
"sync"
"github.com/zap-proto/fiber/v3"
@@ -71,7 +73,7 @@ type registeredOp struct {
// registered long before that. The invoke seam asks it on every call and the
// document asks it once — so "is this op governed" has one answer, and a
// gated op cannot publish a contract its seam does not keep.
rule func() Authorizer
rule func() Authorizer
// result is the [App.OnResult] hook in force over this op, asked the same way
// and for the same reason as rule: composition settles it at build, and the
// op was registered before that.
@@ -159,15 +161,15 @@ func WithOperationID(id string) OpOption {
// A declared non-2xx is for an op that answers a failure with its OWN TYPED
// BODY — a 409 carrying the conflicting record, a 404 carrying what was
// searched for. It is not a second way to spell an error: [ErrNotFound] and
// friends remain how a handler returns a failure, and they render the standard
// {status, code, error} envelope every client already parses.
// friends remain how a handler returns a failure, and they render the RFC 9457
// problem document every refusal is written in.
//
// The difference is which body reaches the wire. An error returns the envelope;
// a declared status returns the op's Out type, described in the document under
// that code like any other response. Reach for the error unless the caller
// genuinely needs a typed body it cannot get from the envelope — two ways to
// spell the same failure is exactly the drift this package spends its doc
// comments avoiding.
// The difference is which body reaches the wire. An error returns the problem
// document; a declared status returns the op's Out type, described in the
// document under that code like any other response. Reach for the error unless
// the caller genuinely needs a typed body a problem document cannot carry — two
// ways to spell the same failure is exactly the drift this package spends its
// doc comments avoiding.
func WithStatus(codes ...int) OpOption {
if len(codes) == 0 {
panic("zip: WithStatus needs at least one status")
@@ -586,20 +588,42 @@ var textReader = reflect.TypeOf((*encoding.TextUnmarshaler)(nil)).Elem()
// names a function fully — "github.com/luxfi/node/vms/platformvm.(*Service).getHeight"
// — and the package ends at the last '/' segment's first '.', which is the one
// place a path cannot have one.
//
// Package main is the exception: a built binary names its functions
// "main.(*Store).Add", while cmd/zipdoc files their prose under the package's
// import path. The build info carries that path, so a service whose handlers
// live in package main finds its own doc comments. Under go test the package
// keeps its import path and nothing is substituted.
func pkgOf(fn any) string {
at := runtime.FuncForPC(reflect.ValueOf(fn).Pointer())
if at == nil {
return ""
}
full := at.Name()
return packageOf(at.Name(), mainPath())
}
// packageOf reads the package out of a runtime function name. program is the
// import path that "main" stands for.
func packageOf(full, program string) string {
slash := strings.LastIndexByte(full, '/')
dot := strings.IndexByte(full[slash+1:], '.')
if dot < 0 {
return full
}
return full[:slash+1+dot]
if pkg := full[:slash+1+dot]; pkg != "main" {
return pkg
}
return program
}
// mainPath is the import path of this binary's package main.
var mainPath = sync.OnceValue(func() string {
if bi, ok := debug.ReadBuildInfo(); ok && bi.Path != "" {
return bi.Path
}
return "main"
})
func registerTyped[In, Out any](on OpTarget, method, path string, fn TypedHandler[In, Out], opts ...OpOption) {
scope := on.OpScope()
app := scope.App