From e4307a936cc324c2c1223876ea5cd4d21e3d7f73 Mon Sep 17 00:00:00 2001 From: KT-Doan Date: Wed, 15 Jul 2026 14:29:45 +0900 Subject: [PATCH] fix: make TextMarshaler detection independent of the toolchain environment IsTextMarshaler resolved encoding.TextMarshaler by importing the real "encoding" package through go/importer's default importer, which locates stdlib export data by running "go list -export" against the GOROOT the binary was built against. When GOTOOLCHAIN selects a different toolchain at runtime -- or the binary runs on a machine whose Go installation lives at a different path than the build machine's -- that invocation fails with "cannot find main module", the error is silently swallowed, and every TextMarshaler check returns false. Types that should render as type: string via a MarshalText method (for example UUID types such as github.com/gofrs/uuid.UUID) then degrade to a $ref of their underlying [16]byte array, which Swagger 2.0 forbids in path parameters, so the generated spec fails validation. Synthesize the interface with go/types instead: types.Implements compares method sets structurally, so a synthesized interface{ MarshalText() ([]byte, error) } is equivalent to the imported one and removes the runtime dependency on a working "go list" entirely. Signed-off-by: KT-Doan --- internal/builders/resolvers/assertions.go | 45 ++++++++++++------- internal/builders/resolvers/resolvers_test.go | 44 ++++++++++++++++++ 2 files changed, 72 insertions(+), 17 deletions(-) diff --git a/internal/builders/resolvers/assertions.go b/internal/builders/resolvers/assertions.go index 3dfc8d5..3f7d5e1 100644 --- a/internal/builders/resolvers/assertions.go +++ b/internal/builders/resolvers/assertions.go @@ -6,7 +6,7 @@ package resolvers import ( "fmt" "go/ast" - "go/importer" + "go/token" "go/types" oaispec "github.com/go-openapi/spec" @@ -75,23 +75,34 @@ func IsFieldStringable(tpe ast.Expr) bool { return false } -func IsTextMarshaler(tpe types.Type) bool { - encoding, err := importer.Default().Import("encoding") - if err != nil { - return false - } - - iface := encoding.Scope().Lookup("TextMarshaler") - if iface == nil { - return false - } - - asInterface, ok := iface.Type().Underlying().(*types.Interface) - if !ok { - return false - } +// textMarshalerIface is the encoding.TextMarshaler interface, synthesized with go/types: +// +// interface{ MarshalText() (text []byte, err error) } +// +// It is built structurally rather than by importing the real "encoding" package through +// go/importer's default importer. That importer resolves stdlib export data by running +// "go list -export" against the GOROOT the binary was built against. When GOTOOLCHAIN +// selects a different toolchain at runtime — or the binary simply runs on a machine whose Go +// installation lives at a different path than the build machine's — that invocation fails +// ("cannot find main module"), the error is silently swallowed, and every TextMarshaler check +// returns false. types.Implements compares method sets structurally, so a synthesized, +// structurally identical interface is equivalent to the imported one and removes the runtime +// dependency on a working "go list". +var textMarshalerIface = types.NewInterfaceType([]*types.Func{ //nolint:gochecknoglobals // immutable synthesized interface, built once, read-only + types.NewFunc(token.NoPos, nil, "MarshalText", + types.NewSignatureType(nil, nil, nil, + nil, + types.NewTuple( + types.NewVar(token.NoPos, nil, "text", types.NewSlice(types.Typ[types.Byte])), + types.NewVar(token.NoPos, nil, "err", types.Universe.Lookup("error").Type()), + ), + false, + ), + ), +}, nil).Complete() - return types.Implements(tpe, asInterface) +func IsTextMarshaler(tpe types.Type) bool { + return types.Implements(tpe, textMarshalerIface) } // IsJSONMapKey reports whether a Go map with this key type marshals to a JSON object under diff --git a/internal/builders/resolvers/resolvers_test.go b/internal/builders/resolvers/resolvers_test.go index 83227ac..38d5a48 100644 --- a/internal/builders/resolvers/resolvers_test.go +++ b/internal/builders/resolvers/resolvers_test.go @@ -410,6 +410,50 @@ func TestParseFieldTag(t *testing.T) { }) } +func TestIsTextMarshaler(t *testing.T) { + pkg := types.NewPackage("example.com/x", "x") + + // namedWithMarshalText builds a named struct type carrying a value-receiver method + // MarshalText whose results are the supplied tuple. + namedWithMarshalText := func(name string, results *types.Tuple) *types.Named { + tn := types.NewTypeName(token.NoPos, pkg, name, nil) + named := types.NewNamed(tn, types.NewStruct(nil, nil), nil) + recv := types.NewVar(token.NoPos, pkg, "m", named) + sig := types.NewSignatureType(recv, nil, nil, types.NewTuple(), results, false) + named.AddMethod(types.NewFunc(token.NoPos, pkg, "MarshalText", sig)) + return named + } + + bytesAndError := types.NewTuple( + types.NewVar(token.NoPos, nil, "", types.NewSlice(types.Typ[types.Byte])), + types.NewVar(token.NoPos, nil, "", types.Universe.Lookup("error").Type()), + ) + + t.Run("MarshalText() ([]byte, error) satisfies the interface", func(t *testing.T) { + named := namedWithMarshalText("Marshaler", bytesAndError) + assert.TrueT(t, IsTextMarshaler(named)) + // A value-receiver method promotes to the pointer method set too. + assert.TrueT(t, IsTextMarshaler(types.NewPointer(named))) + }) + + t.Run("type without MarshalText does not", func(t *testing.T) { + tn := types.NewTypeName(token.NoPos, pkg, "Plain", nil) + named := types.NewNamed(tn, types.NewStruct(nil, nil), nil) + assert.FalseT(t, IsTextMarshaler(named)) + }) + + t.Run("wrong signature (no error result) does not", func(t *testing.T) { + onlyBytes := types.NewTuple( + types.NewVar(token.NoPos, nil, "", types.NewSlice(types.Typ[types.Byte])), + ) + assert.FalseT(t, IsTextMarshaler(namedWithMarshalText("Bad", onlyBytes))) + }) + + t.Run("builtin string does not", func(t *testing.T) { + assert.FalseT(t, IsTextMarshaler(types.Typ[types.String])) + }) +} + func TestMustNotBeABuiltinType(t *testing.T) { t.Run("user type does not panic", func(t *testing.T) { pkg := types.NewPackage("example.com/foo", "foo")