mirror of
https://github.com/zap-proto/zip.git
synced 2026-10-19 15:38:57 +00:00
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:
@@ -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)
|
||||
}
|
||||
}
|
||||
`
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user