Compare commits

..

11 Commits

Author SHA1 Message Date
林桉 925f8a2bcc fix(goctl): recurse into inline struct in IsTagMember (#5671)
Co-authored-by: kevin <wanjunfeng@gmail.com>
2026-07-31 17:19:17 +00:00
Qiu shao 36f2619756 perf(collection): optimize queue growth (#5704) 2026-07-31 15:57:23 +00:00
dependabot[bot] 84313f2e92 chore(deps): bump actions/setup-go from 6 to 7 (#5694)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-21 01:16:50 +00:00
lxffong 394ffcc19a fix(swagger): expand inline pointer members (#5664)
Co-authored-by: kevin <wanjunfeng@gmail.com>
2026-07-20 00:20:41 +08:00
Kevin Wan 565bcb3f21 feat(goctl): support custom Swagger response status (#5691)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-07-19 23:57:26 +08:00
dependabot[bot] 6a6b81ef20 chore(deps): bump go.mongodb.org/mongo-driver/v2 from 2.7.0 to 2.8.0 (#5688) 2026-07-17 19:27:27 +08:00
dependabot[bot] 35a7ca9d98 chore(deps): bump github.com/pelletier/go-toml/v2 from 2.4.2 to 2.4.3 (#5678) 2026-07-12 17:07:04 +08:00
Puneet Dixit f910257ec9 fix(goctl): include nested client aliases (#5627)
Co-authored-by: Deepak kudi <deepakkudi23@adsl-172-10-9-116.dsl.sndg02.sbcglobal.net>
Co-authored-by: kevin <wanjunfeng@gmail.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-06-27 16:09:03 +00:00
YangWJ d318de1212 fix(mapping): correct unmarshaling of pointer-to-slice fields (#5662)
Co-authored-by: kevin <wanjunfeng@gmail.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-06-27 14:43:41 +00:00
Kevin Wan 99515480cf chore(redis): minor refactor (#5666) 2026-06-27 19:18:46 +08:00
shenbaise9527 dbc71bb57b fix(redis): circuit breaking under high concurrency (#5640) (#5654) 2026-06-27 18:57:26 +08:00
36 changed files with 1926 additions and 267 deletions
+2 -2
View File
@@ -15,7 +15,7 @@ jobs:
uses: actions/checkout@v7
- name: Set up Go 1.x
uses: actions/setup-go@v6
uses: actions/setup-go@v7
with:
go-version-file: go.mod
check-latest: true
@@ -55,7 +55,7 @@ jobs:
uses: actions/checkout@v7
- name: Set up Go 1.x
uses: actions/setup-go@v6
uses: actions/setup-go@v7
with:
# make sure Go version compatible with go-zero
go-version-file: go.mod
+1 -1
View File
@@ -6,7 +6,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/setup-go@v6
- uses: actions/setup-go@v7
with:
go-version-file: go.mod
check-latest: true
+1 -1
View File
@@ -13,7 +13,7 @@ jobs:
- uses: actions/checkout@v7
- name: Set up Go
uses: actions/setup-go@v6
uses: actions/setup-go@v7
with:
go-version: '1.21'
+22 -7
View File
@@ -2,11 +2,12 @@ package collection
import "sync"
const queueGrowThreshold = 256
// A Queue is a FIFO queue.
type Queue struct {
lock sync.Mutex
elements []any
size int
head int
tail int
count int
@@ -14,9 +15,12 @@ type Queue struct {
// NewQueue returns a Queue object.
func NewQueue(size int) *Queue {
if size < 1 {
panic("size must be greater than 0")
}
return &Queue{
elements: make([]any, size),
size: size,
}
}
@@ -34,12 +38,12 @@ func (q *Queue) Put(element any) {
q.lock.Lock()
defer q.lock.Unlock()
if q.head == q.tail && q.count > 0 {
nodes := make([]any, len(q.elements)+q.size)
copy(nodes, q.elements[q.head:])
copy(nodes[len(q.elements)-q.head:], q.elements[:q.head])
if q.count == len(q.elements) {
nodes := make([]any, nextQueueCapacity(len(q.elements)))
n := copy(nodes, q.elements[q.head:])
copy(nodes[n:], q.elements[:q.head])
q.head = 0
q.tail = len(q.elements)
q.tail = q.count
q.elements = nodes
}
@@ -58,8 +62,19 @@ func (q *Queue) Take() (any, bool) {
}
element := q.elements[q.head]
q.elements[q.head] = nil
q.head = (q.head + 1) % len(q.elements)
q.count--
return element, true
}
func nextQueueCapacity(capacity int) int {
if capacity < queueGrowThreshold {
return capacity << 1
}
// Use a growth curve similar to Go slices: double small queues, then
// transition smoothly toward 1.25x growth for larger queues.
return capacity + ((capacity + 3*queueGrowThreshold) >> 2)
}
+245 -78
View File
@@ -6,96 +6,263 @@ import (
"github.com/stretchr/testify/assert"
)
func TestFifo(t *testing.T) {
elements := [][]byte{
[]byte("hello"),
[]byte("world"),
[]byte("again"),
}
queue := NewQueue(8)
for i := range elements {
queue.Put(elements[i])
func TestQueueOrder(t *testing.T) {
tests := []struct {
name string
size int
initial []int
takeBefore int
additional []int
wantCapacity int
}{
{
name: "within initial capacity",
size: 8,
initial: []int{1, 2, 3},
wantCapacity: 8,
},
{
name: "grow from beginning",
size: 2,
initial: []int{1, 2, 3},
wantCapacity: 4,
},
{
name: "grow after wrapping",
size: 4,
initial: []int{1, 2, 3, 4},
takeBefore: 1,
additional: []int{5, 6},
wantCapacity: 8,
},
{
name: "grow repeatedly",
size: 1,
initial: sequence(20),
wantCapacity: 32,
},
{
name: "grow above threshold",
size: queueGrowThreshold,
initial: sequence(queueGrowThreshold + 1),
wantCapacity: queueGrowThreshold * 2,
},
{
name: "grow well above threshold",
size: 1024,
initial: sequence(1025),
wantCapacity: 1472,
},
}
for _, element := range elements {
body, ok := queue.Take()
assert.True(t, ok)
assert.Equal(t, string(element), string(body.([]byte)))
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
queue := NewQueue(test.size)
assert.True(t, queue.Empty())
for _, value := range test.initial {
queue.Put(value)
}
for i := 0; i < test.takeBefore; i++ {
value, ok := queue.Take()
assert.True(t, ok)
assert.Equal(t, test.initial[i], value)
}
for _, value := range test.additional {
queue.Put(value)
}
want := append([]int(nil), test.initial[test.takeBefore:]...)
want = append(want, test.additional...)
for _, expected := range want {
actual, ok := queue.Take()
assert.True(t, ok)
assert.Equal(t, expected, actual)
}
assert.Equal(t, test.wantCapacity, len(queue.elements))
assert.True(t, queue.Empty())
_, ok := queue.Take()
assert.False(t, ok)
})
}
}
func TestTakeTooMany(t *testing.T) {
elements := [][]byte{
[]byte("hello"),
[]byte("world"),
[]byte("again"),
}
queue := NewQueue(8)
for i := range elements {
queue.Put(elements[i])
func TestQueueTakeClearsElement(t *testing.T) {
tests := []struct {
name string
size int
operations string
}{
{
name: "take from beginning",
size: 2,
operations: "ppt",
},
{
name: "take after wrapping",
size: 2,
operations: "pptptt",
},
{
name: "take after growing",
size: 2,
operations: "pppt",
},
}
for range elements {
queue.Take()
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
queue := NewQueue(test.size)
value := 0
assert.True(t, queue.Empty())
_, ok := queue.Take()
assert.False(t, ok)
}
func TestPutMore(t *testing.T) {
elements := [][]byte{
[]byte("hello"),
[]byte("world"),
[]byte("again"),
}
queue := NewQueue(2)
for i := range elements {
queue.Put(elements[i])
}
for _, element := range elements {
body, ok := queue.Take()
assert.True(t, ok)
assert.Equal(t, string(element), string(body.([]byte)))
for _, operation := range test.operations {
switch operation {
case 'p':
value++
element := new(int)
*element = value
queue.Put(element)
case 't':
index := queue.head
_, ok := queue.Take()
assert.True(t, ok)
assert.Nil(t, queue.elements[index])
default:
t.Fatalf("unknown operation: %q", operation)
}
}
})
}
}
func TestPutMoreWithHeaderNotZero(t *testing.T) {
elements := [][]byte{
[]byte("hello"),
[]byte("world"),
[]byte("again"),
}
queue := NewQueue(4)
for i := range elements {
queue.Put(elements[i])
func TestNewQueueWithInvalidSize(t *testing.T) {
tests := []struct {
name string
size int
}{
{
name: "zero",
},
{
name: "negative",
size: -1,
},
}
// take 1
body, ok := queue.Take()
assert.True(t, ok)
element, ok := body.([]byte)
assert.True(t, ok)
assert.Equal(t, element, []byte("hello"))
// put more
queue.Put([]byte("b4"))
queue.Put([]byte("b5")) // will store in elements[0]
queue.Put([]byte("b6")) // cause expansion
results := [][]byte{
[]byte("world"),
[]byte("again"),
[]byte("b4"),
[]byte("b5"),
[]byte("b6"),
}
for _, element := range results {
body, ok := queue.Take()
assert.True(t, ok)
assert.Equal(t, string(element), string(body.([]byte)))
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
assert.Panics(t, func() {
NewQueue(test.size)
})
})
}
}
func BenchmarkQueueGrowth(b *testing.B) {
tests := []struct {
name string
size int
count int
}{
{
name: "initial_1_count_256",
size: 1,
count: 256,
},
{
name: "initial_1_count_4096",
size: 1,
count: 4096,
},
{
name: "initial_1_count_65536",
size: 1,
count: 65536,
},
{
name: "initial_8_count_4096",
size: 8,
count: 4096,
},
{
name: "initial_256_count_4096",
size: 256,
count: 4096,
},
}
for _, test := range tests {
b.Run(test.name, func(b *testing.B) {
elements := make([]any, test.count)
for i := range elements {
elements[i] = i
}
b.ReportAllocs()
b.ResetTimer()
for i := 0; i < b.N; i++ {
queue := NewQueue(test.size)
for _, element := range elements {
queue.Put(element)
}
}
})
}
}
func BenchmarkQueueWrappedGrowth(b *testing.B) {
tests := []struct {
name string
size int
}{
{
name: "capacity_8",
size: 8,
},
{
name: "capacity_256",
size: 256,
},
{
name: "capacity_4096",
size: 4096,
},
}
for _, test := range tests {
b.Run(test.name, func(b *testing.B) {
elements := make([]any, test.size)
for i := range elements {
elements[i] = i
}
b.ReportAllocs()
b.ResetTimer()
for i := 0; i < b.N; i++ {
queue := NewQueue(test.size)
for _, element := range elements {
queue.Put(element)
}
for j := 0; j < test.size/2; j++ {
queue.Take()
}
for j := 0; j < test.size/2; j++ {
queue.Put(elements[j])
}
// The queue is full and wrapped. One more Put triggers growth
// and copies both sides of the ring into FIFO order.
queue.Put(elements[0])
}
})
}
}
func sequence(size int) []int {
values := make([]int, size)
for i := range values {
values[i] = i
}
return values
}
+107
View File
@@ -931,6 +931,113 @@ func TestUnmarshalJsonArray(t *testing.T) {
assert.Equal(t, 18, v[0].Age)
}
func TestUnmarshalJsonBytesPointerSliceUint64(t *testing.T) {
t.Run("with values", func(t *testing.T) {
var c struct {
IDs *[]uint64 `json:"ids,optional"`
}
content := []byte(`{"ids":[9000,9001]}`)
assert.Nil(t, UnmarshalJsonBytes(content, &c))
assert.NotNil(t, c.IDs)
assert.Equal(t, []uint64{9000, 9001}, *c.IDs)
})
t.Run("omitted", func(t *testing.T) {
var c struct {
IDs *[]uint64 `json:"ids,optional"`
}
content := []byte(`{}`)
assert.Nil(t, UnmarshalJsonBytes(content, &c))
assert.Nil(t, c.IDs)
})
t.Run("null", func(t *testing.T) {
var c struct {
IDs *[]uint64 `json:"ids,optional"`
}
content := []byte(`{"ids":null}`)
assert.Nil(t, UnmarshalJsonBytes(content, &c))
assert.Nil(t, c.IDs)
})
t.Run("empty array", func(t *testing.T) {
var c struct {
IDs *[]uint64 `json:"ids,optional"`
}
content := []byte(`{"ids":[]}`)
assert.Nil(t, UnmarshalJsonBytes(content, &c))
assert.NotNil(t, c.IDs)
assert.Equal(t, []uint64{}, *c.IDs)
})
}
func TestUnmarshalJsonBytesPointerSliceOtherTypes(t *testing.T) {
t.Run("pointer to []string", func(t *testing.T) {
var c struct {
Names *[]string `json:"names,optional"`
}
content := []byte(`{"names":["a","b"]}`)
assert.Nil(t, UnmarshalJsonBytes(content, &c))
assert.NotNil(t, c.Names)
assert.Equal(t, []string{"a", "b"}, *c.Names)
})
t.Run("pointer to []int", func(t *testing.T) {
var c struct {
Values *[]int `json:"values,optional"`
}
content := []byte(`{"values":[1,2,3]}`)
assert.Nil(t, UnmarshalJsonBytes(content, &c))
assert.NotNil(t, c.Values)
assert.Equal(t, []int{1, 2, 3}, *c.Values)
})
}
func TestUnmarshalJsonBytesPointerSliceStruct(t *testing.T) {
type Item struct {
Name string `json:"name"`
Age int `json:"age"`
}
t.Run("with values", func(t *testing.T) {
var c struct {
Items *[]Item `json:"items,optional"`
}
content := []byte(`{"items":[{"name":"alice","age":30},{"name":"bob","age":25}]}`)
assert.Nil(t, UnmarshalJsonBytes(content, &c))
assert.NotNil(t, c.Items)
assert.Equal(t, []Item{{Name: "alice", Age: 30}, {Name: "bob", Age: 25}}, *c.Items)
})
t.Run("omitted", func(t *testing.T) {
var c struct {
Items *[]Item `json:"items,optional"`
}
content := []byte(`{}`)
assert.Nil(t, UnmarshalJsonBytes(content, &c))
assert.Nil(t, c.Items)
})
t.Run("empty array", func(t *testing.T) {
var c struct {
Items *[]Item `json:"items,optional"`
}
content := []byte(`{"items":[]}`)
assert.Nil(t, UnmarshalJsonBytes(content, &c))
assert.NotNil(t, c.Items)
assert.Equal(t, []Item{}, *c.Items)
})
}
func TestUnmarshalJsonBytesError(t *testing.T) {
var v []struct {
Name string `json:"name"`
+5 -5
View File
@@ -142,11 +142,11 @@ func (u *Unmarshaler) fillSlice(fieldType reflect.Type, value reflect.Value,
return nil
}
baseType := fieldType.Elem()
baseType := Deref(fieldType).Elem()
dereffedBaseType := Deref(baseType)
dereffedBaseKind := dereffedBaseType.Kind()
if refValue.Len() == 0 {
value.Set(reflect.MakeSlice(reflect.SliceOf(baseType), 0, 0))
SetValue(fieldType, value, reflect.MakeSlice(reflect.SliceOf(baseType), 0, 0))
return nil
}
@@ -179,7 +179,7 @@ func (u *Unmarshaler) fillSlice(fieldType reflect.Type, value reflect.Value,
}
if valid {
value.Set(conv)
SetValue(fieldType, value, conv)
}
return nil
@@ -201,7 +201,7 @@ func (u *Unmarshaler) fillSliceFromString(fieldType reflect.Type, value reflect.
return errUnsupportedType
}
baseFieldType := fieldType.Elem()
baseFieldType := Deref(fieldType).Elem()
baseFieldKind := baseFieldType.Kind()
conv := reflect.MakeSlice(reflect.SliceOf(baseFieldType), len(slice), cap(slice))
@@ -211,7 +211,7 @@ func (u *Unmarshaler) fillSliceFromString(fieldType reflect.Type, value reflect.
}
}
value.Set(conv)
SetValue(fieldType, value, conv)
return nil
}
+1
View File
@@ -10,6 +10,7 @@ import (
var ignoreCmds = map[string]lang.PlaceholderType{
"blpop": {},
"hello": {},
}
type breakerHook struct {
+40
View File
@@ -7,6 +7,7 @@ import (
"time"
"github.com/alicebob/miniredis/v2"
red "github.com/redis/go-redis/v9"
"github.com/stretchr/testify/assert"
"github.com/zeromicro/go-zero/core/breaker"
)
@@ -75,6 +76,45 @@ func TestBreakerHook_ProcessHook(t *testing.T) {
}
assert.Equal(t, someError.Error(), err.Error())
})
t.Run("breakerHook_ignoreHello", func(t *testing.T) {
// hello is issued on connection init and is in ignoreCmds, so repeated
// failures must never trip the breaker into ErrServiceUnavailable.
h := breakerHook{brk: breaker.NewBreaker()}
someError := errors.New("ERR some error")
process := h.ProcessHook(func(_ context.Context, _ red.Cmder) error {
return someError
})
ctx := context.Background()
var err error
for i := 0; i < 1000; i++ {
err = process(ctx, red.NewCmd(ctx, "hello", 3))
if err != nil && err.Error() != someError.Error() {
break
}
}
assert.Equal(t, someError.Error(), err.Error())
})
t.Run("breakerHook_notIgnored", func(t *testing.T) {
// a regular command is not ignored, so repeated failures open the breaker.
h := breakerHook{brk: breaker.NewBreaker()}
someError := errors.New("ERR some error")
process := h.ProcessHook(func(_ context.Context, _ red.Cmder) error {
return someError
})
ctx := context.Background()
var err error
for i := 0; i < 1000; i++ {
err = process(ctx, red.NewCmd(ctx, "get", "key"))
if err != nil && err.Error() != someError.Error() {
break
}
}
assert.Equal(t, breaker.ErrServiceUnavailable, err)
})
}
func TestBreakerHook_ProcessPipelineHook(t *testing.T) {
+24
View File
@@ -23,6 +23,30 @@ type (
Pass string `json:",optional"`
Tls bool `json:",optional"`
NonBlock bool `json:",default=true"`
// DisableIdentity is used to disable CLIENT SETINFO command on connect.
//
// Some redis versions/proxies do not support CLIENT SETINFO and return an
// error on connect; since that command runs through the breaker hook it can
// trip the breaker. Set this to true to skip it on such servers. Together
// with the default MaintNotifications=disabled (and the always-ignored
// HELLO command), this keeps the connect-time commands from tripping the
// breaker on incompatible servers, without forcing RESP2.
//
// default: false
DisableIdentity bool `json:",default=false"`
// Protocol 2 or 3. Use the version to negotiate RESP version with redis-server.
//
// default: 3.
Protocol int `json:",default=3"`
// MaintNotifications controls the CLIENT MAINT_NOTIFICATIONS handshake mode
// (go-redis MaintNotificationsConfig.Mode):
// - disabled: never send the command (avoids tripping the breaker on servers
// that don't support it; keeps RESP3 intact)
// - auto: try, silently fall back on error (go-redis default)
// - enabled: force, fail the connection on error
//
// default: disabled
MaintNotifications string `json:",default=disabled,options=disabled|enabled|auto"`
// PingTimeout is the timeout for ping redis.
PingTimeout time.Duration `json:",default=1s"`
}
+68 -21
View File
@@ -8,6 +8,7 @@ import (
"time"
red "github.com/redis/go-redis/v9"
"github.com/redis/go-redis/v9/maintnotifications"
"github.com/zeromicro/go-zero/core/breaker"
"github.com/zeromicro/go-zero/core/errorx"
"github.com/zeromicro/go-zero/core/logx"
@@ -53,13 +54,16 @@ type (
// Redis defines a redis node/cluster. It is thread-safe.
Redis struct {
Addr string
Type string
User string
Pass string
tls bool
brk breaker.Breaker
hooks []red.Hook
Addr string
Type string
User string
Pass string
protocol int
identity bool
maintNotifications maintnotifications.Mode
tls bool
brk breaker.Breaker
hooks []red.Hook
}
// RedisNode interface represents a redis node.
@@ -136,6 +140,15 @@ func NewRedis(conf RedisConf, opts ...Option) (*Redis, error) {
if conf.Tls {
opts = append([]Option{WithTLS()}, opts...)
}
if conf.Protocol > 0 {
opts = append([]Option{WithProtocol(conf.Protocol)}, opts...)
}
if conf.DisableIdentity {
opts = append([]Option{WithIdentity()}, opts...)
}
if len(conf.MaintNotifications) > 0 {
opts = append([]Option{WithMaintNotifications(conf.MaintNotifications)}, opts...)
}
rds := newRedis(conf.Host, opts...)
if !conf.NonBlock {
@@ -147,20 +160,6 @@ func NewRedis(conf RedisConf, opts ...Option) (*Redis, error) {
return rds, nil
}
func newRedis(addr string, opts ...Option) *Redis {
r := &Redis{
Addr: addr,
Type: NodeType,
brk: breaker.NewBreaker(),
}
for _, opt := range opts {
opt(r)
}
return r
}
// NewScript returns a new Script instance.
func NewScript(script string) *Script {
return red.NewScript(script)
@@ -2686,6 +2685,18 @@ func (s *Redis) checkConnection(pingTimeout time.Duration) error {
return conn.Ping(ctx).Err()
}
// maintNotificationsConfig builds the go-redis maintenance notifications config
// from the configured mode, defaulting to disabled when unset so that the
// CLIENT MAINT_NOTIFICATIONS command is not issued on connect.
func (r *Redis) maintNotificationsConfig() *maintnotifications.Config {
mode := r.maintNotifications
if len(mode) == 0 {
mode = maintnotifications.ModeDisabled
}
return &maintnotifications.Config{Mode: mode}
}
// Cluster customizes the given Redis as a cluster.
func Cluster() Option {
return func(r *Redis) {
@@ -2726,6 +2737,28 @@ func WithUser(user string) Option {
}
}
// WithProtocol customizes the given Redis with protocol.
func WithProtocol(protocol int) Option {
return func(r *Redis) {
r.protocol = protocol
}
}
// WithIdentity customizes the given Redis with Identity enabled.
func WithIdentity() Option {
return func(r *Redis) {
r.identity = true
}
}
// WithMaintNotifications customizes the given Redis with the maintenance
// notifications mode (disabled, enabled or auto).
func WithMaintNotifications(mode string) Option {
return func(r *Redis) {
r.maintNotifications = maintnotifications.Mode(mode)
}
}
func acceptable(err error) bool {
return err == nil || errorx.In(err, red.Nil, context.Canceled)
}
@@ -2741,6 +2774,20 @@ func getRedis(r *Redis) (RedisNode, error) {
}
}
func newRedis(addr string, opts ...Option) *Redis {
r := &Redis{
Addr: addr,
Type: NodeType,
brk: breaker.NewBreaker(),
}
for _, opt := range opts {
opt(r)
}
return r
}
func toPairs(vals []red.Z) []Pair {
pairs := make([]Pair, len(vals))
for i, val := range vals {
+77
View File
@@ -11,6 +11,7 @@ import (
"github.com/alicebob/miniredis/v2"
red "github.com/redis/go-redis/v9"
"github.com/redis/go-redis/v9/maintnotifications"
"github.com/stretchr/testify/assert"
"github.com/zeromicro/go-zero/core/logx"
"github.com/zeromicro/go-zero/core/stringx"
@@ -150,6 +151,82 @@ func TestNewRedis(t *testing.T) {
}
}
func TestGetClientWithProtocolAndIdentity(t *testing.T) {
r := miniredis.RunT(t)
defer r.Close()
c, err := getClient(&Redis{
Addr: r.Addr(),
Type: NodeType,
protocol: 2,
identity: true,
})
if assert.NoError(t, err) {
assert.NotNil(t, c)
assert.Equal(t, 2, c.Options().Protocol)
assert.True(t, c.Options().DisableIdentity)
}
}
func TestNewRedis_ProtocolAndIdentity(t *testing.T) {
logx.Disable()
s := miniredis.RunT(t)
rds, err := NewRedis(RedisConf{
Host: s.Addr(),
Type: NodeType,
Protocol: 2,
DisableIdentity: true,
})
if assert.NoError(t, err) {
assert.Equal(t, 2, rds.protocol)
assert.True(t, rds.identity)
}
}
func TestGetClientWithMaintNotifications(t *testing.T) {
tests := []struct {
name string
mode maintnotifications.Mode
want maintnotifications.Mode
}{
{name: "unset falls back to disabled", mode: "", want: maintnotifications.ModeDisabled},
{name: "disabled", mode: maintnotifications.ModeDisabled, want: maintnotifications.ModeDisabled},
{name: "enabled", mode: maintnotifications.ModeEnabled, want: maintnotifications.ModeEnabled},
{name: "auto", mode: maintnotifications.ModeAuto, want: maintnotifications.ModeAuto},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
r := miniredis.RunT(t)
defer r.Close()
c, err := getClient(&Redis{
Addr: r.Addr(),
Type: NodeType,
maintNotifications: test.mode,
})
if assert.NoError(t, err) {
assert.NotNil(t, c)
assert.NotNil(t, c.Options().MaintNotificationsConfig)
assert.Equal(t, test.want, c.Options().MaintNotificationsConfig.Mode)
}
})
}
}
func TestNewRedis_MaintNotifications(t *testing.T) {
logx.Disable()
s := miniredis.RunT(t)
rds, err := NewRedis(RedisConf{
Host: s.Addr(),
Type: NodeType,
MaintNotifications: string(maintnotifications.ModeAuto),
})
if assert.NoError(t, err) {
assert.Equal(t, maintnotifications.ModeAuto, rds.maintNotifications)
}
}
func TestRedis_NonBlock(t *testing.T) {
logx.Disable()
+21 -15
View File
@@ -50,25 +50,31 @@ func CreateBlockingNode(r *Redis) (ClosableNode, error) {
switch r.Type {
case NodeType:
client := red.NewClient(&red.Options{
Addr: r.Addr,
Username: r.User,
Password: r.Pass,
DB: defaultDatabase,
MaxRetries: maxRetries,
PoolSize: 1,
MinIdleConns: 1,
ReadTimeout: timeout,
Addr: r.Addr,
Username: r.User,
Password: r.Pass,
DB: defaultDatabase,
MaxRetries: maxRetries,
PoolSize: 1,
MinIdleConns: 1,
ReadTimeout: timeout,
Protocol: r.protocol,
DisableIdentity: r.identity,
MaintNotificationsConfig: r.maintNotificationsConfig(),
})
return &clientBridge{client}, nil
case ClusterType:
client := red.NewClusterClient(&red.ClusterOptions{
Addrs: splitClusterAddrs(r.Addr),
Username: r.User,
Password: r.Pass,
MaxRetries: maxRetries,
PoolSize: 1,
MinIdleConns: 1,
ReadTimeout: timeout,
Addrs: splitClusterAddrs(r.Addr),
Username: r.User,
Password: r.Pass,
MaxRetries: maxRetries,
PoolSize: 1,
MinIdleConns: 1,
ReadTimeout: timeout,
Protocol: r.protocol,
DisableIdentity: r.identity,
MaintNotificationsConfig: r.maintNotificationsConfig(),
})
return &clusterBridge{client}, nil
default:
@@ -43,4 +43,32 @@ func TestBlockingNode(t *testing.T) {
_, err = CreateBlockingNode(New(r.Addr(), badType()))
assert.Error(t, err)
})
t.Run("test blocking node with protocol and identity", func(t *testing.T) {
r, err := miniredis.Run()
assert.NoError(t, err)
defer r.Close()
node, err := CreateBlockingNode(New(r.Addr(), WithProtocol(2), WithIdentity()))
assert.NoError(t, err)
bridge, ok := node.(*clientBridge)
assert.True(t, ok)
assert.Equal(t, 2, bridge.Options().Protocol)
assert.True(t, bridge.Options().DisableIdentity)
node.Close()
})
t.Run("test blocking node with cluster, protocol and identity", func(t *testing.T) {
r, err := miniredis.Run()
assert.NoError(t, err)
defer r.Close()
node, err := CreateBlockingNode(New(r.Addr(), Cluster(), WithProtocol(2), WithIdentity()))
assert.NoError(t, err)
bridge, ok := node.(*clusterBridge)
assert.True(t, ok)
assert.Equal(t, 2, bridge.Options().Protocol)
assert.True(t, bridge.Options().DisableIdentity)
node.Close()
})
}
+10 -7
View File
@@ -30,13 +30,16 @@ func getClient(r *Redis) (*red.Client, error) {
}
}
store := red.NewClient(&red.Options{
Addr: r.Addr,
Username: r.User,
Password: r.Pass,
DB: defaultDatabase,
MaxRetries: maxRetries,
MinIdleConns: idleConns,
TLSConfig: tlsConfig,
Addr: r.Addr,
Username: r.User,
Password: r.Pass,
DB: defaultDatabase,
MaxRetries: maxRetries,
MinIdleConns: idleConns,
TLSConfig: tlsConfig,
Protocol: r.protocol,
DisableIdentity: r.identity,
MaintNotificationsConfig: r.maintNotificationsConfig(),
})
hooks := append([]red.Hook{defaultDurationHook, breakerHook{
+9 -6
View File
@@ -27,12 +27,15 @@ func getCluster(r *Redis) (*red.ClusterClient, error) {
}
}
store := red.NewClusterClient(&red.ClusterOptions{
Addrs: splitClusterAddrs(r.Addr),
Username: r.User,
Password: r.Pass,
MaxRetries: maxRetries,
MinIdleConns: idleConns,
TLSConfig: tlsConfig,
Addrs: splitClusterAddrs(r.Addr),
Username: r.User,
Password: r.Pass,
MaxRetries: maxRetries,
MinIdleConns: idleConns,
TLSConfig: tlsConfig,
Protocol: r.protocol,
DisableIdentity: r.identity,
MaintNotificationsConfig: r.maintNotificationsConfig(),
})
hooks := append([]red.Hook{defaultDurationHook, breakerHook{
@@ -5,6 +5,7 @@ import (
"github.com/alicebob/miniredis/v2"
red "github.com/redis/go-redis/v9"
"github.com/redis/go-redis/v9/maintnotifications"
"github.com/stretchr/testify/assert"
)
@@ -57,3 +58,50 @@ func TestGetCluster(t *testing.T) {
assert.NotNil(t, c)
}
}
func TestGetClusterWithProtocolAndIdentity(t *testing.T) {
r := miniredis.RunT(t)
defer r.Close()
c, err := getCluster(&Redis{
Addr: r.Addr(),
Type: ClusterType,
protocol: 2,
identity: true,
hooks: []red.Hook{defaultDurationHook},
})
if assert.NoError(t, err) {
assert.NotNil(t, c)
assert.Equal(t, 2, c.Options().Protocol)
assert.True(t, c.Options().DisableIdentity)
}
}
func TestGetClusterWithMaintNotifications(t *testing.T) {
tests := []struct {
name string
mode maintnotifications.Mode
want maintnotifications.Mode
}{
{name: "unset falls back to disabled", mode: "", want: maintnotifications.ModeDisabled},
{name: "disabled", mode: maintnotifications.ModeDisabled, want: maintnotifications.ModeDisabled},
{name: "auto", mode: maintnotifications.ModeAuto, want: maintnotifications.ModeAuto},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
r := miniredis.RunT(t)
defer r.Close()
c, err := getCluster(&Redis{
Addr: r.Addr(),
Type: ClusterType,
maintNotifications: test.mode,
hooks: []red.Hook{defaultDurationHook},
})
if assert.NoError(t, err) {
assert.NotNil(t, c)
assert.NotNil(t, c.Options().MaintNotificationsConfig)
assert.Equal(t, test.want, c.Options().MaintNotificationsConfig.Mode)
}
})
}
}
+2 -2
View File
@@ -15,7 +15,7 @@ require (
github.com/jackc/pgx/v5 v5.8.0
github.com/jhump/protoreflect v1.18.0
github.com/modelcontextprotocol/go-sdk v1.4.0
github.com/pelletier/go-toml/v2 v2.4.2
github.com/pelletier/go-toml/v2 v2.4.3
github.com/prometheus/client_golang v1.23.2
github.com/redis/go-redis/v9 v9.21.0
github.com/spaolacci/murmur3 v1.1.0
@@ -23,7 +23,7 @@ require (
github.com/titanous/json5 v1.0.0
go.etcd.io/etcd/api/v3 v3.5.21
go.etcd.io/etcd/client/v3 v3.5.21
go.mongodb.org/mongo-driver/v2 v2.7.0
go.mongodb.org/mongo-driver/v2 v2.8.0
go.opentelemetry.io/otel v1.40.0
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.40.0
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.40.0
+4 -4
View File
@@ -147,8 +147,8 @@ github.com/onsi/gomega v1.35.1 h1:Cwbd75ZBPxFSuZ6T+rN/WCb/gOc6YgFBXLlZLhC7Ds4=
github.com/onsi/gomega v1.35.1/go.mod h1:PvZbdDc8J6XJEpDK4HCuRBm8a6Fzp9/DmhC9C7yFlog=
github.com/openzipkin/zipkin-go v0.4.3 h1:9EGwpqkgnwdEIJ+Od7QVSEIH+ocmm5nPat0G7sjsSdg=
github.com/openzipkin/zipkin-go v0.4.3/go.mod h1:M9wCJZFWCo2RiY+o1eBCEMe0Dp2S5LDHcMZmk3RmK7c=
github.com/pelletier/go-toml/v2 v2.4.2 h1:M2fKKbmyvI+hGId/D0W64qDBMVhJnNR10O5gIbMc//Q=
github.com/pelletier/go-toml/v2 v2.4.2/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/pelletier/go-toml/v2 v2.4.3 h1:GTRvJQutkOSftxIFD5xw9aepkYNuPWmVJpffdDPYVpY=
github.com/pelletier/go-toml/v2 v2.4.3/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/petermattis/goid v0.0.0-20260113132338-7c7de50cc741 h1:KPpdlQLZcHfTMQRi6bFQ7ogNO0ltFT4PmtwTLW4W+14=
github.com/petermattis/goid v0.0.0-20260113132338-7c7de50cc741/go.mod h1:pxMtw7cyUw6B2bRH0ZBANSPg+AoSud1I1iyJHI69jH4=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
@@ -224,8 +224,8 @@ go.etcd.io/etcd/client/pkg/v3 v3.5.21 h1:lPBu71Y7osQmzlflM9OfeIV2JlmpBjqBNlLtcoB
go.etcd.io/etcd/client/pkg/v3 v3.5.21/go.mod h1:BgqT/IXPjK9NkeSDjbzwsHySX3yIle2+ndz28nVsjUs=
go.etcd.io/etcd/client/v3 v3.5.21 h1:T6b1Ow6fNjOLOtM0xSoKNQt1ASPCLWrF9XMHcH9pEyY=
go.etcd.io/etcd/client/v3 v3.5.21/go.mod h1:mFYy67IOqmbRf/kRUvsHixzo3iG+1OF2W2+jVIQRAnU=
go.mongodb.org/mongo-driver/v2 v2.7.0 h1:RO+zqavD2/GCL3cxOMyZhx6R9Irzr8/6gsoqx5tcY/c=
go.mongodb.org/mongo-driver/v2 v2.7.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0=
go.mongodb.org/mongo-driver/v2 v2.8.0 h1:CxWDGQYY8QQwNjAl/aq2sfWakdnWZynnqJ9F4DhHbP8=
go.mongodb.org/mongo-driver/v2 v2.8.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0=
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
go.opentelemetry.io/otel v1.40.0 h1:oA5YeOcpRTXq6NN7frwmwFR0Cn3RhTVZvXsP4duvCms=
+69
View File
@@ -0,0 +1,69 @@
package parser
import (
"testing"
"github.com/stretchr/testify/require"
"github.com/zeromicro/go-zero/tools/goctl/api/spec"
)
const inlineTagAPI = `
syntax = "v1"
type (
Auth {
Token string ` + "`header:\"Authorization\"`" + `
}
Middle {
Auth
}
PointerRequest {
*Auth
}
NestedRequest {
Middle
}
RecursiveRequest {
Token string ` + "`header:\"X-Token\"`" + `
*RecursiveRequest
}
)
service test-api {
@handler Pointer
get /pointer (PointerRequest)
@handler Nested
get /nested (NestedRequest)
@handler Recursive
get /recursive (RecursiveRequest)
}
`
func TestParseContentResolvesInlineTypesForTagLookup(t *testing.T) {
apiSpec, err := ParseContent(inlineTagAPI)
require.NoError(t, err)
for _, name := range []string{"PointerRequest", "NestedRequest", "RecursiveRequest"} {
t.Run(name, func(t *testing.T) {
tp := findStructByName(t, apiSpec.Types, name)
require.NotEmpty(t, tp.GetTagMembers("header"))
require.Empty(t, tp.GetTagMembers("path"))
})
}
}
func findStructByName(t *testing.T, types []spec.Type, name string) spec.DefineStruct {
t.Helper()
for _, tp := range types {
if tp.Name() == name {
defined, ok := tp.(spec.DefineStruct)
require.True(t, ok)
return defined
}
}
t.Fatalf("type %s not found", name)
return spec.DefineStruct{}
}
+67
View File
@@ -145,6 +145,17 @@ func (p parser) fillTypes() error {
case spec.DefineStruct:
var members []spec.Member
for _, member := range v.Members {
if member.IsInline {
tp, err := p.resolveInlineType(member.Type, map[string]bool{v.RawName: true})
if err != nil {
return err
}
member.Type = tp
members = append(members, member)
continue
}
switch v := member.Type.(type) {
case spec.DefineStruct:
tp, err := p.findDefinedType(v.RawName)
@@ -167,6 +178,62 @@ func (p parser) fillTypes() error {
return nil
}
func (p parser) resolveInlineType(tp spec.Type, resolving map[string]bool) (spec.Type, error) {
switch v := tp.(type) {
case spec.DefineStruct:
if resolving[v.RawName] {
return v, nil
}
tp, err := p.findDefinedType(v.RawName)
if err != nil {
return nil, err
}
defined, ok := (*tp).(spec.DefineStruct)
if !ok {
return nil, fmt.Errorf("type %s is not a struct", v.RawName)
}
resolving[v.RawName] = true
defer delete(resolving, v.RawName)
for i := range defined.Members {
if !defined.Members[i].IsInline {
continue
}
resolved, err := p.resolveInlineType(defined.Members[i].Type, resolving)
if err != nil {
return nil, err
}
defined.Members[i].Type = resolved
}
return defined, nil
case spec.NestedStruct:
for i := range v.Members {
if !v.Members[i].IsInline {
continue
}
resolved, err := p.resolveInlineType(v.Members[i].Type, resolving)
if err != nil {
return nil, err
}
v.Members[i].Type = resolved
}
return v, nil
case spec.PointerType:
resolved, err := p.resolveInlineType(v.Type, resolving)
if err != nil {
return nil, err
}
v.Type = resolved
return v, nil
default:
return tp, nil
}
}
func (p parser) findDefinedType(name string) (*spec.Type, error) {
for _, item := range p.spec.Types {
if _, ok := item.(spec.DefineStruct); ok {
+30 -5
View File
@@ -139,18 +139,43 @@ func (m Member) IsFormMember() bool {
return false
}
// IsTagMember returns true if contains given tag
// IsTagMember returns true if the member contains the given tag.
// For inline members, it recursively checks the members of the referenced
// struct, since inline members themselves carry no tag and any matching tag
// must live on one of their children. This avoids spuriously reporting the
// presence of a tag (e.g. `header`) for an inline struct whose children do
// not actually use that tag. See go-zero #4800.
func (m Member) IsTagMember(tagKey string) bool {
if m.IsInline {
return true
}
tags := m.Tags()
for _, tag := range tags {
if tag.Key == tagKey {
return true
}
}
if m.IsInline {
return typeContainsTag(m.Type, tagKey)
}
return false
}
func typeContainsTag(tp Type, tagKey string) bool {
var members []Member
switch v := tp.(type) {
case DefineStruct:
members = v.Members
case NestedStruct:
members = v.Members
case PointerType:
return typeContainsTag(v.Type, tagKey)
default:
return false
}
for _, child := range members {
if child.IsTagMember(tagKey) {
return true
}
}
return false
}
+167
View File
@@ -0,0 +1,167 @@
package spec
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestMember_IsTagMember(t *testing.T) {
t.Run("non-inline member with matching tag returns true", func(t *testing.T) {
m := Member{Tag: `header:"Authorization"`}
assert.True(t, m.IsTagMember("header"))
})
t.Run("non-inline member without matching tag returns false", func(t *testing.T) {
m := Member{Tag: `json:"username"`}
assert.False(t, m.IsTagMember("header"))
assert.False(t, m.IsTagMember("path"))
assert.False(t, m.IsTagMember("form"))
})
t.Run("non-inline member without any tag returns false", func(t *testing.T) {
m := Member{}
assert.False(t, m.IsTagMember("header"))
})
t.Run("inline struct without matching child tag returns false (#4800)", func(t *testing.T) {
m := Member{
Name: "Pagination",
IsInline: true,
Type: DefineStruct{
RawName: "Pagination",
Members: []Member{
{Name: "Page", Tag: `json:"page"`},
{Name: "PageSize", Tag: `json:"pageSize"`},
},
},
}
assert.False(t, m.IsTagMember("header"))
assert.False(t, m.IsTagMember("path"))
assert.False(t, m.IsTagMember("form"))
})
t.Run("inline struct whose child has matching tag returns true", func(t *testing.T) {
m := Member{
Name: "Auth",
IsInline: true,
Type: DefineStruct{
RawName: "Auth",
Members: []Member{
{Name: "Token", Tag: `header:"Authorization"`},
},
},
}
assert.True(t, m.IsTagMember("header"))
})
t.Run("nested inline structs are recursed", func(t *testing.T) {
m := Member{
Name: "Outer",
IsInline: true,
Type: DefineStruct{
RawName: "Outer",
Members: []Member{
{
Name: "Inner",
IsInline: true,
Type: DefineStruct{
RawName: "Inner",
Members: []Member{
{Name: "Token", Tag: `header:"X-Token"`},
},
},
},
},
},
}
assert.True(t, m.IsTagMember("header"))
})
t.Run("nested inline structs without matching child return false", func(t *testing.T) {
m := Member{
Name: "Outer",
IsInline: true,
Type: DefineStruct{
RawName: "Outer",
Members: []Member{
{
Name: "Inner",
IsInline: true,
Type: DefineStruct{
RawName: "Inner",
Members: []Member{
{Name: "Page", Tag: `json:"page"`},
},
},
},
},
},
}
assert.False(t, m.IsTagMember("header"))
})
t.Run("inline NestedStruct whose child has matching tag returns true", func(t *testing.T) {
m := Member{
Name: "Auth",
IsInline: true,
Type: NestedStruct{
RawName: "Auth",
Members: []Member{
{Name: "Token", Tag: `header:"Authorization"`},
},
},
}
assert.True(t, m.IsTagMember("header"))
})
t.Run("inline PointerType whose child has matching tag returns true", func(t *testing.T) {
m := Member{
Name: "Auth",
IsInline: true,
Type: PointerType{
RawName: "*Auth",
Type: DefineStruct{
RawName: "Auth",
Members: []Member{
{Name: "Token", Tag: `header:"Authorization"`},
},
},
},
}
assert.True(t, m.IsTagMember("header"))
assert.False(t, m.IsTagMember("path"))
})
t.Run("empty inline struct returns false", func(t *testing.T) {
m := Member{
Name: "Empty",
IsInline: true,
Type: DefineStruct{RawName: "Empty"},
}
assert.False(t, m.IsTagMember("header"))
})
}
func TestDefineStruct_GetTagMembers_InlineRegression(t *testing.T) {
s := DefineStruct{
RawName: "QueryUserListReq",
Members: []Member{
{Name: "Username", Tag: `json:"username,optional"`},
{
Name: "Pagination",
IsInline: true,
Type: DefineStruct{
RawName: "Pagination",
Members: []Member{
{Name: "Page", Tag: `json:"page"`},
{Name: "PageSize", Tag: `json:"pageSize"`},
},
},
},
},
}
assert.Empty(t, s.GetTagMembers("header"),
"inline struct without header-tagged children must not match header (#4800)")
assert.Empty(t, s.GetTagMembers("path"))
}
+2
View File
@@ -54,6 +54,8 @@ const (
propertyKeyDeprecated = "deprecated"
propertyKeyPrefix = "prefix"
propertyKeyAuthType = "authType"
propertyKeyRespCode = "respCode"
propertyKeyResponses = "responses"
propertyKeyHost = "host"
propertyKeyBasePath = "basePath"
propertyKeyWrapCodeMsg = "wrapCodeMsg"
+28 -1
View File
@@ -82,6 +82,7 @@ type (
service Swagger {
@doc (
description: "form demo"
respCode: "201" // HTTP status code corresponding to Swagger
)
@handler form
post /form (FormReq) returns (FormResp)
@@ -102,6 +103,27 @@ type (
Language string `json:"language"`
Gender string `json:"gender"`
}
EmbeddedUser {
UserId int `json:"userId,example=10"`
Username string `json:"username,example=keson.an"`
}
EmbeddedAudit {
TraceId string `json:"traceId,example=trace-001"`
CreatedBy string `json:"createdBy,optional,example=system"`
}
EmbeddedProfile {
EmbeddedUser
*EmbeddedAudit
Nickname string `json:"nickname,optional,example=keson"`
}
EmbeddedJsonReq {
EmbeddedProfile
RequestId string `json:"requestId,example=req-001"`
}
EmbeddedJsonResp {
EmbeddedProfile
Success bool `json:"success,example=true"`
}
ComplexJsonLevel2 {
// basic
Integer int `json:"integer,example=1"`
@@ -237,5 +259,10 @@ service Swagger {
)
@handler jsonComplex
post /json/complex (ComplexJsonReq) returns (ComplexJsonResp)
}
@doc (
description: "embedded json request body API"
)
@handler jsonEmbedded
post /json/embedded (EmbeddedJsonReq) returns (EmbeddedJsonResp)
}
@@ -60,7 +60,7 @@
}
],
"responses": {
"200": {
"201": {
"description": "",
"schema": {
"type": "object",
@@ -84,6 +84,7 @@ type (
service Swagger {
@doc (
description: "form 接口"
respCode: "201" // 对应 Swagger 的 HTTP 状态码
)
@handler form
post /form (FormReq) returns (FormResp)
@@ -244,4 +245,3 @@ service Swagger {
@handler jsonComplex
post /json/complex (ComplexJsonReq) returns (ComplexJsonResp)
}
@@ -60,7 +60,7 @@
}
],
"responses": {
"200": {
"201": {
"description": "",
"schema": {
"type": "object",
+85 -41
View File
@@ -2,64 +2,108 @@ package swagger
import (
"net/http"
"strconv"
"strings"
"github.com/go-openapi/spec"
apiSpec "github.com/zeromicro/go-zero/tools/goctl/api/spec"
)
func jsonResponseFromType(ctx Context, atDoc apiSpec.AtDoc, tp apiSpec.Type) *spec.Responses {
statusCode := responseStatusCode(atDoc)
var response spec.Response
if tp == nil {
return &spec.Responses{
ResponsesProps: spec.ResponsesProps{
StatusCodeResponses: map[int]spec.Response{
http.StatusOK: {
ResponseProps: spec.ResponseProps{
Description: "",
Schema: &spec.Schema{},
},
},
},
response = spec.Response{
ResponseProps: spec.ResponseProps{
Description: "",
},
}
}
props := spec.SchemaProps{
AdditionalProperties: mapFromGoType(ctx, tp),
Items: itemsFromGoType(ctx, tp),
}
if ctx.UseDefinitions {
structName, ok := containsStruct(tp)
if ok {
props.Ref = spec.MustCreateRef(getRefName(structName))
return &spec.Responses{
ResponsesProps: spec.ResponsesProps{
StatusCodeResponses: map[int]spec.Response{
http.StatusOK: {
ResponseProps: spec.ResponseProps{
Schema: &spec.Schema{
SchemaProps: wrapCodeMsgProps(ctx, props, atDoc),
},
},
},
},
},
}
} else {
props := spec.SchemaProps{
AdditionalProperties: mapFromGoType(ctx, tp),
Items: itemsFromGoType(ctx, tp),
}
}
p, _ := propertiesFromType(ctx, tp)
props.Type = typeFromGoType(ctx, tp)
props.Properties = p
return &spec.Responses{
ResponsesProps: spec.ResponsesProps{
StatusCodeResponses: map[int]spec.Response{
http.StatusOK: {
if ctx.UseDefinitions {
structName, ok := containsStruct(tp)
if ok {
props.Ref = spec.MustCreateRef(getRefName(structName))
response = spec.Response{
ResponseProps: spec.ResponseProps{
Schema: &spec.Schema{
SchemaProps: wrapCodeMsgProps(ctx, props, atDoc),
},
},
}
return responsesFromStatusCode(atDoc, statusCode, response)
}
}
p, _ := propertiesFromType(ctx, tp)
props.Type = typeFromGoType(ctx, tp)
props.Properties = p
response = spec.Response{
ResponseProps: spec.ResponseProps{
Schema: &spec.Schema{
SchemaProps: wrapCodeMsgProps(ctx, props, atDoc),
},
},
}
}
return responsesFromStatusCode(atDoc, statusCode, response)
}
func responsesFromStatusCode(atDoc apiSpec.AtDoc, statusCode int, response spec.Response) *spec.Responses {
statusCodeResponses := map[int]spec.Response{
statusCode: response,
}
for code, description := range responseDescriptions(atDoc) {
if code == statusCode {
responseWithDescription := response
responseWithDescription.Description = description
statusCodeResponses[code] = responseWithDescription
} else {
statusCodeResponses[code] = spec.Response{
ResponseProps: spec.ResponseProps{
Description: description,
},
}
}
}
return &spec.Responses{
ResponsesProps: spec.ResponsesProps{
StatusCodeResponses: statusCodeResponses,
},
}
}
func responseStatusCode(atDoc apiSpec.AtDoc) int {
return getOrDefault(atDoc.Properties, propertyKeyRespCode, http.StatusOK, func(str string, def int) int {
statusCode, err := strconv.Atoi(str)
if err != nil || statusCode < http.StatusContinue || statusCode > 599 {
return def
}
return statusCode
})
}
func responseDescriptions(atDoc apiSpec.AtDoc) map[int]string {
descriptions := make(map[int]string)
for _, item := range strings.Split(getStringFromKVOrDefault(atDoc.Properties, propertyKeyResponses, ""), "<br>") {
codeText, description, ok := strings.Cut(item, "-")
if !ok {
continue
}
code, err := strconv.Atoi(strings.TrimSpace(codeText))
if err != nil || code < http.StatusContinue || code > 599 {
continue
}
descriptions[code] = strings.TrimSpace(description)
}
return descriptions
}
+92
View File
@@ -0,0 +1,92 @@
package swagger
import (
"net/http"
"testing"
"github.com/stretchr/testify/assert"
"github.com/zeromicro/go-zero/tools/goctl/api/spec"
)
func TestJsonResponseFromTypeStatusCode(t *testing.T) {
tests := []struct {
name string
properties map[string]string
response spec.Type
want int
}{
{
name: "defaults to ok",
response: spec.PrimitiveType{RawName: "string"},
want: http.StatusOK,
},
{
name: "uses custom status code",
properties: map[string]string{
propertyKeyRespCode: "201",
},
response: spec.PrimitiveType{RawName: "string"},
want: http.StatusCreated,
},
{
name: "supports quoted custom status code",
properties: map[string]string{
propertyKeyRespCode: `"204"`,
},
want: http.StatusNoContent,
},
{
name: "defaults for invalid status code",
properties: map[string]string{
propertyKeyRespCode: "600",
},
response: spec.PrimitiveType{RawName: "string"},
want: http.StatusOK,
},
{
name: "defaults for non-numeric status code",
properties: map[string]string{
propertyKeyRespCode: "created",
},
response: spec.PrimitiveType{RawName: "string"},
want: http.StatusOK,
},
{
name: "uses custom status code without response body",
properties: map[string]string{
propertyKeyRespCode: "204",
},
want: http.StatusNoContent,
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
responses := jsonResponseFromType(testingContext(t), spec.AtDoc{
Properties: test.properties,
}, test.response)
assert.Len(t, responses.StatusCodeResponses, 1)
assert.Contains(t, responses.StatusCodeResponses, test.want)
if test.response == nil {
assert.Nil(t, responses.StatusCodeResponses[test.want].Schema)
}
})
}
}
func TestJsonResponseFromTypeMultipleStatusCodes(t *testing.T) {
responses := jsonResponseFromType(testingContext(t), spec.AtDoc{
Properties: map[string]string{
propertyKeyResponses: "200-OK<br>401-Unauthorized<br>404-User not found",
},
}, spec.PrimitiveType{RawName: "string"})
assert.Len(t, responses.StatusCodeResponses, 3)
assert.Equal(t, "OK", responses.StatusCodeResponses[http.StatusOK].Description)
assert.NotNil(t, responses.StatusCodeResponses[http.StatusOK].Schema)
assert.Equal(t, "Unauthorized", responses.StatusCodeResponses[http.StatusUnauthorized].Description)
assert.Nil(t, responses.StatusCodeResponses[http.StatusUnauthorized].Schema)
assert.Equal(t, "User not found", responses.StatusCodeResponses[http.StatusNotFound].Description)
assert.Nil(t, responses.StatusCodeResponses[http.StatusNotFound].Schema)
}
+2
View File
@@ -202,6 +202,8 @@ func expandMembers(ctx Context, tp apiSpec.Type) []apiSpec.Member {
}
members = append(members, v)
}
case apiSpec.PointerType:
members = expandMembers(ctx, val.Type)
}
return members
+55 -3
View File
@@ -3,8 +3,8 @@ package swagger
import (
"testing"
"github.com/zeromicro/go-zero/tools/goctl/api/spec"
"github.com/stretchr/testify/assert"
"github.com/zeromicro/go-zero/tools/goctl/api/spec"
)
func Test_pathVariable2SwaggerVariable(t *testing.T) {
@@ -66,7 +66,7 @@ func TestArrayDefinitionsBug(t *testing.T) {
// Verify the array field has correct structure
assert.Equal(t, "array", arrayField.Type[0])
// Check that we have items
assert.NotNil(t, arrayField.Items, "Array should have items defined")
assert.NotNil(t, arrayField.Items.Schema, "Array items should have schema")
@@ -74,7 +74,7 @@ func TestArrayDefinitionsBug(t *testing.T) {
// The FIX: $ref should be inside items, not at schema level
hasRef := arrayField.Ref.String() != ""
assert.False(t, hasRef, "Schema level should NOT have $ref")
// The $ref should be in the items
hasItemsRef := arrayField.Items.Schema.Ref.String() != ""
assert.True(t, hasItemsRef, "Items should have $ref")
@@ -138,3 +138,55 @@ func TestArrayWithoutDefinitions(t *testing.T) {
assert.Contains(t, arrayField.Items.Schema.Properties, "itemName")
assert.Equal(t, []string{"itemName"}, arrayField.Items.Schema.Required)
}
func TestPropertiesFromTypeInlinePointerMembers(t *testing.T) {
ctx := testingContext(t)
baseStruct := spec.DefineStruct{
RawName: "EmbeddedUser",
Members: []spec.Member{
{
Name: "UserId",
Type: spec.PrimitiveType{RawName: "int"},
Tag: `json:"userId"`,
},
},
}
auditStruct := spec.DefineStruct{
RawName: "EmbeddedAudit",
Members: []spec.Member{
{
Name: "TraceId",
Type: spec.PrimitiveType{RawName: "string"},
Tag: `json:"traceId"`,
},
},
}
testStruct := spec.DefineStruct{
RawName: "EmbeddedProfile",
Members: []spec.Member{
{
Type: baseStruct,
IsInline: true,
},
{
Type: spec.PointerType{
Type: auditStruct,
},
IsInline: true,
},
{
Name: "Nickname",
Type: spec.PrimitiveType{RawName: "string"},
Tag: `json:"nickname,optional"`,
},
},
}
properties, required := propertiesFromType(ctx, testStruct)
assert.Contains(t, properties, "userId")
assert.Contains(t, properties, "traceId")
assert.Contains(t, properties, "nickname")
assert.ElementsMatch(t, []string{"userId", "traceId"}, required)
}
@@ -349,6 +349,17 @@ func (a *Analyzer) fillTypes() error {
case spec.DefineStruct:
var members []spec.Member
for _, member := range v.Members {
if member.IsInline {
tp, err := a.resolveInlineType(member.Type, map[string]bool{v.RawName: true})
if err != nil {
return err
}
member.Type = tp
members = append(members, member)
continue
}
switch v := member.Type.(type) {
case spec.DefineStruct:
tp, err := a.findDefinedType(v.RawName)
@@ -371,6 +382,62 @@ func (a *Analyzer) fillTypes() error {
return nil
}
func (a *Analyzer) resolveInlineType(tp spec.Type, resolving map[string]bool) (spec.Type, error) {
switch v := tp.(type) {
case spec.DefineStruct:
if resolving[v.RawName] {
return v, nil
}
tp, err := a.findDefinedType(v.RawName)
if err != nil {
return nil, err
}
defined, ok := tp.(spec.DefineStruct)
if !ok {
return nil, fmt.Errorf("type %s is not a struct", v.RawName)
}
resolving[v.RawName] = true
defer delete(resolving, v.RawName)
for i := range defined.Members {
if !defined.Members[i].IsInline {
continue
}
resolved, err := a.resolveInlineType(defined.Members[i].Type, resolving)
if err != nil {
return nil, err
}
defined.Members[i].Type = resolved
}
return defined, nil
case spec.NestedStruct:
for i := range v.Members {
if !v.Members[i].IsInline {
continue
}
resolved, err := a.resolveInlineType(v.Members[i].Type, resolving)
if err != nil {
return nil, err
}
v.Members[i].Type = resolved
}
return v, nil
case spec.PointerType:
resolved, err := a.resolveInlineType(v.Type, resolving)
if err != nil {
return nil, err
}
v.Type = resolved
return v, nil
default:
return tp, nil
}
}
func (a *Analyzer) fillTypeExpr(expr *ast.TypeExpr) error {
head, _ := expr.CommentGroup()
switch val := expr.DataType.(type) {
@@ -0,0 +1,69 @@
package parser
import (
"testing"
"github.com/stretchr/testify/require"
"github.com/zeromicro/go-zero/tools/goctl/api/spec"
)
const inlineTagAPI = `
syntax = "v1"
type (
Auth {
Token string ` + "`header:\"Authorization\"`" + `
}
Middle {
Auth
}
PointerRequest {
*Auth
}
NestedRequest {
Middle
}
RecursiveRequest {
Token string ` + "`header:\"X-Token\"`" + `
*RecursiveRequest
}
)
service test-api {
@handler Pointer
get /pointer (PointerRequest)
@handler Nested
get /nested (NestedRequest)
@handler Recursive
get /recursive (RecursiveRequest)
}
`
func TestParseResolvesInlineTypesForTagLookup(t *testing.T) {
apiSpec, err := Parse("inline.api", inlineTagAPI)
require.NoError(t, err)
for _, name := range []string{"PointerRequest", "NestedRequest", "RecursiveRequest"} {
t.Run(name, func(t *testing.T) {
tp := findStructByName(t, apiSpec.Types, name)
require.NotEmpty(t, tp.GetTagMembers("header"))
require.Empty(t, tp.GetTagMembers("path"))
})
}
}
func findStructByName(t *testing.T, types []spec.Type, name string) spec.DefineStruct {
t.Helper()
for _, tp := range types {
if tp.Name() == name {
defined, ok := tp.(spec.DefineStruct)
require.True(t, ok)
return defined
}
}
t.Fatalf("type %s not found", name)
return spec.DefineStruct{}
}
+81 -16
View File
@@ -67,12 +67,9 @@ func (g *Generator) genCallGroup(ctx DirContext, proto parser.Proto, cfg *conf.C
serviceName := stringx.From(service.Name).ToCamel()
// Collect only the message types actually used by this service's RPCs,
// so that each client file only aliases its own request/response types.
usedTypes := collection.NewSet[string]()
for _, rpc := range service.RPC {
usedTypes.Add(parser.CamelCase(rpc.RequestType))
usedTypes.Add(parser.CamelCase(rpc.ReturnsType))
}
// so that each client file only aliases its own request/response types
// and their same-file message dependencies.
usedTypes := collectServiceUsedTypes(proto.Message, service)
alias := collection.NewSet[string]()
var hasSameNameBetweenMessageAndService bool
@@ -337,17 +334,85 @@ func (g *Generator) getInterfaceFuncs(goPackage, mainGoPackage string, service p
return functions, nil
}
// collectServiceUsedTypes returns the set of CamelCase message names that are
// reachable from any of the service's RPC request or response types via field
// references within the same proto file. This ensures per-service client files
// alias their own request/response types and all transitively-referenced message
// types, but never unrelated messages from other services.
func collectServiceUsedTypes(messages []parser.Message, service parser.Service) *collection.Set[string] {
messageByName := make(map[string]*proto.Message, len(messages))
for _, item := range messages {
msgName := parser.CamelCase(getMessageName(*item.Message))
messageByName[msgName] = item.Message
}
usedTypes := collection.NewSet[string]()
for _, rpc := range service.RPC {
collectMessageDependencies(rpc.RequestType, messageByName, usedTypes)
collectMessageDependencies(rpc.ReturnsType, messageByName, usedTypes)
}
return usedTypes
}
// collectMessageDependencies recursively adds protoType and all message types
// referenced by its fields into usedTypes, looking up messages by CamelCase
// name in messageByName. The cycle guard (usedTypes.Contains) prevents
// infinite recursion on circular field references.
func collectMessageDependencies(protoType string, messageByName map[string]*proto.Message,
usedTypes *collection.Set[string]) {
for _, candidate := range messageTypeCandidates(protoType) {
msg, ok := messageByName[candidate]
if !ok {
continue
}
if usedTypes.Contains(candidate) {
return
}
usedTypes.Add(candidate)
for _, elem := range msg.Elements {
switch field := elem.(type) {
case *proto.NormalField:
collectMessageDependencies(field.Type, messageByName, usedTypes)
case *proto.MapField:
// Map key types are always scalars in proto3; only the value type
// can be a message.
collectMessageDependencies(field.Type, messageByName, usedTypes)
case *proto.Oneof:
for _, oneofElem := range field.Elements {
if oneofField, ok := oneofElem.(*proto.OneOfField); ok {
collectMessageDependencies(oneofField.Type, messageByName, usedTypes)
}
}
}
}
return
}
}
// messageTypeCandidates returns the CamelCase lookup keys to try for a proto
// field type. Two candidates are produced to handle both simple names
// ("MyMsg") and dotted/qualified names ("pkg.MyMsg" → "PkgMyMsg").
func messageTypeCandidates(protoType string) []string {
protoType = strings.TrimPrefix(protoType, ".")
return []string{
parser.CamelCase(protoType),
parser.CamelCase(strings.ReplaceAll(protoType, ".", "_")),
}
}
// buildExtraImportLines converts a set of import paths into quoted import lines
// for use in the call.tpl {{.extraImports}} placeholder.
func buildExtraImportLines(extraImports *collection.Set[string]) string {
if extraImports.Count() == 0 {
return ""
}
keys := extraImports.Keys()
sort.Strings(keys)
lines := make([]string, 0, len(keys))
for _, k := range keys {
lines = append(lines, fmt.Sprintf(`"%s"`, k))
}
return strings.Join(lines, "\n\t")
if extraImports.Count() == 0 {
return ""
}
keys := extraImports.Keys()
sort.Strings(keys)
lines := make([]string, 0, len(keys))
for _, k := range keys {
lines = append(lines, fmt.Sprintf(`"%s"`, k))
}
return strings.Join(lines, "\n\t")
}
+394 -49
View File
@@ -34,50 +34,261 @@ func (m *mockDirContext) GetMain() Dir { return Dir{} }
func (m *mockDirContext) GetServiceName() stringx.String { return stringx.From("test") }
func (m *mockDirContext) SetPbDir(pbDir, grpcDir string) {}
// TestGenCallGroup_OnlyUsedTypesAliased verifies that in multi-service mode each
// generated client file contains type aliases only for the message types actually
// used by that service's RPCs (fix for issue #5481).
// newTestDirContext builds a mockDirContext that writes generated files under
// callBase, with a pb directory that differs (so alias generation is triggered).
func newTestDirContext(t *testing.T, callBase, pbBase string, services ...string) *mockDirContext {
t.Helper()
for _, svc := range services {
require.NoError(t, os.MkdirAll(filepath.Join(callBase, strings.ToLower(svc)), 0755))
}
require.NoError(t, os.MkdirAll(pbBase, 0755))
return &mockDirContext{
callDir: Dir{
Filename: callBase,
Package: "example.com/test/call",
Base: "call",
GetChildPackage: func(childPath string) (string, error) {
return filepath.Join(callBase, strings.ToLower(childPath)), nil
},
},
pbDir: Dir{Filename: pbBase, Package: "example.com/test/pb", Base: "pb"},
protoGo: Dir{
// Must differ from service dir names so isCallPkgSameToPbPkg stays
// false and alias generation is triggered.
Filename: pbBase,
Package: "example.com/test/pb",
Base: "pb",
},
}
}
// ---- unit tests for collectServiceUsedTypes --------------------------------
// TestCollectServiceUsedTypes_DirectOnly verifies that request and response
// types with no message fields are collected as-is.
func TestCollectServiceUsedTypes_DirectOnly(t *testing.T) {
messages := []parser.Message{
{Message: &proto.Message{Name: "AReq"}},
{Message: &proto.Message{Name: "AResp"}},
{Message: &proto.Message{Name: "Unrelated"}},
}
service := parser.Service{
Service: &proto.Service{Name: "ServiceA"},
RPC: []*parser.RPC{
{RPC: &proto.RPC{Name: "Do", RequestType: "AReq", ReturnsType: "AResp"}},
},
}
got := collectServiceUsedTypes(messages, service)
assert.True(t, got.Contains("AReq"))
assert.True(t, got.Contains("AResp"))
assert.False(t, got.Contains("Unrelated"), "unrelated message must not be collected")
}
// TestCollectServiceUsedTypes_NestedNormalField verifies that a message type
// referenced via a NormalField inside a response is transitively collected
// (regression test for issue #5618).
func TestCollectServiceUsedTypes_NestedNormalField(t *testing.T) {
messages := []parser.Message{
{Message: &proto.Message{Name: "AReq"}},
{Message: &proto.Message{
Name: "AResp",
Elements: []proto.Visitee{
&proto.NormalField{Field: &proto.Field{Name: "items", Type: "AItem"}},
},
}},
{Message: &proto.Message{Name: "AItem"}},
}
service := parser.Service{
Service: &proto.Service{Name: "ServiceA"},
RPC: []*parser.RPC{
{RPC: &proto.RPC{Name: "List", RequestType: "AReq", ReturnsType: "AResp"}},
},
}
got := collectServiceUsedTypes(messages, service)
assert.True(t, got.Contains("AReq"))
assert.True(t, got.Contains("AResp"))
assert.True(t, got.Contains("AItem"), "field type AItem must be transitively collected")
}
// TestCollectServiceUsedTypes_MapValueField verifies that the value type of a
// MapField inside a response message is transitively collected.
func TestCollectServiceUsedTypes_MapValueField(t *testing.T) {
messages := []parser.Message{
{Message: &proto.Message{Name: "AReq"}},
{Message: &proto.Message{
Name: "AResp",
Elements: []proto.Visitee{
&proto.MapField{KeyType: "string", Field: &proto.Field{Name: "index", Type: "AItem"}},
},
}},
{Message: &proto.Message{Name: "AItem"}},
}
service := parser.Service{
Service: &proto.Service{Name: "ServiceA"},
RPC: []*parser.RPC{
{RPC: &proto.RPC{Name: "GetMap", RequestType: "AReq", ReturnsType: "AResp"}},
},
}
got := collectServiceUsedTypes(messages, service)
assert.True(t, got.Contains("AResp"))
assert.True(t, got.Contains("AItem"), "map value type AItem must be transitively collected")
}
// TestCollectServiceUsedTypes_OneofField verifies that message types referenced
// inside a Oneof element are transitively collected.
func TestCollectServiceUsedTypes_OneofField(t *testing.T) {
oneof := &proto.Oneof{Name: "result"}
oneof.Elements = []proto.Visitee{
&proto.OneOfField{Field: &proto.Field{Name: "success", Type: "SuccessMsg"}},
&proto.OneOfField{Field: &proto.Field{Name: "failure", Type: "FailureMsg"}},
}
messages := []parser.Message{
{Message: &proto.Message{Name: "AReq"}},
{Message: &proto.Message{
Name: "AResp",
Elements: []proto.Visitee{oneof},
}},
{Message: &proto.Message{Name: "SuccessMsg"}},
{Message: &proto.Message{Name: "FailureMsg"}},
}
service := parser.Service{
Service: &proto.Service{Name: "ServiceA"},
RPC: []*parser.RPC{
{RPC: &proto.RPC{Name: "Do", RequestType: "AReq", ReturnsType: "AResp"}},
},
}
got := collectServiceUsedTypes(messages, service)
assert.True(t, got.Contains("AResp"))
assert.True(t, got.Contains("SuccessMsg"), "oneof field type SuccessMsg must be collected")
assert.True(t, got.Contains("FailureMsg"), "oneof field type FailureMsg must be collected")
}
// TestCollectServiceUsedTypes_MultiLevelTransitive verifies that a chain
// AResp → BMsg → CMsg is fully collected (multi-level transitivity).
func TestCollectServiceUsedTypes_MultiLevelTransitive(t *testing.T) {
messages := []parser.Message{
{Message: &proto.Message{Name: "AReq"}},
{Message: &proto.Message{
Name: "AResp",
Elements: []proto.Visitee{
&proto.NormalField{Field: &proto.Field{Name: "b", Type: "BMsg"}},
},
}},
{Message: &proto.Message{
Name: "BMsg",
Elements: []proto.Visitee{
&proto.NormalField{Field: &proto.Field{Name: "c", Type: "CMsg"}},
},
}},
{Message: &proto.Message{Name: "CMsg"}},
}
service := parser.Service{
Service: &proto.Service{Name: "ServiceA"},
RPC: []*parser.RPC{
{RPC: &proto.RPC{Name: "Do", RequestType: "AReq", ReturnsType: "AResp"}},
},
}
got := collectServiceUsedTypes(messages, service)
assert.True(t, got.Contains("AReq"))
assert.True(t, got.Contains("AResp"))
assert.True(t, got.Contains("BMsg"), "BMsg must be transitively collected via AResp")
assert.True(t, got.Contains("CMsg"), "CMsg must be transitively collected via BMsg")
}
// TestCollectServiceUsedTypes_CycleDetection verifies that circular field
// references (AResp ↔ BMsg) do not cause infinite recursion.
func TestCollectServiceUsedTypes_CycleDetection(t *testing.T) {
messages := []parser.Message{
{Message: &proto.Message{Name: "AReq"}},
{Message: &proto.Message{
Name: "AResp",
Elements: []proto.Visitee{
&proto.NormalField{Field: &proto.Field{Name: "b", Type: "BMsg"}},
},
}},
{Message: &proto.Message{
Name: "BMsg",
Elements: []proto.Visitee{
// circular back-reference to AResp
&proto.NormalField{Field: &proto.Field{Name: "a", Type: "AResp"}},
},
}},
}
service := parser.Service{
Service: &proto.Service{Name: "ServiceA"},
RPC: []*parser.RPC{
{RPC: &proto.RPC{Name: "Do", RequestType: "AReq", ReturnsType: "AResp"}},
},
}
// Must not panic or loop; both messages are reachable.
got := collectServiceUsedTypes(messages, service)
assert.True(t, got.Contains("AResp"))
assert.True(t, got.Contains("BMsg"))
}
// TestCollectServiceUsedTypes_ExcludesUnrelatedService verifies that messages
// belonging only to another service are not included.
func TestCollectServiceUsedTypes_ExcludesUnrelatedService(t *testing.T) {
messages := []parser.Message{
{Message: &proto.Message{Name: "AReq"}},
{Message: &proto.Message{Name: "AResp"}},
{Message: &proto.Message{Name: "BReq"}},
{Message: &proto.Message{Name: "BResp"}},
}
service := parser.Service{
Service: &proto.Service{Name: "ServiceA"},
RPC: []*parser.RPC{
{RPC: &proto.RPC{Name: "DoA", RequestType: "AReq", ReturnsType: "AResp"}},
},
}
got := collectServiceUsedTypes(messages, service)
assert.True(t, got.Contains("AReq"))
assert.True(t, got.Contains("AResp"))
assert.False(t, got.Contains("BReq"), "BReq belongs to ServiceB and must be excluded")
assert.False(t, got.Contains("BResp"), "BResp belongs to ServiceB and must be excluded")
}
// ---- integration tests via genCallGroup ------------------------------------
// TestGenCallGroup_OnlyUsedTypesAliased verifies that in multi-service mode
// each generated client file aliases only its own request/response types and
// their transitive field dependencies (fix for issues #5481 and #5618).
func TestGenCallGroup_OnlyUsedTypesAliased(t *testing.T) {
tmpDir := t.TempDir()
callBase := filepath.Join(tmpDir, "call")
pbBase := filepath.Join(tmpDir, "pb")
// Pre-create subdirs that genCallGroup will write into.
require.NoError(t, os.MkdirAll(filepath.Join(callBase, "servicea"), 0755))
require.NoError(t, os.MkdirAll(filepath.Join(callBase, "serviceb"), 0755))
require.NoError(t, os.MkdirAll(pbBase, 0755))
mctx := newTestDirContext(t, callBase, pbBase, "ServiceA", "ServiceB")
mctx := &mockDirContext{
callDir: Dir{
Filename: callBase,
Package: "example.com/multitest/call",
Base: "call",
GetChildPackage: func(childPath string) (string, error) {
// Return a package path whose Base() is the lowercase service name.
return filepath.Join(callBase, strings.ToLower(childPath)), nil
},
},
pbDir: Dir{
Filename: pbBase,
Package: "example.com/multitest/pb",
Base: "pb",
},
protoGo: Dir{
// Must differ from "servicea"/"serviceb" so isCallPkgSameToPbPkg stays false
// and alias generation is triggered.
Filename: pbBase,
Package: "example.com/multitest/pb",
Base: "pb",
},
}
// Proto with two services that use completely disjoint message types.
// ServiceA: AResp contains a NormalField of type AItem (issue #5618).
// ServiceB: BResp has no nested message fields.
// AItem must appear in ServiceA's file but not ServiceB's.
protoData := parser.Proto{
Name: "multi.proto",
PbPackage: "pb",
Message: []parser.Message{
{Message: &proto.Message{Name: "AReq"}},
{Message: &proto.Message{Name: "AResp"}},
{Message: &proto.Message{
Name: "AResp",
Elements: []proto.Visitee{
&proto.NormalField{Field: &proto.Field{Name: "items", Type: "AItem"}},
},
}},
{Message: &proto.Message{Name: "AItem"}},
{Message: &proto.Message{Name: "BReq"}},
{Message: &proto.Message{Name: "BResp"}},
},
@@ -99,29 +310,163 @@ func TestGenCallGroup_OnlyUsedTypesAliased(t *testing.T) {
cfg, err := conf.NewConfig("")
require.NoError(t, err)
require.NoError(t, NewGenerator("gozero", false).genCallGroup(mctx, protoData, cfg))
g := NewGenerator("gozero", false)
require.NoError(t, g.genCallGroup(mctx, protoData, cfg))
aFile := normalizeWS(readGenFile(t, callBase, "servicea", "servicea.go"))
assert.Contains(t, aFile, "AReq = pb.AReq", "ServiceA must alias AReq")
assert.Contains(t, aFile, "AResp = pb.AResp", "ServiceA must alias AResp")
assert.Contains(t, aFile, "AItem = pb.AItem", "ServiceA must alias AItem (transitive NormalField)")
assert.NotContains(t, aFile, "BReq = pb.BReq", "ServiceA must not alias BReq")
assert.NotContains(t, aFile, "BResp = pb.BResp", "ServiceA must not alias BResp")
// servicea/servicea.go — aliases for AReq/AResp only
aContent, err := os.ReadFile(filepath.Join(callBase, "servicea", "servicea.go"))
bFile := normalizeWS(readGenFile(t, callBase, "serviceb", "serviceb.go"))
assert.Contains(t, bFile, "BReq = pb.BReq", "ServiceB must alias BReq")
assert.Contains(t, bFile, "BResp = pb.BResp", "ServiceB must alias BResp")
assert.NotContains(t, bFile, "AReq = pb.AReq", "ServiceB must not alias AReq")
assert.NotContains(t, bFile, "AResp = pb.AResp", "ServiceB must not alias AResp")
assert.NotContains(t, bFile, "AItem = pb.AItem", "ServiceB must not alias AItem")
}
// TestGenCallGroup_MapValueAliased verifies that the value type of a MapField
// inside a service response is included in the generated aliases.
func TestGenCallGroup_MapValueAliased(t *testing.T) {
tmpDir := t.TempDir()
callBase := filepath.Join(tmpDir, "call")
pbBase := filepath.Join(tmpDir, "pb")
mctx := newTestDirContext(t, callBase, pbBase, "ServiceA")
protoData := parser.Proto{
Name: "map.proto",
PbPackage: "pb",
Message: []parser.Message{
{Message: &proto.Message{Name: "AReq"}},
{Message: &proto.Message{
Name: "AResp",
Elements: []proto.Visitee{
&proto.MapField{KeyType: "string", Field: &proto.Field{Name: "index", Type: "AItem"}},
},
}},
{Message: &proto.Message{Name: "AItem"}},
},
Service: parser.Services{
{
Service: &proto.Service{Name: "ServiceA"},
RPC: []*parser.RPC{
{RPC: &proto.RPC{Name: "GetMap", RequestType: "AReq", ReturnsType: "AResp"}},
},
},
},
}
cfg, err := conf.NewConfig("")
require.NoError(t, err)
aFile := normalizeWS(string(aContent))
require.NoError(t, NewGenerator("gozero", false).genCallGroup(mctx, protoData, cfg))
assert.Contains(t, aFile, "AReq = pb.AReq", "ServiceA file should alias AReq")
assert.Contains(t, aFile, "AResp = pb.AResp", "ServiceA file should alias AResp")
assert.NotContains(t, aFile, "BReq = pb.BReq", "ServiceA file must not alias BReq")
assert.NotContains(t, aFile, "BResp = pb.BResp", "ServiceA file must not alias BResp")
aFile := normalizeWS(readGenFile(t, callBase, "servicea", "servicea.go"))
assert.Contains(t, aFile, "AResp = pb.AResp")
assert.Contains(t, aFile, "AItem = pb.AItem", "map value type AItem must be aliased")
}
// serviceb/serviceb.go — aliases for BReq/BResp only
bContent, err := os.ReadFile(filepath.Join(callBase, "serviceb", "serviceb.go"))
// TestGenCallGroup_OneofAliased verifies that message types referenced inside a
// Oneof element are included in the generated aliases.
func TestGenCallGroup_OneofAliased(t *testing.T) {
tmpDir := t.TempDir()
callBase := filepath.Join(tmpDir, "call")
pbBase := filepath.Join(tmpDir, "pb")
mctx := newTestDirContext(t, callBase, pbBase, "ServiceA")
oneof := &proto.Oneof{Name: "result"}
oneof.Elements = []proto.Visitee{
&proto.OneOfField{Field: &proto.Field{Name: "ok", Type: "SuccessMsg"}},
&proto.OneOfField{Field: &proto.Field{Name: "err", Type: "FailureMsg"}},
}
protoData := parser.Proto{
Name: "oneof.proto",
PbPackage: "pb",
Message: []parser.Message{
{Message: &proto.Message{Name: "AReq"}},
{Message: &proto.Message{
Name: "AResp",
Elements: []proto.Visitee{oneof},
}},
{Message: &proto.Message{Name: "SuccessMsg"}},
{Message: &proto.Message{Name: "FailureMsg"}},
},
Service: parser.Services{
{
Service: &proto.Service{Name: "ServiceA"},
RPC: []*parser.RPC{
{RPC: &proto.RPC{Name: "Do", RequestType: "AReq", ReturnsType: "AResp"}},
},
},
},
}
cfg, err := conf.NewConfig("")
require.NoError(t, err)
bFile := normalizeWS(string(bContent))
require.NoError(t, NewGenerator("gozero", false).genCallGroup(mctx, protoData, cfg))
assert.Contains(t, bFile, "BReq = pb.BReq", "ServiceB file should alias BReq")
assert.Contains(t, bFile, "BResp = pb.BResp", "ServiceB file should alias BResp")
assert.NotContains(t, bFile, "AReq = pb.AReq", "ServiceB file must not alias AReq")
assert.NotContains(t, bFile, "AResp = pb.AResp", "ServiceB file must not alias AResp")
aFile := normalizeWS(readGenFile(t, callBase, "servicea", "servicea.go"))
assert.Contains(t, aFile, "SuccessMsg = pb.SuccessMsg", "oneof type SuccessMsg must be aliased")
assert.Contains(t, aFile, "FailureMsg = pb.FailureMsg", "oneof type FailureMsg must be aliased")
}
// TestGenCallGroup_MultiLevelTransitiveAliased verifies that a dependency chain
// AResp → BMsg → CMsg causes all three types to be aliased in the client file.
func TestGenCallGroup_MultiLevelTransitiveAliased(t *testing.T) {
tmpDir := t.TempDir()
callBase := filepath.Join(tmpDir, "call")
pbBase := filepath.Join(tmpDir, "pb")
mctx := newTestDirContext(t, callBase, pbBase, "ServiceA")
protoData := parser.Proto{
Name: "transitive.proto",
PbPackage: "pb",
Message: []parser.Message{
{Message: &proto.Message{Name: "AReq"}},
{Message: &proto.Message{
Name: "AResp",
Elements: []proto.Visitee{
&proto.NormalField{Field: &proto.Field{Name: "b", Type: "BMsg"}},
},
}},
{Message: &proto.Message{
Name: "BMsg",
Elements: []proto.Visitee{
&proto.NormalField{Field: &proto.Field{Name: "c", Type: "CMsg"}},
},
}},
{Message: &proto.Message{Name: "CMsg"}},
},
Service: parser.Services{
{
Service: &proto.Service{Name: "ServiceA"},
RPC: []*parser.RPC{
{RPC: &proto.RPC{Name: "Do", RequestType: "AReq", ReturnsType: "AResp"}},
},
},
},
}
cfg, err := conf.NewConfig("")
require.NoError(t, err)
require.NoError(t, NewGenerator("gozero", false).genCallGroup(mctx, protoData, cfg))
aFile := normalizeWS(readGenFile(t, callBase, "servicea", "servicea.go"))
assert.Contains(t, aFile, "AResp = pb.AResp")
assert.Contains(t, aFile, "BMsg = pb.BMsg", "BMsg must be transitively aliased via AResp")
assert.Contains(t, aFile, "CMsg = pb.CMsg", "CMsg must be transitively aliased via BMsg")
}
// readGenFile reads a generated file relative to callBase and returns its content.
func readGenFile(t *testing.T, callBase string, parts ...string) string {
t.Helper()
content, err := os.ReadFile(filepath.Join(append([]string{callBase}, parts...)...))
require.NoError(t, err)
return string(content)
}
// normalizeWS replaces runs of whitespace with a single space.